Iterate on space "Add existing rooms" logic and ui

This commit is contained in:
ganfra
2026-01-21 17:33:17 +01:00
parent 9fe7c50972
commit ca1d98928d
6 changed files with 106 additions and 91 deletions

View File

@@ -10,11 +10,11 @@ package io.element.android.features.space.impl.addroom
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
sealed interface AddRoomToSpaceEvents {
data class ToggleRoom(val room: SelectRoomInfo) : AddRoomToSpaceEvents
data class UpdateSearchQuery(val query: String) : AddRoomToSpaceEvents
data class OnSearchActiveChanged(val active: Boolean) : AddRoomToSpaceEvents
data object Save : AddRoomToSpaceEvents
data object CloseSearch : AddRoomToSpaceEvents
data object ClearError : AddRoomToSpaceEvents
sealed interface AddRoomToSpaceEvent {
data class ToggleRoom(val room: SelectRoomInfo) : AddRoomToSpaceEvent
data class UpdateSearchQuery(val query: String) : AddRoomToSpaceEvent
data class OnSearchActiveChanged(val active: Boolean) : AddRoomToSpaceEvent
data object Save : AddRoomToSpaceEvent
data object CloseSearch : AddRoomToSpaceEvent
data object ResetSaveAction : AddRoomToSpaceEvent
}

View File

@@ -9,6 +9,8 @@
package io.element.android.features.space.impl.addroom
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
@@ -17,6 +19,7 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.space.impl.di.SpaceFlowScope
import io.element.android.libraries.architecture.appyx.launchMolecule
import io.element.android.libraries.architecture.callback
@ContributesNode(SpaceFlowScope::class)
@@ -31,10 +34,11 @@ class AddRoomToSpaceNode(
}
private val callback: Callback = callback()
private val stateFlow = launchMolecule { presenter.present() }
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val state by stateFlow.collectAsState()
AddRoomToSpaceView(
state = state,
onBackClick = ::navigateUp,

View File

@@ -16,7 +16,6 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
@@ -25,34 +24,22 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.recent.getRecentRooms
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
import io.element.android.libraries.matrix.api.spaces.SpaceService
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
private const val MAX_SUGGESTIONS_COUNT = 5
@Inject
class AddRoomToSpacePresenter(
private val spaceRoomList: SpaceRoomList,
private val dataSource: AddRoomToSpaceSearchDataSource,
private val spaceService: SpaceService,
private val matrixClient: MatrixClient,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
) : Presenter<AddRoomToSpaceState> {
@@ -63,74 +50,56 @@ class AddRoomToSpacePresenter(
var isSearchActive by remember { mutableStateOf(false) }
val saveAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
// Load data source
LaunchedEffect(Unit) { dataSource.load() }
// Update search query in data source
LaunchedEffect(searchQuery) {
dataSource.setSearchQuery(searchQuery)
}
// Get rooms already in space
val spaceChildrenIds by remember {
spaceRoomList.spaceRoomsFlow.map { spaceChildren ->
spaceChildren.map { it.roomId }
}
}.collectAsState(initial = emptyList())
// Suggestions from recently visited rooms (excluding DMs, spaces, and rooms already in space)
val suggestions by produceState(persistentListOf(), spaceChildrenIds) {
value = matrixClient
.getRecentRooms { info ->
!info.isSpace && !info.isDm && info.currentUserMembership == CurrentUserMembership.JOINED
}
.take(MAX_SUGGESTIONS_COUNT)
.map { info -> info.toSelectRoomInfo() }
.toList()
.toImmutableList()
LaunchedEffect(isSearchActive) {
dataSource.setIsActive(isSearchActive)
}
val allRooms by dataSource.roomInfoList.collectAsState(initial = persistentListOf())
val suggestions by dataSource.suggestions.collectAsState(initial = persistentListOf())
val filteredRooms by dataSource.roomInfoList.collectAsState(initial = persistentListOf())
val searchResults by remember<State<SearchBarResultState<ImmutableList<SelectRoomInfo>>>> {
derivedStateOf {
val filtered = allRooms.filterNot { it.roomId in spaceChildrenIds }
when {
filtered.isNotEmpty() -> SearchBarResultState.Results(filtered.toImmutableList())
filteredRooms.isNotEmpty() -> SearchBarResultState.Results(filteredRooms)
isSearchActive && searchQuery.isNotEmpty() -> SearchBarResultState.NoResultsFound()
else -> SearchBarResultState.Initial()
}
}
}
fun handleEvent(event: AddRoomToSpaceEvents) {
fun handleEvent(event: AddRoomToSpaceEvent) {
when (event) {
is AddRoomToSpaceEvents.ToggleRoom -> {
is AddRoomToSpaceEvent.ToggleRoom -> {
selectedRooms = if (selectedRooms.any { it.roomId == event.room.roomId }) {
selectedRooms.filterNot { it.roomId == event.room.roomId }.toPersistentList()
} else {
(selectedRooms + event.room).toPersistentList()
}
}
is AddRoomToSpaceEvents.UpdateSearchQuery -> {
is AddRoomToSpaceEvent.UpdateSearchQuery -> {
searchQuery = event.query
}
is AddRoomToSpaceEvents.OnSearchActiveChanged -> {
is AddRoomToSpaceEvent.OnSearchActiveChanged -> {
isSearchActive = event.active
if (!event.active) {
searchQuery = ""
}
}
AddRoomToSpaceEvents.CloseSearch -> {
AddRoomToSpaceEvent.CloseSearch -> {
isSearchActive = false
searchQuery = ""
}
AddRoomToSpaceEvents.Save -> {
AddRoomToSpaceEvent.Save -> {
sessionCoroutineScope.addRoomsToSpace(
selectedRooms = selectedRooms,
addAction = saveAction,
)
}
AddRoomToSpaceEvents.ClearError -> {
AddRoomToSpaceEvent.ResetSaveAction -> {
saveAction.value = AsyncAction.Uninitialized
}
}

View File

@@ -10,31 +10,42 @@ package io.element.android.features.space.impl.addroom
import dev.zacsweers.metro.Inject
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.recent.getRecentRooms
import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.loadAllIncrementally
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
private const val PAGE_SIZE = 30
private const val MAX_SUGGESTIONS_COUNT = 5
/**
* DataSource for rooms that can be added to a space.
* Filters out DMs, spaces, and only includes rooms the user has joined.
* Filters out DMs, spaces, rooms already in the space, and only includes rooms the user has joined.
*/
@Inject
class AddRoomToSpaceSearchDataSource(
roomListService: RoomListService,
coroutineDispatchers: CoroutineDispatchers,
spaceRoomList: SpaceRoomList,
private val matrixClient: MatrixClient,
private val coroutineDispatchers: CoroutineDispatchers,
) {
private val roomList = roomListService.createRoomList(
pageSize = PAGE_SIZE,
@@ -42,22 +53,42 @@ class AddRoomToSpaceSearchDataSource(
source = RoomList.Source.All,
)
val roomInfoList: Flow<ImmutableList<SelectRoomInfo>> = roomList.filteredSummaries
.map { roomSummaries ->
roomSummaries
.filter {
it.info.currentUserMembership == CurrentUserMembership.JOINED &&
!it.info.isDm &&
!it.info.isSpace
}
.distinctBy { it.roomId }
.map { roomSummary -> roomSummary.toSelectRoomInfo() }
.toImmutableList()
}
.flowOn(coroutineDispatchers.computation)
private val spaceChildrenFlow = spaceRoomList.spaceRoomsFlow.map { spaceChildren ->
spaceChildren.map { it.roomId }.toSet()
}
suspend fun load() = coroutineScope {
roomList.loadAllIncrementally(this)
private val filterRoomPredicate: (RoomInfo, Set<RoomId>) -> Boolean = { info, childIds ->
!info.isSpace &&
!info.isDm &&
info.currentUserMembership == CurrentUserMembership.JOINED &&
info.id !in childIds
}
val roomInfoList: Flow<ImmutableList<SelectRoomInfo>> = combine(
roomList.filteredSummaries,
spaceChildrenFlow,
) { roomSummaries, childIds ->
roomSummaries
.filter { filterRoomPredicate(it.info, childIds) }
.map { it.toSelectRoomInfo() }
.toImmutableList()
}.flowOn(coroutineDispatchers.computation)
val suggestions: Flow<ImmutableList<SelectRoomInfo>> = spaceChildrenFlow.map { childIds ->
matrixClient
.getRecentRooms { filterRoomPredicate(it, childIds) }
.take(MAX_SUGGESTIONS_COUNT)
.map { it.toSelectRoomInfo() }
.toList()
.toImmutableList()
}.flowOn(coroutineDispatchers.computation)
suspend fun setIsActive(isActive: Boolean) = coroutineScope {
if (isActive) {
roomList.loadAllIncrementally(this)
} else {
roomList.reset()
}
}
suspend fun setSearchQuery(searchQuery: String) {

View File

@@ -20,7 +20,7 @@ data class AddRoomToSpaceState(
val selectedRooms: ImmutableList<SelectRoomInfo>,
val suggestions: ImmutableList<SelectRoomInfo>,
val saveAction: AsyncAction<Unit>,
val eventSink: (AddRoomToSpaceEvents) -> Unit,
val eventSink: (AddRoomToSpaceEvent) -> Unit,
) {
val canSave: Boolean = selectedRooms.isNotEmpty() && !saveAction.isLoading()
}

View File

@@ -28,11 +28,10 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
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.list.AvatarListItem
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -58,12 +57,12 @@ fun AddRoomToSpaceView(
modifier: Modifier = Modifier,
) {
fun onRoomRemoved(roomInfo: SelectRoomInfo) {
state.eventSink(AddRoomToSpaceEvents.ToggleRoom(roomInfo))
state.eventSink(AddRoomToSpaceEvent.ToggleRoom(roomInfo))
}
fun onBack() {
if (state.isSearchActive) {
state.eventSink(AddRoomToSpaceEvents.CloseSearch)
state.eventSink(AddRoomToSpaceEvent.CloseSearch)
} else {
onBackClick()
}
@@ -90,7 +89,7 @@ fun AddRoomToSpaceView(
TextButton(
text = stringResource(CommonStrings.action_save),
enabled = state.canSave,
onClick = { state.eventSink(AddRoomToSpaceEvents.Save) }
onClick = { state.eventSink(AddRoomToSpaceEvent.Save) }
)
}
)
@@ -105,9 +104,9 @@ fun AddRoomToSpaceView(
modifier = Modifier.fillMaxWidth(),
placeHolderTitle = stringResource(CommonStrings.action_search),
query = state.searchQuery,
onQueryChange = { state.eventSink(AddRoomToSpaceEvents.UpdateSearchQuery(it)) },
onQueryChange = { state.eventSink(AddRoomToSpaceEvent.UpdateSearchQuery(it)) },
active = state.isSearchActive,
onActiveChange = { state.eventSink(AddRoomToSpaceEvents.OnSearchActiveChanged(it)) },
onActiveChange = { state.eventSink(AddRoomToSpaceEvent.OnSearchActiveChanged(it)) },
resultState = state.searchResults,
showBackButton = false,
contentPrefix = {
@@ -125,7 +124,7 @@ fun AddRoomToSpaceView(
RoomListItem(
roomInfo = roomInfo,
isSelected = state.selectedRooms.any { it.roomId == roomInfo.roomId },
onToggle = { state.eventSink(AddRoomToSpaceEvents.ToggleRoom(it)) }
onToggle = { state.eventSink(AddRoomToSpaceEvent.ToggleRoom(it)) }
)
}
}
@@ -154,7 +153,7 @@ fun AddRoomToSpaceView(
RoomListItem(
roomInfo = roomInfo,
isSelected = state.selectedRooms.any { it.roomId == roomInfo.roomId },
onToggle = { state.eventSink(AddRoomToSpaceEvents.ToggleRoom(it)) }
onToggle = { state.eventSink(AddRoomToSpaceEvent.ToggleRoom(it)) }
)
}
}
@@ -162,19 +161,31 @@ fun AddRoomToSpaceView(
}
}
}
SaveActionView(
saveAction = state.saveAction,
onRetry = { state.eventSink(AddRoomToSpaceEvent.Save)},
onDismiss = {state.eventSink(AddRoomToSpaceEvent.ResetSaveAction)}
)
}
// Loading dialog
if (state.saveAction.isLoading()) {
ProgressDialog()
}
// Error dialog
if (state.saveAction.isFailure()) {
ErrorDialog(
content = stringResource(CommonStrings.error_unknown),
onSubmit = { state.eventSink(AddRoomToSpaceEvents.ClearError) },
)
}
@Composable
private fun SaveActionView(
saveAction: AsyncAction<Unit>,
onRetry: () -> Unit,
onDismiss: () -> Unit,
) {
AsyncActionView(
async = saveAction,
onRetry = onRetry,
errorTitle = {
stringResource(CommonStrings.common_something_went_wrong)
},
errorMessage = {
stringResource(CommonStrings.error_network_or_server_issue)
},
onSuccess = { onDismiss() },
onErrorDismiss = onDismiss,
)
}
@Composable