Iterate on manage space rooms, but not happy with the reset method.

This commit is contained in:
ganfra 2026-01-26 21:39:00 +01:00
parent 4ccfb31646
commit 1e25938ef7
15 changed files with 170 additions and 117 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()
} }

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,8 +24,10 @@ 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.api.spaces.resetAndWaitForFullReload
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -35,6 +36,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.seconds
@Inject @Inject
class AddRoomToSpacePresenter( class AddRoomToSpacePresenter(
@ -45,7 +47,7 @@ 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) }
@ -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,7 +93,11 @@ class AddRoomToSpacePresenter(
AddRoomToSpaceEvent.Save -> { AddRoomToSpaceEvent.Save -> {
coroutineScope.addRoomsToSpace( coroutineScope.addRoomsToSpace(
selectedRooms = selectedRooms, selectedRooms = selectedRooms,
addAction = saveAction, dataSource = dataSource,
saveAction = saveAction,
onPartialSuccess = { successfullyAdded ->
selectedRooms = selectedRooms.filterNot { it.roomId in successfullyAdded }.toImmutableList()
},
) )
} }
AddRoomToSpaceEvent.ResetSaveAction -> { AddRoomToSpaceEvent.ResetSaveAction -> {
@ -113,21 +119,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

@ -16,19 +16,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.api.room.RoomInfo 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.ui.model.SelectRoomInfo
import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo
import io.element.android.libraries.matrix.api.room.recent.getRecentlyVisitedRoomInfoFlow import io.element.android.libraries.matrix.api.room.recent.getRecentlyVisitedRoomInfoFlow
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.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.ImmutableList
import kotlinx.collections.immutable.toImmutableList 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 failure cases
private val addedRoomIds = MutableStateFlow<Set<RoomId>>(emptySet())
/**
* Marks rooms as added to the space (for partial failure handling).
* These rooms will be filtered out from search results and suggestions.
*/
fun markAsAdded(roomIds: Set<RoomId>) {
addedRoomIds.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 -> addedRoomIds,
) { 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,
addedRoomIds,
) { 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,8 +58,8 @@ 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() {
@ -114,18 +114,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 +142,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 +159,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 +205,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

@ -49,6 +49,7 @@ import kotlinx.collections.immutable.toImmutableSet
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.delay
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -69,7 +70,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 +111,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()
} }
} }
@ -141,7 +138,8 @@ class SpacePresenter(
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)
} }
@ -186,7 +184,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 +194,18 @@ 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 isManageMode = false
selectedRoomIds = emptySet() selectedRoomIds = emptySet()
// Reset the space room list to see the updates.
spaceRoomList.reset()
} }
} }
} }
@ -246,8 +246,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,11 +17,16 @@ 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 kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
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
@ -276,6 +281,7 @@ class AddRoomToSpacePresenterTest {
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 +307,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

@ -20,7 +20,7 @@ import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.pressBack
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toPersistentList
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule import org.junit.rules.TestRule
@ -62,7 +62,7 @@ class AddRoomToSpaceViewTest {
val eventsRecorder = EventsRecorder<AddRoomToSpaceEvent>() val eventsRecorder = EventsRecorder<AddRoomToSpaceEvent>()
rule.setAddRoomToSpaceView( rule.setAddRoomToSpaceView(
anAddRoomToSpaceState( anAddRoomToSpaceState(
selectedRooms = aSelectRoomInfoList().take(1).toImmutableList(), selectedRooms = aSelectRoomInfoList().take(1).toPersistentList(),
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )

View file

@ -30,6 +30,7 @@ import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomType import io.element.android.libraries.matrix.api.room.RoomType
import io.element.android.libraries.matrix.api.room.join.JoinRoom import io.element.android.libraries.matrix.api.room.join.JoinRoom
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.loadAllIncrementally
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.A_ROOM_ID_2
@ -42,6 +43,7 @@ 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
@ -56,10 +58,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 +73,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 +104,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 +193,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 +207,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),
@ -389,6 +385,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 +393,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 +414,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 +463,7 @@ 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)
} }
} }
@ -501,10 +499,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 +513,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 +536,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 +581,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

@ -25,38 +25,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> 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) { fun SpaceRoomList.loadAllIncrementally(coroutineScope: CoroutineScope) {
paginationStatusFlow paginationStatusFlow
.onEach { paginationStatus -> .onEach { paginationStatus ->
when (paginationStatus) { if (paginationStatus is SpaceRoomList.PaginationStatus.Idle && paginationStatus.hasMoreToLoad) {
is SpaceRoomList.PaginationStatus.Idle -> { paginate()
if (paginationStatus.hasMoreToLoad) {
paginate()
}
}
SpaceRoomList.PaginationStatus.Loading -> Unit
} }
} }
.launchIn(coroutineScope) .launchIn(coroutineScope)
} }
suspend fun SpaceRoomList.resetAndWaitForFullReload(timeout: Duration) {
reset()
withTimeoutOrNull(timeout) {
paginationStatusFlow.first { status ->
status is SpaceRoomList.PaginationStatus.Idle && !status.hasMoreToLoad
}
}
}

View file

@ -17,6 +17,7 @@ import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
@ -29,7 +30,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,
@ -89,7 +90,7 @@ class RustSpaceRoomList(
@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

@ -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,7 +21,7 @@ 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,
@ -35,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>) {