Leave space: use the SDK API.

This commit is contained in:
Benoit Marty 2025-09-30 15:27:55 +02:00 committed by Benoit Marty
parent f726b2a9a4
commit c36577889d
16 changed files with 493 additions and 118 deletions

View file

@ -9,21 +9,39 @@ package io.element.android.features.space.impl.leave
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode 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.features.space.impl.di.SpaceFlowScope
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.matrix.api.MatrixClient
@ContributesNode(SpaceFlowScope::class) @ContributesNode(SpaceFlowScope::class)
@AssistedInject @AssistedInject
class LeaveSpaceNode( class LeaveSpaceNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val presenter: LeaveSpacePresenter, matrixClient: MatrixClient,
presenterFactory: LeaveSpacePresenter.Factory,
) : Node(buildContext, plugins = plugins) { ) : 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 @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()

View file

@ -9,65 +9,79 @@ package io.element.android.features.space.impl.leave
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope 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.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter 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.architecture.runUpdatingState
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.SpaceRoom import io.element.android.libraries.matrix.api.spaces.LeaveSpaceHandle
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList import io.element.android.libraries.matrix.api.spaces.LeaveSpaceRoom
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentSet import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.jvm.optionals.getOrNull
@Inject @AssistedInject
class LeaveSpacePresenter( class LeaveSpacePresenter(
private val spaceRoomList: SpaceRoomList, @Assisted private val leaveSpaceHandle: LeaveSpaceHandle,
) : Presenter<LeaveSpaceState> { ) : Presenter<LeaveSpaceState> {
@AssistedFactory
fun interface Factory {
fun create(leaveSpaceHandle: LeaveSpaceHandle): LeaveSpacePresenter
}
@Composable @Composable
override fun present(): LeaveSpaceState { override fun present(): LeaveSpaceState {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val currentSpace by spaceRoomList.currentSpaceFlow.collectAsState() var currentSpace: LeaveSpaceRoom? by remember { mutableStateOf(null) }
val leaveSpaceAction = remember { val leaveSpaceAction = remember {
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
} }
val selectedRoomIds = remember { val selectedRoomIds = remember {
mutableStateOf<ImmutableSet<RoomId>>(persistentSetOf()) mutableStateOf<ImmutableSet<RoomId>>(persistentSetOf())
} }
val joinedSpaceRooms by produceState(emptyList()) { val leaveSpaceRooms by produceState(AsyncData.Loading()) {
// TODO Get the joined room from the SDK, should also have the isLastAdmin boolean val rooms = leaveSpaceHandle.rooms()
val rooms = emptyList<SpaceRoom>() val (currentRoom, otherRooms) = rooms.getOrNull()
// By default select all rooms .orEmpty()
selectedRoomIds.value = rooms.map { it.roomId }.toPersistentSet() .partition { it.spaceRoom.roomId == leaveSpaceHandle.id }
value = rooms 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<AsyncData<ImmutableList<SelectableSpaceRoom>>>( val selectableSpaceRooms by produceState(
initialValue = AsyncData.Uninitialized, initialValue = AsyncData.Loading(),
key1 = joinedSpaceRooms, key1 = leaveSpaceRooms,
key2 = selectedRoomIds.value, key2 = selectedRoomIds.value,
) { ) {
value = AsyncData.Success( value = leaveSpaceRooms.map { list ->
joinedSpaceRooms.map { list.orEmpty().map { room ->
SelectableSpaceRoom( SelectableSpaceRoom(
spaceRoom = it, spaceRoom = room.spaceRoom,
// TODO Get this value from the SDK isLastAdmin = room.isLastAdmin,
isLastAdmin = false, isSelected = selectedRoomIds.value.contains(room.spaceRoom.roomId),
isSelected = selectedRoomIds.value.contains(it.roomId),
) )
}.toPersistentList() }.toImmutableList()
) }
} }
fun handleEvents(event: LeaveSpaceEvents) { fun handleEvents(event: LeaveSpaceEvents) {
@ -102,7 +116,8 @@ class LeaveSpacePresenter(
} }
return LeaveSpaceState( return LeaveSpaceState(
spaceName = currentSpace.getOrNull()?.name, spaceName = currentSpace?.spaceRoom?.name,
isLastAdmin = currentSpace?.isLastAdmin == true,
selectableSpaceRooms = selectableSpaceRooms, selectableSpaceRooms = selectableSpaceRooms,
leaveSpaceAction = leaveSpaceAction.value, leaveSpaceAction = leaveSpaceAction.value,
eventSink = ::handleEvents, eventSink = ::handleEvents,
@ -111,11 +126,10 @@ class LeaveSpacePresenter(
private fun CoroutineScope.leaveSpace( private fun CoroutineScope.leaveSpace(
leaveSpaceAction: MutableState<AsyncAction<Unit>>, leaveSpaceAction: MutableState<AsyncAction<Unit>>,
@Suppress("unused") selectedRoomIds: Set<RoomId>, selectedRoomIds: Set<RoomId>,
) = launch { ) = launch {
runUpdatingState(leaveSpaceAction) { runUpdatingState(leaveSpaceAction) {
// TODO SDK API call to leave all the rooms and space leaveSpaceHandle.leave(selectedRoomIds.toList())
Result.failure(Exception("Not implemented"))
} }
} }
} }

View file

@ -13,6 +13,7 @@ import kotlinx.collections.immutable.ImmutableList
data class LeaveSpaceState( data class LeaveSpaceState(
val spaceName: String?, val spaceName: String?,
val isLastAdmin: Boolean,
val selectableSpaceRooms: AsyncData<ImmutableList<SelectableSpaceRoom>>, val selectableSpaceRooms: AsyncData<ImmutableList<SelectableSpaceRoom>>,
val leaveSpaceAction: AsyncAction<Unit>, val leaveSpaceAction: AsyncAction<Unit>,
val eventSink: (LeaveSpaceEvents) -> Unit, val eventSink: (LeaveSpaceEvents) -> Unit,
@ -25,7 +26,12 @@ data class LeaveSpaceState(
/** /**
* True if we should show the quick action to select/deselect all rooms. * 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. * True if there all the selectable rooms are selected.

View file

@ -105,15 +105,20 @@ class LeaveSpaceStateProvider : PreviewParameterProvider<LeaveSpaceState> {
aLeaveSpaceState( aLeaveSpaceState(
selectableSpaceRooms = AsyncData.Failure(Exception("An error")), selectableSpaceRooms = AsyncData.Failure(Exception("An error")),
), ),
aLeaveSpaceState(
isLastAdmin = true,
),
) )
} }
fun aLeaveSpaceState( fun aLeaveSpaceState(
spaceName: String? = "Space name", spaceName: String? = "Space name",
isLastAdmin: Boolean = false,
selectableSpaceRooms: AsyncData<ImmutableList<SelectableSpaceRoom>> = AsyncData.Uninitialized, selectableSpaceRooms: AsyncData<ImmutableList<SelectableSpaceRoom>> = AsyncData.Uninitialized,
leaveSpaceAction: AsyncAction<Unit> = AsyncAction.Uninitialized, leaveSpaceAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
) = LeaveSpaceState( ) = LeaveSpaceState(
spaceName = spaceName, spaceName = spaceName,
isLastAdmin = isLastAdmin,
selectableSpaceRooms = selectableSpaceRooms, selectableSpaceRooms = selectableSpaceRooms,
leaveSpaceAction = leaveSpaceAction, leaveSpaceAction = leaveSpaceAction,
eventSink = { } eventSink = { }

View file

@ -9,8 +9,10 @@
package io.element.android.features.space.impl.leave package io.element.android.features.space.impl.leave
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@ -85,41 +87,42 @@ fun LeaveSpaceView(
.imePadding() .imePadding()
.consumeWindowInsets(padding) .consumeWindowInsets(padding)
.fillMaxSize() .fillMaxSize()
.padding(16.dp)
) { ) {
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.weight(1f), .weight(1f),
) { ) {
when (state.selectableSpaceRooms) { if (state.isLastAdmin.not()) {
is AsyncData.Success -> { when (state.selectableSpaceRooms) {
// List rooms where the user is the only admin is AsyncData.Success -> {
state.selectableSpaceRooms.data.forEach { selectableSpaceRoom -> // List rooms where the user is the only admin
item { state.selectableSpaceRooms.data.forEach { selectableSpaceRoom ->
SpaceItem( item {
selectableSpaceRoom = selectableSpaceRoom, SpaceItem(
showCheckBox = state.hasOnlyLastAdminRoom.not(), selectableSpaceRoom = selectableSpaceRoom,
onClick = { showCheckBox = state.hasOnlyLastAdminRoom.not(),
state.eventSink(LeaveSpaceEvents.ToggleRoomSelection(selectableSpaceRoom.spaceRoom.roomId)) onClick = {
} state.eventSink(LeaveSpaceEvents.ToggleRoomSelection(selectableSpaceRoom.spaceRoom.roomId))
) }
)
}
} }
} }
} is AsyncData.Failure -> item {
is AsyncData.Failure -> item { AsyncFailure(
AsyncFailure( throwable = state.selectableSpaceRooms.error,
throwable = state.selectableSpaceRooms.error, onRetry = null,
onRetry = null, )
) }
} is AsyncData.Loading,
is AsyncData.Loading, AsyncData.Uninitialized -> item {
AsyncData.Uninitialized -> item { AsyncLoading()
AsyncLoading() }
} }
} }
} }
LeaveSpaceButtons( LeaveSpaceButtons(
showLeaveButton = state.selectableSpaceRooms is AsyncData.Success, showLeaveButton = state.showLeaveButton,
selectedRoomsCount = state.selectedRoomsCount, selectedRoomsCount = state.selectedRoomsCount,
onLeaveSpace = { onLeaveSpace = {
state.eventSink(LeaveSpaceEvents.LeaveSpace) state.eventSink(LeaveSpaceEvents.LeaveSpace)
@ -132,6 +135,7 @@ fun LeaveSpaceView(
AsyncActionView( AsyncActionView(
async = state.leaveSpaceAction, async = state.leaveSpaceAction,
onSuccess = { /* Nothing to do, the screen will be dismissed automatically */ }, onSuccess = { /* Nothing to do, the screen will be dismissed automatically */ },
errorMessage = { stringResource(CommonStrings.error_unknown) },
onErrorDismiss = { state.eventSink(LeaveSpaceEvents.CloseError) }, 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), modifier = Modifier.padding(top = 0.dp, bottom = 8.dp, start = 24.dp, end = 24.dp),
iconStyle = BigIcon.Style.AlertSolid, iconStyle = BigIcon.Style.AlertSolid,
title = stringResource( 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) state.spaceName ?: stringResource(CommonStrings.common_space)
), ),
subTitle = 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) { if (state.hasOnlyLastAdminRoom) {
stringResource(R.string.screen_leave_space_subtitle_only_last_admin) stringResource(R.string.screen_leave_space_subtitle_only_last_admin)
} else { } else {
@ -168,34 +174,35 @@ private fun LeaveSpaceHeader(
) )
if (state.showQuickAction) { if (state.showQuickAction) {
if (state.areAllSelected) { if (state.areAllSelected) {
Text( QuickActionButton(CommonStrings.common_deselect_all) {
modifier = Modifier state.eventSink(LeaveSpaceEvents.DeselectAllRooms)
.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,
)
} else { } else {
Text( QuickActionButton(resId = CommonStrings.common_select_all) {
modifier = Modifier state.eventSink(LeaveSpaceEvents.SelectAllRooms)
.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,
)
} }
} }
} }
} }
@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 @Composable
private fun LeaveSpaceButtons( private fun LeaveSpaceButtons(
showLeaveButton: Boolean, showLeaveButton: Boolean,
@ -204,7 +211,7 @@ private fun LeaveSpaceButtons(
onCancel: () -> Unit, onCancel: () -> Unit,
) { ) {
ButtonColumnMolecule( ButtonColumnMolecule(
modifier = Modifier.padding(top = 16.dp) modifier = Modifier.padding(16.dp)
) { ) {
if (showLeaveButton) { if (showLeaveButton) {
val text = if (selectedRoomsCount > 0) { val text = if (selectedRoomsCount > 0) {
@ -220,6 +227,8 @@ private fun LeaveSpaceButtons(
destructive = true, 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( TextButton(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_cancel), text = stringResource(CommonStrings.action_cancel),
@ -302,18 +311,15 @@ private fun SpaceItem(
) )
} }
// Number of members // Number of members
val subTitle = buildString { val membersCount = pluralStringResource(
append( CommonPlurals.common_member_count,
pluralStringResource( room.numJoinedMembers,
CommonPlurals.common_member_count, room.numJoinedMembers
room.numJoinedMembers, )
room.numJoinedMembers val subTitle = if (selectableSpaceRoom.isLastAdmin) {
) stringResource(R.string.screen_leave_space_last_admin_info, membersCount)
) } else {
if (selectableSpaceRoom.isLastAdmin) { membersCount
append(" ")
append(stringResource(R.string.screen_leave_space_last_admin_info))
}
} }
Text( Text(
modifier = Modifier.padding(end = 16.dp), modifier = Modifier.padding(end = 16.dp),

View file

@ -252,11 +252,16 @@ private fun SpaceViewTopBar(
showMenu = false showMenu = false
onLeaveSpaceClick() onLeaveSpaceClick()
}, },
text = { Text(stringResource(id = CommonStrings.action_leave)) }, text = {
Text(
text = stringResource(id = CommonStrings.action_leave),
color = ElementTheme.colors.textCriticalPrimary,
)
},
leadingIcon = { leadingIcon = {
Icon( Icon(
imageVector = CompoundIcons.Leave(), imageVector = CompoundIcons.Leave(),
tint = ElementTheme.colors.iconSecondary, tint = ElementTheme.colors.iconCriticalPrimary,
contentDescription = null, contentDescription = null,
) )
} }

View file

@ -1,11 +1,13 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_leave_space_last_admin_info">"(Admin)"</string> <string name="screen_leave_space_last_admin_info">"%1$s (Admin)"</string>
<plurals name="screen_leave_space_submit"> <plurals name="screen_leave_space_submit">
<item quantity="one">"Leave %1$d room and space"</item> <item quantity="one">"Leave %1$d room and space"</item>
<item quantity="other">"Leave %1$d rooms and space"</item> <item quantity="other">"Leave %1$d rooms and space"</item>
</plurals> </plurals>
<string name="screen_leave_space_subtitle">"Select the rooms youd like to leave which you\'re not the only administrator for:"</string> <string name="screen_leave_space_subtitle">"Select the rooms youd like to leave which you\'re not the only administrator for:"</string>
<string name="screen_leave_space_subtitle_last_admin">"You need to assign another admin for this space before you can leave."</string>
<string name="screen_leave_space_subtitle_only_last_admin">"You will not be removed from the following room(s) because you\'re the only administrator:"</string> <string name="screen_leave_space_subtitle_only_last_admin">"You will not be removed from the following room(s) because you\'re the only administrator:"</string>
<string name="screen_leave_space_title">"Leave %1$s?"</string> <string name="screen_leave_space_title">"Leave %1$s?"</string>
<string name="screen_leave_space_title_last_admin">"You are the only admin for %1$s"</string>
</resources> </resources>

View file

@ -5,60 +5,197 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.space.impl.leave package io.element.android.features.space.impl.leave
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList import io.element.android.libraries.matrix.api.spaces.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.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.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 io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
class LeaveSpacePresenterTest { class LeaveSpacePresenterTest {
private val aSpace = aSpaceRoom(
roomId = A_SPACE_ID,
name = A_SPACE_NAME,
)
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val presenter = createLeaveSpacePresenter() val presenter = createLeaveSpacePresenter(
leaveSpaceHandle = FakeLeaveSpaceHandle(
roomsResult = { Result.success(emptyList()) },
),
)
presenter.test { presenter.test {
val state = awaitItem() val state = awaitItem()
assertThat(state.spaceName).isNull() 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) assertThat(state.leaveSpaceAction).isEqualTo(AsyncAction.Uninitialized)
skipItems(1) cancelAndIgnoreRemainingEvents()
} }
} }
@Test @Test
fun `present - current space name`() = runTest { fun `present - fail to load rooms`() = runTest {
val fakeSpaceRoomList = FakeSpaceRoomList()
val presenter = createLeaveSpacePresenter( val presenter = createLeaveSpacePresenter(
spaceRoomList = fakeSpaceRoomList, leaveSpaceHandle = FakeLeaveSpaceHandle(
roomsResult = { Result.failure(AN_EXCEPTION) },
)
) )
presenter.test { presenter.test {
val state = awaitItem() val state = awaitItem()
advanceUntilIdle() assertThat(state.selectableSpaceRooms.isLoading()).isTrue()
assertThat(state.spaceName).isNull() assertThat(state.leaveSpaceAction).isEqualTo(AsyncAction.Uninitialized)
val aSpace = aSpaceRoom( skipItems(2)
name = A_SPACE_NAME 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<List<RoomId>, Result<Unit>> { 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) 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<List<RoomId>, Result<Unit>> {
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( private fun createLeaveSpacePresenter(
spaceRoomList: SpaceRoomList = FakeSpaceRoomList(), leaveSpaceHandle: LeaveSpaceHandle = FakeLeaveSpaceHandle(),
): LeaveSpacePresenter { ): LeaveSpacePresenter {
return 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,
)

View file

@ -20,6 +20,7 @@ class LeaveSpaceStateTest {
selectableSpaceRooms = AsyncData.Loading() selectableSpaceRooms = AsyncData.Loading()
) )
assertThat(sut.showQuickAction).isFalse() assertThat(sut.showQuickAction).isFalse()
assertThat(sut.showLeaveButton).isFalse()
assertThat(sut.areAllSelected).isTrue() assertThat(sut.areAllSelected).isTrue()
assertThat(sut.hasOnlyLastAdminRoom).isFalse() assertThat(sut.hasOnlyLastAdminRoom).isFalse()
assertThat(sut.selectedRoomsCount).isEqualTo(0) assertThat(sut.selectedRoomsCount).isEqualTo(0)
@ -33,11 +34,29 @@ class LeaveSpaceStateTest {
) )
) )
assertThat(sut.showQuickAction).isFalse() assertThat(sut.showQuickAction).isFalse()
assertThat(sut.showLeaveButton).isTrue()
assertThat(sut.areAllSelected).isTrue() assertThat(sut.areAllSelected).isTrue()
assertThat(sut.hasOnlyLastAdminRoom).isFalse() assertThat(sut.hasOnlyLastAdminRoom).isFalse()
assertThat(sut.selectedRoomsCount).isEqualTo(0) 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 @Test
fun `test no last admin, 1 selected, 1 not selected`() { fun `test no last admin, 1 selected, 1 not selected`() {
val sut = aLeaveSpaceState( val sut = aLeaveSpaceState(
@ -49,6 +68,7 @@ class LeaveSpaceStateTest {
) )
) )
assertThat(sut.showQuickAction).isTrue() assertThat(sut.showQuickAction).isTrue()
assertThat(sut.showLeaveButton).isTrue()
assertThat(sut.areAllSelected).isFalse() assertThat(sut.areAllSelected).isFalse()
assertThat(sut.hasOnlyLastAdminRoom).isFalse() assertThat(sut.hasOnlyLastAdminRoom).isFalse()
assertThat(sut.selectedRoomsCount).isEqualTo(1) assertThat(sut.selectedRoomsCount).isEqualTo(1)
@ -65,6 +85,7 @@ class LeaveSpaceStateTest {
) )
) )
assertThat(sut.showQuickAction).isTrue() assertThat(sut.showQuickAction).isTrue()
assertThat(sut.showLeaveButton).isTrue()
assertThat(sut.areAllSelected).isTrue() assertThat(sut.areAllSelected).isTrue()
assertThat(sut.hasOnlyLastAdminRoom).isFalse() assertThat(sut.hasOnlyLastAdminRoom).isFalse()
assertThat(sut.selectedRoomsCount).isEqualTo(2) assertThat(sut.selectedRoomsCount).isEqualTo(2)
@ -82,6 +103,7 @@ class LeaveSpaceStateTest {
) )
) )
assertThat(sut.showQuickAction).isTrue() assertThat(sut.showQuickAction).isTrue()
assertThat(sut.showLeaveButton).isTrue()
assertThat(sut.areAllSelected).isTrue() assertThat(sut.areAllSelected).isTrue()
assertThat(sut.hasOnlyLastAdminRoom).isFalse() assertThat(sut.hasOnlyLastAdminRoom).isFalse()
assertThat(sut.selectedRoomsCount).isEqualTo(2) assertThat(sut.selectedRoomsCount).isEqualTo(2)
@ -98,6 +120,7 @@ class LeaveSpaceStateTest {
) )
) )
assertThat(sut.showQuickAction).isFalse() assertThat(sut.showQuickAction).isFalse()
assertThat(sut.showLeaveButton).isTrue()
assertThat(sut.areAllSelected).isTrue() assertThat(sut.areAllSelected).isTrue()
assertThat(sut.hasOnlyLastAdminRoom).isTrue() assertThat(sut.hasOnlyLastAdminRoom).isTrue()
assertThat(sut.selectedRoomsCount).isEqualTo(0) assertThat(sut.selectedRoomsCount).isEqualTo(0)

View file

@ -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<List<LeaveSpaceRoom>>
/**
* Leave the space and the given rooms.
* If [roomIds] is empty, only the space will be left.
*/
suspend fun leave(roomIds: List<RoomId>): Result<Unit>
/**
* Close the handle and free resources.
*/
fun close()
}

View file

@ -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,
)

View file

@ -15,4 +15,6 @@ interface SpaceService {
suspend fun joinedSpaces(): Result<List<SpaceRoom>> suspend fun joinedSpaces(): Result<List<SpaceRoom>>
fun spaceRoomList(id: RoomId): SpaceRoomList fun spaceRoomList(id: RoomId): SpaceRoomList
fun getLeaveSpaceHandle(spaceId: RoomId): LeaveSpaceHandle
} }

View file

@ -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<RustLeaveSpaceHandle>()
init {
sessionCoroutineScope.launch {
inner.complete(innerProvider())
}
}
override suspend fun rooms(): Result<List<LeaveSpaceRoom>> = runCatchingExceptions {
inner.await().rooms().map { leaveSpaceRoom ->
LeaveSpaceRoom(
spaceRoom = spaceRoomMapper.map(leaveSpaceRoom.spaceRoom),
isLastAdmin = leaveSpaceRoom.isLastAdmin,
)
}
}
override suspend fun leave(roomIds: List<RoomId>): Result<Unit> = 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
}
}
}

View file

@ -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.coroutine.childScope
import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.LeaveSpaceHandle
import io.element.android.libraries.matrix.api.spaces.SpaceRoom 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.SpaceRoomList
import io.element.android.libraries.matrix.api.spaces.SpaceService 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 { init {
innerSpaceService innerSpaceService
.spaceListUpdate() .spaceListUpdate()

View file

@ -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<List<LeaveSpaceRoom>> = { lambdaError() },
private val leaveResult: (List<RoomId>) -> Result<Unit> = { lambdaError() },
private val closeResult: () -> Unit = { lambdaError() },
) : LeaveSpaceHandle {
override suspend fun rooms(): Result<List<LeaveSpaceRoom>> = simulateLongTask {
roomsResult()
}
override suspend fun leave(roomIds: List<RoomId>): Result<Unit> = simulateLongTask {
leaveResult(roomIds)
}
override fun close() {
return closeResult()
}
}

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.matrix.test.spaces package io.element.android.libraries.matrix.test.spaces
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.LeaveSpaceHandle
import io.element.android.libraries.matrix.api.spaces.SpaceRoom 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.SpaceRoomList
import io.element.android.libraries.matrix.api.spaces.SpaceService import io.element.android.libraries.matrix.api.spaces.SpaceService
@ -20,6 +21,7 @@ import kotlinx.coroutines.flow.asSharedFlow
class FakeSpaceService( class FakeSpaceService(
private val joinedSpacesResult: () -> Result<List<SpaceRoom>> = { lambdaError() }, private val joinedSpacesResult: () -> Result<List<SpaceRoom>> = { lambdaError() },
private val spaceRoomListResult: (RoomId) -> SpaceRoomList = { lambdaError() }, private val spaceRoomListResult: (RoomId) -> SpaceRoomList = { lambdaError() },
private val leaveSpaceHandleResult: (RoomId) -> LeaveSpaceHandle = { lambdaError() },
) : SpaceService { ) : SpaceService {
private val _spaceRoomsFlow = MutableSharedFlow<List<SpaceRoom>>() private val _spaceRoomsFlow = MutableSharedFlow<List<SpaceRoom>>()
override val spaceRoomsFlow: SharedFlow<List<SpaceRoom>> override val spaceRoomsFlow: SharedFlow<List<SpaceRoom>>
@ -36,4 +38,8 @@ class FakeSpaceService(
override fun spaceRoomList(id: RoomId): SpaceRoomList { override fun spaceRoomList(id: RoomId): SpaceRoomList {
return spaceRoomListResult(id) return spaceRoomListResult(id)
} }
override fun getLeaveSpaceHandle(spaceId: RoomId): LeaveSpaceHandle {
return leaveSpaceHandleResult(spaceId)
}
} }