Browse Source

Extract RoomList select to its own module

pull/1975/head
Benoit Marty 10 months ago
parent
commit
5e5662f194
  1. 1
      features/messages/impl/build.gradle.kts
  2. 8
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesEvents.kt
  3. 65
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesNode.kt
  4. 56
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenter.kt
  5. 7
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesState.kt
  6. 34
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesStateProvider.kt
  7. 233
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesView.kt
  8. 95
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenterTests.kt
  9. 27
      libraries/roomselect/api/build.gradle.kts
  10. 42
      libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectEntryPoint.kt
  11. 21
      libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectMode.kt
  12. 52
      libraries/roomselect/impl/build.gradle.kts
  13. 50
      libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/DefaultRoomSelectEntryPoint.kt
  14. 28
      libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectEvents.kt
  15. 67
      libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectNode.kt
  16. 98
      libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt
  17. 31
      libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectState.kt
  18. 89
      libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStateProvider.kt
  19. 267
      libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt
  20. 117
      libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTests.kt
  21. 1
      plugins/src/main/kotlin/extension/DependencyHandleScope.kt

1
features/messages/impl/build.gradle.kts

@ -58,6 +58,7 @@ dependencies {
implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.mediaupload.api)
implementation(projects.libraries.permissions.api) implementation(projects.libraries.permissions.api)
implementation(projects.libraries.preferences.api) implementation(projects.libraries.preferences.api)
implementation(projects.libraries.roomselect.api)
implementation(projects.libraries.voicerecorder.api) implementation(projects.libraries.voicerecorder.api)
implementation(projects.libraries.mediaplayer.api) implementation(projects.libraries.mediaplayer.api)
implementation(projects.libraries.uiUtils) implementation(projects.libraries.uiUtils)

8
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 package io.element.android.features.messages.impl.forward
import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails
sealed interface ForwardMessagesEvents { 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 data object ClearError : ForwardMessagesEvents
} }

65
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 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.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.modality.BuildContext 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.Node
import com.bumble.appyx.core.node.ParentNode
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject 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.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId 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.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.collections.immutable.ImmutableList
import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
class ForwardMessagesNode @AssistedInject constructor( class ForwardMessagesNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
presenterFactory: ForwardMessagesPresenter.Factory, presenterFactory: ForwardMessagesPresenter.Factory,
) : Node(buildContext, plugins = plugins) { private val roomSelectEntryPoint: RoomSelectEntryPoint,
) : ParentNode<ForwardMessagesNode.NavTarget>(
navModel = PermanentNavModel(
navTargets = setOf(NavTarget),
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins,
) {
@Parcelize
object NavTarget : Parcelable
interface Callback : Plugin { interface Callback : Plugin {
fun onForwardedToSingleRoom(roomId: RoomId) fun onForwardedToSingleRoom(roomId: RoomId)
@ -48,6 +67,39 @@ class ForwardMessagesNode @AssistedInject constructor(
private val presenter = presenterFactory.create(inputs.eventId.value) private val presenter = presenterFactory.create(inputs.eventId.value)
private val callbacks = plugins.filterIsInstance<Callback>() private val callbacks = plugins.filterIsInstance<Callback>()
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
val callback = object : RoomSelectEntryPoint.Callback {
override fun onRoomSelected(roomIds: List<RoomId>) {
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<RoomId>) { private fun onSucceeded(roomIds: ImmutableList<RoomId>) {
navigateUp() navigateUp()
if (roomIds.size == 1) { if (roomIds.size == 1) {
@ -55,15 +107,4 @@ class ForwardMessagesNode @AssistedInject constructor(
callbacks.forEach { it.onForwardedToSingleRoom(targetRoomId) } callbacks.forEach { it.onForwardedToSingleRoom(targetRoomId) }
} }
} }
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
ForwardMessagesView(
state = state,
onDismiss = ::navigateUp,
onForwardingSucceeded = ::onSucceeded,
modifier = modifier
)
}
} }

56
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 package io.element.android.features.messages.impl.forward
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter 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.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId 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.core.RoomId
import io.element.android.libraries.matrix.api.room.MatrixRoom 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.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -57,62 +50,25 @@ class ForwardMessagesPresenter @AssistedInject constructor(
fun create(eventId: String): ForwardMessagesPresenter fun create(eventId: String): ForwardMessagesPresenter
} }
@Composable private val forwardingActionState: MutableState<Async<ImmutableList<RoomId>>> = mutableStateOf(Async.Uninitialized)
override fun present(): ForwardMessagesState {
var selectedRooms by remember { mutableStateOf(persistentListOf<RoomSummaryDetails>()) }
var query by remember { mutableStateOf<String>("") }
var isSearchActive by remember { mutableStateOf(false) }
var results: SearchBarResultState<ImmutableList<RoomSummaryDetails>> by remember { mutableStateOf(SearchBarResultState.NotSearching()) }
val forwardingActionState: MutableState<Async<ImmutableList<RoomId>>> = remember { mutableStateOf(Async.Uninitialized) }
val summaries by client.roomListService.allRooms.summaries.collectAsState() fun onRoomSelected(roomIds: List<RoomId>) {
matrixCoroutineScope.forwardEvent(eventId, roomIds.toPersistentList(), forwardingActionState)
LaunchedEffect(query, summaries) { }
val filteredSummaries = summaries.filterIsInstance<RoomSummary.Filled>()
.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()
}
}
@Composable
override fun present(): ForwardMessagesState {
val forwardingSucceeded by remember { val forwardingSucceeded by remember {
derivedStateOf { forwardingActionState.value.dataOrNull() } derivedStateOf { forwardingActionState.value.dataOrNull() }
} }
fun handleEvents(event: ForwardMessagesEvents) { fun handleEvents(event: ForwardMessagesEvents) {
when (event) { 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 ForwardMessagesEvents.ClearError -> forwardingActionState.value = Async.Uninitialized
} }
} }
return ForwardMessagesState( return ForwardMessagesState(
resultState = results,
query = query,
isSearchActive = isSearchActive,
selectedRooms = selectedRooms,
isForwarding = forwardingActionState.value.isLoading(), isForwarding = forwardingActionState.value.isLoading(),
error = (forwardingActionState.value as? Async.Failure)?.error, error = (forwardingActionState.value as? Async.Failure)?.error,
forwardingSucceeded = forwardingSucceeded, forwardingSucceeded = forwardingSucceeded,

7
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 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.core.RoomId
import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
data class ForwardMessagesState( data class ForwardMessagesState(
val resultState: SearchBarResultState<ImmutableList<RoomSummaryDetails>>, // TODO Migrate to an Async
val query: String,
val isSearchActive: Boolean,
val selectedRooms: ImmutableList<RoomSummaryDetails>,
val isForwarding: Boolean, val isForwarding: Boolean,
val error: Throwable?, val error: Throwable?,
val forwardingSucceeded: ImmutableList<RoomId>?, val forwardingSucceeded: ImmutableList<RoomId>?,

34
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 package io.element.android.features.messages.impl.forward
import androidx.compose.ui.tooling.preview.PreviewParameterProvider 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.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomMember 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.room.message.RoomMessage
@ -29,38 +28,13 @@ open class ForwardMessagesStateProvider : PreviewParameterProvider<ForwardMessag
override val values: Sequence<ForwardMessagesState> override val values: Sequence<ForwardMessagesState>
get() = sequenceOf( get() = sequenceOf(
aForwardMessagesState(), aForwardMessagesState(),
aForwardMessagesState(query = "Test", isSearchActive = true),
aForwardMessagesState(resultState = SearchBarResultState.Results(aForwardMessagesRoomList())),
aForwardMessagesState( 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, isForwarding = true,
), ),
aForwardMessagesState( aForwardMessagesState(
resultState = SearchBarResultState.Results(aForwardMessagesRoomList()),
query = "Test",
isSearchActive = true,
selectedRooms = persistentListOf(aRoomDetailsState(roomId = RoomId("!room2:domain"))),
forwardingSucceeded = persistentListOf(RoomId("!room2:domain")), forwardingSucceeded = persistentListOf(RoomId("!room2:domain")),
), ),
aForwardMessagesState( aForwardMessagesState(
resultState = SearchBarResultState.Results(aForwardMessagesRoomList()),
query = "Test",
isSearchActive = true,
selectedRooms = persistentListOf(aRoomDetailsState(roomId = RoomId("!room2:domain"))),
error = Throwable("error"), error = Throwable("error"),
), ),
// Add other states here // Add other states here
@ -68,18 +42,10 @@ open class ForwardMessagesStateProvider : PreviewParameterProvider<ForwardMessag
} }
fun aForwardMessagesState( fun aForwardMessagesState(
resultState: SearchBarResultState<ImmutableList<RoomSummaryDetails>> = SearchBarResultState.NotSearching(),
query: String = "",
isSearchActive: Boolean = false,
selectedRooms: ImmutableList<RoomSummaryDetails> = persistentListOf(),
isForwarding: Boolean = false, isForwarding: Boolean = false,
error: Throwable? = null, error: Throwable? = null,
forwardingSucceeded: ImmutableList<RoomId>? = null, forwardingSucceeded: ImmutableList<RoomId>? = null,
) = ForwardMessagesState( ) = ForwardMessagesState(
resultState = resultState,
query = query,
isSearchActive = isSearchActive,
selectedRooms = selectedRooms,
isForwarding = isForwarding, isForwarding = isForwarding,
error = error, error = error,
forwardingSucceeded = forwardingSucceeded, forwardingSucceeded = forwardingSucceeded,

233
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 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.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.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.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.ErrorDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialogDefaults import io.element.android.libraries.designsystem.components.dialogs.ErrorDialogDefaults
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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.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 import kotlinx.collections.immutable.ImmutableList
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun ForwardMessagesView( fun ForwardMessagesView(
state: ForwardMessagesState, state: ForwardMessagesState,
onDismiss: () -> Unit,
onForwardingSucceeded: (ImmutableList<RoomId>) -> Unit, onForwardingSucceeded: (ImmutableList<RoomId>) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -81,193 +38,16 @@ fun ForwardMessagesView(
return return
} }
@Suppress("UNUSED_PARAMETER") if (state.isForwarding) {
fun onRoomRemoved(roomSummaryDetails: RoomSummaryDetails) { ProgressDialog(modifier)
// TODO toggle selection when multi-selection is enabled
state.eventSink(ForwardMessagesEvents.RemoveSelectedRoom)
} }
@Composable if (state.error != null) {
fun SelectedRoomsHelper(isForwarding: Boolean, selectedRooms: ImmutableList<RoomSummaryDetails>) { ForwardingErrorDialog(
if (isForwarding) return modifier = modifier,
SelectedRooms( onDismiss = { state.eventSink(ForwardMessagesEvents.ClearError) },
selectedRooms = selectedRooms,
onRoomRemoved = ::onRoomRemoved,
modifier = Modifier.padding(vertical = 16.dp)
) )
} }
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<RoomSummaryDetails>,
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 @Composable
@ -284,7 +64,6 @@ private fun ForwardingErrorDialog(onDismiss: () -> Unit, modifier: Modifier = Mo
internal fun ForwardMessagesViewPreview(@PreviewParameter(ForwardMessagesStateProvider::class) state: ForwardMessagesState) = ElementPreview { internal fun ForwardMessagesViewPreview(@PreviewParameter(ForwardMessagesStateProvider::class) state: ForwardMessagesState) = ElementPreview {
ForwardMessagesView( ForwardMessagesView(
state = state, state = state,
onDismiss = {},
onForwardingSucceeded = {} onForwardingSucceeded = {}
) )
} }

95
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.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat 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.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.AN_EVENT_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient 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.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomSummaryDetail 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 io.element.android.tests.testutils.WarmUpRule
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
@ -40,7 +36,6 @@ class ForwardMessagesPresenterTests {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val presenter = aPresenter() val presenter = aPresenter()
@ -48,75 +43,23 @@ class ForwardMessagesPresenterTests {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() 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.isForwarding).isFalse()
assertThat(initialState.error).isNull() assertThat(initialState.error).isNull()
assertThat(initialState.forwardingSucceeded).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 @Test
fun `present - update query`() = runTest { fun `present - forward successful`() = 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 {
val presenter = aPresenter() val presenter = aPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem()
skipItems(1) skipItems(1)
val summary = aRoomSummaryDetail() val summary = aRoomSummaryDetail()
presenter.onRoomSelected(listOf(summary.roomId))
initialState.eventSink(ForwardMessagesEvents.SetSelectedRoom(summary))
awaitItem()
// Test successful forwarding
initialState.eventSink(ForwardMessagesEvents.ForwardEvent)
val forwardingState = awaitItem() val forwardingState = awaitItem()
assertThat(forwardingState.isSearchActive).isFalse()
assertThat(forwardingState.isForwarding).isTrue() assertThat(forwardingState.isForwarding).isTrue()
val successfulForwardState = awaitItem() val successfulForwardState = awaitItem()
assertThat(successfulForwardState.isForwarding).isFalse() assertThat(successfulForwardState.isForwarding).isFalse()
assertThat(successfulForwardState.forwardingSucceeded).isNotNull() assertThat(successfulForwardState.forwardingSucceeded).isNotNull()
@ -130,46 +73,20 @@ class ForwardMessagesPresenterTests {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem()
skipItems(1)
val summary = aRoomSummaryDetail()
initialState.eventSink(ForwardMessagesEvents.SetSelectedRoom(summary))
awaitItem()
// Test failed forwarding // Test failed forwarding
room.givenForwardEventResult(Result.failure(Throwable("error"))) room.givenForwardEventResult(Result.failure(Throwable("error")))
initialState.eventSink(ForwardMessagesEvents.ForwardEvent)
skipItems(1) skipItems(1)
val summary = aRoomSummaryDetail()
presenter.onRoomSelected(listOf(summary.roomId))
skipItems(1)
val failedForwardState = awaitItem() val failedForwardState = awaitItem()
assertThat(failedForwardState.isForwarding).isFalse()
assertThat(failedForwardState.error).isNotNull() assertThat(failedForwardState.error).isNotNull()
// Then clear error // Then clear error
initialState.eventSink(ForwardMessagesEvents.ClearError) failedForwardState.eventSink(ForwardMessagesEvents.ClearError)
assertThat(awaitItem().error).isNull() 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( private fun CoroutineScope.aPresenter(
eventId: EventId = AN_EVENT_ID, eventId: EventId = AN_EVENT_ID,
fakeMatrixRoom: FakeMatrixRoom = FakeMatrixRoom(), fakeMatrixRoom: FakeMatrixRoom = FakeMatrixRoom(),

27
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)
}

42
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<RoomId>)
fun onCancel()
}
}

21
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,
}

52
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)
}

50
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<Plugin>()
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<RoomSelectNode>(buildContext, plugins)
}
}
}
}

28
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
}

67
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<Plugin>,
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<RoomSelectEntryPoint.Callback>()
private fun onDismiss() {
callbacks.forEach { it.onCancel() }
}
private fun onSubmit(roomIds: List<RoomId>) {
callbacks.forEach { it.onRoomSelected(roomIds) }
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
RoomSelectView(
state = state,
onDismiss = ::onDismiss,
onSubmit = ::onSubmit,
modifier = modifier
)
}
}

98
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<RoomSelectState> {
@AssistedFactory
interface Factory {
fun create(mode: RoomSelectMode): RoomSelectPresenter
}
@Composable
override fun present(): RoomSelectState {
var selectedRooms by remember { mutableStateOf(persistentListOf<RoomSummaryDetails>()) }
var query by remember { mutableStateOf("") }
var isSearchActive by remember { mutableStateOf(false) }
var results: SearchBarResultState<ImmutableList<RoomSummaryDetails>> by remember { mutableStateOf(SearchBarResultState.NotSearching()) }
val summaries by client.roomListService.allRooms.summaries.collectAsState()
LaunchedEffect(query, summaries) {
val filteredSummaries = summaries.filterIsInstance<RoomSummary.Filled>()
.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) }
)
}
}

31
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<ImmutableList<RoomSummaryDetails>>,
val query: String,
val isSearchActive: Boolean,
val selectedRooms: ImmutableList<RoomSummaryDetails>,
val eventSink: (RoomSelectEvents) -> Unit
)

89
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<RoomSelectState> {
override val values: Sequence<RoomSelectState>
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<ImmutableList<RoomSummaryDetails>> = SearchBarResultState.NotSearching(),
query: String = "",
isSearchActive: Boolean = false,
selectedRooms: ImmutableList<RoomSummaryDetails> = 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,
)

267
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<RoomId>) -> 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<RoomSummaryDetails>) {
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<RoomSummaryDetails>,
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 = {},
)
}

117
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,
)
}

1
plugins/src/main/kotlin/extension/DependencyHandleScope.kt

@ -102,6 +102,7 @@ fun DependencyHandlerScope.allLibrariesImpl() {
implementation(project(":libraries:mediaupload:impl")) implementation(project(":libraries:mediaupload:impl"))
implementation(project(":libraries:usersearch:impl")) implementation(project(":libraries:usersearch:impl"))
implementation(project(":libraries:textcomposer:impl")) implementation(project(":libraries:textcomposer:impl"))
implementation(project(":libraries:roomselect:impl"))
implementation(project(":libraries:cryptography:impl")) implementation(project(":libraries:cryptography:impl"))
implementation(project(":libraries:voicerecorder:impl")) implementation(project(":libraries:voicerecorder:impl"))
implementation(project(":libraries:mediaplayer:impl")) implementation(project(":libraries:mediaplayer:impl"))

Loading…
Cancel
Save