From 5e5662f194c80f0dd686c1fa1ccedf4ca65066e7 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 7 Dec 2023 18:18:42 +0100 Subject: [PATCH] Extract RoomList select to its own module --- features/messages/impl/build.gradle.kts | 1 + .../impl/forward/ForwardMessagesEvents.kt | 8 - .../impl/forward/ForwardMessagesNode.kt | 65 ++++- .../impl/forward/ForwardMessagesPresenter.kt | 56 +--- .../impl/forward/ForwardMessagesState.kt | 7 +- .../forward/ForwardMessagesStateProvider.kt | 34 --- .../impl/forward/ForwardMessagesView.kt | 233 +-------------- .../forward/ForwardMessagesPresenterTests.kt | 95 +------ libraries/roomselect/api/build.gradle.kts | 27 ++ .../roomselect/api/RoomSelectEntryPoint.kt | 42 +++ .../roomselect/api/RoomSelectMode.kt | 21 ++ libraries/roomselect/impl/build.gradle.kts | 52 ++++ .../impl/DefaultRoomSelectEntryPoint.kt | 50 ++++ .../roomselect/impl/RoomSelectEvents.kt | 28 ++ .../roomselect/impl/RoomSelectNode.kt | 67 +++++ .../roomselect/impl/RoomSelectPresenter.kt | 98 +++++++ .../roomselect/impl/RoomSelectState.kt | 31 ++ .../impl/RoomSelectStateProvider.kt | 89 ++++++ .../roomselect/impl/RoomSelectView.kt | 267 ++++++++++++++++++ .../impl/RoomSelectPresenterTests.kt | 117 ++++++++ .../kotlin/extension/DependencyHandleScope.kt | 1 + 21 files changed, 963 insertions(+), 426 deletions(-) create mode 100644 libraries/roomselect/api/build.gradle.kts create mode 100644 libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectEntryPoint.kt create mode 100644 libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectMode.kt create mode 100644 libraries/roomselect/impl/build.gradle.kts create mode 100644 libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/DefaultRoomSelectEntryPoint.kt create mode 100644 libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectEvents.kt create mode 100644 libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectNode.kt create mode 100644 libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt create mode 100644 libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectState.kt create mode 100644 libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStateProvider.kt create mode 100644 libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt create mode 100644 libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTests.kt diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index e392c96e04..655eecd0ed 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -58,6 +58,7 @@ dependencies { implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.permissions.api) implementation(projects.libraries.preferences.api) + implementation(projects.libraries.roomselect.api) implementation(projects.libraries.voicerecorder.api) implementation(projects.libraries.mediaplayer.api) implementation(projects.libraries.uiUtils) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesEvents.kt index f7058e95b3..dd8c4730bb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesEvents.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesEvents.kt @@ -16,14 +16,6 @@ package io.element.android.features.messages.impl.forward -import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails - sealed interface ForwardMessagesEvents { - data class SetSelectedRoom(val room: RoomSummaryDetails) : ForwardMessagesEvents - // TODO remove to restore multi-selection - data object RemoveSelectedRoom : ForwardMessagesEvents - data object ToggleSearchActive : ForwardMessagesEvents - data class UpdateQuery(val query: String) : ForwardMessagesEvents - data object ForwardEvent : ForwardMessagesEvents data object ClearError : ForwardMessagesEvents } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesNode.kt index 13d26b9881..9d57f8e2fc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesNode.kt @@ -16,10 +16,15 @@ package io.element.android.features.messages.impl.forward +import android.os.Parcelable +import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import com.bumble.appyx.core.composable.Children import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.node.ParentNode import com.bumble.appyx.core.plugin.Plugin import dagger.assisted.Assisted import dagger.assisted.AssistedInject @@ -29,14 +34,28 @@ import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.RoomScope import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint +import io.element.android.libraries.roomselect.api.RoomSelectMode import kotlinx.collections.immutable.ImmutableList +import kotlinx.parcelize.Parcelize @ContributesNode(RoomScope::class) class ForwardMessagesNode @AssistedInject constructor( @Assisted buildContext: BuildContext, @Assisted plugins: List, presenterFactory: ForwardMessagesPresenter.Factory, -) : Node(buildContext, plugins = plugins) { + private val roomSelectEntryPoint: RoomSelectEntryPoint, +) : ParentNode( + navModel = PermanentNavModel( + navTargets = setOf(NavTarget), + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins, +) { + + @Parcelize + object NavTarget : Parcelable interface Callback : Plugin { fun onForwardedToSingleRoom(roomId: RoomId) @@ -48,6 +67,39 @@ class ForwardMessagesNode @AssistedInject constructor( private val presenter = presenterFactory.create(inputs.eventId.value) private val callbacks = plugins.filterIsInstance() + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + val callback = object : RoomSelectEntryPoint.Callback { + override fun onRoomSelected(roomIds: List) { + presenter.onRoomSelected(roomIds) + } + + override fun onCancel() { + navigateUp() + } + } + + return roomSelectEntryPoint.nodeBuilder(this, buildContext) + .callback(callback) + .params(RoomSelectEntryPoint.Params(mode = RoomSelectMode.Forward)) + .build() + } + + @Composable + override fun View(modifier: Modifier) { + Box(modifier = modifier) { + // Will render to room select screen + Children( + navModel = navModel, + ) + + val state = presenter.present() + ForwardMessagesView( + state = state, + onForwardingSucceeded = ::onSucceeded, + ) + } + } + private fun onSucceeded(roomIds: ImmutableList) { navigateUp() if (roomIds.size == 1) { @@ -55,15 +107,4 @@ class ForwardMessagesNode @AssistedInject constructor( callbacks.forEach { it.onForwardedToSingleRoom(targetRoomId) } } } - - @Composable - override fun View(modifier: Modifier) { - val state = presenter.present() - ForwardMessagesView( - state = state, - onDismiss = ::navigateUp, - onForwardingSucceeded = ::onSucceeded, - modifier = modifier - ) - } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenter.kt index b7d77ed8cb..0ee33de26c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenter.kt @@ -17,28 +17,21 @@ package io.element.android.features.messages.impl.forward import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Presenter -import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.room.MatrixRoom -import io.element.android.libraries.matrix.api.roomlist.RoomSummary -import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -57,62 +50,25 @@ class ForwardMessagesPresenter @AssistedInject constructor( fun create(eventId: String): ForwardMessagesPresenter } - @Composable - override fun present(): ForwardMessagesState { - var selectedRooms by remember { mutableStateOf(persistentListOf()) } - var query by remember { mutableStateOf("") } - var isSearchActive by remember { mutableStateOf(false) } - var results: SearchBarResultState> by remember { mutableStateOf(SearchBarResultState.NotSearching()) } - val forwardingActionState: MutableState>> = remember { mutableStateOf(Async.Uninitialized) } + private val forwardingActionState: MutableState>> = mutableStateOf(Async.Uninitialized) - val summaries by client.roomListService.allRooms.summaries.collectAsState() - - LaunchedEffect(query, summaries) { - val filteredSummaries = summaries.filterIsInstance() - .map { it.details } - .filter { it.name.contains(query, ignoreCase = true) } - .distinctBy { it.roomId } // This should be removed once we're sure no duplicate Rooms can be received - .toPersistentList() - results = if (filteredSummaries.isNotEmpty()) { - SearchBarResultState.Results(filteredSummaries) - } else { - SearchBarResultState.NoResults() - } - } + fun onRoomSelected(roomIds: List) { + matrixCoroutineScope.forwardEvent(eventId, roomIds.toPersistentList(), forwardingActionState) + } + @Composable + override fun present(): ForwardMessagesState { val forwardingSucceeded by remember { derivedStateOf { forwardingActionState.value.dataOrNull() } } fun handleEvents(event: ForwardMessagesEvents) { when (event) { - is ForwardMessagesEvents.SetSelectedRoom -> { - selectedRooms = persistentListOf(event.room) - // Restore for multi-selection -// val index = selectedRooms.indexOfFirst { it.roomId == event.room.roomId } -// selectedRooms = if (index >= 0) { -// selectedRooms.removeAt(index) -// } else { -// selectedRooms.add(event.room) -// } - } - ForwardMessagesEvents.RemoveSelectedRoom -> selectedRooms = persistentListOf() - is ForwardMessagesEvents.UpdateQuery -> query = event.query - ForwardMessagesEvents.ToggleSearchActive -> isSearchActive = !isSearchActive - ForwardMessagesEvents.ForwardEvent -> { - isSearchActive = false - val roomIds = selectedRooms.map { it.roomId }.toPersistentList() - matrixCoroutineScope.forwardEvent(eventId, roomIds, forwardingActionState) - } ForwardMessagesEvents.ClearError -> forwardingActionState.value = Async.Uninitialized } } return ForwardMessagesState( - resultState = results, - query = query, - isSearchActive = isSearchActive, - selectedRooms = selectedRooms, isForwarding = forwardingActionState.value.isLoading(), error = (forwardingActionState.value as? Async.Failure)?.error, forwardingSucceeded = forwardingSucceeded, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesState.kt index 953a7897f6..d64c4dd7e9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesState.kt @@ -16,16 +16,11 @@ package io.element.android.features.messages.impl.forward -import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails import kotlinx.collections.immutable.ImmutableList data class ForwardMessagesState( - val resultState: SearchBarResultState>, - val query: String, - val isSearchActive: Boolean, - val selectedRooms: ImmutableList, + // TODO Migrate to an Async val isForwarding: Boolean, val error: Throwable?, val forwardingSucceeded: ImmutableList?, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesStateProvider.kt index dbcb4c8c3c..3029a174fc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesStateProvider.kt @@ -17,7 +17,6 @@ package io.element.android.features.messages.impl.forward import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.message.RoomMessage @@ -29,38 +28,13 @@ open class ForwardMessagesStateProvider : PreviewParameterProvider get() = sequenceOf( aForwardMessagesState(), - aForwardMessagesState(query = "Test", isSearchActive = true), - aForwardMessagesState(resultState = SearchBarResultState.Results(aForwardMessagesRoomList())), aForwardMessagesState( - resultState = SearchBarResultState.Results(aForwardMessagesRoomList()), - query = "Test", - isSearchActive = true, - ), - aForwardMessagesState( - resultState = SearchBarResultState.Results(aForwardMessagesRoomList()), - query = "Test", - isSearchActive = true, - selectedRooms = persistentListOf(aRoomDetailsState(roomId = RoomId("!room2:domain"))) - ), - aForwardMessagesState( - resultState = SearchBarResultState.Results(aForwardMessagesRoomList()), - query = "Test", - isSearchActive = true, - selectedRooms = persistentListOf(aRoomDetailsState(roomId = RoomId("!room2:domain"))), isForwarding = true, ), aForwardMessagesState( - resultState = SearchBarResultState.Results(aForwardMessagesRoomList()), - query = "Test", - isSearchActive = true, - selectedRooms = persistentListOf(aRoomDetailsState(roomId = RoomId("!room2:domain"))), forwardingSucceeded = persistentListOf(RoomId("!room2:domain")), ), aForwardMessagesState( - resultState = SearchBarResultState.Results(aForwardMessagesRoomList()), - query = "Test", - isSearchActive = true, - selectedRooms = persistentListOf(aRoomDetailsState(roomId = RoomId("!room2:domain"))), error = Throwable("error"), ), // Add other states here @@ -68,18 +42,10 @@ open class ForwardMessagesStateProvider : PreviewParameterProvider> = SearchBarResultState.NotSearching(), - query: String = "", - isSearchActive: Boolean = false, - selectedRooms: ImmutableList = persistentListOf(), isForwarding: Boolean = false, error: Throwable? = null, forwardingSucceeded: ImmutableList? = null, ) = ForwardMessagesState( - resultState = resultState, - query = query, - isSearchActive = isSearchActive, - selectedRooms = selectedRooms, isForwarding = isForwarding, error = error, forwardingSucceeded = forwardingSucceeded, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesView.kt index 32d46e8070..089046544b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesView.kt @@ -16,63 +16,20 @@ package io.element.android.features.messages.impl.forward -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.unit.dp -import io.element.android.compound.theme.ElementTheme import io.element.android.libraries.designsystem.components.ProgressDialog -import io.element.android.libraries.designsystem.components.avatar.Avatar -import io.element.android.libraries.designsystem.components.avatar.AvatarData -import io.element.android.libraries.designsystem.components.avatar.AvatarSize -import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.dialogs.ErrorDialogDefaults import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.theme.aliasScreenTitle -import io.element.android.libraries.designsystem.theme.components.HorizontalDivider -import io.element.android.libraries.designsystem.theme.components.RadioButton -import io.element.android.libraries.designsystem.theme.components.Scaffold -import io.element.android.libraries.designsystem.theme.components.SearchBar -import io.element.android.libraries.designsystem.theme.components.SearchBarResultState -import io.element.android.libraries.designsystem.theme.components.Text -import io.element.android.libraries.designsystem.theme.components.TextButton -import io.element.android.libraries.designsystem.theme.components.TopAppBar -import io.element.android.libraries.designsystem.theme.roomListRoomMessage -import io.element.android.libraries.designsystem.theme.roomListRoomName import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails -import io.element.android.libraries.matrix.ui.components.SelectedRoom -import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList -@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun ForwardMessagesView( state: ForwardMessagesState, - onDismiss: () -> Unit, onForwardingSucceeded: (ImmutableList) -> Unit, modifier: Modifier = Modifier, ) { @@ -81,193 +38,16 @@ fun ForwardMessagesView( return } - @Suppress("UNUSED_PARAMETER") - fun onRoomRemoved(roomSummaryDetails: RoomSummaryDetails) { - // TODO toggle selection when multi-selection is enabled - state.eventSink(ForwardMessagesEvents.RemoveSelectedRoom) + if (state.isForwarding) { + ProgressDialog(modifier) } - @Composable - fun SelectedRoomsHelper(isForwarding: Boolean, selectedRooms: ImmutableList) { - if (isForwarding) return - SelectedRooms( - selectedRooms = selectedRooms, - onRoomRemoved = ::onRoomRemoved, - modifier = Modifier.padding(vertical = 16.dp) + if (state.error != null) { + ForwardingErrorDialog( + modifier = modifier, + onDismiss = { state.eventSink(ForwardMessagesEvents.ClearError) }, ) } - - fun onBackButton(state: ForwardMessagesState) { - if (state.isSearchActive) { - state.eventSink(ForwardMessagesEvents.ToggleSearchActive) - } else { - onDismiss() - } - } - - BackHandler(onBack = { onBackButton(state) }) - - Scaffold( - modifier = modifier, - topBar = { - TopAppBar( - title = { - Text( - text = stringResource(CommonStrings.common_forward_message), - style = ElementTheme.typography.aliasScreenTitle - ) - }, - navigationIcon = { - BackButton(onClick = { onBackButton(state) }) - }, - actions = { - TextButton( - text = stringResource(CommonStrings.action_send), - enabled = state.selectedRooms.isNotEmpty(), - onClick = { state.eventSink(ForwardMessagesEvents.ForwardEvent) } - ) - } - ) - } - ) { paddingValues -> - Column( - Modifier - .padding(paddingValues) - .consumeWindowInsets(paddingValues) - ) { - SearchBar( - placeHolderTitle = stringResource(CommonStrings.action_search), - query = state.query, - onQueryChange = { state.eventSink(ForwardMessagesEvents.UpdateQuery(it)) }, - active = state.isSearchActive, - onActiveChange = { state.eventSink(ForwardMessagesEvents.ToggleSearchActive) }, - resultState = state.resultState, - showBackButton = false, - ) { summaries -> - LazyColumn { - item { - SelectedRoomsHelper( - isForwarding = state.isForwarding, - selectedRooms = state.selectedRooms - ) - } - items(summaries, key = { it.roomId.value }) { roomSummary -> - Column { - RoomSummaryView( - roomSummary, - isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId }, - onSelection = { roomSummary -> - state.eventSink(ForwardMessagesEvents.SetSelectedRoom(roomSummary)) - } - ) - HorizontalDivider(modifier = Modifier.fillMaxWidth()) - } - } - } - } - - if (!state.isSearchActive) { - // TODO restore for multi-selection -// SelectedRoomsHelper( -// isForwarding = state.isForwarding, -// selectedRooms = state.selectedRooms -// ) - Spacer(modifier = Modifier.height(20.dp)) - - if (state.resultState is SearchBarResultState.Results) { - LazyColumn { - items(state.resultState.results, key = { it.roomId.value }) { roomSummary -> - Column { - RoomSummaryView( - roomSummary, - isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId }, - onSelection = { roomSummary -> - state.eventSink(ForwardMessagesEvents.SetSelectedRoom(roomSummary)) - } - ) - HorizontalDivider(modifier = Modifier.fillMaxWidth()) - } - } - } - } - } - - if (state.isForwarding) { - ProgressDialog() - } - - if (state.error != null) { - ForwardingErrorDialog(onDismiss = { state.eventSink(ForwardMessagesEvents.ClearError) }) - } - } - } -} - -@Composable -private fun SelectedRooms( - selectedRooms: ImmutableList, - onRoomRemoved: (RoomSummaryDetails) -> Unit, - modifier: Modifier = Modifier, -) { - LazyRow( - modifier, - contentPadding = PaddingValues(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(32.dp) - ) { - items(selectedRooms, key = { it.roomId.value }) { roomSummary -> - SelectedRoom(roomSummary = roomSummary, onRoomRemoved = onRoomRemoved) - } - } -} - -@Composable -private fun RoomSummaryView( - summary: RoomSummaryDetails, - isSelected: Boolean, - onSelection: (RoomSummaryDetails) -> Unit, - modifier: Modifier = Modifier -) { - Row( - modifier = modifier - .clickable { onSelection(summary) } - .fillMaxWidth() - .padding(start = 16.dp, end = 4.dp) - .heightIn(56.dp), - verticalAlignment = Alignment.CenterVertically - ) { - val roomAlias = summary.canonicalAlias ?: summary.roomId.value - Avatar( - avatarData = AvatarData( - id = roomAlias, - name = summary.name, - url = summary.avatarURLString, - size = AvatarSize.ForwardRoomListItem, - ), - ) - Column( - modifier = Modifier - .padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp) - .weight(1f) - ) { - // Name - Text( - style = ElementTheme.typography.fontBodyLgRegular, - text = summary.name, - color = MaterialTheme.roomListRoomName(), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - // Id - Text( - text = roomAlias, - color = MaterialTheme.roomListRoomMessage(), - style = ElementTheme.typography.fontBodySmRegular, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - RadioButton(selected = isSelected, onClick = { onSelection(summary) }) - } } @Composable @@ -284,7 +64,6 @@ private fun ForwardingErrorDialog(onDismiss: () -> Unit, modifier: Modifier = Mo internal fun ForwardMessagesViewPreview(@PreviewParameter(ForwardMessagesStateProvider::class) state: ForwardMessagesState) = ElementPreview { ForwardMessagesView( state = state, - onDismiss = {}, onForwardingSucceeded = {} ) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenterTests.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenterTests.kt index cdfd100d84..45a110b553 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenterTests.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenterTests.kt @@ -20,16 +20,12 @@ import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.matrix.api.core.EventId -import io.element.android.libraries.matrix.api.roomlist.RoomSummary import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.aRoomSummaryDetail -import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.tests.testutils.WarmUpRule -import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.test.runTest import org.junit.Rule @@ -40,7 +36,6 @@ class ForwardMessagesPresenterTests { @get:Rule val warmUpRule = WarmUpRule() - @Test fun `present - initial state`() = runTest { val presenter = aPresenter() @@ -48,75 +43,23 @@ class ForwardMessagesPresenterTests { presenter.present() }.test { val initialState = awaitItem() - assertThat(initialState.selectedRooms).isEmpty() - assertThat(initialState.resultState).isInstanceOf(SearchBarResultState.NotSearching::class.java) - assertThat(initialState.isSearchActive).isFalse() assertThat(initialState.isForwarding).isFalse() assertThat(initialState.error).isNull() assertThat(initialState.forwardingSucceeded).isNull() - - // Search is run automatically - val searchState = awaitItem() - assertThat(searchState.resultState).isInstanceOf(SearchBarResultState.NoResults::class.java) - } - } - - @Test - fun `present - toggle search active`() = runTest { - val presenter = aPresenter() - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - val initialState = awaitItem() - skipItems(1) - - initialState.eventSink(ForwardMessagesEvents.ToggleSearchActive) - assertThat(awaitItem().isSearchActive).isTrue() - - initialState.eventSink(ForwardMessagesEvents.ToggleSearchActive) - assertThat(awaitItem().isSearchActive).isFalse() } } @Test - fun `present - update query`() = runTest { - val roomListService = FakeRoomListService().apply { - postAllRooms(listOf(RoomSummary.Filled(aRoomSummaryDetail()))) - } - val client = FakeMatrixClient(roomListService = roomListService) - val presenter = aPresenter(client = client) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - val initialState = awaitItem() - assertThat(awaitItem().resultState as? SearchBarResultState.Results).isEqualTo(SearchBarResultState.Results(listOf(aRoomSummaryDetail()))) - - initialState.eventSink(ForwardMessagesEvents.UpdateQuery("string not contained")) - assertThat(awaitItem().query).isEqualTo("string not contained") - assertThat(awaitItem().resultState).isInstanceOf(SearchBarResultState.NoResults::class.java) - } - } - - @Test - fun `present - select a room and forward successful`() = runTest { + fun `present - forward successful`() = runTest { val presenter = aPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - val initialState = awaitItem() skipItems(1) val summary = aRoomSummaryDetail() - - initialState.eventSink(ForwardMessagesEvents.SetSelectedRoom(summary)) - awaitItem() - - // Test successful forwarding - initialState.eventSink(ForwardMessagesEvents.ForwardEvent) - + presenter.onRoomSelected(listOf(summary.roomId)) val forwardingState = awaitItem() - assertThat(forwardingState.isSearchActive).isFalse() assertThat(forwardingState.isForwarding).isTrue() - val successfulForwardState = awaitItem() assertThat(successfulForwardState.isForwarding).isFalse() assertThat(successfulForwardState.forwardingSucceeded).isNotNull() @@ -130,46 +73,20 @@ class ForwardMessagesPresenterTests { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - val initialState = awaitItem() - skipItems(1) - val summary = aRoomSummaryDetail() - - initialState.eventSink(ForwardMessagesEvents.SetSelectedRoom(summary)) - awaitItem() - // Test failed forwarding room.givenForwardEventResult(Result.failure(Throwable("error"))) - initialState.eventSink(ForwardMessagesEvents.ForwardEvent) skipItems(1) - + val summary = aRoomSummaryDetail() + presenter.onRoomSelected(listOf(summary.roomId)) + skipItems(1) val failedForwardState = awaitItem() - assertThat(failedForwardState.isForwarding).isFalse() assertThat(failedForwardState.error).isNotNull() - // Then clear error - initialState.eventSink(ForwardMessagesEvents.ClearError) + failedForwardState.eventSink(ForwardMessagesEvents.ClearError) assertThat(awaitItem().error).isNull() } } - @Test - fun `present - select and remove a room`() = runTest { - val presenter = aPresenter() - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - val initialState = awaitItem() - skipItems(1) - val summary = aRoomSummaryDetail() - - initialState.eventSink(ForwardMessagesEvents.SetSelectedRoom(summary)) - assertThat(awaitItem().selectedRooms).isEqualTo(persistentListOf(summary)) - - initialState.eventSink(ForwardMessagesEvents.RemoveSelectedRoom) - assertThat(awaitItem().selectedRooms).isEmpty() - } - } - private fun CoroutineScope.aPresenter( eventId: EventId = AN_EVENT_ID, fakeMatrixRoom: FakeMatrixRoom = FakeMatrixRoom(), diff --git a/libraries/roomselect/api/build.gradle.kts b/libraries/roomselect/api/build.gradle.kts new file mode 100644 index 0000000000..fa19346197 --- /dev/null +++ b/libraries/roomselect/api/build.gradle.kts @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.roomselect.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) +} diff --git a/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectEntryPoint.kt b/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectEntryPoint.kt new file mode 100644 index 0000000000..873d6f4a9b --- /dev/null +++ b/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectEntryPoint.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.roomselect.api + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import io.element.android.libraries.architecture.FeatureEntryPoint +import io.element.android.libraries.matrix.api.core.RoomId + +interface RoomSelectEntryPoint : FeatureEntryPoint { + data class Params( + val mode: RoomSelectMode, + ) + + fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder + interface NodeBuilder { + fun params(params: Params): NodeBuilder + fun callback(callback: Callback): NodeBuilder + fun build(): Node + } + + interface Callback : Plugin { + fun onRoomSelected(roomIds: List) + fun onCancel() + } +} + diff --git a/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectMode.kt b/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectMode.kt new file mode 100644 index 0000000000..d3f63e366c --- /dev/null +++ b/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectMode.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.roomselect.api + +enum class RoomSelectMode { + Forward, +} diff --git a/libraries/roomselect/impl/build.gradle.kts b/libraries/roomselect/impl/build.gradle.kts new file mode 100644 index 0000000000..2eaf5c3db7 --- /dev/null +++ b/libraries/roomselect/impl/build.gradle.kts @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) + alias(libs.plugins.ksp) + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.libraries.roomselect.impl" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(projects.anvilannotations) + anvil(projects.anvilcodegen) + implementation(projects.libraries.core) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) + api(projects.libraries.roomselect.api) + ksp(libs.showkase.processor) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.tests.testutils) +} diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/DefaultRoomSelectEntryPoint.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/DefaultRoomSelectEntryPoint.kt new file mode 100644 index 0000000000..d3700c96f5 --- /dev/null +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/DefaultRoomSelectEntryPoint.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.roomselect.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint +import javax.inject.Inject + +@ContributesBinding(SessionScope::class) +class DefaultRoomSelectEntryPoint @Inject constructor() : RoomSelectEntryPoint { + override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): RoomSelectEntryPoint.NodeBuilder { + val plugins = ArrayList() + + return object : RoomSelectEntryPoint.NodeBuilder { + override fun params(params: RoomSelectEntryPoint.Params): RoomSelectEntryPoint.NodeBuilder { + plugins += RoomSelectNode.Inputs(mode = params.mode) + return this + } + + override fun callback(callback: RoomSelectEntryPoint.Callback): RoomSelectEntryPoint.NodeBuilder { + plugins += callback + return this + } + + override fun build(): Node { + return parentNode.createNode(buildContext, plugins) + } + } + } +} + diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectEvents.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectEvents.kt new file mode 100644 index 0000000000..76dce0dd5d --- /dev/null +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectEvents.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.roomselect.impl + +import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails + +sealed interface RoomSelectEvents { + data class SetSelectedRoom(val room: RoomSummaryDetails) : RoomSelectEvents + + // TODO remove to restore multi-selection + data object RemoveSelectedRoom : RoomSelectEvents + data object ToggleSearchActive : RoomSelectEvents + data class UpdateQuery(val query: String) : RoomSelectEvents +} diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectNode.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectNode.kt new file mode 100644 index 0000000000..6b7b8054b2 --- /dev/null +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectNode.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.roomselect.impl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint +import io.element.android.libraries.roomselect.api.RoomSelectMode + +@ContributesNode(SessionScope::class) +class RoomSelectNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: RoomSelectPresenter.Factory, +) : Node(buildContext, plugins = plugins) { + data class Inputs( + val mode: RoomSelectMode, + ) : NodeInputs + + private val inputs: Inputs = inputs() + private val presenter = presenterFactory.create(inputs.mode) + + private val callbacks = plugins.filterIsInstance() + + private fun onDismiss() { + callbacks.forEach { it.onCancel() } + } + + private fun onSubmit(roomIds: List) { + callbacks.forEach { it.onRoomSelected(roomIds) } + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + RoomSelectView( + state = state, + onDismiss = ::onDismiss, + onSubmit = ::onSubmit, + modifier = modifier + ) + } +} diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt new file mode 100644 index 0000000000..16cd7b813d --- /dev/null +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.roomselect.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.element.android.libraries.roomselect.api.RoomSelectMode +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.roomlist.RoomSummary +import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList + +class RoomSelectPresenter @AssistedInject constructor( + @Assisted private val mode: RoomSelectMode, + private val client: MatrixClient, +) : Presenter { + + @AssistedFactory + interface Factory { + fun create(mode: RoomSelectMode): RoomSelectPresenter + } + + @Composable + override fun present(): RoomSelectState { + var selectedRooms by remember { mutableStateOf(persistentListOf()) } + var query by remember { mutableStateOf("") } + var isSearchActive by remember { mutableStateOf(false) } + var results: SearchBarResultState> by remember { mutableStateOf(SearchBarResultState.NotSearching()) } + + val summaries by client.roomListService.allRooms.summaries.collectAsState() + + LaunchedEffect(query, summaries) { + val filteredSummaries = summaries.filterIsInstance() + .map { it.details } + .filter { it.name.contains(query, ignoreCase = true) } + .distinctBy { it.roomId } // This should be removed once we're sure no duplicate Rooms can be received + .toPersistentList() + results = if (filteredSummaries.isNotEmpty()) { + SearchBarResultState.Results(filteredSummaries) + } else { + SearchBarResultState.NoResults() + } + } + + fun handleEvents(event: RoomSelectEvents) { + when (event) { + is RoomSelectEvents.SetSelectedRoom -> { + selectedRooms = persistentListOf(event.room) + // Restore for multi-selection +// val index = selectedRooms.indexOfFirst { it.roomId == event.room.roomId } +// selectedRooms = if (index >= 0) { +// selectedRooms.removeAt(index) +// } else { +// selectedRooms.add(event.room) +// } + } + RoomSelectEvents.RemoveSelectedRoom -> selectedRooms = persistentListOf() + is RoomSelectEvents.UpdateQuery -> query = event.query + RoomSelectEvents.ToggleSearchActive -> isSearchActive = !isSearchActive + } + } + + return RoomSelectState( + mode = mode, + resultState = results, + query = query, + isSearchActive = isSearchActive, + selectedRooms = selectedRooms, + eventSink = { handleEvents(it) } + ) + } +} diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectState.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectState.kt new file mode 100644 index 0000000000..20fa7d910d --- /dev/null +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectState.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.roomselect.impl + +import io.element.android.libraries.roomselect.api.RoomSelectMode +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails +import kotlinx.collections.immutable.ImmutableList + +data class RoomSelectState( + val mode: RoomSelectMode, + val resultState: SearchBarResultState>, + val query: String, + val isSearchActive: Boolean, + val selectedRooms: ImmutableList, + val eventSink: (RoomSelectEvents) -> Unit +) diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStateProvider.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStateProvider.kt new file mode 100644 index 0000000000..f15a734337 --- /dev/null +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStateProvider.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.roomselect.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.roomselect.api.RoomSelectMode +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.message.RoomMessage +import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +open class RoomSelectStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aRoomSelectState(), + aRoomSelectState(query = "Test", isSearchActive = true), + aRoomSelectState(resultState = SearchBarResultState.Results(aForwardMessagesRoomList())), + aRoomSelectState( + resultState = SearchBarResultState.Results(aForwardMessagesRoomList()), + query = "Test", + isSearchActive = true, + ), + aRoomSelectState( + resultState = SearchBarResultState.Results(aForwardMessagesRoomList()), + query = "Test", + isSearchActive = true, + selectedRooms = persistentListOf(aRoomDetailsState(roomId = RoomId("!room2:domain"))) + ), + // Add other states here + ) +} + +fun aRoomSelectState( + resultState: SearchBarResultState> = SearchBarResultState.NotSearching(), + query: String = "", + isSearchActive: Boolean = false, + selectedRooms: ImmutableList = persistentListOf(), +) = RoomSelectState( + mode = RoomSelectMode.Forward, + resultState = resultState, + query = query, + isSearchActive = isSearchActive, + selectedRooms = selectedRooms, + eventSink = {} +) + +internal fun aForwardMessagesRoomList() = persistentListOf( + aRoomDetailsState(), + aRoomDetailsState(roomId = RoomId("!room2:domain"), canonicalAlias = "#element-x-room:matrix.org"), +) + +fun aRoomDetailsState( + roomId: RoomId = RoomId("!room:domain"), + name: String = "roomName", + canonicalAlias: String? = null, + isDirect: Boolean = true, + avatarURLString: String? = null, + lastMessage: RoomMessage? = null, + lastMessageTimestamp: Long? = null, + unreadNotificationCount: Int = 0, + inviter: RoomMember? = null, +) = RoomSummaryDetails( + roomId = roomId, + name = name, + canonicalAlias = canonicalAlias, + isDirect = isDirect, + avatarURLString = avatarURLString, + lastMessage = lastMessage, + lastMessageTimestamp = lastMessageTimestamp, + unreadNotificationCount = unreadNotificationCount, + inviter = inviter, +) diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt new file mode 100644 index 0000000000..3913184b8f --- /dev/null +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt @@ -0,0 +1,267 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.roomselect.impl + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.roomselect.api.RoomSelectMode +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.aliasScreenTitle +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider +import io.element.android.libraries.designsystem.theme.components.RadioButton +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.SearchBar +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.designsystem.theme.roomListRoomMessage +import io.element.android.libraries.designsystem.theme.roomListRoomName +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails +import io.element.android.libraries.matrix.ui.components.SelectedRoom +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RoomSelectView( + state: RoomSelectState, + onDismiss: () -> Unit, + onSubmit: (List) -> Unit, + modifier: Modifier = Modifier, +) { + @Suppress("UNUSED_PARAMETER") + fun onRoomRemoved(roomSummaryDetails: RoomSummaryDetails) { + // TODO toggle selection when multi-selection is enabled + state.eventSink(RoomSelectEvents.RemoveSelectedRoom) + } + + @Composable + fun SelectedRoomsHelper(isForwarding: Boolean, selectedRooms: ImmutableList) { + if (isForwarding) return + SelectedRooms( + selectedRooms = selectedRooms, + onRoomRemoved = ::onRoomRemoved, + modifier = Modifier.padding(vertical = 16.dp) + ) + } + + fun onBackButton(state: RoomSelectState) { + if (state.isSearchActive) { + state.eventSink(RoomSelectEvents.ToggleSearchActive) + } else { + onDismiss() + } + } + + BackHandler(onBack = { onBackButton(state) }) + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { + Text( + text = when (state.mode) { + RoomSelectMode.Forward -> stringResource(CommonStrings.common_forward_message) + }, + style = ElementTheme.typography.aliasScreenTitle + ) + }, + navigationIcon = { + BackButton(onClick = { onBackButton(state) }) + }, + actions = { + TextButton( + text = stringResource(CommonStrings.action_send), + enabled = state.selectedRooms.isNotEmpty(), + onClick = { onSubmit(state.selectedRooms.map { it.roomId }) } + ) + } + ) + } + ) { paddingValues -> + Column( + Modifier + .padding(paddingValues) + .consumeWindowInsets(paddingValues) + ) { + SearchBar( + placeHolderTitle = stringResource(CommonStrings.action_search), + query = state.query, + onQueryChange = { state.eventSink(RoomSelectEvents.UpdateQuery(it)) }, + active = state.isSearchActive, + onActiveChange = { state.eventSink(RoomSelectEvents.ToggleSearchActive) }, + resultState = state.resultState, + showBackButton = false, + ) { summaries -> + LazyColumn { + item { + SelectedRoomsHelper( + isForwarding = false, // TODO state.isForwarding, + selectedRooms = state.selectedRooms + ) + } + items(summaries, key = { it.roomId.value }) { roomSummary -> + Column { + RoomSummaryView( + roomSummary, + isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId }, + onSelection = { roomSummary -> + state.eventSink(RoomSelectEvents.SetSelectedRoom(roomSummary)) + } + ) + HorizontalDivider(modifier = Modifier.fillMaxWidth()) + } + } + } + } + + if (!state.isSearchActive) { + // TODO restore for multi-selection +// SelectedRoomsHelper( +// isForwarding = state.isForwarding, +// selectedRooms = state.selectedRooms +// ) + Spacer(modifier = Modifier.height(20.dp)) + + if (state.resultState is SearchBarResultState.Results) { + LazyColumn { + items(state.resultState.results, key = { it.roomId.value }) { roomSummary -> + Column { + RoomSummaryView( + roomSummary, + isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId }, + onSelection = { roomSummary -> + state.eventSink(RoomSelectEvents.SetSelectedRoom(roomSummary)) + } + ) + HorizontalDivider(modifier = Modifier.fillMaxWidth()) + } + } + } + } + } + } + } +} + +@Composable +private fun SelectedRooms( + selectedRooms: ImmutableList, + onRoomRemoved: (RoomSummaryDetails) -> Unit, + modifier: Modifier = Modifier, +) { + LazyRow( + modifier, + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(32.dp) + ) { + items(selectedRooms, key = { it.roomId.value }) { roomSummary -> + SelectedRoom(roomSummary = roomSummary, onRoomRemoved = onRoomRemoved) + } + } +} + +@Composable +private fun RoomSummaryView( + summary: RoomSummaryDetails, + isSelected: Boolean, + onSelection: (RoomSummaryDetails) -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .clickable { onSelection(summary) } + .fillMaxWidth() + .padding(start = 16.dp, end = 4.dp) + .heightIn(56.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val roomAlias = summary.canonicalAlias ?: summary.roomId.value + Avatar( + avatarData = AvatarData( + id = roomAlias, + name = summary.name, + url = summary.avatarURLString, + size = AvatarSize.ForwardRoomListItem, + ), + ) + Column( + modifier = Modifier + .padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp) + .weight(1f) + ) { + // Name + Text( + style = ElementTheme.typography.fontBodyLgRegular, + text = summary.name, + color = MaterialTheme.roomListRoomName(), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + // Id + Text( + text = roomAlias, + color = MaterialTheme.roomListRoomMessage(), + style = ElementTheme.typography.fontBodySmRegular, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + RadioButton(selected = isSelected, onClick = { onSelection(summary) }) + } +} + +@PreviewsDayNight +@Composable +internal fun RoomSelectViewPreview(@PreviewParameter(RoomSelectStateProvider::class) state: RoomSelectState) = ElementPreview { + RoomSelectView( + state = state, + onDismiss = {}, + onSubmit = {}, + ) +} diff --git a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTests.kt b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTests.kt new file mode 100644 index 0000000000..88ae560bb2 --- /dev/null +++ b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTests.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.roomselect.impl + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.roomselect.api.RoomSelectMode +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.api.roomlist.RoomSummary +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.room.aRoomSummaryDetail +import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class RoomSelectPresenterTests { + + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = aPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.selectedRooms).isEmpty() + assertThat(initialState.resultState).isInstanceOf(SearchBarResultState.NotSearching::class.java) + assertThat(initialState.isSearchActive).isFalse() + // Search is run automatically + val searchState = awaitItem() + assertThat(searchState.resultState).isInstanceOf(SearchBarResultState.NoResults::class.java) + } + } + + @Test + fun `present - toggle search active`() = runTest { + val presenter = aPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + skipItems(1) + + initialState.eventSink(RoomSelectEvents.ToggleSearchActive) + assertThat(awaitItem().isSearchActive).isTrue() + + initialState.eventSink(RoomSelectEvents.ToggleSearchActive) + assertThat(awaitItem().isSearchActive).isFalse() + } + } + + @Test + fun `present - update query`() = runTest { + val roomListService = FakeRoomListService().apply { + postAllRooms(listOf(RoomSummary.Filled(aRoomSummaryDetail()))) + } + val client = FakeMatrixClient(roomListService = roomListService) + val presenter = aPresenter(client = client) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(awaitItem().resultState as? SearchBarResultState.Results).isEqualTo(SearchBarResultState.Results(listOf(aRoomSummaryDetail()))) + + initialState.eventSink(RoomSelectEvents.UpdateQuery("string not contained")) + assertThat(awaitItem().query).isEqualTo("string not contained") + assertThat(awaitItem().resultState).isInstanceOf(SearchBarResultState.NoResults::class.java) + } + } + + @Test + fun `present - select and remove a room`() = runTest { + val presenter = aPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + skipItems(1) + val summary = aRoomSummaryDetail() + + initialState.eventSink(RoomSelectEvents.SetSelectedRoom(summary)) + assertThat(awaitItem().selectedRooms).isEqualTo(persistentListOf(summary)) + + initialState.eventSink(RoomSelectEvents.RemoveSelectedRoom) + assertThat(awaitItem().selectedRooms).isEmpty() + } + } + + private fun aPresenter( + mode: RoomSelectMode = RoomSelectMode.Forward, + client: FakeMatrixClient = FakeMatrixClient(), + ) = RoomSelectPresenter( + mode = mode, + client = client, + ) +} diff --git a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt index b8aadaa52c..625d2cf7dc 100644 --- a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt +++ b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt @@ -102,6 +102,7 @@ fun DependencyHandlerScope.allLibrariesImpl() { implementation(project(":libraries:mediaupload:impl")) implementation(project(":libraries:usersearch:impl")) implementation(project(":libraries:textcomposer:impl")) + implementation(project(":libraries:roomselect:impl")) implementation(project(":libraries:cryptography:impl")) implementation(project(":libraries:voicerecorder:impl")) implementation(project(":libraries:mediaplayer:impl"))