Merge pull request #5432 from element-hq/feature/bma/leaveSpace

Leave space: use SDK API.
This commit is contained in:
Benoit Marty 2025-10-03 16:43:45 +02:00 committed by GitHub
commit ffde95fb84
48 changed files with 602 additions and 210 deletions

View file

@ -10,6 +10,7 @@ package io.element.android.features.space.impl.leave
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
sealed interface LeaveSpaceEvents { sealed interface LeaveSpaceEvents {
data object Retry : LeaveSpaceEvents
data object SelectAllRooms : LeaveSpaceEvents data object SelectAllRooms : LeaveSpaceEvents
data object DeselectAllRooms : LeaveSpaceEvents data object DeselectAllRooms : LeaveSpaceEvents
data class ToggleRoomSelection(val roomId: RoomId) : LeaveSpaceEvents data class ToggleRoomSelection(val roomId: RoomId) : LeaveSpaceEvents

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

@ -8,92 +8,119 @@
package io.element.android.features.space.impl.leave package io.element.android.features.space.impl.leave
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
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.ImmutableList
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.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
}
data class LeaveSpaceRooms(
val current: LeaveSpaceRoom?,
val others: List<LeaveSpaceRoom>,
)
@Composable @Composable
override fun present(): LeaveSpaceState { override fun present(): LeaveSpaceState {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val currentSpace by spaceRoomList.currentSpaceFlow.collectAsState() var retryCount by remember { mutableIntStateOf(0) }
val leaveSpaceAction = remember { val leaveSpaceAction = remember {
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
} }
val selectedRoomIds = remember { var selectedRoomIds by remember {
mutableStateOf<ImmutableSet<RoomId>>(persistentSetOf()) mutableStateOf<Collection<RoomId>>(setOf())
} }
val joinedSpaceRooms by produceState(emptyList()) { var leaveSpaceRooms by remember {
// TODO Get the joined room from the SDK, should also have the isLastAdmin boolean mutableStateOf<AsyncData<LeaveSpaceRooms>>(AsyncData.Loading())
val rooms = emptyList<SpaceRoom>()
// By default select all rooms
selectedRoomIds.value = rooms.map { it.roomId }.toPersistentSet()
value = rooms
} }
val selectableSpaceRooms by produceState<AsyncData<ImmutableList<SelectableSpaceRoom>>>( LaunchedEffect(retryCount) {
initialValue = AsyncData.Uninitialized, val rooms = leaveSpaceHandle.rooms()
key1 = joinedSpaceRooms, val (currentRoom, otherRooms) = rooms.getOrNull()
key2 = selectedRoomIds.value, .orEmpty()
) { .partition { it.spaceRoom.roomId == leaveSpaceHandle.id }
value = AsyncData.Success( // By default select all rooms that can be left
joinedSpaceRooms.map { selectedRoomIds = otherRooms
SelectableSpaceRoom( .filter { it.isLastAdmin.not() }
spaceRoom = it, .map { it.spaceRoom.roomId }
// TODO Get this value from the SDK leaveSpaceRooms = rooms.fold(
isLastAdmin = false, onSuccess = {
isSelected = selectedRoomIds.value.contains(it.roomId), AsyncData.Success(
LeaveSpaceRooms(
current = currentRoom.firstOrNull(),
others = otherRooms.toImmutableList(),
)
) )
}.toPersistentList() },
onFailure = { AsyncData.Failure(it) }
) )
} }
var selectableSpaceRooms by remember {
mutableStateOf<AsyncData<ImmutableList<SelectableSpaceRoom>>>(AsyncData.Loading())
}
LaunchedEffect(selectedRoomIds, leaveSpaceRooms) {
selectableSpaceRooms = leaveSpaceRooms.map {
it?.others.orEmpty().map { room ->
SelectableSpaceRoom(
spaceRoom = room.spaceRoom,
isLastAdmin = room.isLastAdmin,
isSelected = selectedRoomIds.contains(room.spaceRoom.roomId),
)
}.toImmutableList()
}
}
fun handleEvents(event: LeaveSpaceEvents) { fun handleEvents(event: LeaveSpaceEvents) {
when (event) { when (event) {
LeaveSpaceEvents.Retry -> {
leaveSpaceRooms = AsyncData.Loading()
retryCount += 1
}
LeaveSpaceEvents.DeselectAllRooms -> { LeaveSpaceEvents.DeselectAllRooms -> {
selectedRoomIds.value = persistentSetOf() selectedRoomIds = persistentSetOf()
} }
LeaveSpaceEvents.SelectAllRooms -> { LeaveSpaceEvents.SelectAllRooms -> {
selectedRoomIds.value = selectableSpaceRooms.dataOrNull() selectedRoomIds = selectableSpaceRooms.dataOrNull()
.orEmpty() .orEmpty()
.filter { it.isLastAdmin.not() } .filter { it.isLastAdmin.not() }
.map { it.spaceRoom.roomId } .map { it.spaceRoom.roomId }
.toPersistentSet()
} }
is LeaveSpaceEvents.ToggleRoomSelection -> { is LeaveSpaceEvents.ToggleRoomSelection -> {
val currentSet = selectedRoomIds.value selectedRoomIds = if (selectedRoomIds.contains(event.roomId)) {
selectedRoomIds.value = if (currentSet.contains(event.roomId)) { selectedRoomIds - event.roomId
currentSet - event.roomId
} else { } else {
currentSet + event.roomId selectedRoomIds + event.roomId
} }
.toPersistentSet()
} }
LeaveSpaceEvents.LeaveSpace -> coroutineScope.leaveSpace( LeaveSpaceEvents.LeaveSpace -> coroutineScope.leaveSpace(
leaveSpaceAction = leaveSpaceAction, leaveSpaceAction = leaveSpaceAction,
selectedRoomIds = selectedRoomIds.value, selectedRoomIds = selectedRoomIds,
) )
LeaveSpaceEvents.CloseError -> { LeaveSpaceEvents.CloseError -> {
leaveSpaceAction.value = AsyncAction.Uninitialized leaveSpaceAction.value = AsyncAction.Uninitialized
@ -102,7 +129,8 @@ class LeaveSpacePresenter(
} }
return LeaveSpaceState( return LeaveSpaceState(
spaceName = currentSpace.getOrNull()?.name, spaceName = leaveSpaceRooms.dataOrNull()?.current?.spaceRoom?.name,
isLastAdmin = leaveSpaceRooms.dataOrNull()?.current?.isLastAdmin == true,
selectableSpaceRooms = selectableSpaceRooms, selectableSpaceRooms = selectableSpaceRooms,
leaveSpaceAction = leaveSpaceAction.value, leaveSpaceAction = leaveSpaceAction.value,
eventSink = ::handleEvents, eventSink = ::handleEvents,
@ -111,11 +139,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: Collection<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
@ -71,6 +73,12 @@ fun LeaveSpaceView(
) { ) {
Scaffold( Scaffold(
modifier = modifier, modifier = modifier,
topBar = {
LeaveSpaceHeader(
state = state,
onBackClick = onCancel,
)
},
containerColor = ElementTheme.colors.bgCanvasDefault, containerColor = ElementTheme.colors.bgCanvasDefault,
) { padding -> ) { padding ->
Column( Column(
@ -79,45 +87,44 @@ fun LeaveSpaceView(
.imePadding() .imePadding()
.consumeWindowInsets(padding) .consumeWindowInsets(padding)
.fillMaxSize() .fillMaxSize()
.padding(16.dp)
) { ) {
LeaveSpaceHeader(
state = state,
onBackClick = onCancel,
)
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 = {
onRetry = null, state.eventSink(LeaveSpaceEvents.Retry)
) },
} )
is AsyncData.Loading, }
AsyncData.Uninitialized -> item { is AsyncData.Loading,
AsyncLoading() AsyncData.Uninitialized -> item {
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)
@ -130,6 +137,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) },
) )
} }
@ -150,11 +158,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 {
@ -166,34 +176,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,
@ -202,7 +213,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) {
@ -218,6 +229,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),
@ -300,18 +313,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

@ -202,7 +202,7 @@ private fun LoadingMoreIndicator(
private fun SpaceViewTopBar( private fun SpaceViewTopBar(
currentSpace: SpaceRoom?, currentSpace: SpaceRoom?,
onBackClick: () -> Unit, onBackClick: () -> Unit,
@Suppress("unused") onLeaveSpaceClick: () -> Unit, onLeaveSpaceClick: () -> Unit,
onShareSpace: () -> Unit, onShareSpace: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -247,23 +247,25 @@ private fun SpaceViewTopBar(
) )
} }
) )
/*
// TODO re-enable when we have SDK APIs to leave a space
DropdownMenuItem( DropdownMenuItem(
onClick = { onClick = {
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,6 +1,5 @@
<?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">"(Správce)"</string>
<plurals name="screen_leave_space_submit"> <plurals name="screen_leave_space_submit">
<item quantity="one">"Opustit %1$d místnost a prostor"</item> <item quantity="one">"Opustit %1$d místnost a prostor"</item>
<item quantity="few">"Opustit %1$d místnosti a prostor"</item> <item quantity="few">"Opustit %1$d místnosti a prostor"</item>

View file

@ -1,6 +1,5 @@
<?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">"Administrator"</string>
<plurals name="screen_leave_space_submit"> <plurals name="screen_leave_space_submit">
<item quantity="one">"Forlad %1$d rum og klynge"</item> <item quantity="one">"Forlad %1$d rum og klynge"</item>
<item quantity="other">"Forlad %1$d rum og klynger"</item> <item quantity="other">"Forlad %1$d rum og klynger"</item>

View file

@ -1,6 +1,5 @@
<?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>
<plurals name="screen_leave_space_submit"> <plurals name="screen_leave_space_submit">
<item quantity="one">"%1$d Chat und Space verlassen"</item> <item quantity="one">"%1$d Chat und Space verlassen"</item>
<item quantity="other">"%1$d Chats und Space verlassen"</item> <item quantity="other">"%1$d Chats und Space verlassen"</item>

View file

@ -1,6 +1,5 @@
<?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>
<plurals name="screen_leave_space_submit"> <plurals name="screen_leave_space_submit">
<item quantity="one">"Quitter %1$d salon et lespace"</item> <item quantity="one">"Quitter %1$d salon et lespace"</item>
<item quantity="other">"Quitter %1$d salons et lespace"</item> <item quantity="other">"Quitter %1$d salons et lespace"</item>

View file

@ -1,6 +1,5 @@
<?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">"(Adminisztrátor)"</string>
<plurals name="screen_leave_space_submit"> <plurals name="screen_leave_space_submit">
<item quantity="one">"%1$d szoba és tér elhagyása"</item> <item quantity="one">"%1$d szoba és tér elhagyása"</item>
<item quantity="other">"%1$d szoba és tér elhagyása"</item> <item quantity="other">"%1$d szoba és tér elhagyása"</item>

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,203 @@
* 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(3)
name = A_SPACE_NAME val stateError = awaitItem()
) assertThat(stateError.selectableSpaceRooms.isFailure()).isTrue()
fakeSpaceRoomList.emitCurrentSpace(aSpace) // Retry
stateError.eventSink(LeaveSpaceEvents.Retry)
skipItems(1) skipItems(1)
assertThat(awaitItem().spaceName).isEqualTo(A_SPACE_NAME) val stateLoadingAgain = awaitItem()
assertThat(stateLoadingAgain.selectableSpaceRooms.isLoading()).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
@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))) },
)
)
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)
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(4)
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

@ -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 # 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 # 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. # 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 # Others
coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" }

View file

@ -69,14 +69,6 @@ object MatrixPatterns {
str matches PATTERN_CONTAIN_MATRIX_USER_IDENTIFIER 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. * Tells if a string is a valid room id.
* *

View file

@ -7,23 +7,7 @@
package io.element.android.libraries.matrix.api.core package io.element.android.libraries.matrix.api.core
import io.element.android.libraries.androidutils.metadata.isInDebug typealias SpaceId = RoomId
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
}
/** /**
* Value to use when no space is selected by the user. * Value to use when no space is selected by the user.

View file

@ -11,6 +11,7 @@ import io.element.android.libraries.matrix.api.exception.ClientException
sealed class RecoveryException(message: String) : Exception(message) { sealed class RecoveryException(message: String) : Exception(message) {
class SecretStorage(message: String) : RecoveryException(message) class SecretStorage(message: String) : RecoveryException(message)
class Import(message: String) : RecoveryException(message)
data object BackupExistsOnServer : RecoveryException("BackupExistsOnServer") data object BackupExistsOnServer : RecoveryException("BackupExistsOnServer")
data class Client(val exception: ClientException) : RecoveryException(exception.message ?: "Unknown error") data class Client(val exception: ClientException) : RecoveryException(exception.message ?: "Unknown error")
} }

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

@ -147,6 +147,8 @@ class RustMatrixClient(
private val innerRoomListService = innerSyncService.roomListService() private val innerRoomListService = innerSyncService.roomListService()
private val innerSpaceService = innerClient.spaceService() private val innerSpaceService = innerClient.spaceService()
private val roomMembershipObserver = RoomMembershipObserver()
private val rustSyncService = RustSyncService( private val rustSyncService = RustSyncService(
inner = innerSyncService, inner = innerSyncService,
dispatcher = sessionDispatcher, dispatcher = sessionDispatcher,
@ -189,6 +191,7 @@ class RustMatrixClient(
override val spaceService: SpaceService = RustSpaceService( override val spaceService: SpaceService = RustSpaceService(
innerSpaceService = innerSpaceService, innerSpaceService = innerSpaceService,
roomMembershipObserver = roomMembershipObserver,
sessionCoroutineScope = sessionCoroutineScope, sessionCoroutineScope = sessionCoroutineScope,
sessionDispatcher = sessionDispatcher, sessionDispatcher = sessionDispatcher,
) )
@ -200,7 +203,7 @@ class RustMatrixClient(
) )
private val roomInfoMapper = RoomInfoMapper() private val roomInfoMapper = RoomInfoMapper()
private val roomMembershipObserver = RoomMembershipObserver()
private val roomFactory = RustRoomFactory( private val roomFactory = RustRoomFactory(
roomListService = roomListService, roomListService = roomListService,
innerRoomListService = innerRoomListService, innerRoomListService = innerRoomListService,

View file

@ -20,6 +20,9 @@ fun Throwable.mapRecoveryException(): RecoveryException {
message = errorMessage message = errorMessage
) )
is RustRecoveryException.BackupExistsOnServer -> RecoveryException.BackupExistsOnServer is RustRecoveryException.BackupExistsOnServer -> RecoveryException.BackupExistsOnServer
is RustRecoveryException.Import -> RecoveryException.Import(
message = errorMessage
)
is RustRecoveryException.Client -> RecoveryException.Client( is RustRecoveryException.Client -> RecoveryException.Client(
source.mapClientException() source.mapClientException()
) )

View file

@ -0,0 +1,68 @@
/*
* 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.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
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,
private val roomMembershipObserver: RoomMembershipObserver,
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 })
}.onSuccess {
roomMembershipObserver.notifyUserLeftRoom(
roomId = id,
isSpace = true,
membershipBeforeLeft = CurrentUserMembership.JOINED,
)
}
@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,8 @@ 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.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.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
@ -37,6 +39,7 @@ class RustSpaceService(
private val innerSpaceService: ClientSpaceService, private val innerSpaceService: ClientSpaceService,
private val sessionCoroutineScope: CoroutineScope, private val sessionCoroutineScope: CoroutineScope,
private val sessionDispatcher: CoroutineDispatcher, private val sessionDispatcher: CoroutineDispatcher,
private val roomMembershipObserver: RoomMembershipObserver,
) : SpaceService { ) : SpaceService {
private val spaceRoomMapper = SpaceRoomMapper() private val spaceRoomMapper = SpaceRoomMapper()
override val spaceRoomsFlow = MutableSharedFlow<List<SpaceRoom>>(replay = 1, extraBufferCapacity = 1) override val spaceRoomsFlow = MutableSharedFlow<List<SpaceRoom>>(replay = 1, extraBufferCapacity = 1)
@ -64,6 +67,17 @@ class RustSpaceService(
) )
} }
override fun getLeaveSpaceHandle(spaceId: RoomId): LeaveSpaceHandle {
return RustLeaveSpaceHandle(
id = spaceId,
spaceRoomMapper = spaceRoomMapper,
roomMembershipObserver = roomMembershipObserver,
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)
}
} }

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:5219016a0af4e1e4f7703dcef3400bf030444845dc0ef52f084e69963170bf1e oid sha256:3c569a42e638a49554127d3dc59c1dda5306e50406a6eaf1033345b44e2fe037
size 13951 size 13941

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:964aff2fc49b9d71ebec506bc9acc5010071efca01ed95f240a65b2c91f0f3b1 oid sha256:b43c73a9da70133fdff8c63dd6ed8b130b76fbbc28977fae9a34733b0f64faea
size 15852 size 15840

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a2097486621a733b1662c02849ec71423d00f9c97c679996fafc8f20a4ef27ce oid sha256:02ea4a02c0901c3d31a86272659f251e3b9299f669177b7a46a9007159989519
size 43844 size 44313

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:daff7333af912c8a0d7f009ba10d044aaa7cdfea2dfa3ccff2fd97209bf3278e oid sha256:0a8d851c9393b9a64642d08be96b0c449ce894fdf69d52d0eb664d83c7005520
size 44225 size 44211

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:aa05867ac79a3bfb9f59bf1dafb02185274b46e9a5fbf27415a0e907780a0493 oid sha256:78cc2ef488b13f72da4d1d149329930816e1d91541bbe62eb48c78b61564dc18
size 36393 size 35868

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e74abc389c25fa9a5d33ade6d0f8a2242aeb5150fe81a26e26e9b561c46bb802 oid sha256:426847168cf1cf81df60193ba69d1c02cad567f0a72c03f0b2dadb76e92f41c0
size 43010 size 42600

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b5c8bdf8d389cccb4cced29aa854a70860e9b44cbdde2a25ab95ccd5bc5e1674 oid sha256:479fb84a857b3fcb15a9bba6a6fac3f8e1309a7d5af9c10c1cfe1d16c2eb678f
size 39222 size 42208

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:bbf5d7c5320daa0d6b3c6678a6766c7143bc877850257e2c6923b174dfa9b6d4 oid sha256:ba7631fe1e5d20946256829e8414f5a305e3a2755d79fc0693fd932bfac40f4a
size 34565 size 40182

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:298db58a3f80f4194c5710ceeabec0e49b0e80861b931693d58b1a17f4394b4e oid sha256:6d5be43f0ae09dfea01efa28519b003c6c3bbd8a47c8329ccc9721acdc84a116
size 13873 size 16531

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:520698bcbc65cd2ccbc73aff01132a6b5d39c7d349a814e5a26e584cec5f2eeb
size 26083

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b982dc465fd47dbce6f88321f80c5390ecc90f7e8ad59a76dd44b6c2b9b80c8a oid sha256:1935a53ffe3653f9c094996a7b62359fe70c18ea0b46a7204969f70ce439b5e7
size 13923 size 13924

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:00b13582a4a8a380cc502ca2bb181c1ee7c9f2e1685e1d2a75817107d5ebea99 oid sha256:f90882b794619797d17c9bdb8ff2e56664a5bd95ab9bee0bb59371f954c0e7ca
size 15393 size 15395

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:6ad6ae058adb3decb4733c74ad40e4efdd33744eea3e9a793e92fc6b4cbc1464 oid sha256:4071cfff533916af1c99636ce6f61e2fbfd8d945dbba1b11e449ce55fd4bd7b9
size 42784 size 43148

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ad30950d847bd769e19ce67d8944c16145e929c52210cd54b1ebc5966efe89b5 oid sha256:03dd230a42399b5db39a57dbde170955c7f40793fced9420e2fcf8c55820b8c4
size 43221 size 42948

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:6465f03be41ae02635cbdc94f521059bdd6476c5b2ccfca6f28ccd8898112cea oid sha256:f8d4a0d5f17cf6781d34af68ec0fae1c64cdf88d59ddf4306db411c4c67fcfd8
size 35530 size 34945

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:af017745220027708f5a84ca639c9990fe7e474a38aaeecc3cba82011d195360 oid sha256:26fcf6cfae843737df553b143d9cbb8049001548b0c3479e1e24a28e0c5e6527
size 42080 size 41632

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:73f85bf9ec6c6682e293db2c740d206f6d895d79b3f3d30b967ad55aedbb268c oid sha256:bc8a9e1d6fd7a81cf1ef718d2f3e38babcf96e58e00c2c92827cba0aadd8903e
size 37895 size 40613

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:49e2727c42d334c539ee14aab396cadd68b1290e6a256ec2f677f7e4801b30e9 oid sha256:1f0ce9d4bbe5a8bdadee5663e8ad8b9b189aa25b5eec7e7c1a40430c09f7d1d3
size 32677 size 38108

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:6ef494178398bc0f8e7721b7dfde669da4d723aeafa0d4ca6975a58215193416 oid sha256:16d9c0ce7ffefb7825efb5e824c06bc721f85f3ff88340abf9c41b2b4679492c
size 13848 size 16435

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3ac66ddfcf3125726d2a9a7a83ef23880339f56c12bf2cad68bc969a4deb0f38
size 25776