From c459af6e61d94519f58b27f5c70d94793a987583 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 30 Sep 2025 15:27:55 +0200 Subject: [PATCH] Leave space: use the SDK API. --- .../space/impl/leave/LeaveSpaceNode.kt | 20 +- .../space/impl/leave/LeaveSpacePresenter.kt | 76 +++++--- .../space/impl/leave/LeaveSpaceState.kt | 8 +- .../impl/leave/LeaveSpaceStateProvider.kt | 5 + .../space/impl/leave/LeaveSpaceView.kt | 128 +++++++------ .../features/space/impl/root/SpaceView.kt | 9 +- .../impl/src/main/res/values/localazy.xml | 4 +- .../impl/leave/LeaveSpacePresenterTest.kt | 179 ++++++++++++++++-- .../space/impl/leave/LeaveSpaceStateTest.kt | 23 +++ .../matrix/api/spaces/LeaveSpaceHandle.kt | 34 ++++ .../matrix/api/spaces/LeaveSpaceRoom.kt | 13 ++ .../matrix/api/spaces/SpaceService.kt | 2 + .../impl/spaces/RustLeaveSpaceHandle.kt | 59 ++++++ .../matrix/impl/spaces/RustSpaceService.kt | 11 ++ .../test/spaces/FakeLeaveSpaceHandle.kt | 34 ++++ .../matrix/test/spaces/FakeSpaceService.kt | 6 + 16 files changed, 493 insertions(+), 118 deletions(-) create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/LeaveSpaceHandle.kt create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/LeaveSpaceRoom.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustLeaveSpaceHandle.kt create mode 100644 libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeLeaveSpaceHandle.kt diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceNode.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceNode.kt index df313481a1..c60bddea1d 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceNode.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceNode.kt @@ -9,21 +9,39 @@ package io.element.android.features.space.impl.leave import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode +import io.element.android.features.space.api.SpaceEntryPoint import io.element.android.features.space.impl.di.SpaceFlowScope +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.matrix.api.MatrixClient @ContributesNode(SpaceFlowScope::class) @AssistedInject class LeaveSpaceNode( @Assisted buildContext: BuildContext, @Assisted plugins: List, - private val presenter: LeaveSpacePresenter, + matrixClient: MatrixClient, + presenterFactory: LeaveSpacePresenter.Factory, ) : Node(buildContext, plugins = plugins) { + private val inputs: SpaceEntryPoint.Inputs = inputs() + private val leaveSpaceHandle = matrixClient.spaceService.getLeaveSpaceHandle(inputs.roomId) + private val presenter: LeaveSpacePresenter = presenterFactory.create(leaveSpaceHandle) + + override fun onBuilt() { + super.onBuilt() + lifecycle.subscribe( + onDestroy = { + leaveSpaceHandle.close() + } + ) + } + @Composable override fun View(modifier: Modifier) { val state = presenter.present() diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpacePresenter.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpacePresenter.kt index 7af18c1b6d..2754364676 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpacePresenter.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpacePresenter.kt @@ -9,65 +9,79 @@ package io.element.android.features.space.impl.leave import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import dev.zacsweers.metro.Inject +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.map import io.element.android.libraries.architecture.runUpdatingState import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.spaces.SpaceRoom -import io.element.android.libraries.matrix.api.spaces.SpaceRoomList -import kotlinx.collections.immutable.ImmutableList +import io.element.android.libraries.matrix.api.spaces.LeaveSpaceHandle +import io.element.android.libraries.matrix.api.spaces.LeaveSpaceRoom import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentSetOf -import kotlinx.collections.immutable.toPersistentList +import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toPersistentSet import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import kotlin.jvm.optionals.getOrNull -@Inject +@AssistedInject class LeaveSpacePresenter( - private val spaceRoomList: SpaceRoomList, + @Assisted private val leaveSpaceHandle: LeaveSpaceHandle, ) : Presenter { + @AssistedFactory + fun interface Factory { + fun create(leaveSpaceHandle: LeaveSpaceHandle): LeaveSpacePresenter + } + @Composable override fun present(): LeaveSpaceState { val coroutineScope = rememberCoroutineScope() - val currentSpace by spaceRoomList.currentSpaceFlow.collectAsState() + var currentSpace: LeaveSpaceRoom? by remember { mutableStateOf(null) } val leaveSpaceAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } val selectedRoomIds = remember { mutableStateOf>(persistentSetOf()) } - val joinedSpaceRooms by produceState(emptyList()) { - // TODO Get the joined room from the SDK, should also have the isLastAdmin boolean - val rooms = emptyList() - // By default select all rooms - selectedRoomIds.value = rooms.map { it.roomId }.toPersistentSet() - value = rooms + val leaveSpaceRooms by produceState(AsyncData.Loading()) { + val rooms = leaveSpaceHandle.rooms() + val (currentRoom, otherRooms) = rooms.getOrNull() + .orEmpty() + .partition { it.spaceRoom.roomId == leaveSpaceHandle.id } + currentSpace = currentRoom.firstOrNull() + // By default select all rooms that can be left + selectedRoomIds.value = otherRooms + .filter { it.isLastAdmin.not() } + .map { it.spaceRoom.roomId } + .toPersistentSet() + value = rooms.fold( + onSuccess = { AsyncData.Success(otherRooms) }, + onFailure = { AsyncData.Failure(it) } + ) } - val selectableSpaceRooms by produceState>>( - initialValue = AsyncData.Uninitialized, - key1 = joinedSpaceRooms, + val selectableSpaceRooms by produceState( + initialValue = AsyncData.Loading(), + key1 = leaveSpaceRooms, key2 = selectedRoomIds.value, ) { - value = AsyncData.Success( - joinedSpaceRooms.map { + value = leaveSpaceRooms.map { list -> + list.orEmpty().map { room -> SelectableSpaceRoom( - spaceRoom = it, - // TODO Get this value from the SDK - isLastAdmin = false, - isSelected = selectedRoomIds.value.contains(it.roomId), + spaceRoom = room.spaceRoom, + isLastAdmin = room.isLastAdmin, + isSelected = selectedRoomIds.value.contains(room.spaceRoom.roomId), ) - }.toPersistentList() - ) + }.toImmutableList() + } } fun handleEvents(event: LeaveSpaceEvents) { @@ -102,7 +116,8 @@ class LeaveSpacePresenter( } return LeaveSpaceState( - spaceName = currentSpace.getOrNull()?.name, + spaceName = currentSpace?.spaceRoom?.name, + isLastAdmin = currentSpace?.isLastAdmin == true, selectableSpaceRooms = selectableSpaceRooms, leaveSpaceAction = leaveSpaceAction.value, eventSink = ::handleEvents, @@ -111,11 +126,10 @@ class LeaveSpacePresenter( private fun CoroutineScope.leaveSpace( leaveSpaceAction: MutableState>, - @Suppress("unused") selectedRoomIds: Set, + selectedRoomIds: Set, ) = launch { runUpdatingState(leaveSpaceAction) { - // TODO SDK API call to leave all the rooms and space - Result.failure(Exception("Not implemented")) + leaveSpaceHandle.leave(selectedRoomIds.toList()) } } } diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceState.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceState.kt index f63eef2333..0f2a0f93f6 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceState.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceState.kt @@ -13,6 +13,7 @@ import kotlinx.collections.immutable.ImmutableList data class LeaveSpaceState( val spaceName: String?, + val isLastAdmin: Boolean, val selectableSpaceRooms: AsyncData>, val leaveSpaceAction: AsyncAction, val eventSink: (LeaveSpaceEvents) -> Unit, @@ -25,7 +26,12 @@ data class LeaveSpaceState( /** * True if we should show the quick action to select/deselect all rooms. */ - val showQuickAction = selectableRooms.isNotEmpty() + val showQuickAction = isLastAdmin.not() && selectableRooms.isNotEmpty() + + /** + * True if we should show the leave button. + */ + val showLeaveButton = isLastAdmin.not() && selectableSpaceRooms is AsyncData.Success /** * True if there all the selectable rooms are selected. diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceStateProvider.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceStateProvider.kt index 6795cba3a7..16eb85442c 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceStateProvider.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceStateProvider.kt @@ -105,15 +105,20 @@ class LeaveSpaceStateProvider : PreviewParameterProvider { aLeaveSpaceState( selectableSpaceRooms = AsyncData.Failure(Exception("An error")), ), + aLeaveSpaceState( + isLastAdmin = true, + ), ) } fun aLeaveSpaceState( spaceName: String? = "Space name", + isLastAdmin: Boolean = false, selectableSpaceRooms: AsyncData> = AsyncData.Uninitialized, leaveSpaceAction: AsyncAction = AsyncAction.Uninitialized, ) = LeaveSpaceState( spaceName = spaceName, + isLastAdmin = isLastAdmin, selectableSpaceRooms = selectableSpaceRooms, leaveSpaceAction = leaveSpaceAction, eventSink = { } diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceView.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceView.kt index 6952229f3a..c28b1661c7 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceView.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceView.kt @@ -9,8 +9,10 @@ package io.element.android.features.space.impl.leave +import androidx.annotation.StringRes import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize @@ -85,41 +87,42 @@ fun LeaveSpaceView( .imePadding() .consumeWindowInsets(padding) .fillMaxSize() - .padding(16.dp) ) { LazyColumn( modifier = Modifier .weight(1f), ) { - when (state.selectableSpaceRooms) { - is AsyncData.Success -> { - // List rooms where the user is the only admin - state.selectableSpaceRooms.data.forEach { selectableSpaceRoom -> - item { - SpaceItem( - selectableSpaceRoom = selectableSpaceRoom, - showCheckBox = state.hasOnlyLastAdminRoom.not(), - onClick = { - state.eventSink(LeaveSpaceEvents.ToggleRoomSelection(selectableSpaceRoom.spaceRoom.roomId)) - } - ) + if (state.isLastAdmin.not()) { + when (state.selectableSpaceRooms) { + is AsyncData.Success -> { + // List rooms where the user is the only admin + state.selectableSpaceRooms.data.forEach { selectableSpaceRoom -> + item { + SpaceItem( + selectableSpaceRoom = selectableSpaceRoom, + showCheckBox = state.hasOnlyLastAdminRoom.not(), + onClick = { + state.eventSink(LeaveSpaceEvents.ToggleRoomSelection(selectableSpaceRoom.spaceRoom.roomId)) + } + ) + } } } - } - is AsyncData.Failure -> item { - AsyncFailure( - throwable = state.selectableSpaceRooms.error, - onRetry = null, - ) - } - is AsyncData.Loading, - AsyncData.Uninitialized -> item { - AsyncLoading() + is AsyncData.Failure -> item { + AsyncFailure( + throwable = state.selectableSpaceRooms.error, + onRetry = null, + ) + } + is AsyncData.Loading, + AsyncData.Uninitialized -> item { + AsyncLoading() + } } } } LeaveSpaceButtons( - showLeaveButton = state.selectableSpaceRooms is AsyncData.Success, + showLeaveButton = state.showLeaveButton, selectedRoomsCount = state.selectedRoomsCount, onLeaveSpace = { state.eventSink(LeaveSpaceEvents.LeaveSpace) @@ -132,6 +135,7 @@ fun LeaveSpaceView( AsyncActionView( async = state.leaveSpaceAction, onSuccess = { /* Nothing to do, the screen will be dismissed automatically */ }, + errorMessage = { stringResource(CommonStrings.error_unknown) }, onErrorDismiss = { state.eventSink(LeaveSpaceEvents.CloseError) }, ) } @@ -152,11 +156,13 @@ private fun LeaveSpaceHeader( modifier = Modifier.padding(top = 0.dp, bottom = 8.dp, start = 24.dp, end = 24.dp), iconStyle = BigIcon.Style.AlertSolid, title = stringResource( - R.string.screen_leave_space_title, + if (state.isLastAdmin) R.string.screen_leave_space_title_last_admin else R.string.screen_leave_space_title, state.spaceName ?: stringResource(CommonStrings.common_space) ), subTitle = - if (state.selectableSpaceRooms is AsyncData.Success && state.selectableSpaceRooms.data.isNotEmpty()) { + if (state.isLastAdmin) { + stringResource(R.string.screen_leave_space_subtitle_last_admin) + } else if (state.selectableSpaceRooms is AsyncData.Success && state.selectableSpaceRooms.data.isNotEmpty()) { if (state.hasOnlyLastAdminRoom) { stringResource(R.string.screen_leave_space_subtitle_only_last_admin) } else { @@ -168,34 +174,35 @@ private fun LeaveSpaceHeader( ) if (state.showQuickAction) { if (state.areAllSelected) { - Text( - modifier = Modifier - .align(Alignment.End) - .clickable { - state.eventSink(LeaveSpaceEvents.DeselectAllRooms) - } - .padding(vertical = 8.dp, horizontal = 8.dp), - text = stringResource(CommonStrings.common_deselect_all), - color = ElementTheme.colors.textActionPrimary, - style = ElementTheme.typography.fontBodyMdMedium, - ) + QuickActionButton(CommonStrings.common_deselect_all) { + state.eventSink(LeaveSpaceEvents.DeselectAllRooms) + } } else { - Text( - modifier = Modifier - .align(Alignment.End) - .clickable { - state.eventSink(LeaveSpaceEvents.SelectAllRooms) - } - .padding(vertical = 8.dp, horizontal = 8.dp), - text = stringResource(CommonStrings.common_select_all), - color = ElementTheme.colors.textActionPrimary, - style = ElementTheme.typography.fontBodyMdMedium, - ) + QuickActionButton(resId = CommonStrings.common_select_all) { + state.eventSink(LeaveSpaceEvents.SelectAllRooms) + } } } } } +@Composable +private fun ColumnScope.QuickActionButton( + @StringRes resId: Int, + onClick: () -> Unit, +) { + Text( + modifier = Modifier + .align(Alignment.End) + .padding(end = 8.dp) + .clickable(onClick = onClick) + .padding(8.dp), + text = stringResource(resId), + color = ElementTheme.colors.textActionPrimary, + style = ElementTheme.typography.fontBodyMdMedium, + ) +} + @Composable private fun LeaveSpaceButtons( showLeaveButton: Boolean, @@ -204,7 +211,7 @@ private fun LeaveSpaceButtons( onCancel: () -> Unit, ) { ButtonColumnMolecule( - modifier = Modifier.padding(top = 16.dp) + modifier = Modifier.padding(16.dp) ) { if (showLeaveButton) { val text = if (selectedRoomsCount > 0) { @@ -220,6 +227,8 @@ private fun LeaveSpaceButtons( destructive = true, ) } + // TODO For least admin space, add a button to open the settings. + // See https://www.figma.com/design/kcnHxunG1LDWXsJhaNuiHz/ER-145--Spaces-on-Element-X?node-id=4622-59600 TextButton( modifier = Modifier.fillMaxWidth(), text = stringResource(CommonStrings.action_cancel), @@ -302,18 +311,15 @@ private fun SpaceItem( ) } // Number of members - val subTitle = buildString { - append( - pluralStringResource( - CommonPlurals.common_member_count, - room.numJoinedMembers, - room.numJoinedMembers - ) - ) - if (selectableSpaceRoom.isLastAdmin) { - append(" ") - append(stringResource(R.string.screen_leave_space_last_admin_info)) - } + val membersCount = pluralStringResource( + CommonPlurals.common_member_count, + room.numJoinedMembers, + room.numJoinedMembers + ) + val subTitle = if (selectableSpaceRoom.isLastAdmin) { + stringResource(R.string.screen_leave_space_last_admin_info, membersCount) + } else { + membersCount } Text( modifier = Modifier.padding(end = 16.dp), diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt index da21a166b1..25d4e69b9c 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt @@ -252,11 +252,16 @@ private fun SpaceViewTopBar( showMenu = false onLeaveSpaceClick() }, - text = { Text(stringResource(id = CommonStrings.action_leave)) }, + text = { + Text( + text = stringResource(id = CommonStrings.action_leave), + color = ElementTheme.colors.textCriticalPrimary, + ) + }, leadingIcon = { Icon( imageVector = CompoundIcons.Leave(), - tint = ElementTheme.colors.iconSecondary, + tint = ElementTheme.colors.iconCriticalPrimary, contentDescription = null, ) } diff --git a/features/space/impl/src/main/res/values/localazy.xml b/features/space/impl/src/main/res/values/localazy.xml index 07c5468ce6..c6ced29d41 100644 --- a/features/space/impl/src/main/res/values/localazy.xml +++ b/features/space/impl/src/main/res/values/localazy.xml @@ -1,11 +1,13 @@ - "(Admin)" + "%1$s (Admin)" "Leave %1$d room and space" "Leave %1$d rooms and space" "Select the rooms you’d like to leave which you\'re not the only administrator for:" + "You need to assign another admin for this space before you can leave." "You will not be removed from the following room(s) because you\'re the only administrator:" "Leave %1$s?" + "You are the only admin for %1$s" diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/leave/LeaveSpacePresenterTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/leave/LeaveSpacePresenterTest.kt index ee8962a345..b5d6f90923 100644 --- a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/leave/LeaveSpacePresenterTest.kt +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/leave/LeaveSpacePresenterTest.kt @@ -5,60 +5,197 @@ * Please see LICENSE files in the repository root for full details. */ -@file:OptIn(ExperimentalCoroutinesApi::class) - package io.element.android.features.space.impl.leave import com.google.common.truth.Truth.assertThat import io.element.android.libraries.architecture.AsyncAction -import io.element.android.libraries.architecture.AsyncData -import io.element.android.libraries.matrix.api.spaces.SpaceRoomList +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.spaces.LeaveSpaceHandle +import io.element.android.libraries.matrix.api.spaces.LeaveSpaceRoom +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +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_2 +import io.element.android.libraries.matrix.test.A_SPACE_ID import io.element.android.libraries.matrix.test.A_SPACE_NAME -import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList +import io.element.android.libraries.matrix.test.spaces.FakeLeaveSpaceHandle import io.element.android.libraries.previewutils.room.aSpaceRoom +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Test class LeaveSpacePresenterTest { + private val aSpace = aSpaceRoom( + roomId = A_SPACE_ID, + name = A_SPACE_NAME, + ) + @Test fun `present - initial state`() = runTest { - val presenter = createLeaveSpacePresenter() + val presenter = createLeaveSpacePresenter( + leaveSpaceHandle = FakeLeaveSpaceHandle( + roomsResult = { Result.success(emptyList()) }, + ), + ) presenter.test { val state = awaitItem() assertThat(state.spaceName).isNull() - assertThat(state.selectableSpaceRooms).isEqualTo(AsyncData.Uninitialized) + assertThat(state.isLastAdmin).isFalse() + assertThat(state.selectableSpaceRooms.isLoading()).isTrue() assertThat(state.leaveSpaceAction).isEqualTo(AsyncAction.Uninitialized) - skipItems(1) + cancelAndIgnoreRemainingEvents() } } @Test - fun `present - current space name`() = runTest { - val fakeSpaceRoomList = FakeSpaceRoomList() + fun `present - fail to load rooms`() = runTest { val presenter = createLeaveSpacePresenter( - spaceRoomList = fakeSpaceRoomList, + leaveSpaceHandle = FakeLeaveSpaceHandle( + roomsResult = { Result.failure(AN_EXCEPTION) }, + ) ) presenter.test { val state = awaitItem() - advanceUntilIdle() - assertThat(state.spaceName).isNull() - val aSpace = aSpaceRoom( - name = A_SPACE_NAME + assertThat(state.selectableSpaceRooms.isLoading()).isTrue() + assertThat(state.leaveSpaceAction).isEqualTo(AsyncAction.Uninitialized) + skipItems(2) + val stateError = awaitItem() + assertThat(stateError.selectableSpaceRooms.isFailure()).isTrue() + } + } + + @Test + fun `present - current space name and is last admin`() = runTest { + val presenter = createLeaveSpacePresenter( + leaveSpaceHandle = FakeLeaveSpaceHandle( + roomsResult = { Result.success(listOf(aLeaveSpaceRoom(spaceRoom = aSpace, isLastAdmin = true))) }, ) - fakeSpaceRoomList.emitCurrentSpace(aSpace) + ) + presenter.test { + val state = awaitItem() + assertThat(state.spaceName).isNull() + skipItems(3) + val finalState = awaitItem() + assertThat(finalState.spaceName).isEqualTo(A_SPACE_NAME) + assertThat(finalState.isLastAdmin).isTrue() + // The current state is not in the sub room list + assertThat(finalState.selectableSpaceRooms.dataOrNull()!!).isEmpty() + } + } + + @Test + fun `present - leave space and sub rooms`() = runTest { + val leaveResult = lambdaRecorder, Result> { Result.success(Unit) } + val presenter = createLeaveSpacePresenter( + leaveSpaceHandle = FakeLeaveSpaceHandle( + roomsResult = { + Result.success( + listOf( + LeaveSpaceRoom(aSpaceRoom(roomId = A_ROOM_ID), isLastAdmin = false), + LeaveSpaceRoom(aSpaceRoom(roomId = A_ROOM_ID_2), isLastAdmin = true), + ) + ) + }, + leaveResult = leaveResult, + ) + ) + presenter.test { + skipItems(4) + val state = awaitItem() + assertThat(state.spaceName).isNull() + assertThat(state.isLastAdmin).isFalse() + val data = state.selectableSpaceRooms.dataOrNull()!! + assertThat(data.size).isEqualTo(2) + // Only one room is selectable as the user is the last admin in the other one + val room1 = data[0] + assertThat(room1.spaceRoom.roomId).isEqualTo(A_ROOM_ID) + assertThat(room1.isSelected).isTrue() + assertThat(room1.isLastAdmin).isFalse() + val room2 = data[1] + assertThat(room2.spaceRoom.roomId).isEqualTo(A_ROOM_ID_2) + assertThat(room2.isSelected).isFalse() + assertThat(room2.isLastAdmin).isTrue() + // Deselect all + state.eventSink(LeaveSpaceEvents.DeselectAllRooms) skipItems(1) - assertThat(awaitItem().spaceName).isEqualTo(A_SPACE_NAME) + val stateAllDeselected = awaitItem() + val dataAllDeselected = stateAllDeselected.selectableSpaceRooms.dataOrNull()!! + assertThat(dataAllDeselected.any { it.isSelected }).isFalse() + // Select all + stateAllDeselected.eventSink(LeaveSpaceEvents.SelectAllRooms) + skipItems(1) + val stateAllSelected = awaitItem() + val dataAllSelected = stateAllSelected.selectableSpaceRooms.dataOrNull()!! + // The last admin room should not be selected + assertThat(dataAllSelected.count { it.isSelected }).isEqualTo(1) + // Toggle selection of the first room + stateAllSelected.eventSink(LeaveSpaceEvents.ToggleRoomSelection(A_ROOM_ID)) + skipItems(1) + val stateOneDeselected = awaitItem() + val dataOneDeselected = stateOneDeselected.selectableSpaceRooms.dataOrNull()!! + assertThat(dataOneDeselected[0].isSelected).isFalse() + // Toggle selection of the first room + stateOneDeselected.eventSink(LeaveSpaceEvents.ToggleRoomSelection(A_ROOM_ID)) + skipItems(1) + val stateOneSelected = awaitItem() + val dataOneSelected = stateOneSelected.selectableSpaceRooms.dataOrNull()!! + assertThat(dataOneSelected[0].isSelected).isTrue() + // Leave space + stateOneSelected.eventSink(LeaveSpaceEvents.LeaveSpace) + val stateLeaving = awaitItem() + assertThat(stateLeaving.leaveSpaceAction).isEqualTo(AsyncAction.Loading) + val stateLeft = awaitItem() + assertThat(stateLeft.leaveSpaceAction.isSuccess()).isTrue() + leaveResult.assertions().isCalledOnce().with( + value(listOf(A_ROOM_ID)) + ) + } + } + + @Test + fun `present - leave space error and close`() = runTest { + val leaveResult = lambdaRecorder, Result> { + Result.failure(AN_EXCEPTION) + } + val presenter = createLeaveSpacePresenter( + leaveSpaceHandle = FakeLeaveSpaceHandle( + roomsResult = { Result.success(emptyList()) }, + leaveResult = leaveResult, + ) + ) + presenter.test { + skipItems(3) + val state = awaitItem() + state.eventSink(LeaveSpaceEvents.LeaveSpace) + val stateLeaving = awaitItem() + assertThat(stateLeaving.leaveSpaceAction).isEqualTo(AsyncAction.Loading) + val stateError = awaitItem() + assertThat(stateError.leaveSpaceAction.isFailure()).isTrue() + // Close error + stateError.eventSink(LeaveSpaceEvents.CloseError) + val stateErrorClosed = awaitItem() + assertThat(stateErrorClosed.leaveSpaceAction).isEqualTo(AsyncAction.Uninitialized) } } private fun createLeaveSpacePresenter( - spaceRoomList: SpaceRoomList = FakeSpaceRoomList(), + leaveSpaceHandle: LeaveSpaceHandle = FakeLeaveSpaceHandle(), ): LeaveSpacePresenter { return LeaveSpacePresenter( - spaceRoomList = spaceRoomList, + leaveSpaceHandle = leaveSpaceHandle, ) } } + +private fun aLeaveSpaceRoom( + spaceRoom: SpaceRoom = aSpaceRoom( + roomId = A_SPACE_ID, + name = A_SPACE_NAME, + ), + isLastAdmin: Boolean = false, +) = LeaveSpaceRoom( + spaceRoom = spaceRoom, + isLastAdmin = isLastAdmin, +) diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceStateTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceStateTest.kt index eaf3f1a783..bfec2f2326 100644 --- a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceStateTest.kt +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceStateTest.kt @@ -20,6 +20,7 @@ class LeaveSpaceStateTest { selectableSpaceRooms = AsyncData.Loading() ) assertThat(sut.showQuickAction).isFalse() + assertThat(sut.showLeaveButton).isFalse() assertThat(sut.areAllSelected).isTrue() assertThat(sut.hasOnlyLastAdminRoom).isFalse() assertThat(sut.selectedRoomsCount).isEqualTo(0) @@ -33,11 +34,29 @@ class LeaveSpaceStateTest { ) ) assertThat(sut.showQuickAction).isFalse() + assertThat(sut.showLeaveButton).isTrue() assertThat(sut.areAllSelected).isTrue() assertThat(sut.hasOnlyLastAdminRoom).isFalse() assertThat(sut.selectedRoomsCount).isEqualTo(0) } + @Test + fun `test last admin`() { + val sut = aLeaveSpaceState( + isLastAdmin = true, + selectableSpaceRooms = AsyncData.Success( + persistentListOf( + aSelectableSpaceRoom(isLastAdmin = false, isSelected = false), + ) + ) + ) + assertThat(sut.showQuickAction).isFalse() + assertThat(sut.showLeaveButton).isFalse() + assertThat(sut.areAllSelected).isFalse() + assertThat(sut.hasOnlyLastAdminRoom).isFalse() + assertThat(sut.selectedRoomsCount).isEqualTo(0) + } + @Test fun `test no last admin, 1 selected, 1 not selected`() { val sut = aLeaveSpaceState( @@ -49,6 +68,7 @@ class LeaveSpaceStateTest { ) ) assertThat(sut.showQuickAction).isTrue() + assertThat(sut.showLeaveButton).isTrue() assertThat(sut.areAllSelected).isFalse() assertThat(sut.hasOnlyLastAdminRoom).isFalse() assertThat(sut.selectedRoomsCount).isEqualTo(1) @@ -65,6 +85,7 @@ class LeaveSpaceStateTest { ) ) assertThat(sut.showQuickAction).isTrue() + assertThat(sut.showLeaveButton).isTrue() assertThat(sut.areAllSelected).isTrue() assertThat(sut.hasOnlyLastAdminRoom).isFalse() assertThat(sut.selectedRoomsCount).isEqualTo(2) @@ -82,6 +103,7 @@ class LeaveSpaceStateTest { ) ) assertThat(sut.showQuickAction).isTrue() + assertThat(sut.showLeaveButton).isTrue() assertThat(sut.areAllSelected).isTrue() assertThat(sut.hasOnlyLastAdminRoom).isFalse() assertThat(sut.selectedRoomsCount).isEqualTo(2) @@ -98,6 +120,7 @@ class LeaveSpaceStateTest { ) ) assertThat(sut.showQuickAction).isFalse() + assertThat(sut.showLeaveButton).isTrue() assertThat(sut.areAllSelected).isTrue() assertThat(sut.hasOnlyLastAdminRoom).isTrue() assertThat(sut.selectedRoomsCount).isEqualTo(0) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/LeaveSpaceHandle.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/LeaveSpaceHandle.kt new file mode 100644 index 0000000000..292a973dda --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/LeaveSpaceHandle.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.spaces + +import io.element.android.libraries.matrix.api.core.RoomId + +interface LeaveSpaceHandle { + /** + * The id of the space to leave. + */ + val id: RoomId + + /** + * Get a list of rooms that can be left when leaving the space. + * It will include the current space and all the subspaces and rooms that the user has joined. + */ + suspend fun rooms(): Result> + + /** + * Leave the space and the given rooms. + * If [roomIds] is empty, only the space will be left. + */ + suspend fun leave(roomIds: List): Result + + /** + * Close the handle and free resources. + */ + fun close() +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/LeaveSpaceRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/LeaveSpaceRoom.kt new file mode 100644 index 0000000000..fb90896e05 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/LeaveSpaceRoom.kt @@ -0,0 +1,13 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.spaces + +data class LeaveSpaceRoom( + val spaceRoom: SpaceRoom, + val isLastAdmin: Boolean, +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt index b4572ad0bb..f1fea6b62a 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt @@ -15,4 +15,6 @@ interface SpaceService { suspend fun joinedSpaces(): Result> fun spaceRoomList(id: RoomId): SpaceRoomList + + fun getLeaveSpaceHandle(spaceId: RoomId): LeaveSpaceHandle } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustLeaveSpaceHandle.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustLeaveSpaceHandle.kt new file mode 100644 index 0000000000..aa8cff7024 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustLeaveSpaceHandle.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.spaces + +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.spaces.LeaveSpaceHandle +import io.element.android.libraries.matrix.api.spaces.LeaveSpaceRoom +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import timber.log.Timber +import org.matrix.rustcomponents.sdk.LeaveSpaceHandle as RustLeaveSpaceHandle + +class RustLeaveSpaceHandle( + override val id: RoomId, + private val spaceRoomMapper: SpaceRoomMapper, + sessionCoroutineScope: CoroutineScope, + private val innerProvider: suspend () -> RustLeaveSpaceHandle, +) : LeaveSpaceHandle { + private val inner = CompletableDeferred() + + init { + sessionCoroutineScope.launch { + inner.complete(innerProvider()) + } + } + + override suspend fun rooms(): Result> = runCatchingExceptions { + inner.await().rooms().map { leaveSpaceRoom -> + LeaveSpaceRoom( + spaceRoom = spaceRoomMapper.map(leaveSpaceRoom.spaceRoom), + isLastAdmin = leaveSpaceRoom.isLastAdmin, + ) + } + } + + override suspend fun leave(roomIds: List): Result = runCatchingExceptions { + // Ensure the space is included and is the last room to be left + val roomToLeave = roomIds - id + id + inner.await().leave(roomToLeave.map { it.value }) + } + + @OptIn(ExperimentalCoroutinesApi::class) + override fun close() { + Timber.d("Destroying LeaveSpaceHandle $id") + try { + inner.getCompleted().destroy() + } catch (_: Exception) { + // Ignore, we just want to make sure it's completed + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt index 86d50a477c..784cc586e2 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt @@ -10,6 +10,7 @@ package io.element.android.libraries.matrix.impl.spaces import io.element.android.libraries.core.coroutine.childScope import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.spaces.LeaveSpaceHandle import io.element.android.libraries.matrix.api.spaces.SpaceRoom import io.element.android.libraries.matrix.api.spaces.SpaceRoomList import io.element.android.libraries.matrix.api.spaces.SpaceService @@ -64,6 +65,16 @@ class RustSpaceService( ) } + override fun getLeaveSpaceHandle(spaceId: RoomId): LeaveSpaceHandle { + return RustLeaveSpaceHandle( + id = spaceId, + spaceRoomMapper = spaceRoomMapper, + sessionCoroutineScope = sessionCoroutineScope, + ) { + innerSpaceService.leaveSpace(spaceId.value) + } + } + init { innerSpaceService .spaceListUpdate() diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeLeaveSpaceHandle.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeLeaveSpaceHandle.kt new file mode 100644 index 0000000000..b19d3f1344 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeLeaveSpaceHandle.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.spaces + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.spaces.LeaveSpaceHandle +import io.element.android.libraries.matrix.api.spaces.LeaveSpaceRoom +import io.element.android.libraries.matrix.test.A_SPACE_ID +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.simulateLongTask + +class FakeLeaveSpaceHandle( + override val id: RoomId = A_SPACE_ID, + private val roomsResult: () -> Result> = { lambdaError() }, + private val leaveResult: (List) -> Result = { lambdaError() }, + private val closeResult: () -> Unit = { lambdaError() }, +) : LeaveSpaceHandle { + override suspend fun rooms(): Result> = simulateLongTask { + roomsResult() + } + + override suspend fun leave(roomIds: List): Result = simulateLongTask { + leaveResult(roomIds) + } + + override fun close() { + return closeResult() + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceService.kt index 43cc8ae8d2..d768b175d0 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceService.kt @@ -8,6 +8,7 @@ package io.element.android.libraries.matrix.test.spaces import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.spaces.LeaveSpaceHandle import io.element.android.libraries.matrix.api.spaces.SpaceRoom import io.element.android.libraries.matrix.api.spaces.SpaceRoomList import io.element.android.libraries.matrix.api.spaces.SpaceService @@ -20,6 +21,7 @@ import kotlinx.coroutines.flow.asSharedFlow class FakeSpaceService( private val joinedSpacesResult: () -> Result> = { lambdaError() }, private val spaceRoomListResult: (RoomId) -> SpaceRoomList = { lambdaError() }, + private val leaveSpaceHandleResult: (RoomId) -> LeaveSpaceHandle = { lambdaError() }, ) : SpaceService { private val _spaceRoomsFlow = MutableSharedFlow>() override val spaceRoomsFlow: SharedFlow> @@ -36,4 +38,8 @@ class FakeSpaceService( override fun spaceRoomList(id: RoomId): SpaceRoomList { return spaceRoomListResult(id) } + + override fun getLeaveSpaceHandle(spaceId: RoomId): LeaveSpaceHandle { + return leaveSpaceHandleResult(spaceId) + } }