From a36f10ae300091ff83a4a848a6348c7c9a234180 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 2 Oct 2025 14:40:47 +0200 Subject: [PATCH 01/10] Update SDK --- gradle/libs.versions.toml | 2 +- .../libraries/matrix/api/encryption/RecoveryException.kt | 1 + .../matrix/impl/encryption/RecoveryExceptionMapper.kt | 3 +++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ba09935e55..b58d63d0b9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -166,7 +166,7 @@ test_detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version # https://github.com/matrix-org/matrix-rust-components-kotlin/commits/main/sdk/sdk-android/src/main/kotlin/org/matrix/rustcomponents/sdk/matrix_sdk_ffi.kt # All new features should not be implemented in the pull request that upgrades the version, developers should # only fix API breaks and may add some TODOs. -matrix_sdk = "org.matrix.rustcomponents:sdk-android:25.10.1" +matrix_sdk = "org.matrix.rustcomponents:sdk-android:25.10.2" # Others coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/RecoveryException.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/RecoveryException.kt index 70b335d4f9..7dffbf3653 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/RecoveryException.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/RecoveryException.kt @@ -11,6 +11,7 @@ import io.element.android.libraries.matrix.api.exception.ClientException sealed class RecoveryException(message: String) : Exception(message) { class SecretStorage(message: String) : RecoveryException(message) + class Import(message: String) : RecoveryException(message) data object BackupExistsOnServer : RecoveryException("BackupExistsOnServer") data class Client(val exception: ClientException) : RecoveryException(exception.message ?: "Unknown error") } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RecoveryExceptionMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RecoveryExceptionMapper.kt index b9679eb160..5568d008ec 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RecoveryExceptionMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RecoveryExceptionMapper.kt @@ -20,6 +20,9 @@ fun Throwable.mapRecoveryException(): RecoveryException { message = errorMessage ) is RustRecoveryException.BackupExistsOnServer -> RecoveryException.BackupExistsOnServer + is RustRecoveryException.Import -> RecoveryException.Import( + message = errorMessage + ) is RustRecoveryException.Client -> RecoveryException.Client( source.mapClientException() ) From fb346a15877c142324bf53e58e8642d1d69a5840 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 30 Sep 2025 17:09:45 +0200 Subject: [PATCH 02/10] Let SpaceId be an alias of RoomId --- .../matrix/api/core/MatrixPatterns.kt | 8 -------- .../libraries/matrix/api/core/SpaceId.kt | 18 +----------------- 2 files changed, 1 insertion(+), 25 deletions(-) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatterns.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatterns.kt index 26a030d361..c833d46718 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatterns.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatterns.kt @@ -69,14 +69,6 @@ object MatrixPatterns { str matches PATTERN_CONTAIN_MATRIX_USER_IDENTIFIER } - /** - * Tells if a string is a valid space id. This is an alias for [isRoomId] - * - * @param str the string to test - * @return true if the string is a valid space Id - */ - fun isSpaceId(str: String?) = isRoomId(str) - /** * Tells if a string is a valid room id. * diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/SpaceId.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/SpaceId.kt index 74074ed8e4..6db907bacd 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/SpaceId.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/SpaceId.kt @@ -7,23 +7,7 @@ package io.element.android.libraries.matrix.api.core -import io.element.android.libraries.androidutils.metadata.isInDebug -import java.io.Serializable - -@JvmInline -value class SpaceId(val value: String) : Serializable { - init { - if (isInDebug && !MatrixPatterns.isSpaceId(value)) { - error( - "`$value` is not a valid space id.\n" + - "Space ids are the same as room ids.\n" + - "Example space id: `!space_id:domain`." - ) - } - } - - override fun toString(): String = value -} +typealias SpaceId = RoomId /** * Value to use when no space is selected by the user. From 21aa94aa4a56c1e96970a907e9e9b52de836451a Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 30 Sep 2025 14:50:13 +0200 Subject: [PATCH 03/10] Enable leave space entry point. --- .../io/element/android/features/space/impl/root/SpaceView.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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 870e294ba5..da21a166b1 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 @@ -202,7 +202,7 @@ private fun LoadingMoreIndicator( private fun SpaceViewTopBar( currentSpace: SpaceRoom?, onBackClick: () -> Unit, - @Suppress("unused") onLeaveSpaceClick: () -> Unit, + onLeaveSpaceClick: () -> Unit, onShareSpace: () -> Unit, modifier: Modifier = Modifier, ) { @@ -247,8 +247,6 @@ private fun SpaceViewTopBar( ) } ) - /* - // TODO re-enable when we have SDK APIs to leave a space DropdownMenuItem( onClick = { showMenu = false @@ -263,7 +261,6 @@ private fun SpaceViewTopBar( ) } ) - */ } }, ) From f726b2a9a4f1754cfbfbd9cb8cf437f7e9e81495 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 30 Sep 2025 15:32:59 +0200 Subject: [PATCH 04/10] Leave space: Fix UI issue on top bar. --- .../features/space/impl/leave/LeaveSpaceView.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 2a3cd73ea9..6952229f3a 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 @@ -71,6 +71,12 @@ fun LeaveSpaceView( ) { Scaffold( modifier = modifier, + topBar = { + LeaveSpaceHeader( + state = state, + onBackClick = onCancel, + ) + }, containerColor = ElementTheme.colors.bgCanvasDefault, ) { padding -> Column( @@ -81,10 +87,6 @@ fun LeaveSpaceView( .fillMaxSize() .padding(16.dp) ) { - LeaveSpaceHeader( - state = state, - onBackClick = onCancel, - ) LazyColumn( modifier = Modifier .weight(1f), From c36577889d0a2cc0d19d1432a6c7144de8b3e0a3 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 30 Sep 2025 15:27:55 +0200 Subject: [PATCH 05/10] 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) + } } From 35dd2c76c11b27c59c10a6f08be1539eb76a8a18 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Thu, 2 Oct 2025 15:40:56 +0000 Subject: [PATCH 06/10] Update screenshots --- .../features.space.impl.leave_LeaveSpaceView_Day_0_en.png | 4 ++-- .../features.space.impl.leave_LeaveSpaceView_Day_1_en.png | 4 ++-- .../features.space.impl.leave_LeaveSpaceView_Day_2_en.png | 4 ++-- .../features.space.impl.leave_LeaveSpaceView_Day_3_en.png | 4 ++-- .../features.space.impl.leave_LeaveSpaceView_Day_4_en.png | 4 ++-- .../features.space.impl.leave_LeaveSpaceView_Day_5_en.png | 4 ++-- .../features.space.impl.leave_LeaveSpaceView_Day_6_en.png | 4 ++-- .../features.space.impl.leave_LeaveSpaceView_Day_7_en.png | 4 ++-- .../features.space.impl.leave_LeaveSpaceView_Day_8_en.png | 4 ++-- .../features.space.impl.leave_LeaveSpaceView_Day_9_en.png | 3 +++ .../features.space.impl.leave_LeaveSpaceView_Night_0_en.png | 4 ++-- .../features.space.impl.leave_LeaveSpaceView_Night_1_en.png | 4 ++-- .../features.space.impl.leave_LeaveSpaceView_Night_2_en.png | 4 ++-- .../features.space.impl.leave_LeaveSpaceView_Night_3_en.png | 4 ++-- .../features.space.impl.leave_LeaveSpaceView_Night_4_en.png | 4 ++-- .../features.space.impl.leave_LeaveSpaceView_Night_5_en.png | 4 ++-- .../features.space.impl.leave_LeaveSpaceView_Night_6_en.png | 4 ++-- .../features.space.impl.leave_LeaveSpaceView_Night_7_en.png | 4 ++-- .../features.space.impl.leave_LeaveSpaceView_Night_8_en.png | 4 ++-- .../features.space.impl.leave_LeaveSpaceView_Night_9_en.png | 3 +++ 20 files changed, 42 insertions(+), 36 deletions(-) create mode 100644 tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_9_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_9_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_0_en.png index b3abe80b38..ba768c622f 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5219016a0af4e1e4f7703dcef3400bf030444845dc0ef52f084e69963170bf1e -size 13951 +oid sha256:3c569a42e638a49554127d3dc59c1dda5306e50406a6eaf1033345b44e2fe037 +size 13941 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_1_en.png index c7a2988917..b42cde52d6 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:964aff2fc49b9d71ebec506bc9acc5010071efca01ed95f240a65b2c91f0f3b1 -size 15852 +oid sha256:b43c73a9da70133fdff8c63dd6ed8b130b76fbbc28977fae9a34733b0f64faea +size 15840 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_2_en.png index 8be29a4767..a6ed2b4caa 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a2097486621a733b1662c02849ec71423d00f9c97c679996fafc8f20a4ef27ce -size 43844 +oid sha256:02ea4a02c0901c3d31a86272659f251e3b9299f669177b7a46a9007159989519 +size 44313 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_3_en.png index 3b29fd1488..78de62dc26 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:daff7333af912c8a0d7f009ba10d044aaa7cdfea2dfa3ccff2fd97209bf3278e -size 44225 +oid sha256:0a8d851c9393b9a64642d08be96b0c449ce894fdf69d52d0eb664d83c7005520 +size 44211 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_4_en.png index 6ffb98cf91..9580492e5e 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:aa05867ac79a3bfb9f59bf1dafb02185274b46e9a5fbf27415a0e907780a0493 -size 36393 +oid sha256:78cc2ef488b13f72da4d1d149329930816e1d91541bbe62eb48c78b61564dc18 +size 35868 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_5_en.png index aba8f0ea75..434ea3b936 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e74abc389c25fa9a5d33ade6d0f8a2242aeb5150fe81a26e26e9b561c46bb802 -size 43010 +oid sha256:426847168cf1cf81df60193ba69d1c02cad567f0a72c03f0b2dadb76e92f41c0 +size 42600 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_6_en.png index 3191bb9c6e..ae57be48c5 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b5c8bdf8d389cccb4cced29aa854a70860e9b44cbdde2a25ab95ccd5bc5e1674 -size 39222 +oid sha256:479fb84a857b3fcb15a9bba6a6fac3f8e1309a7d5af9c10c1cfe1d16c2eb678f +size 42208 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_7_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_7_en.png index 774c1e01c3..e2a34d1b7f 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bbf5d7c5320daa0d6b3c6678a6766c7143bc877850257e2c6923b174dfa9b6d4 -size 34565 +oid sha256:ba7631fe1e5d20946256829e8414f5a305e3a2755d79fc0693fd932bfac40f4a +size 40182 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_8_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_8_en.png index 38f435b1b0..5040a8439e 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:298db58a3f80f4194c5710ceeabec0e49b0e80861b931693d58b1a17f4394b4e -size 13873 +oid sha256:792f623cbd264f29724864f2d8db1c8044580fe309241b0a9afd8bea355ba289 +size 13862 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_9_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_9_en.png new file mode 100644 index 0000000000..8d62c122e7 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_9_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:520698bcbc65cd2ccbc73aff01132a6b5d39c7d349a814e5a26e584cec5f2eeb +size 26083 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_0_en.png index 1c82d3955f..4b003761fc 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b982dc465fd47dbce6f88321f80c5390ecc90f7e8ad59a76dd44b6c2b9b80c8a -size 13923 +oid sha256:1935a53ffe3653f9c094996a7b62359fe70c18ea0b46a7204969f70ce439b5e7 +size 13924 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_1_en.png index 485fa05bb5..855181d5fe 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:00b13582a4a8a380cc502ca2bb181c1ee7c9f2e1685e1d2a75817107d5ebea99 -size 15393 +oid sha256:f90882b794619797d17c9bdb8ff2e56664a5bd95ab9bee0bb59371f954c0e7ca +size 15395 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_2_en.png index a52fe36b2e..8f6d72cc9e 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6ad6ae058adb3decb4733c74ad40e4efdd33744eea3e9a793e92fc6b4cbc1464 -size 42784 +oid sha256:4071cfff533916af1c99636ce6f61e2fbfd8d945dbba1b11e449ce55fd4bd7b9 +size 43148 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_3_en.png index 225298ea51..e4a21c1d3e 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ad30950d847bd769e19ce67d8944c16145e929c52210cd54b1ebc5966efe89b5 -size 43221 +oid sha256:03dd230a42399b5db39a57dbde170955c7f40793fced9420e2fcf8c55820b8c4 +size 42948 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_4_en.png index d7a024d955..cd30a49899 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6465f03be41ae02635cbdc94f521059bdd6476c5b2ccfca6f28ccd8898112cea -size 35530 +oid sha256:f8d4a0d5f17cf6781d34af68ec0fae1c64cdf88d59ddf4306db411c4c67fcfd8 +size 34945 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_5_en.png index 171d67f826..6038b2a50e 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:af017745220027708f5a84ca639c9990fe7e474a38aaeecc3cba82011d195360 -size 42080 +oid sha256:26fcf6cfae843737df553b143d9cbb8049001548b0c3479e1e24a28e0c5e6527 +size 41632 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_6_en.png index ea980ce184..64dab99eb8 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:73f85bf9ec6c6682e293db2c740d206f6d895d79b3f3d30b967ad55aedbb268c -size 37895 +oid sha256:bc8a9e1d6fd7a81cf1ef718d2f3e38babcf96e58e00c2c92827cba0aadd8903e +size 40613 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_7_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_7_en.png index 55995f7fd6..5629c9bab1 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:49e2727c42d334c539ee14aab396cadd68b1290e6a256ec2f677f7e4801b30e9 -size 32677 +oid sha256:1f0ce9d4bbe5a8bdadee5663e8ad8b9b189aa25b5eec7e7c1a40430c09f7d1d3 +size 38108 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_8_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_8_en.png index a3e293c5f5..76f1c87fd1 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6ef494178398bc0f8e7721b7dfde669da4d723aeafa0d4ca6975a58215193416 -size 13848 +oid sha256:fffb92c5520c5b8665ce4be8eba4f1d9133047140ef116562440a40f319dc2ac +size 13852 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_9_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_9_en.png new file mode 100644 index 0000000000..dc1018d69b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_9_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3ac66ddfcf3125726d2a9a7a83ef23880339f56c12bf2cad68bc969a4deb0f38 +size 25776 From 2c3e4a45e42a83008564910bc709a8fde2cc0972 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 2 Oct 2025 19:49:34 +0200 Subject: [PATCH 07/10] Leave space: notify the room membership change --- .../android/libraries/matrix/impl/RustMatrixClient.kt | 5 ++++- .../libraries/matrix/impl/spaces/RustLeaveSpaceHandle.kt | 9 +++++++++ .../libraries/matrix/impl/spaces/RustSpaceService.kt | 3 +++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 3f84621cb7..a84a7bc164 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -147,6 +147,8 @@ class RustMatrixClient( private val innerRoomListService = innerSyncService.roomListService() private val innerSpaceService = innerClient.spaceService() + private val roomMembershipObserver = RoomMembershipObserver() + private val rustSyncService = RustSyncService( inner = innerSyncService, dispatcher = sessionDispatcher, @@ -189,6 +191,7 @@ class RustMatrixClient( override val spaceService: SpaceService = RustSpaceService( innerSpaceService = innerSpaceService, + roomMembershipObserver = roomMembershipObserver, sessionCoroutineScope = sessionCoroutineScope, sessionDispatcher = sessionDispatcher, ) @@ -200,7 +203,7 @@ class RustMatrixClient( ) private val roomInfoMapper = RoomInfoMapper() - private val roomMembershipObserver = RoomMembershipObserver() + private val roomFactory = RustRoomFactory( roomListService = roomListService, innerRoomListService = innerRoomListService, 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 index aa8cff7024..e7e629b40b 100644 --- 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 @@ -9,6 +9,8 @@ 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.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.spaces.LeaveSpaceHandle import io.element.android.libraries.matrix.api.spaces.LeaveSpaceRoom import kotlinx.coroutines.CompletableDeferred @@ -21,6 +23,7 @@ import org.matrix.rustcomponents.sdk.LeaveSpaceHandle as RustLeaveSpaceHandle class RustLeaveSpaceHandle( override val id: RoomId, private val spaceRoomMapper: SpaceRoomMapper, + private val roomMembershipObserver: RoomMembershipObserver, sessionCoroutineScope: CoroutineScope, private val innerProvider: suspend () -> RustLeaveSpaceHandle, ) : LeaveSpaceHandle { @@ -45,6 +48,12 @@ class RustLeaveSpaceHandle( // 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 }) + }.onSuccess { + roomMembershipObserver.notifyUserLeftRoom( + roomId = id, + isSpace = true, + membershipBeforeLeft = CurrentUserMembership.JOINED, + ) } @OptIn(ExperimentalCoroutinesApi::class) 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 784cc586e2..88f738f0c4 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.room.RoomMembershipObserver 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 @@ -38,6 +39,7 @@ class RustSpaceService( private val innerSpaceService: ClientSpaceService, private val sessionCoroutineScope: CoroutineScope, private val sessionDispatcher: CoroutineDispatcher, + private val roomMembershipObserver: RoomMembershipObserver, ) : SpaceService { private val spaceRoomMapper = SpaceRoomMapper() override val spaceRoomsFlow = MutableSharedFlow>(replay = 1, extraBufferCapacity = 1) @@ -69,6 +71,7 @@ class RustSpaceService( return RustLeaveSpaceHandle( id = spaceId, spaceRoomMapper = spaceRoomMapper, + roomMembershipObserver = roomMembershipObserver, sessionCoroutineScope = sessionCoroutineScope, ) { innerSpaceService.leaveSpace(spaceId.value) From b8e046cf4d8017228d924d4142c665fa1df083c8 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 3 Oct 2025 14:43:47 +0200 Subject: [PATCH 08/10] Improve LeaveSpacePresenter and add a retry mechanism if loading the rooms fails. --- .../space/impl/leave/LeaveSpaceEvents.kt | 1 + .../space/impl/leave/LeaveSpacePresenter.kt | 77 +++++++++++-------- .../space/impl/leave/LeaveSpaceView.kt | 4 +- .../impl/leave/LeaveSpacePresenterTest.kt | 10 ++- 4 files changed, 57 insertions(+), 35 deletions(-) diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceEvents.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceEvents.kt index 3c963a0bf5..558ae8454d 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceEvents.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceEvents.kt @@ -10,6 +10,7 @@ package io.element.android.features.space.impl.leave import io.element.android.libraries.matrix.api.core.RoomId sealed interface LeaveSpaceEvents { + data object Retry : LeaveSpaceEvents data object SelectAllRooms : LeaveSpaceEvents data object DeselectAllRooms : LeaveSpaceEvents data class ToggleRoomSelection(val roomId: RoomId) : LeaveSpaceEvents 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 2754364676..ffaca68a67 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 @@ -8,10 +8,11 @@ package io.element.android.features.space.impl.leave import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -26,10 +27,9 @@ import io.element.android.libraries.architecture.runUpdatingState 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.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toImmutableList -import kotlinx.collections.immutable.toPersistentSet import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -42,43 +42,55 @@ class LeaveSpacePresenter( fun create(leaveSpaceHandle: LeaveSpaceHandle): LeaveSpacePresenter } + data class LeaveSpaceRooms( + val current: LeaveSpaceRoom?, + val others: List, + ) + @Composable override fun present(): LeaveSpaceState { val coroutineScope = rememberCoroutineScope() - var currentSpace: LeaveSpaceRoom? by remember { mutableStateOf(null) } + var retryCount by remember { mutableIntStateOf(0) } val leaveSpaceAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } - val selectedRoomIds = remember { - mutableStateOf>(persistentSetOf()) + var selectedRoomIds by remember { + mutableStateOf>(setOf()) } - val leaveSpaceRooms by produceState(AsyncData.Loading()) { + var leaveSpaceRooms by remember { + mutableStateOf>(AsyncData.Loading()) + } + LaunchedEffect(retryCount) { 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 + selectedRoomIds = otherRooms .filter { it.isLastAdmin.not() } .map { it.spaceRoom.roomId } - .toPersistentSet() - value = rooms.fold( - onSuccess = { AsyncData.Success(otherRooms) }, + leaveSpaceRooms = rooms.fold( + onSuccess = { + AsyncData.Success( + LeaveSpaceRooms( + current = currentRoom.firstOrNull(), + others = otherRooms.toImmutableList(), + ) + ) + }, onFailure = { AsyncData.Failure(it) } ) } - val selectableSpaceRooms by produceState( - initialValue = AsyncData.Loading(), - key1 = leaveSpaceRooms, - key2 = selectedRoomIds.value, - ) { - value = leaveSpaceRooms.map { list -> - list.orEmpty().map { room -> + var selectableSpaceRooms by remember { + mutableStateOf>>(AsyncData.Loading()) + } + LaunchedEffect(selectedRoomIds, leaveSpaceRooms) { + selectableSpaceRooms = leaveSpaceRooms.map { + it?.others.orEmpty().map { room -> SelectableSpaceRoom( spaceRoom = room.spaceRoom, isLastAdmin = room.isLastAdmin, - isSelected = selectedRoomIds.value.contains(room.spaceRoom.roomId), + isSelected = selectedRoomIds.contains(room.spaceRoom.roomId), ) }.toImmutableList() } @@ -86,28 +98,29 @@ class LeaveSpacePresenter( fun handleEvents(event: LeaveSpaceEvents) { when (event) { + LeaveSpaceEvents.Retry -> { + leaveSpaceRooms = AsyncData.Loading() + retryCount += 1 + } LeaveSpaceEvents.DeselectAllRooms -> { - selectedRoomIds.value = persistentSetOf() + selectedRoomIds = persistentSetOf() } LeaveSpaceEvents.SelectAllRooms -> { - selectedRoomIds.value = selectableSpaceRooms.dataOrNull() + selectedRoomIds = selectableSpaceRooms.dataOrNull() .orEmpty() .filter { it.isLastAdmin.not() } .map { it.spaceRoom.roomId } - .toPersistentSet() } is LeaveSpaceEvents.ToggleRoomSelection -> { - val currentSet = selectedRoomIds.value - selectedRoomIds.value = if (currentSet.contains(event.roomId)) { - currentSet - event.roomId + selectedRoomIds = if (selectedRoomIds.contains(event.roomId)) { + selectedRoomIds - event.roomId } else { - currentSet + event.roomId + selectedRoomIds + event.roomId } - .toPersistentSet() } LeaveSpaceEvents.LeaveSpace -> coroutineScope.leaveSpace( leaveSpaceAction = leaveSpaceAction, - selectedRoomIds = selectedRoomIds.value, + selectedRoomIds = selectedRoomIds, ) LeaveSpaceEvents.CloseError -> { leaveSpaceAction.value = AsyncAction.Uninitialized @@ -116,8 +129,8 @@ class LeaveSpacePresenter( } return LeaveSpaceState( - spaceName = currentSpace?.spaceRoom?.name, - isLastAdmin = currentSpace?.isLastAdmin == true, + spaceName = leaveSpaceRooms.dataOrNull()?.current?.spaceRoom?.name, + isLastAdmin = leaveSpaceRooms.dataOrNull()?.current?.isLastAdmin == true, selectableSpaceRooms = selectableSpaceRooms, leaveSpaceAction = leaveSpaceAction.value, eventSink = ::handleEvents, @@ -126,7 +139,7 @@ class LeaveSpacePresenter( private fun CoroutineScope.leaveSpace( leaveSpaceAction: MutableState>, - selectedRoomIds: Set, + selectedRoomIds: Collection, ) = launch { runUpdatingState(leaveSpaceAction) { leaveSpaceHandle.leave(selectedRoomIds.toList()) 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 c28b1661c7..ebf4bd178a 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 @@ -111,7 +111,9 @@ fun LeaveSpaceView( is AsyncData.Failure -> item { AsyncFailure( throwable = state.selectableSpaceRooms.error, - onRetry = null, + onRetry = { + state.eventSink(LeaveSpaceEvents.Retry) + }, ) } is AsyncData.Loading, 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 b5d6f90923..5bdd100c93 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 @@ -60,9 +60,15 @@ class LeaveSpacePresenterTest { val state = awaitItem() assertThat(state.selectableSpaceRooms.isLoading()).isTrue() assertThat(state.leaveSpaceAction).isEqualTo(AsyncAction.Uninitialized) - skipItems(2) + skipItems(3) val stateError = awaitItem() assertThat(stateError.selectableSpaceRooms.isFailure()).isTrue() + // Retry + stateError.eventSink(LeaveSpaceEvents.Retry) + skipItems(1) + val stateLoadingAgain = awaitItem() + assertThat(stateLoadingAgain.selectableSpaceRooms.isLoading()).isTrue() + cancelAndIgnoreRemainingEvents() } } @@ -166,7 +172,7 @@ class LeaveSpacePresenterTest { ) ) presenter.test { - skipItems(3) + skipItems(4) val state = awaitItem() state.eventSink(LeaveSpaceEvents.LeaveSpace) val stateLeaving = awaitItem() From 9d93314dd93e21e56fbf3a9b875475ea1bb480da Mon Sep 17 00:00:00 2001 From: ElementBot Date: Fri, 3 Oct 2025 13:13:53 +0000 Subject: [PATCH 09/10] Update screenshots --- .../features.space.impl.leave_LeaveSpaceView_Day_8_en.png | 4 ++-- .../features.space.impl.leave_LeaveSpaceView_Night_8_en.png | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_8_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_8_en.png index 5040a8439e..58c84eca38 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Day_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:792f623cbd264f29724864f2d8db1c8044580fe309241b0a9afd8bea355ba289 -size 13862 +oid sha256:6d5be43f0ae09dfea01efa28519b003c6c3bbd8a47c8329ccc9721acdc84a116 +size 16531 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_8_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_8_en.png index 76f1c87fd1..501d938326 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.leave_LeaveSpaceView_Night_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fffb92c5520c5b8665ce4be8eba4f1d9133047140ef116562440a40f319dc2ac -size 13852 +oid sha256:16d9c0ce7ffefb7825efb5e824c06bc721f85f3ff88340abf9c41b2b4679492c +size 16435 From 9064318168d578cdcb09e641c80531c66c827e24 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 3 Oct 2025 16:11:25 +0200 Subject: [PATCH 10/10] Fix lint issue by removing old translations --- features/space/impl/src/main/res/values-cs/translations.xml | 1 - features/space/impl/src/main/res/values-da/translations.xml | 1 - features/space/impl/src/main/res/values-de/translations.xml | 1 - features/space/impl/src/main/res/values-fr/translations.xml | 1 - features/space/impl/src/main/res/values-hu/translations.xml | 1 - 5 files changed, 5 deletions(-) diff --git a/features/space/impl/src/main/res/values-cs/translations.xml b/features/space/impl/src/main/res/values-cs/translations.xml index 8a0886e786..8ab1f64989 100644 --- a/features/space/impl/src/main/res/values-cs/translations.xml +++ b/features/space/impl/src/main/res/values-cs/translations.xml @@ -1,6 +1,5 @@ - "(Správce)" "Opustit %1$d místnost a prostor" "Opustit %1$d místnosti a prostor" diff --git a/features/space/impl/src/main/res/values-da/translations.xml b/features/space/impl/src/main/res/values-da/translations.xml index ee6c2fcfb9..4863ff7cca 100644 --- a/features/space/impl/src/main/res/values-da/translations.xml +++ b/features/space/impl/src/main/res/values-da/translations.xml @@ -1,6 +1,5 @@ - "Administrator" "Forlad %1$d rum og klynge" "Forlad %1$d rum og klynger" diff --git a/features/space/impl/src/main/res/values-de/translations.xml b/features/space/impl/src/main/res/values-de/translations.xml index faab578205..2ecdac3115 100644 --- a/features/space/impl/src/main/res/values-de/translations.xml +++ b/features/space/impl/src/main/res/values-de/translations.xml @@ -1,6 +1,5 @@ - "(Admin)" "%1$d Chat und Space verlassen" "%1$d Chats und Space verlassen" diff --git a/features/space/impl/src/main/res/values-fr/translations.xml b/features/space/impl/src/main/res/values-fr/translations.xml index 5ff48f6c39..3c9f2d3ab9 100644 --- a/features/space/impl/src/main/res/values-fr/translations.xml +++ b/features/space/impl/src/main/res/values-fr/translations.xml @@ -1,6 +1,5 @@ - "(Admin)" "Quitter %1$d salon et l’espace" "Quitter %1$d salons et l’espace" diff --git a/features/space/impl/src/main/res/values-hu/translations.xml b/features/space/impl/src/main/res/values-hu/translations.xml index 8196780cf9..bb326f759a 100644 --- a/features/space/impl/src/main/res/values-hu/translations.xml +++ b/features/space/impl/src/main/res/values-hu/translations.xml @@ -1,6 +1,5 @@ - "(Adminisztrátor)" "%1$d szoba és tér elhagyása" "%1$d szoba és tér elhagyása"