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 import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
sealed interface AddRoomToSpaceEvents { sealed interface AddRoomToSpaceEvent {
data class ToggleRoom(val room: SelectRoomInfo) : AddRoomToSpaceEvents data class ToggleRoom(val room: SelectRoomInfo) : AddRoomToSpaceEvent
data class UpdateSearchQuery(val query: String) : AddRoomToSpaceEvents data class UpdateSearchQuery(val query: String) : AddRoomToSpaceEvent
data class OnSearchActiveChanged(val active: Boolean) : AddRoomToSpaceEvents data class OnSearchActiveChanged(val active: Boolean) : AddRoomToSpaceEvent
data object Save : AddRoomToSpaceEvents data object Save : AddRoomToSpaceEvent
data object CloseSearch : AddRoomToSpaceEvents data object CloseSearch : AddRoomToSpaceEvent
data object ClearError : AddRoomToSpaceEvents data object ResetSaveAction : AddRoomToSpaceEvent
} }

View file

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

View file

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

View file

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

View file

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