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 c83fda1cad
commit c459af6e61
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.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<Plugin>,
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()

View file

@ -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<LeaveSpaceState> {
@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<Unit>>(AsyncAction.Uninitialized)
}
val selectedRoomIds = remember {
mutableStateOf<ImmutableSet<RoomId>>(persistentSetOf())
}
val joinedSpaceRooms by produceState(emptyList()) {
// TODO Get the joined room from the SDK, should also have the isLastAdmin boolean
val rooms = emptyList<SpaceRoom>()
// 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<AsyncData<ImmutableList<SelectableSpaceRoom>>>(
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<AsyncAction<Unit>>,
@Suppress("unused") selectedRoomIds: Set<RoomId>,
selectedRoomIds: Set<RoomId>,
) = launch {
runUpdatingState(leaveSpaceAction) {
// TODO SDK API call to leave all the rooms and space
Result.failure(Exception("Not implemented"))
leaveSpaceHandle.leave(selectedRoomIds.toList())
}
}
}

View file

@ -13,6 +13,7 @@ import kotlinx.collections.immutable.ImmutableList
data class LeaveSpaceState(
val spaceName: String?,
val isLastAdmin: Boolean,
val selectableSpaceRooms: AsyncData<ImmutableList<SelectableSpaceRoom>>,
val leaveSpaceAction: AsyncAction<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.
*/
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.

View file

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

View file

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

View file

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

View file

@ -1,11 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<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">
<item quantity="one">"Leave %1$d room and space"</item>
<item quantity="other">"Leave %1$d rooms and space"</item>
</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_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_title">"Leave %1$s?"</string>
<string name="screen_leave_space_title_last_admin">"You are the only admin for %1$s"</string>
</resources>

View file

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

View file

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

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>>
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.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()

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
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<List<SpaceRoom>> = { lambdaError() },
private val spaceRoomListResult: (RoomId) -> SpaceRoomList = { lambdaError() },
private val leaveSpaceHandleResult: (RoomId) -> LeaveSpaceHandle = { lambdaError() },
) : SpaceService {
private val _spaceRoomsFlow = MutableSharedFlow<List<SpaceRoom>>()
override val spaceRoomsFlow: SharedFlow<List<SpaceRoom>>
@ -36,4 +38,8 @@ class FakeSpaceService(
override fun spaceRoomList(id: RoomId): SpaceRoomList {
return spaceRoomListResult(id)
}
override fun getLeaveSpaceHandle(spaceId: RoomId): LeaveSpaceHandle {
return leaveSpaceHandleResult(spaceId)
}
}