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 ae4d635357
commit e896c7604d
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.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
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.room.JoinedRoom
import io.element.android.libraries.matrix.api.spaces.SpaceService
import io.element.android.libraries.matrix.api.spaces.loadAllIncrementally
import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class)
@ -83,6 +85,9 @@ class SpaceFlowNode(
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe(
onCreate = {
spaceRoomList.loadAllIncrementally(lifecycleScope)
},
onDestroy = {
spaceRoomList.destroy()
}

View file

@ -12,7 +12,6 @@ import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
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.runUpdatingState
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.SpaceService
import io.element.android.libraries.matrix.api.spaces.resetAndWaitForFullReload
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -35,6 +36,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.seconds
@Inject
class AddRoomToSpacePresenter(
@ -45,7 +47,7 @@ class AddRoomToSpacePresenter(
@Composable
override fun present(): AddRoomToSpaceState {
var selectedRooms: ImmutableList<SelectRoomInfo> by remember { mutableStateOf(persistentListOf()) }
var searchQuery = rememberTextFieldState()
val searchQuery = rememberTextFieldState()
var isSearchActive by remember { mutableStateOf(false) }
val saveAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
@ -63,12 +65,12 @@ class AddRoomToSpacePresenter(
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 {
derivedStateOf {
when {
filteredRooms.isNotEmpty() -> SearchBarResultState.Results(filteredRooms)
isSearchActive && searchQuery.text.isNotEmpty() -> SearchBarResultState.NoResultsFound()
else -> SearchBarResultState.Initial()
isSearchActive && searchQuery.text.isNotEmpty() -> SearchBarResultState.NoResultsFound<ImmutableList<SelectRoomInfo>>()
else -> SearchBarResultState.Initial<ImmutableList<SelectRoomInfo>>()
}
}
}
@ -91,7 +93,11 @@ class AddRoomToSpacePresenter(
AddRoomToSpaceEvent.Save -> {
coroutineScope.addRoomsToSpace(
selectedRooms = selectedRooms,
addAction = saveAction,
dataSource = dataSource,
saveAction = saveAction,
onPartialSuccess = { successfullyAdded ->
selectedRooms = selectedRooms.filterNot { it.roomId in successfullyAdded }.toImmutableList()
},
)
}
AddRoomToSpaceEvent.ResetSaveAction -> {
@ -113,21 +119,30 @@ class AddRoomToSpacePresenter(
private fun CoroutineScope.addRoomsToSpace(
selectedRooms: ImmutableList<SelectRoomInfo>,
addAction: MutableState<AsyncAction<Unit>>,
dataSource: AddRoomToSpaceSearchDataSource,
saveAction: MutableState<AsyncAction<Unit>>,
onPartialSuccess: (Set<RoomId>) -> Unit,
) = launch {
addAction.runUpdatingState {
val results = selectedRooms.map { selectedRoom ->
saveAction.runUpdatingState {
val spaceId = spaceRoomList.spaceId
val successfullyAdded = mutableSetOf<RoomId>()
val results = selectedRooms.map { room ->
async {
spaceService.addChildToSpace(
spaceId = spaceRoomList.roomId,
childId = selectedRoom.roomId,
)
spaceId = spaceId,
childId = room.roomId,
).onSuccess { successfullyAdded.add(room.roomId) }
}
}.awaitAll()
val anyFailure = results.any { it.isFailure }
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"))
} else {
// On full success, refresh the space room list
spaceRoomList.reset()
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.RoomInfo
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.roomlist.RoomList
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.loadAllIncrementally
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
@ -48,7 +49,7 @@ class AddRoomToSpaceSearchDataSource(
roomListService: RoomListService,
spaceRoomList: SpaceRoomList,
private val matrixClient: MatrixClient,
private val coroutineDispatchers: CoroutineDispatchers,
coroutineDispatchers: CoroutineDispatchers,
) {
@AssistedFactory
interface Factory {
@ -62,33 +63,49 @@ class AddRoomToSpaceSearchDataSource(
coroutineScope = coroutineScope,
)
private val spaceChildrenFlow = spaceRoomList.spaceRoomsFlow.map { spaceChildren ->
spaceChildren.map { it.roomId }.toSet()
private val spaceChildrenFlow = spaceRoomList.spaceRoomsFlow.map { rooms ->
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.isDm &&
info.currentUserMembership == CurrentUserMembership.JOINED &&
info.id !in childIds
info.id !in childIds &&
info.id !in addedIds
}
val roomInfoList: Flow<ImmutableList<SelectRoomInfo>> = combine(
roomList.filteredSummaries,
spaceChildrenFlow,
) { roomSummaries, childIds ->
addedRoomIds,
) { roomSummaries, childIds, addedIds ->
roomSummaries
.filter { filterRoomPredicate(it.info, childIds) }
.map { it.toSelectRoomInfo() }
.filter { filterRoomPredicate(it.info, childIds, addedIds) }
.map { it.info.toSelectRoomInfo() }
.toImmutableList()
}.flowOn(coroutineDispatchers.computation)
val suggestions: Flow<ImmutableList<SelectRoomInfo>> = spaceChildrenFlow.map { childIds ->
val suggestions: Flow<ImmutableList<SelectRoomInfo>> = combine(
spaceChildrenFlow,
addedRoomIds,
) { childIds, addedIds ->
matrixClient
.getRecentlyVisitedRoomInfoFlow { filterRoomPredicate(it, childIds) }
.getRecentlyVisitedRoomInfoFlow { filterRoomPredicate(it, childIds, addedIds) }
.take(MAX_SUGGESTIONS_COUNT)
.map { it.toSelectRoomInfo() }
.toList()
.map { it.toSelectRoomInfo() }
.toImmutableList()
}.flowOn(coroutineDispatchers.computation)

View file

@ -58,8 +58,8 @@ fun AddRoomToSpaceView(
onRoomsAdded: () -> Unit,
modifier: Modifier = Modifier,
) {
fun onRoomRemoved(roomInfo: SelectRoomInfo) {
state.eventSink(AddRoomToSpaceEvent.ToggleRoom(roomInfo))
fun onRoomToggled(room: SelectRoomInfo) {
state.eventSink(AddRoomToSpaceEvent.ToggleRoom(room))
}
fun onBack() {
@ -114,18 +114,18 @@ fun AddRoomToSpaceView(
if (state.selectedRooms.isNotEmpty()) {
SelectedRoomsRow(
selectedRooms = state.selectedRooms,
onRemoveRoom = ::onRoomRemoved,
onRemoveRoom = ::onRoomToggled,
modifier = Modifier.padding(vertical = 16.dp)
)
}
},
) { rooms ->
LazyColumn {
items(rooms, key = { it.roomId.value }) { roomInfo ->
items(rooms, key = { it.roomId }) { roomInfo ->
RoomListItem(
roomInfo = roomInfo,
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()) {
SelectedRoomsRow(
selectedRooms = state.selectedRooms,
onRemoveRoom = ::onRoomRemoved,
onRemoveRoom = ::onRoomToggled,
modifier = Modifier.padding(vertical = 16.dp)
)
}
@ -159,7 +159,7 @@ fun AddRoomToSpaceView(
RoomListItem(
roomInfo = roomInfo,
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),
horizontalArrangement = Arrangement.spacedBy(32.dp)
) {
items(selectedRooms, key = { it.roomId.value }) { roomInfo ->
SelectedRoom(roomInfo = roomInfo, onRemoveRoom = onRemoveRoom)
items(selectedRooms, key = { it.roomId }) { roomInfo ->
SelectedRoom(roomInfo = roomInfo, onRemoveRoom = { onRemoveRoom(roomInfo) })
}
}
}

View file

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

View file

@ -49,6 +49,7 @@ import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
@ -69,7 +70,6 @@ class SpacePresenter(
@Composable
override fun present(): SpaceState {
LaunchedEffect(Unit) {
paginate()
spaceRoomList.spaceRoomsFlow.collect { children = it.toImmutableList() }
}
@ -111,21 +111,18 @@ class SpacePresenter(
var isManageMode by remember { mutableStateOf(false) }
var selectedRoomIds by remember { mutableStateOf<Set<RoomId>>(emptySet()) }
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()) }
val filteredChildren by remember {
derivedStateOf {
children
.filterNot { it.roomId in removedRoomIds }
.let { list ->
if (isManageMode) {
// In manage mode, only show rooms (not spaces)
list.filter { !it.isSpace }
} else {
list
}
}
.toImmutableList()
val notRemoved = children.filterNot { it.roomId in removedRoomIds }
if (isManageMode) {
// In manage mode, only show rooms (not spaces)
notRemoved.filter { !it.isSpace }.toImmutableList()
} else {
notRemoved.toImmutableList()
}
}
}
@ -141,7 +138,8 @@ class SpacePresenter(
fun handleEvent(event: SpaceEvents) {
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 -> {
sessionCoroutineScope.joinRoom(event.spaceRoom, joinActions, setJoinActions)
}
@ -186,7 +184,7 @@ class SpacePresenter(
SpaceEvents.ConfirmRoomRemoval -> {
localCoroutineScope.launch {
removeRoomsAction = AsyncAction.Loading
val spaceId = spaceRoomList.roomId
val spaceId = spaceRoomList.spaceId
val roomsToRemove = selectedRoomIds.toSet()
val successfullyRemoved = mutableSetOf<RoomId>()
val results = roomsToRemove.map { roomId ->
@ -196,16 +194,18 @@ class SpacePresenter(
}
}
results.awaitAll()
if (successfullyRemoved.isNotEmpty()) {
removedRoomIds = removedRoomIds + successfullyRemoved
}
val hasError = successfullyRemoved.size < roomsToRemove.size
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"))
} else {
removeRoomsAction = AsyncAction.Success(Unit)
isManageMode = false
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)))
}
}
private fun CoroutineScope.paginate() = launch {
spaceRoomList.paginate()
}
}

View file

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