Merge pull request #6099 from element-hq/feature/fga/space_management_iteration

Improve space management with pagination and partial failure handling
This commit is contained in:
ganfra 2026-01-29 10:57:14 +01:00 committed by GitHub
commit 3d75b92ec3
18 changed files with 339 additions and 107 deletions

View file

@ -14,6 +14,7 @@ import android.os.Parcelable
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.lifecycle.subscribe
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
@ -40,6 +41,7 @@ import io.element.android.libraries.di.RoomScope
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.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.spaces.SpaceService import io.element.android.libraries.matrix.api.spaces.SpaceService
import io.element.android.libraries.matrix.api.spaces.loadAllIncrementally
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
@ -83,6 +85,9 @@ class SpaceFlowNode(
override fun onBuilt() { override fun onBuilt() {
super.onBuilt() super.onBuilt()
lifecycle.subscribe( lifecycle.subscribe(
onCreate = {
spaceRoomList.loadAllIncrementally(lifecycleScope)
},
onDestroy = { onDestroy = {
spaceRoomList.destroy() spaceRoomList.destroy()
} }
@ -161,7 +166,7 @@ class SpaceFlowNode(
buildContext = buildContext, buildContext = buildContext,
callback = callback, callback = callback,
) )
.setParentSpace(spaceRoomList.roomId) .setParentSpace(spaceRoomList.spaceId)
.build() .build()
} }
NavTarget.AddRoom -> { NavTarget.AddRoom -> {

View file

@ -14,4 +14,5 @@ sealed interface AddRoomToSpaceEvent {
data class OnSearchActiveChanged(val active: Boolean) : AddRoomToSpaceEvent data class OnSearchActiveChanged(val active: Boolean) : AddRoomToSpaceEvent
data object Save : AddRoomToSpaceEvent data object Save : AddRoomToSpaceEvent
data object ResetSaveAction : AddRoomToSpaceEvent data object ResetSaveAction : AddRoomToSpaceEvent
data object Dismiss : AddRoomToSpaceEvent
} }

View file

@ -40,7 +40,7 @@ class AddRoomToSpaceNode(
val state by stateFlow.collectAsState() val state by stateFlow.collectAsState()
AddRoomToSpaceView( AddRoomToSpaceView(
state = state, state = state,
onBackClick = ::navigateUp, onBackClick = callback::onFinish,
onRoomsAdded = callback::onFinish, onRoomsAdded = callback::onFinish,
modifier = modifier modifier = modifier
) )

View file

@ -12,7 +12,6 @@ import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState 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
@ -25,6 +24,7 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter 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.matrix.api.core.RoomId
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
@ -45,9 +45,11 @@ class AddRoomToSpacePresenter(
@Composable @Composable
override fun present(): AddRoomToSpaceState { override fun present(): AddRoomToSpaceState {
var selectedRooms: ImmutableList<SelectRoomInfo> by remember { mutableStateOf(persistentListOf()) } var selectedRooms: ImmutableList<SelectRoomInfo> by remember { mutableStateOf(persistentListOf()) }
var searchQuery = rememberTextFieldState() val searchQuery = rememberTextFieldState()
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) }
// Track whether any rooms were added (for conditional reset on Dismiss)
var hasAddedRooms by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val dataSource = remember { dataSourceFactory.create(coroutineScope) } val dataSource = remember { dataSourceFactory.create(coroutineScope) }
@ -63,12 +65,12 @@ class AddRoomToSpacePresenter(
val suggestions by dataSource.suggestions.collectAsState(initial = persistentListOf()) val suggestions by dataSource.suggestions.collectAsState(initial = persistentListOf())
val filteredRooms by dataSource.roomInfoList.collectAsState(initial = persistentListOf()) val filteredRooms by dataSource.roomInfoList.collectAsState(initial = persistentListOf())
val searchResults by remember<State<SearchBarResultState<ImmutableList<SelectRoomInfo>>>> { val searchResults by remember {
derivedStateOf { derivedStateOf {
when { when {
filteredRooms.isNotEmpty() -> SearchBarResultState.Results(filteredRooms) filteredRooms.isNotEmpty() -> SearchBarResultState.Results(filteredRooms)
isSearchActive && searchQuery.text.isNotEmpty() -> SearchBarResultState.NoResultsFound() isSearchActive && searchQuery.text.isNotEmpty() -> SearchBarResultState.NoResultsFound<ImmutableList<SelectRoomInfo>>()
else -> SearchBarResultState.Initial() else -> SearchBarResultState.Initial<ImmutableList<SelectRoomInfo>>()
} }
} }
} }
@ -91,12 +93,24 @@ class AddRoomToSpacePresenter(
AddRoomToSpaceEvent.Save -> { AddRoomToSpaceEvent.Save -> {
coroutineScope.addRoomsToSpace( coroutineScope.addRoomsToSpace(
selectedRooms = selectedRooms, selectedRooms = selectedRooms,
addAction = saveAction, dataSource = dataSource,
saveAction = saveAction,
onPartialSuccess = { successfullyAdded ->
if (successfullyAdded.isNotEmpty()) {
hasAddedRooms = true
}
selectedRooms = selectedRooms.filterNot { it.roomId in successfullyAdded }.toImmutableList()
},
) )
} }
AddRoomToSpaceEvent.ResetSaveAction -> { AddRoomToSpaceEvent.ResetSaveAction -> {
saveAction.value = AsyncAction.Uninitialized saveAction.value = AsyncAction.Uninitialized
} }
AddRoomToSpaceEvent.Dismiss -> {
if (hasAddedRooms) {
coroutineScope.launch { spaceRoomList.reset() }
}
}
} }
} }
@ -113,21 +127,30 @@ class AddRoomToSpacePresenter(
private fun CoroutineScope.addRoomsToSpace( private fun CoroutineScope.addRoomsToSpace(
selectedRooms: ImmutableList<SelectRoomInfo>, selectedRooms: ImmutableList<SelectRoomInfo>,
addAction: MutableState<AsyncAction<Unit>>, dataSource: AddRoomToSpaceSearchDataSource,
saveAction: MutableState<AsyncAction<Unit>>,
onPartialSuccess: (Set<RoomId>) -> Unit,
) = launch { ) = launch {
addAction.runUpdatingState { saveAction.runUpdatingState {
val results = selectedRooms.map { selectedRoom -> val spaceId = spaceRoomList.spaceId
val successfullyAdded = mutableSetOf<RoomId>()
val results = selectedRooms.map { room ->
async { async {
spaceService.addChildToSpace( spaceService.addChildToSpace(
spaceId = spaceRoomList.roomId, spaceId = spaceId,
childId = selectedRoom.roomId, childId = room.roomId,
) ).onSuccess { successfullyAdded.add(room.roomId) }
} }
}.awaitAll() }.awaitAll()
val anyFailure = results.any { it.isFailure } val anyFailure = results.any { it.isFailure }
if (anyFailure) { if (anyFailure) {
// On partial success, mark added rooms in data source and update selection
dataSource.markAsAdded(successfullyAdded)
onPartialSuccess(successfullyAdded)
Result.failure(Exception("Failed to add some rooms")) Result.failure(Exception("Failed to add some rooms"))
} else { } else {
// On full success, refresh the space room list
spaceRoomList.reset()
Result.success(Unit) Result.success(Unit)
} }
} }

View file

@ -29,6 +29,7 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine 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
@ -48,7 +49,7 @@ class AddRoomToSpaceSearchDataSource(
roomListService: RoomListService, roomListService: RoomListService,
spaceRoomList: SpaceRoomList, spaceRoomList: SpaceRoomList,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val coroutineDispatchers: CoroutineDispatchers, coroutineDispatchers: CoroutineDispatchers,
) { ) {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
@ -62,33 +63,49 @@ class AddRoomToSpaceSearchDataSource(
coroutineScope = coroutineScope, coroutineScope = coroutineScope,
) )
private val spaceChildrenFlow = spaceRoomList.spaceRoomsFlow.map { spaceChildren -> private val spaceChildrenFlow = spaceRoomList.spaceRoomsFlow.map { rooms ->
spaceChildren.map { it.roomId }.toSet() rooms.map { it.roomId }.toSet()
} }
private val filterRoomPredicate: (RoomInfo, Set<RoomId>) -> Boolean = { info, childIds -> // Track locally added rooms for partial success handling.
// These rooms will be filtered out from search results and suggestions.
private val addedRoomIdsFlow = MutableStateFlow<Set<RoomId>>(emptySet())
/**
* Marks rooms as added to the space (for partial success handling).
*/
fun markAsAdded(roomIds: Set<RoomId>) {
addedRoomIdsFlow.value += roomIds
}
private val filterRoomPredicate: (RoomInfo, Set<RoomId>, Set<RoomId>) -> Boolean = { info, childIds, addedIds ->
!info.isSpace && !info.isSpace &&
!info.isDm && !info.isDm &&
info.currentUserMembership == CurrentUserMembership.JOINED && info.currentUserMembership == CurrentUserMembership.JOINED &&
info.id !in childIds info.id !in childIds &&
info.id !in addedIds
} }
val roomInfoList: Flow<ImmutableList<SelectRoomInfo>> = combine( val roomInfoList: Flow<ImmutableList<SelectRoomInfo>> = combine(
roomList.filteredSummaries, roomList.filteredSummaries,
spaceChildrenFlow, spaceChildrenFlow,
) { roomSummaries, childIds -> addedRoomIdsFlow,
) { roomSummaries, childIds, addedIds ->
roomSummaries roomSummaries
.filter { filterRoomPredicate(it.info, childIds) } .filter { filterRoomPredicate(it.info, childIds, addedIds) }
.map { it.toSelectRoomInfo() } .map { it.info.toSelectRoomInfo() }
.toImmutableList() .toImmutableList()
}.flowOn(coroutineDispatchers.computation) }.flowOn(coroutineDispatchers.computation)
val suggestions: Flow<ImmutableList<SelectRoomInfo>> = spaceChildrenFlow.map { childIds -> val suggestions: Flow<ImmutableList<SelectRoomInfo>> = combine(
spaceChildrenFlow,
addedRoomIdsFlow,
) { childIds, addedIds ->
matrixClient matrixClient
.getRecentlyVisitedRoomInfoFlow { filterRoomPredicate(it, childIds) } .getRecentlyVisitedRoomInfoFlow { filterRoomPredicate(it, childIds, addedIds) }
.take(MAX_SUGGESTIONS_COUNT) .take(MAX_SUGGESTIONS_COUNT)
.map { it.toSelectRoomInfo() }
.toList() .toList()
.map { it.toSelectRoomInfo() }
.toImmutableList() .toImmutableList()
}.flowOn(coroutineDispatchers.computation) }.flowOn(coroutineDispatchers.computation)

View file

@ -58,14 +58,15 @@ fun AddRoomToSpaceView(
onRoomsAdded: () -> Unit, onRoomsAdded: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
fun onRoomRemoved(roomInfo: SelectRoomInfo) { fun onRoomToggled(room: SelectRoomInfo) {
state.eventSink(AddRoomToSpaceEvent.ToggleRoom(roomInfo)) state.eventSink(AddRoomToSpaceEvent.ToggleRoom(room))
} }
fun onBack() { fun onBack() {
if (state.isSearchActive) { if (state.isSearchActive) {
state.eventSink(AddRoomToSpaceEvent.OnSearchActiveChanged(false)) state.eventSink(AddRoomToSpaceEvent.OnSearchActiveChanged(false))
} else { } else {
state.eventSink(AddRoomToSpaceEvent.Dismiss)
onBackClick() onBackClick()
} }
} }
@ -114,18 +115,18 @@ fun AddRoomToSpaceView(
if (state.selectedRooms.isNotEmpty()) { if (state.selectedRooms.isNotEmpty()) {
SelectedRoomsRow( SelectedRoomsRow(
selectedRooms = state.selectedRooms, selectedRooms = state.selectedRooms,
onRemoveRoom = ::onRoomRemoved, onRemoveRoom = ::onRoomToggled,
modifier = Modifier.padding(vertical = 16.dp) modifier = Modifier.padding(vertical = 16.dp)
) )
} }
}, },
) { rooms -> ) { rooms ->
LazyColumn { LazyColumn {
items(rooms, key = { it.roomId.value }) { roomInfo -> items(rooms, key = { it.roomId }) { roomInfo ->
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(AddRoomToSpaceEvent.ToggleRoom(it)) } onToggle = ::onRoomToggled
) )
} }
} }
@ -142,7 +143,7 @@ fun AddRoomToSpaceView(
if (state.selectedRooms.isNotEmpty()) { if (state.selectedRooms.isNotEmpty()) {
SelectedRoomsRow( SelectedRoomsRow(
selectedRooms = state.selectedRooms, selectedRooms = state.selectedRooms,
onRemoveRoom = ::onRoomRemoved, onRemoveRoom = ::onRoomToggled,
modifier = Modifier.padding(vertical = 16.dp) modifier = Modifier.padding(vertical = 16.dp)
) )
} }
@ -159,7 +160,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(AddRoomToSpaceEvent.ToggleRoom(it)) } onToggle = ::onRoomToggled
) )
} }
} }
@ -205,8 +206,8 @@ private fun SelectedRoomsRow(
contentPadding = PaddingValues(horizontal = 16.dp), contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(32.dp) horizontalArrangement = Arrangement.spacedBy(32.dp)
) { ) {
items(selectedRooms, key = { it.roomId.value }) { roomInfo -> items(selectedRooms, key = { it.roomId }) { roomInfo ->
SelectedRoom(roomInfo = roomInfo, onRemoveRoom = onRemoveRoom) SelectedRoom(roomInfo = roomInfo, onRemoveRoom = { onRemoveRoom(roomInfo) })
} }
} }
} }

View file

@ -54,7 +54,7 @@ class SpaceNode(
private val callback: Callback = callback() private val callback: Callback = callback()
private fun onShareRoom(context: Context) = lifecycleScope.launch { private fun onShareRoom(context: Context) = lifecycleScope.launch {
matrixClient.getRoom(spaceRoomList.roomId)?.use { room -> matrixClient.getRoom(spaceRoomList.spaceId)?.use { room ->
room.getPermalink() room.getPermalink()
.onSuccess { permalink -> .onSuccess { permalink ->
context.startSharePlainTextIntent( context.startSharePlainTextIntent(

View file

@ -69,7 +69,6 @@ class SpacePresenter(
@Composable @Composable
override fun present(): SpaceState { override fun present(): SpaceState {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
paginate()
spaceRoomList.spaceRoomsFlow.collect { children = it.toImmutableList() } spaceRoomList.spaceRoomsFlow.collect { children = it.toImmutableList() }
} }
@ -111,21 +110,18 @@ class SpacePresenter(
var isManageMode by remember { mutableStateOf(false) } var isManageMode by remember { mutableStateOf(false) }
var selectedRoomIds by remember { mutableStateOf<Set<RoomId>>(emptySet()) } var selectedRoomIds by remember { mutableStateOf<Set<RoomId>>(emptySet()) }
var removeRoomsAction by remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) } var removeRoomsAction by remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
// Track locally removed rooms for partial failure cases
var removedRoomIds by remember { mutableStateOf<Set<RoomId>>(emptySet()) } var removedRoomIds by remember { mutableStateOf<Set<RoomId>>(emptySet()) }
val filteredChildren by remember { val filteredChildren by remember {
derivedStateOf { derivedStateOf {
children val notRemoved = children.filterNot { it.roomId in removedRoomIds }
.filterNot { it.roomId in removedRoomIds } if (isManageMode) {
.let { list -> // In manage mode, only show rooms (not spaces)
if (isManageMode) { notRemoved.filter { !it.isSpace }.toImmutableList()
// In manage mode, only show rooms (not spaces) } else {
list.filter { !it.isSpace } notRemoved.toImmutableList()
} else { }
list
}
}
.toImmutableList()
} }
} }
@ -139,9 +135,20 @@ class SpacePresenter(
val acceptDeclineInviteState = acceptDeclineInvitePresenter.present() val acceptDeclineInviteState = acceptDeclineInvitePresenter.present()
suspend fun exitManageMode(shouldReset: Boolean) {
isManageMode = false
selectedRoomIds = emptySet()
removedRoomIds = emptySet()
if (shouldReset) {
// Reset the space room list to see the updates.
spaceRoomList.reset()
}
}
fun handleEvent(event: SpaceEvents) { fun handleEvent(event: SpaceEvents) {
when (event) { when (event) {
SpaceEvents.LoadMore -> localCoroutineScope.paginate() // SpaceRoomList is loaded automatically as backend is really slow. Event is kept for future.
SpaceEvents.LoadMore -> Unit
is SpaceEvents.Join -> { is SpaceEvents.Join -> {
sessionCoroutineScope.joinRoom(event.spaceRoom, joinActions, setJoinActions) sessionCoroutineScope.joinRoom(event.spaceRoom, joinActions, setJoinActions)
} }
@ -170,8 +177,7 @@ class SpacePresenter(
selectedRoomIds = emptySet() selectedRoomIds = emptySet()
} }
SpaceEvents.ExitManageMode -> { SpaceEvents.ExitManageMode -> {
isManageMode = false localCoroutineScope.launch { exitManageMode(shouldReset = removedRoomIds.isNotEmpty()) }
selectedRoomIds = emptySet()
} }
is SpaceEvents.ToggleRoomSelection -> { is SpaceEvents.ToggleRoomSelection -> {
selectedRoomIds = if (event.roomId in selectedRoomIds) { selectedRoomIds = if (event.roomId in selectedRoomIds) {
@ -186,7 +192,7 @@ class SpacePresenter(
SpaceEvents.ConfirmRoomRemoval -> { SpaceEvents.ConfirmRoomRemoval -> {
localCoroutineScope.launch { localCoroutineScope.launch {
removeRoomsAction = AsyncAction.Loading removeRoomsAction = AsyncAction.Loading
val spaceId = spaceRoomList.roomId val spaceId = spaceRoomList.spaceId
val roomsToRemove = selectedRoomIds.toSet() val roomsToRemove = selectedRoomIds.toSet()
val successfullyRemoved = mutableSetOf<RoomId>() val successfullyRemoved = mutableSetOf<RoomId>()
val results = roomsToRemove.map { roomId -> val results = roomsToRemove.map { roomId ->
@ -196,16 +202,15 @@ class SpacePresenter(
} }
} }
results.awaitAll() results.awaitAll()
if (successfullyRemoved.isNotEmpty()) {
removedRoomIds = removedRoomIds + successfullyRemoved
}
val hasError = successfullyRemoved.size < roomsToRemove.size val hasError = successfullyRemoved.size < roomsToRemove.size
if (hasError) { if (hasError) {
// On partial success, update selection to only keep failed rooms
selectedRoomIds = selectedRoomIds - successfullyRemoved
removedRoomIds = removedRoomIds + successfullyRemoved
removeRoomsAction = AsyncAction.Failure(Exception("Failed to remove some rooms")) removeRoomsAction = AsyncAction.Failure(Exception("Failed to remove some rooms"))
} else { } else {
removeRoomsAction = AsyncAction.Success(Unit) removeRoomsAction = AsyncAction.Success(Unit)
isManageMode = false exitManageMode(shouldReset = true)
selectedRoomIds = emptySet()
} }
} }
} }
@ -246,8 +251,4 @@ class SpacePresenter(
setJoinActions(joinActions + mapOf(spaceRoom.roomId to AsyncAction.Failure(it))) setJoinActions(joinActions + mapOf(spaceRoom.roomId to AsyncAction.Failure(it)))
} }
} }
private fun CoroutineScope.paginate() = launch {
spaceRoomList.paginate()
}
} }

View file

@ -139,6 +139,7 @@ fun SpaceView(
SpaceViewTopBar( SpaceViewTopBar(
spaceInfo = state.spaceInfo, spaceInfo = state.spaceInfo,
canAccessSpaceSettings = state.canAccessSpaceSettings, canAccessSpaceSettings = state.canAccessSpaceSettings,
canEditSpaceGraph = state.canEditSpaceGraph,
showManageRoomsAction = state.showManageRoomsAction, showManageRoomsAction = state.showManageRoomsAction,
onBackClick = onBackClick, onBackClick = onBackClick,
onLeaveSpaceClick = onLeaveSpaceClick, onLeaveSpaceClick = onLeaveSpaceClick,
@ -376,6 +377,7 @@ private fun LoadingMoreIndicator(
private fun SpaceViewTopBar( private fun SpaceViewTopBar(
spaceInfo: RoomInfo, spaceInfo: RoomInfo,
canAccessSpaceSettings: Boolean, canAccessSpaceSettings: Boolean,
canEditSpaceGraph: Boolean,
showManageRoomsAction: Boolean, showManageRoomsAction: Boolean,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onLeaveSpaceClick: () -> Unit, onLeaveSpaceClick: () -> Unit,
@ -416,7 +418,7 @@ private fun SpaceViewTopBar(
expanded = showMenu, expanded = showMenu,
onDismissRequest = { showMenu = false } onDismissRequest = { showMenu = false }
) { ) {
if (showManageRoomsAction) { if (canEditSpaceGraph) {
SpaceMenuItem( SpaceMenuItem(
titleRes = CommonStrings.action_create_room, titleRes = CommonStrings.action_create_room,
icon = CompoundIcons.Plus(), icon = CompoundIcons.Plus(),
@ -433,14 +435,16 @@ private fun SpaceViewTopBar(
onAddRoomClick() onAddRoomClick()
} }
) )
SpaceMenuItem( if (showManageRoomsAction) {
titleRes = CommonStrings.action_manage_rooms, SpaceMenuItem(
icon = CompoundIcons.Edit(), titleRes = CommonStrings.action_manage_rooms,
onClick = { icon = CompoundIcons.Edit(),
showMenu = false onClick = {
onManageRoomsClick() showMenu = false
} onManageRoomsClick()
) }
)
}
HorizontalDivider() HorizontalDivider()
} }
SpaceMenuItem( SpaceMenuItem(

View file

@ -17,14 +17,20 @@ 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.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.aRoomSummary import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList
import io.element.android.libraries.matrix.test.spaces.FakeSpaceService import io.element.android.libraries.matrix.test.spaces.FakeSpaceService
import io.element.android.libraries.matrix.ui.components.aSelectRoomInfo
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
@ -273,9 +279,71 @@ class AddRoomToSpacePresenterTest {
} }
} }
@Test
fun `present - Dismiss without additions does not call reset`() = runTest {
val resetResult = lambdaRecorder<Result<Unit>>(ensureNeverCalled = true) { Result.success(Unit) }
val spaceRoomList = FakeSpaceRoomList(
paginateResult = { Result.success(Unit) },
resetResult = resetResult,
)
val presenter = createAddRoomToSpacePresenter(spaceRoomList = spaceRoomList)
presenter.test {
val state = awaitItem()
state.eventSink(AddRoomToSpaceEvent.Dismiss)
advanceUntilIdle()
// reset should NOT be called since no rooms were added
assert(resetResult).isNeverCalled()
}
}
@Test
fun `present - Dismiss after partial success calls reset`() = runTest {
val resetResult = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val spaceRoomList = FakeSpaceRoomList(
paginateResult = { Result.success(Unit) },
resetResult = resetResult,
)
// Room 1 succeeds, Room 2 fails
val addChildToSpaceResult = lambdaRecorder<RoomId, RoomId, Result<Unit>> { _, childId ->
if (childId == A_ROOM_ID_2) {
Result.failure(AN_EXCEPTION)
} else {
Result.success(Unit)
}
}
val spaceService = FakeSpaceService(
addChildToSpaceResult = addChildToSpaceResult,
)
val presenter = createAddRoomToSpacePresenter(
spaceRoomList = spaceRoomList,
spaceService = spaceService,
)
presenter.test {
val state = awaitItem()
// Select two rooms
val room1 = aSelectRoomInfoList()[0]
val room2 = aSelectRoomInfoList()[1]
state.eventSink(AddRoomToSpaceEvent.ToggleRoom(room1))
awaitItem()
state.eventSink(AddRoomToSpaceEvent.ToggleRoom(room2))
awaitItem()
// Save - partial success (one room added, one failed)
state.eventSink(AddRoomToSpaceEvent.Save)
skipItems(1) // Loading
advanceUntilIdle()
val failureState = expectMostRecentItem()
assertThat(failureState.saveAction).isInstanceOf(AsyncAction.Failure::class.java)
// Dismiss after partial success - reset should be called
failureState.eventSink(AddRoomToSpaceEvent.Dismiss)
advanceUntilIdle()
assert(resetResult).isCalledOnce()
}
}
private fun TestScope.createAddRoomToSpacePresenter( private fun TestScope.createAddRoomToSpacePresenter(
spaceRoomList: FakeSpaceRoomList = FakeSpaceRoomList( spaceRoomList: FakeSpaceRoomList = FakeSpaceRoomList(
paginateResult = { Result.success(Unit) }, paginateResult = { Result.success(Unit) },
resetResult = { Result.success(Unit) },
), ),
spaceService: FakeSpaceService = FakeSpaceService( spaceService: FakeSpaceService = FakeSpaceService(
addChildToSpaceResult = { _, _ -> Result.success(Unit) }, addChildToSpaceResult = { _, _ -> Result.success(Unit) },
@ -301,3 +369,8 @@ class AddRoomToSpacePresenterTest {
) )
} }
} }
private fun aSelectRoomInfoList(): ImmutableList<SelectRoomInfo> = listOf(
aSelectRoomInfo(roomId = A_ROOM_ID, name = "Room 1"),
aSelectRoomInfo(roomId = A_ROOM_ID_2, name = "Room 2"),
).toImmutableList()

View file

@ -32,16 +32,19 @@ class AddRoomToSpaceViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>() @get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `clicking back when search inactive invokes onBackClick`() { fun `clicking back when search inactive emits Dismiss and invokes onBackClick`() {
val eventsRecorder = EventsRecorder<AddRoomToSpaceEvent>()
ensureCalledOnce { ensureCalledOnce {
rule.setAddRoomToSpaceView( rule.setAddRoomToSpaceView(
anAddRoomToSpaceState( anAddRoomToSpaceState(
isSearchActive = false, isSearchActive = false,
eventSink = eventsRecorder,
), ),
onBackClick = it, onBackClick = it,
) )
rule.pressBack() rule.pressBack()
} }
eventsRecorder.assertSingle(AddRoomToSpaceEvent.Dismiss)
} }
@Test @Test

View file

@ -42,13 +42,13 @@ import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList
import io.element.android.libraries.matrix.test.spaces.FakeSpaceService import io.element.android.libraries.matrix.test.spaces.FakeSpaceService
import io.element.android.libraries.previewutils.room.aSpaceRoom import io.element.android.libraries.previewutils.room.aSpaceRoom
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import im.vector.app.features.analytics.plan.JoinedRoom as AnalyticsJoinedRoom import im.vector.app.features.analytics.plan.JoinedRoom as AnalyticsJoinedRoom
@ -56,10 +56,9 @@ import im.vector.app.features.analytics.plan.JoinedRoom as AnalyticsJoinedRoom
class SpacePresenterTest { class SpacePresenterTest {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val paginateResult = lambdaRecorder<Result<Unit>> { val spaceRoomList = FakeSpaceRoomList(
Result.success(Unit) paginateResult = { Result.success(Unit) }
} )
val spaceRoomList = FakeSpaceRoomList(paginateResult = paginateResult)
val presenter = createSpacePresenter(spaceRoomList = spaceRoomList) val presenter = createSpacePresenter(spaceRoomList = spaceRoomList)
presenter.test { presenter.test {
val state = awaitItem() val state = awaitItem()
@ -72,8 +71,6 @@ class SpacePresenterTest {
assertThat(state.acceptDeclineInviteState).isEqualTo(anAcceptDeclineInviteState()) assertThat(state.acceptDeclineInviteState).isEqualTo(anAcceptDeclineInviteState())
assertThat(state.topicViewerState).isEqualTo(TopicViewerState.Hidden) assertThat(state.topicViewerState).isEqualTo(TopicViewerState.Hidden)
assertThat(state.canAccessSpaceSettings).isFalse() assertThat(state.canAccessSpaceSettings).isFalse()
advanceUntilIdle()
paginateResult.assertions().isCalledOnce()
} }
} }
@ -105,19 +102,17 @@ class SpacePresenterTest {
} }
@Test @Test
fun `present - load more`() = runTest { fun `present - load more does nothing`() = runTest {
val paginateResult = lambdaRecorder<Result<Unit>> { // LoadMore event is a no-op as pagination is handled automatically for now as backend is slow.
Result.success(Unit) val spaceRoomList = FakeSpaceRoomList(
} paginateResult = { Result.success(Unit) }
val spaceRoomList = FakeSpaceRoomList(paginateResult = paginateResult) )
val presenter = createSpacePresenter(spaceRoomList = spaceRoomList) val presenter = createSpacePresenter(spaceRoomList = spaceRoomList)
presenter.test { presenter.test {
val state = awaitItem() val state = awaitItem()
advanceUntilIdle() // LoadMore event should not cause any state change
paginateResult.assertions().isCalledOnce()
state.eventSink(SpaceEvents.LoadMore) state.eventSink(SpaceEvents.LoadMore)
advanceUntilIdle() expectNoEvents()
paginateResult.assertions().isCalledExactly(2)
} }
} }
@ -196,7 +191,6 @@ class SpacePresenterTest {
assertThat(joiningState.joinActions[A_ROOM_ID_2]).isEqualTo(AsyncAction.Loading) assertThat(joiningState.joinActions[A_ROOM_ID_2]).isEqualTo(AsyncAction.Loading)
// Let the joinRoom call complete // Let the joinRoom call complete
advanceUntilIdle() advanceUntilIdle()
runCurrent()
// The room is joined // The room is joined
fakeSpaceRoomList.emitSpaceRooms( fakeSpaceRoomList.emitSpaceRooms(
listOf( listOf(
@ -211,7 +205,7 @@ class SpacePresenterTest {
val joinedState = awaitItem() val joinedState = awaitItem()
// Joined room is removed from the join actions // Joined room is removed from the join actions
assertThat(joinedState.joinActions).doesNotContainKey(A_ROOM_ID_2) assertThat(joinedState.joinActions).doesNotContainKey(A_ROOM_ID_2)
joinRoom.assertions().isCalledOnce().with( assert(joinRoom).isCalledOnce().with(
value(A_ROOM_ID_2.toRoomIdOrAlias()), value(A_ROOM_ID_2.toRoomIdOrAlias()),
value(serverNames), value(serverNames),
value(AnalyticsJoinedRoom.Trigger.SpaceHierarchy), value(AnalyticsJoinedRoom.Trigger.SpaceHierarchy),
@ -354,16 +348,24 @@ class SpacePresenterTest {
} }
@Test @Test
fun `present - exit manage mode clears selection`() = runTest { fun `present - exit manage mode without removals does not call reset`() = runTest {
val presenter = createSpacePresenter() val resetResult = lambdaRecorder<Result<Unit>>(ensureNeverCalled = true) { Result.success(Unit) }
val fakeSpaceRoomList = FakeSpaceRoomList(
paginateResult = { Result.success(Unit) },
resetResult = resetResult,
)
val presenter = createSpacePresenter(spaceRoomList = fakeSpaceRoomList)
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(SpaceEvents.EnterManageMode) initialState.eventSink(SpaceEvents.EnterManageMode)
initialState.eventSink(SpaceEvents.ToggleRoomSelection(A_ROOM_ID)) initialState.eventSink(SpaceEvents.ToggleRoomSelection(A_ROOM_ID))
initialState.eventSink(SpaceEvents.ExitManageMode) initialState.eventSink(SpaceEvents.ExitManageMode)
advanceUntilIdle()
val finalState = expectMostRecentItem() val finalState = expectMostRecentItem()
assertThat(finalState.isManageMode).isFalse() assertThat(finalState.isManageMode).isFalse()
assertThat(finalState.selectedRoomIds).isEmpty() assertThat(finalState.selectedRoomIds).isEmpty()
// reset should NOT be called since no rooms were actually removed
assert(resetResult).isNeverCalled()
} }
} }
@ -389,6 +391,7 @@ class SpacePresenterTest {
val removeChildFromSpaceResult = lambdaRecorder<RoomId, RoomId, Result<Unit>> { _, _ -> val removeChildFromSpaceResult = lambdaRecorder<RoomId, RoomId, Result<Unit>> { _, _ ->
Result.success(Unit) Result.success(Unit)
} }
val resetResult = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val aRoom = aSpaceRoom( val aRoom = aSpaceRoom(
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
roomType = RoomType.Room, roomType = RoomType.Room,
@ -396,6 +399,7 @@ class SpacePresenterTest {
val fakeSpaceRoomList = FakeSpaceRoomList( val fakeSpaceRoomList = FakeSpaceRoomList(
initialSpaceRoomsValue = listOf(aRoom), initialSpaceRoomsValue = listOf(aRoom),
paginateResult = { Result.success(Unit) }, paginateResult = { Result.success(Unit) },
resetResult = resetResult,
) )
val presenter = createSpacePresenter( val presenter = createSpacePresenter(
spaceRoomList = fakeSpaceRoomList, spaceRoomList = fakeSpaceRoomList,
@ -416,8 +420,8 @@ class SpacePresenterTest {
val successState = expectMostRecentItem() val successState = expectMostRecentItem()
assertThat(successState.removeRoomsAction).isEqualTo(AsyncAction.Success(Unit)) assertThat(successState.removeRoomsAction).isEqualTo(AsyncAction.Success(Unit))
assertThat(successState.isManageMode).isFalse() assertThat(successState.isManageMode).isFalse()
assertThat(successState.children).isEmpty() assert(removeChildFromSpaceResult).isCalledOnce()
removeChildFromSpaceResult.assertions().isCalledOnce() assert(resetResult).isCalledOnce()
} }
} }
@ -465,7 +469,57 @@ class SpacePresenterTest {
assertThat(failureState.children.map { it.roomId }).doesNotContain(A_ROOM_ID) assertThat(failureState.children.map { it.roomId }).doesNotContain(A_ROOM_ID)
// Failed room should still be present // Failed room should still be present
assertThat(failureState.children.map { it.roomId }).contains(A_ROOM_ID_2) assertThat(failureState.children.map { it.roomId }).contains(A_ROOM_ID_2)
removeChildFromSpaceResult.assertions().isCalledExactly(2) assert(removeChildFromSpaceResult).isCalledExactly(2)
}
}
@Test
fun `present - exit manage mode after partial failure calls reset`() = runTest {
val aRoom1 = aSpaceRoom(
roomId = A_ROOM_ID,
roomType = RoomType.Room,
)
val aRoom2 = aSpaceRoom(
roomId = A_ROOM_ID_2,
roomType = RoomType.Room,
)
// Room 1 succeeds, Room 2 fails
val removeChildFromSpaceResult = lambdaRecorder<RoomId, RoomId, Result<Unit>> { _, childId ->
if (childId == A_ROOM_ID_2) {
Result.failure(AN_EXCEPTION)
} else {
Result.success(Unit)
}
}
val resetResult = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val fakeSpaceRoomList = FakeSpaceRoomList(
initialSpaceRoomsValue = listOf(aRoom1, aRoom2),
paginateResult = { Result.success(Unit) },
resetResult = resetResult,
)
val presenter = createSpacePresenter(
spaceRoomList = fakeSpaceRoomList,
spaceService = FakeSpaceService(
removeChildFromSpaceResult = removeChildFromSpaceResult,
),
)
presenter.test {
awaitItem() // Initial empty state
advanceUntilIdle()
val stateWithChildren = awaitItem()
stateWithChildren.eventSink(SpaceEvents.EnterManageMode)
stateWithChildren.eventSink(SpaceEvents.ToggleRoomSelection(A_ROOM_ID))
stateWithChildren.eventSink(SpaceEvents.ToggleRoomSelection(A_ROOM_ID_2))
stateWithChildren.eventSink(SpaceEvents.RemoveSelectedRooms)
stateWithChildren.eventSink(SpaceEvents.ConfirmRoomRemoval)
advanceUntilIdle()
val failureState = expectMostRecentItem()
assertThat(failureState.removeRoomsAction.isFailure()).isTrue()
// Exit manage mode after partial failure - reset should be called
failureState.eventSink(SpaceEvents.ExitManageMode)
advanceUntilIdle()
expectMostRecentItem()
assert(resetResult).isCalledOnce()
} }
} }
@ -501,10 +555,8 @@ class SpacePresenterTest {
} }
@Test @Test
fun `present - removed rooms persist after flow update`() = runTest { fun `present - removed rooms persist after flow update on partial failure`() = runTest {
val removeChildFromSpaceResult = lambdaRecorder<RoomId, RoomId, Result<Unit>> { _, _ -> // On partial failure, successfully removed rooms should stay filtered even after flow updates
Result.success(Unit)
}
val aRoom1 = aSpaceRoom( val aRoom1 = aSpaceRoom(
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
roomType = RoomType.Room, roomType = RoomType.Room,
@ -517,6 +569,14 @@ class SpacePresenterTest {
roomId = A_ROOM_ID_3, roomId = A_ROOM_ID_3,
roomType = RoomType.Room, roomType = RoomType.Room,
) )
// Room 1 succeeds, Room 2 fails
val removeChildFromSpaceResult = lambdaRecorder<RoomId, RoomId, Result<Unit>> { _, childId ->
if (childId == A_ROOM_ID_2) {
Result.failure(AN_EXCEPTION)
} else {
Result.success(Unit)
}
}
val spaceRoomList = FakeSpaceRoomList( val spaceRoomList = FakeSpaceRoomList(
initialSpaceRoomsValue = listOf(aRoom1, aRoom2), initialSpaceRoomsValue = listOf(aRoom1, aRoom2),
paginateResult = { Result.success(Unit) }, paginateResult = { Result.success(Unit) },
@ -532,12 +592,18 @@ class SpacePresenterTest {
advanceUntilIdle() advanceUntilIdle()
val stateWithChildren = awaitItem() val stateWithChildren = awaitItem()
stateWithChildren.eventSink(SpaceEvents.EnterManageMode) stateWithChildren.eventSink(SpaceEvents.EnterManageMode)
// Select both rooms for removal
stateWithChildren.eventSink(SpaceEvents.ToggleRoomSelection(A_ROOM_ID)) stateWithChildren.eventSink(SpaceEvents.ToggleRoomSelection(A_ROOM_ID))
stateWithChildren.eventSink(SpaceEvents.ToggleRoomSelection(A_ROOM_ID_2))
stateWithChildren.eventSink(SpaceEvents.RemoveSelectedRooms) stateWithChildren.eventSink(SpaceEvents.RemoveSelectedRooms)
stateWithChildren.eventSink(SpaceEvents.ConfirmRoomRemoval) stateWithChildren.eventSink(SpaceEvents.ConfirmRoomRemoval)
advanceUntilIdle() advanceUntilIdle()
val successState = expectMostRecentItem() val failureState = expectMostRecentItem()
assertThat(successState.children.map { it.roomId }).doesNotContain(A_ROOM_ID) assertThat(failureState.removeRoomsAction.isFailure()).isTrue()
// Successfully removed room should be filtered out
assertThat(failureState.children.map { it.roomId }).doesNotContain(A_ROOM_ID)
// Failed room should still be present
assertThat(failureState.children.map { it.roomId }).contains(A_ROOM_ID_2)
// Emit new flow update with a new room added (simulating server refresh) // Emit new flow update with a new room added (simulating server refresh)
spaceRoomList.emitSpaceRooms(listOf(aRoom1, aRoom2, aRoom3)) spaceRoomList.emitSpaceRooms(listOf(aRoom1, aRoom2, aRoom3))
advanceUntilIdle() advanceUntilIdle()
@ -571,7 +637,7 @@ class SpacePresenterTest {
seenInvitesStore = seenInvitesStore, seenInvitesStore = seenInvitesStore,
joinRoom = joinRoom, joinRoom = joinRoom,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter, acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
sessionCoroutineScope = backgroundScope, sessionCoroutineScope = this,
featureFlagService = FakeFeatureFlagService( featureFlagService = FakeFeatureFlagService(
initialState = mapOf( initialState = mapOf(
FeatureFlags.SpaceSettings.key to spaceSettingsEnabled, FeatureFlags.SpaceSettings.key to spaceSettingsEnabled,

View file

@ -9,8 +9,11 @@
package io.element.android.libraries.matrix.api.spaces package io.element.android.libraries.matrix.api.spaces
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import java.util.Optional import java.util.Optional
interface SpaceRoomList { interface SpaceRoomList {
@ -19,13 +22,31 @@ interface SpaceRoomList {
data class Idle(val hasMoreToLoad: Boolean) : PaginationStatus data class Idle(val hasMoreToLoad: Boolean) : PaginationStatus
} }
val roomId: RoomId val spaceId: RoomId
val currentSpaceFlow: StateFlow<Optional<SpaceRoom>> val currentSpaceFlow: StateFlow<Optional<SpaceRoom>>
val spaceRoomsFlow: Flow<List<SpaceRoom>> val spaceRoomsFlow: Flow<List<SpaceRoom>>
val paginationStatusFlow: StateFlow<PaginationStatus> val paginationStatusFlow: StateFlow<PaginationStatus>
suspend fun paginate(): Result<Unit> suspend fun paginate(): Result<Unit>
suspend fun reset(): Result<Unit>
fun destroy() fun destroy()
} }
/**
* Loads all space rooms incrementally by automatically paginating whenever more data is available.
* This function observes the pagination status and triggers [paginate] calls until the entire list is loaded.
*
* @param coroutineScope The scope in which the pagination flow will be collected.
*/
fun SpaceRoomList.loadAllIncrementally(coroutineScope: CoroutineScope) {
paginationStatusFlow
.onEach { paginationStatus ->
if (paginationStatus is SpaceRoomList.PaginationStatus.Idle && paginationStatus.hasMoreToLoad) {
paginate()
}
}
.launchIn(coroutineScope)
}

View file

@ -29,7 +29,7 @@ import java.util.Optional
import org.matrix.rustcomponents.sdk.SpaceRoomList as InnerSpaceRoomList import org.matrix.rustcomponents.sdk.SpaceRoomList as InnerSpaceRoomList
class RustSpaceRoomList( class RustSpaceRoomList(
override val roomId: RoomId, override val spaceId: RoomId,
private val innerProvider: suspend () -> InnerSpaceRoomList, private val innerProvider: suspend () -> InnerSpaceRoomList,
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
spaceRoomMapper: SpaceRoomMapper, spaceRoomMapper: SpaceRoomMapper,
@ -81,9 +81,15 @@ class RustSpaceRoomList(
} }
} }
override suspend fun reset(): Result<Unit> {
return runCatchingExceptions {
innerCompletable.await().reset()
}
}
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
override fun destroy() { override fun destroy() {
Timber.d("Destroying SpaceRoomList $roomId") Timber.d("Destroying SpaceRoomList $spaceId")
coroutineScope.cancel() coroutineScope.cancel()
try { try {
innerCompletable.getCompleted().destroy() innerCompletable.getCompleted().destroy()

View file

@ -79,7 +79,7 @@ class RustSpaceService(
override fun spaceRoomList(id: RoomId): SpaceRoomList { override fun spaceRoomList(id: RoomId): SpaceRoomList {
val childCoroutineScope = sessionCoroutineScope.childScope(sessionDispatcher, "SpaceRoomListScope-$this") val childCoroutineScope = sessionCoroutineScope.childScope(sessionDispatcher, "SpaceRoomListScope-$this")
return RustSpaceRoomList( return RustSpaceRoomList(
roomId = id, spaceId = id,
innerProvider = { innerSpaceService.spaceRoomList(id.value) }, innerProvider = { innerSpaceService.spaceRoomList(id.value) },
coroutineScope = childCoroutineScope, coroutineScope = childCoroutineScope,
spaceRoomMapper = spaceRoomMapper, spaceRoomMapper = spaceRoomMapper,

View file

@ -21,6 +21,7 @@ import uniffi.matrix_sdk_ui.SpaceRoomListPaginationState
class FakeFfiSpaceRoomList( class FakeFfiSpaceRoomList(
private val paginateResult: () -> Unit = { lambdaError() }, private val paginateResult: () -> Unit = { lambdaError() },
private val resetResult: () -> Unit = { lambdaError() },
private val paginationStateResult: () -> SpaceRoomListPaginationState = { lambdaError() }, private val paginationStateResult: () -> SpaceRoomListPaginationState = { lambdaError() },
private val roomsResult: () -> List<SpaceRoom> = { lambdaError() }, private val roomsResult: () -> List<SpaceRoom> = { lambdaError() },
) : SpaceRoomList(NoHandle) { ) : SpaceRoomList(NoHandle) {
@ -31,6 +32,10 @@ class FakeFfiSpaceRoomList(
paginateResult() paginateResult()
} }
override suspend fun reset() = simulateLongTask {
resetResult()
}
override fun paginationState(): SpaceRoomListPaginationState { override fun paginationState(): SpaceRoomListPaginationState {
return paginationStateResult() return paginationStateResult()
} }

View file

@ -94,7 +94,7 @@ class RustSpaceRoomListTest {
spaceRoomMapper: SpaceRoomMapper = SpaceRoomMapper(), spaceRoomMapper: SpaceRoomMapper = SpaceRoomMapper(),
): RustSpaceRoomList { ): RustSpaceRoomList {
return RustSpaceRoomList( return RustSpaceRoomList(
roomId = roomId, spaceId = roomId,
innerProvider = innerProvider, innerProvider = innerProvider,
coroutineScope = backgroundScope, coroutineScope = backgroundScope,
spaceRoomMapper = spaceRoomMapper, spaceRoomMapper = spaceRoomMapper,

View file

@ -21,11 +21,12 @@ import kotlinx.coroutines.flow.asStateFlow
import java.util.Optional import java.util.Optional
class FakeSpaceRoomList( class FakeSpaceRoomList(
override val roomId: RoomId = A_ROOM_ID, override val spaceId: RoomId = A_ROOM_ID,
initialSpaceFlowValue: SpaceRoom? = null, initialSpaceFlowValue: SpaceRoom? = null,
initialSpaceRoomsValue: List<SpaceRoom> = emptyList(), initialSpaceRoomsValue: List<SpaceRoom> = emptyList(),
initialSpaceRoomList: SpaceRoomList.PaginationStatus = SpaceRoomList.PaginationStatus.Loading, initialSpaceRoomList: SpaceRoomList.PaginationStatus = SpaceRoomList.PaginationStatus.Loading,
private val paginateResult: () -> Result<Unit> = { lambdaError() }, private val paginateResult: () -> Result<Unit> = { lambdaError() },
private val resetResult: () -> Result<Unit> = { lambdaError() },
) : SpaceRoomList { ) : SpaceRoomList {
private val currentSpaceMutableStateFlow: MutableStateFlow<Optional<SpaceRoom>> = MutableStateFlow(Optional.ofNullable(initialSpaceFlowValue)) private val currentSpaceMutableStateFlow: MutableStateFlow<Optional<SpaceRoom>> = MutableStateFlow(Optional.ofNullable(initialSpaceFlowValue))
override val currentSpaceFlow: StateFlow<Optional<SpaceRoom>> = currentSpaceMutableStateFlow.asStateFlow() override val currentSpaceFlow: StateFlow<Optional<SpaceRoom>> = currentSpaceMutableStateFlow.asStateFlow()
@ -34,7 +35,8 @@ class FakeSpaceRoomList(
currentSpaceMutableStateFlow.value = Optional.ofNullable(value) currentSpaceMutableStateFlow.value = Optional.ofNullable(value)
} }
private val _spaceRoomsFlow: MutableStateFlow<List<SpaceRoom>> = MutableStateFlow(initialSpaceRoomsValue) private val _spaceRoomsFlow = MutableStateFlow<List<SpaceRoom>>(initialSpaceRoomsValue)
override val spaceRoomsFlow: Flow<List<SpaceRoom>> = _spaceRoomsFlow.asStateFlow() override val spaceRoomsFlow: Flow<List<SpaceRoom>> = _spaceRoomsFlow.asStateFlow()
fun emitSpaceRooms(value: List<SpaceRoom>) { fun emitSpaceRooms(value: List<SpaceRoom>) {
@ -52,6 +54,10 @@ class FakeSpaceRoomList(
paginateResult() paginateResult()
} }
override suspend fun reset(): Result<Unit> = simulateLongTask {
resetResult()
}
override fun destroy() { override fun destroy() {
// No op // No op
} }