Leave space - Add screen to leave a space.

This commit is contained in:
Benoit Marty
2025-09-12 12:43:51 +02:00
committed by Benoit Marty
parent f1cd80ede8
commit bc465d724a
28 changed files with 1153 additions and 50 deletions

View File

@@ -31,9 +31,17 @@ class LoggedInEventProcessor(
observingJob = roomMembershipObserver.updates
.filter { !it.isUserInRoom }
.distinctUntilChanged()
.onEach {
when (it.change) {
MembershipChange.LEFT -> displayMessage(CommonStrings.common_current_user_left_room)
.onEach { roomMemberShipUpdate ->
when (roomMemberShipUpdate.change) {
MembershipChange.LEFT -> {
displayMessage(
if (roomMemberShipUpdate.isSpace) {
CommonStrings.common_current_user_left_space
} else {
CommonStrings.common_current_user_left_room
}
)
}
MembershipChange.INVITATION_REJECTED -> displayMessage(CommonStrings.common_current_user_rejected_invite)
MembershipChange.KNOCK_RETRACTED -> displayMessage(CommonStrings.common_current_user_canceled_knock)
else -> Unit

View File

@@ -14,5 +14,7 @@
<string name="screen_bug_report_include_screenshot">"Send screenshot"</string>
<string name="screen_bug_report_logs_description">"Logs will be included with your message to make sure that everything is working properly. To send your message without logs, turn off this setting."</string>
<string name="screen_bug_report_rash_logs_alert_title">"%1$s crashed the last time it was used. Would you like to share a crash report with us?"</string>
<string name="screen_bug_report_send_notification_settings_description">"If you are having issues with notifications, uploading the notification settings can help us pinpoint the root cause."</string>
<string name="screen_bug_report_send_notification_settings_title">"Send notification settings"</string>
<string name="screen_bug_report_view_logs">"View logs"</string>
</resources>

View File

@@ -33,7 +33,7 @@ class DefaultSpaceEntryPoint : SpaceEntryPoint {
}
override fun build(): Node {
return parentNode.createNode<SpaceNode>(buildContext, plugins = plugins.toList())
return parentNode.createNode<SpaceFlowNode>(buildContext, plugins = plugins.toList())
}
}
}

View File

@@ -0,0 +1,81 @@
/*
* 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.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.features.space.impl
import android.os.Parcelable
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import io.element.android.annotations.ContributesNode
import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.features.space.impl.leave.LeaveSpaceNode
import io.element.android.features.space.impl.root.SpaceNode
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
@Inject
class SpaceFlowNode(
@Assisted val buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : BaseFlowNode<SpaceFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins,
) {
private val inputs: SpaceEntryPoint.Inputs = inputs()
private val callback = plugins.filterIsInstance<SpaceEntryPoint.Callback>().single()
sealed interface NavTarget : Parcelable {
@Parcelize
data object Root : NavTarget
@Parcelize
data object Leave : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Leave -> {
createNode<LeaveSpaceNode>(buildContext, listOf(inputs))
}
NavTarget.Root -> {
val callback = object : SpaceNode.Callback {
override fun onOpenRoom(roomId: RoomId, viaParameters: List<String>) {
callback.onOpenRoom(roomId, viaParameters)
}
override fun onLeaveSpace() {
backstack.push(NavTarget.Leave)
}
}
createNode<SpaceNode>(buildContext, listOf(inputs, callback))
}
}
}
@Composable
override fun View(modifier: Modifier) = BackstackView()
}

View File

@@ -0,0 +1,18 @@
/*
* 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.features.space.impl.leave
import io.element.android.libraries.matrix.api.core.RoomId
sealed interface LeaveSpaceEvents {
data object SelectAllRooms : LeaveSpaceEvents
data object DeselectAllRooms : LeaveSpaceEvents
data class ToggleRoomSelection(val roomId: RoomId) : LeaveSpaceEvents
data object LeaveSpace : LeaveSpaceEvents
data object CloseError : LeaveSpaceEvents
}

View File

@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.space.impl
package io.element.android.features.space.impl.leave
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@@ -21,24 +21,20 @@ import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
@AssistedInject
class SpaceNode(
class LeaveSpaceNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: SpacePresenter.Factory,
presenterFactory: LeaveSpacePresenter.Factory,
) : Node(buildContext, plugins = plugins) {
private val inputs: SpaceEntryPoint.Inputs = inputs()
private val callback = plugins.filterIsInstance<SpaceEntryPoint.Callback>().single()
private val presenter = presenterFactory.create(inputs)
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
SpaceView(
LeaveSpaceView(
state = state,
onBackClick = ::navigateUp,
onRoomClick = { spaceRoom ->
callback.onOpenRoom(spaceRoom.roomId, spaceRoom.via)
},
onCancel = ::navigateUp,
modifier = modifier
)
}

View File

@@ -0,0 +1,135 @@
/*
* 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.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.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.Inject
import io.element.android.features.space.api.SpaceEntryPoint
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.runUpdatingState
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.previewutils.room.aSpaceRoom
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.jvm.optionals.getOrNull
@Inject
class LeaveSpacePresenter(
@Assisted private val inputs: SpaceEntryPoint.Inputs,
private val matrixClient: MatrixClient,
) : Presenter<LeaveSpaceState> {
@AssistedFactory
fun interface Factory {
fun create(inputs: SpaceEntryPoint.Inputs): LeaveSpacePresenter
}
private val spaceRoomList = matrixClient.spaceService.spaceRoomList(inputs.roomId)
@Composable
override fun present(): LeaveSpaceState {
val coroutineScope = rememberCoroutineScope()
val currentSpace by spaceRoomList.currentSpaceFlow.collectAsState()
val leaveSpaceAction = remember {
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
}
val selectedRoomIds = remember {
mutableStateOf<Set<RoomId>>(emptySet())
}
val joinedSpaceRooms by produceState(emptyList()) {
// TODO Get the joined room from the SDK, should also have the
val rooms = listOf(
aSpaceRoom(
roomId = RoomId("!roomId1:example.com"),
),
aSpaceRoom(
roomId = RoomId("!roomId2:example.com"),
),
)
value = rooms
}
val selectableSpaceRooms by produceState<AsyncData<ImmutableList<SelectableSpaceRoom>>>(
initialValue = AsyncData.Uninitialized,
key1 = joinedSpaceRooms,
key2 = selectedRoomIds.value,
) {
value = AsyncData.Success(
joinedSpaceRooms.map {
SelectableSpaceRoom(
it,
// TODO Get this value from the SDK
isLastAdmin = false,
selectedRoomIds.value.contains(it.roomId),
)
}.toPersistentList()
)
}
fun handleEvents(event: LeaveSpaceEvents) {
when (event) {
LeaveSpaceEvents.DeselectAllRooms -> selectedRoomIds.value = emptySet()
LeaveSpaceEvents.SelectAllRooms -> {
selectedRoomIds.value = selectableSpaceRooms.dataOrNull()
.orEmpty()
.filter { it.isLastAdmin.not() }
.map { it.spaceRoom.roomId }
.toSet()
}
is LeaveSpaceEvents.ToggleRoomSelection -> {
val currentSet = selectedRoomIds.value
selectedRoomIds.value = if (currentSet.contains(event.roomId)) {
currentSet - event.roomId
} else {
currentSet + event.roomId
}
}
LeaveSpaceEvents.LeaveSpace -> coroutineScope.leaveSpace(
leaveSpaceAction = leaveSpaceAction,
selectedRoomIds = selectedRoomIds.value,
)
LeaveSpaceEvents.CloseError -> {
leaveSpaceAction.value = AsyncAction.Uninitialized
}
}
}
return LeaveSpaceState(
spaceName = currentSpace.getOrNull()?.name,
selectableSpaceRooms = selectableSpaceRooms,
leaveSpaceAction = leaveSpaceAction.value,
eventSink = ::handleEvents,
)
}
private fun CoroutineScope.leaveSpace(
leaveSpaceAction: MutableState<AsyncAction<Unit>>,
@Suppress("unused") selectedRoomIds: Set<RoomId>,
) = launch {
runUpdatingState(leaveSpaceAction) {
// TODO SDK API call to leave all the rooms and space
delay(1000)
val room = matrixClient.getRoom(inputs.roomId)
?: return@runUpdatingState Result.failure(Exception("Room not found"))
room.leave()
}
}
}

View File

@@ -0,0 +1,35 @@
/*
* 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.features.space.impl.leave
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.bool.orFalse
import kotlinx.collections.immutable.ImmutableList
data class LeaveSpaceState(
val spaceName: String?,
val selectableSpaceRooms: AsyncData<ImmutableList<SelectableSpaceRoom>>,
val leaveSpaceAction: AsyncAction<Unit>,
val eventSink: (LeaveSpaceEvents) -> Unit,
) {
val showQuickAction = selectableSpaceRooms.dataOrNull().orEmpty().any { !it.isLastAdmin }
val hasOnlyLastAdminRoom = selectableSpaceRooms.dataOrNull()
?.let { rooms ->
rooms.isNotEmpty() && rooms.all { it.isLastAdmin }
}
.orFalse()
val numberOfSelectRooms = selectableSpaceRooms.dataOrNull().orEmpty().count { it.isSelected }
val areAllSelected = selectableSpaceRooms.dataOrNull()
?.filter { !it.isLastAdmin }
?.let { rooms ->
rooms.isNotEmpty() && rooms.all { it.isSelected }
}
.orFalse()
}

View File

@@ -0,0 +1,113 @@
/*
* 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.features.space.impl.leave
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.previewutils.room.aSpaceRoom
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
class LeaveSpaceStateProvider : PreviewParameterProvider<LeaveSpaceState> {
override val values: Sequence<LeaveSpaceState>
get() = sequenceOf(
aLeaveSpaceState(),
aLeaveSpaceState(
spaceName = null,
selectableSpaceRooms = AsyncData.Success(persistentListOf()),
),
aLeaveSpaceState(
selectableSpaceRooms = AsyncData.Success(
persistentListOf(
aSelectableSpaceRoom(
spaceRoom = aSpaceRoom(
worldReadable = true,
),
isLastAdmin = true,
),
aSelectableSpaceRoom(
spaceRoom = aSpaceRoom(
joinRule = JoinRule.Private,
),
isSelected = false,
),
)
)
),
aLeaveSpaceState(
selectableSpaceRooms = AsyncData.Success(
persistentListOf(
aSelectableSpaceRoom(
spaceRoom = aSpaceRoom(
worldReadable = true,
),
isLastAdmin = true,
),
aSelectableSpaceRoom(
spaceRoom = aSpaceRoom(
joinRule = JoinRule.Private,
),
isSelected = true,
),
)
)
),
aLeaveSpaceState(
selectableSpaceRooms = AsyncData.Success(
persistentListOf(
aSelectableSpaceRoom(
spaceRoom = aSpaceRoom(
worldReadable = true,
),
isLastAdmin = true,
),
)
),
),
aLeaveSpaceState(
selectableSpaceRooms = AsyncData.Success(
List(10) { aSelectableSpaceRoom() }.toPersistentList()
),
leaveSpaceAction = AsyncAction.Loading,
),
aLeaveSpaceState(
selectableSpaceRooms = AsyncData.Success(
List(10) { aSelectableSpaceRoom() }.toPersistentList()
),
leaveSpaceAction = AsyncAction.Failure(Exception("An error")),
),
aLeaveSpaceState(
selectableSpaceRooms = AsyncData.Failure(Exception("An error")),
),
)
}
fun aLeaveSpaceState(
spaceName: String? = "Space name",
selectableSpaceRooms: AsyncData<ImmutableList<SelectableSpaceRoom>> = AsyncData.Uninitialized,
leaveSpaceAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
) = LeaveSpaceState(
spaceName = spaceName,
selectableSpaceRooms = selectableSpaceRooms,
leaveSpaceAction = leaveSpaceAction,
eventSink = { }
)
fun aSelectableSpaceRoom(
spaceRoom: SpaceRoom = aSpaceRoom(),
isLastAdmin: Boolean = false,
isSelected: Boolean = false,
) = SelectableSpaceRoom(
spaceRoom = spaceRoom,
isLastAdmin = isLastAdmin,
isSelected = isSelected,
)

View File

@@ -0,0 +1,312 @@
/*
* 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.features.space.impl.leave
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.selection.toggleable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.space.impl.R
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.async.AsyncFailure
import io.element.android.libraries.designsystem.components.async.AsyncLoading
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Checkbox
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.CommonPlurals
import io.element.android.libraries.ui.strings.CommonStrings
/**
* https://www.figma.com/design/kcnHxunG1LDWXsJhaNuiHz/ER-145--Spaces-on-Element-X?node-id=3947-68767&t=GTf1cLkAf6UCQDan-0
*/
@Composable
fun LeaveSpaceView(
state: LeaveSpaceState,
onCancel: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
containerColor = ElementTheme.colors.bgCanvasDefault,
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.imePadding()
.consumeWindowInsets(padding)
.fillMaxSize()
.padding(16.dp)
) {
LeaveSpaceHeader(state)
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,
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()
}
}
}
LeaveSpaceButtons(
showLeaveButton = state.selectableSpaceRooms is AsyncData.Success,
nbOfSelectedRooms = state.numberOfSelectRooms,
onLeaveSpace = {
state.eventSink(LeaveSpaceEvents.LeaveSpace)
},
onCancel = onCancel,
)
}
}
AsyncActionView(
async = state.leaveSpaceAction,
onSuccess = { /* Nothing to do, the screen will be dismissed automatically */ },
onErrorDismiss = { state.eventSink(LeaveSpaceEvents.CloseError) },
)
}
@Composable
private fun LeaveSpaceHeader(state: LeaveSpaceState) {
Column {
IconTitleSubtitleMolecule(
modifier = Modifier.padding(top = 24.dp, bottom = 8.dp, start = 24.dp, end = 24.dp),
iconStyle = BigIcon.Style.AlertSolid,
title = stringResource(
R.string.screen_leave_space_title,
state.spaceName ?: stringResource(CommonStrings.common_space)
),
subTitle =
if (state.selectableSpaceRooms is AsyncData.Success && state.selectableSpaceRooms.data.isNotEmpty()) {
val count = state.selectableSpaceRooms.data.size
if (state.hasOnlyLastAdminRoom) {
pluralStringResource(R.plurals.screen_leave_space_subtitle_only_last_admin, count, count)
} else {
stringResource(R.string.screen_leave_space_subtitle)
}
} else {
null
},
)
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,
)
} 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,
)
}
}
}
}
@Composable
private fun LeaveSpaceButtons(
showLeaveButton: Boolean,
nbOfSelectedRooms: Int,
onLeaveSpace: () -> Unit,
onCancel: () -> Unit,
) {
ButtonColumnMolecule(
modifier = Modifier.padding(top = 16.dp)
) {
if (showLeaveButton) {
val text = if (nbOfSelectedRooms > 0) {
pluralStringResource(R.plurals.screen_leave_space_submit, nbOfSelectedRooms, nbOfSelectedRooms)
} else {
stringResource(CommonStrings.action_leave_space)
}
Button(
modifier = Modifier.fillMaxWidth(),
text = text,
leadingIcon = IconSource.Vector(CompoundIcons.Leave()),
onClick = onLeaveSpace,
destructive = true,
)
}
TextButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_cancel),
onClick = onCancel,
)
}
}
@Composable
private fun SpaceItem(
selectableSpaceRoom: SelectableSpaceRoom,
onClick: () -> Unit,
) {
val room = selectableSpaceRoom.spaceRoom
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 66.dp)
.toggleable(
value = selectableSpaceRoom.isSelected,
role = Role.Checkbox,
enabled = selectableSpaceRoom.isLastAdmin.not(),
onValueChange = { onClick() }
)
.clickable(
enabled = selectableSpaceRoom.isLastAdmin.not(),
// TODO
onClickLabel = null,
role = Role.Checkbox,
onClick = onClick,
),
verticalAlignment = Alignment.CenterVertically,
) {
Avatar(
modifier = Modifier.padding(horizontal = 16.dp),
avatarData = room.getAvatarData(AvatarSize.LeaveSpaceRoom),
avatarType = if (room.isSpace) AvatarType.Space() else AvatarType.Room(),
)
Column(
modifier = Modifier.weight(1f),
) {
Text(
modifier = Modifier
.padding(end = 16.dp),
text = room.name ?: stringResource(
if (room.isSpace) {
CommonStrings.common_no_space_name
} else {
CommonStrings.common_no_room_name
},
),
color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontBodyLgMedium,
maxLines = 1,
)
Row(
verticalAlignment = Alignment.CenterVertically,
) {
if (room.joinRule == JoinRule.Private) {
// Picto for private
Icon(
modifier = Modifier
.size(16.dp)
.padding(end = 4.dp),
imageVector = CompoundIcons.LockSolid(),
contentDescription = null,
tint = ElementTheme.colors.iconTertiary,
)
} else if (room.worldReadable) {
// Picto for world readable
Icon(
modifier = Modifier
.size(16.dp)
.padding(end = 4.dp),
imageVector = CompoundIcons.Public(),
contentDescription = null,
tint = ElementTheme.colors.iconTertiary,
)
}
// Number of members
Text(
modifier = Modifier.padding(end = 16.dp),
text = pluralStringResource(
CommonPlurals.common_member_count,
room.numJoinedMembers,
room.numJoinedMembers
),
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodyMdRegular,
)
}
}
Checkbox(
checked = selectableSpaceRoom.isSelected,
onCheckedChange = null,
enabled = selectableSpaceRoom.isLastAdmin.not(),
)
}
}
@PreviewsDayNight
@Composable
internal fun LeaveSpaceViewPreview(
@PreviewParameter(LeaveSpaceStateProvider::class) state: LeaveSpaceState,
) = ElementPreview {
LeaveSpaceView(
state = state,
onCancel = {},
)
}

View File

@@ -0,0 +1,16 @@
/*
* 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.features.space.impl.leave
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
data class SelectableSpaceRoom(
val spaceRoom: SpaceRoom,
val isLastAdmin: Boolean,
val isSelected: Boolean,
)

View File

@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.space.impl
package io.element.android.features.space.impl.root
sealed interface SpaceEvents {
data object LoadMore : SpaceEvents

View File

@@ -0,0 +1,85 @@
/*
* 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.features.space.impl.root
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.lifecycleScope
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.Inject
import io.element.android.annotations.ContributesNode
import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.libraries.androidutils.R
import io.element.android.libraries.androidutils.system.startSharePlainTextIntent
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
import timber.log.Timber
@ContributesNode(SessionScope::class)
@Inject
class SpaceNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: SpacePresenter.Factory,
private val matrixClient: MatrixClient,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onOpenRoom(roomId: RoomId, viaParameters: List<String>)
fun onLeaveSpace()
}
private val inputs: SpaceEntryPoint.Inputs = inputs()
private val callback = plugins.filterIsInstance<Callback>().single()
private val presenter = presenterFactory.create(inputs)
private fun onShareRoom(context: Context) = lifecycleScope.launch {
matrixClient.getRoom(inputs.roomId)?.use { room ->
room.getPermalink()
.onSuccess { permalink ->
context.startSharePlainTextIntent(
activityResultLauncher = null,
chooserTitle = context.getString(CommonStrings.common_share_space),
text = permalink,
noActivityFoundMessage = context.getString(R.string.error_no_compatible_app_found)
)
}
.onFailure {
Timber.e(it)
}
}
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val context = LocalContext.current
SpaceView(
state = state,
onBackClick = ::navigateUp,
onLeaveSpaceClick = {
callback.onLeaveSpace()
},
onRoomClick = { spaceRoom ->
callback.onOpenRoom(spaceRoom.roomId, spaceRoom.via)
},
onShareSpace = {
onShareRoom(context)
},
modifier = modifier
)
}
}

View File

@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.space.impl
package io.element.android.features.space.impl.root
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect

View File

@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.space.impl
package io.element.android.features.space.impl.root
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.SpaceRoom

View File

@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.space.impl
package io.element.android.features.space.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.RoomId
@@ -34,7 +34,7 @@ open class SpaceStateProvider : PreviewParameterProvider<SpaceState> {
aSpaceState(
hasMoreToLoad = false,
children = aListOfSpaceRooms()
)
),
// Add other states here
)
}

View File

@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.space.impl
package io.element.android.features.space.impl.root
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
@@ -17,7 +17,10 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -28,6 +31,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@@ -36,6 +40,10 @@ import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.DropdownMenu
import io.element.android.libraries.designsystem.theme.components.DropdownMenuItem
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
@@ -51,13 +59,20 @@ import kotlinx.collections.immutable.toImmutableList
fun SpaceView(
state: SpaceState,
onBackClick: () -> Unit,
onLeaveSpaceClick: () -> Unit,
onRoomClick: (spaceRoom: SpaceRoom) -> Unit,
onShareSpace: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
topBar = {
SpaceViewTopBar(currentSpace = state.currentSpace, onBackClick = onBackClick)
SpaceViewTopBar(
state = state,
onBackClick = onBackClick,
onLeaveSpaceClick = onLeaveSpaceClick,
onShareSpace = onShareSpace,
)
},
content = { padding ->
Box(
@@ -140,10 +155,13 @@ private fun LoadingMoreIndicator(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SpaceViewTopBar(
currentSpace: SpaceRoom?,
state: SpaceState,
onBackClick: () -> Unit,
onLeaveSpaceClick: () -> Unit,
onShareSpace: () -> Unit,
modifier: Modifier = Modifier,
) {
val currentSpace = state.currentSpace
TopAppBar(
modifier = modifier,
navigationIcon = {
@@ -158,6 +176,48 @@ private fun SpaceViewTopBar(
}
},
actions = {
var showMenu by remember { mutableStateOf(false) }
IconButton(
onClick = { showMenu = !showMenu }
) {
Icon(
imageVector = CompoundIcons.OverflowVertical(),
contentDescription = null,
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
DropdownMenuItem(
onClick = {
showMenu = false
onShareSpace()
},
text = { Text(stringResource(id = CommonStrings.action_share)) },
leadingIcon = {
Icon(
imageVector = CompoundIcons.ShareAndroid(),
tint = ElementTheme.colors.iconSecondary,
contentDescription = null,
)
}
)
DropdownMenuItem(
onClick = {
showMenu = false
onLeaveSpaceClick()
},
text = { Text(stringResource(id = CommonStrings.action_leave)) },
leadingIcon = {
Icon(
imageVector = CompoundIcons.Leave(),
tint = ElementTheme.colors.iconSecondary,
contentDescription = null,
)
}
)
}
},
)
}
@@ -198,7 +258,9 @@ internal fun SpaceViewPreview(
) = ElementPreview {
SpaceView(
state = state,
onRoomClick = {},
onBackClick = {},
onLeaveSpaceClick = {},
onRoomClick = {},
onShareSpace = {},
)
}

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<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>
<plurals name="screen_leave_space_subtitle_only_last_admin">
<item quantity="one">"You will not be removed from the following room because you\'re the only administrator:"</item>
<item quantity="other">"You will not be removed from the following rooms because you\'re the only administrator:"</item>
</plurals>
<string name="screen_leave_space_title">"Leave %1$s?"</string>
</resources>

View File

@@ -9,14 +9,11 @@ package io.element.android.features.space.impl
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat
import io.element.android.features.invite.test.InMemorySeenInvitesStore
import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList
import io.element.android.libraries.matrix.test.spaces.FakeSpaceService
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule
@@ -26,38 +23,27 @@ class DefaultSpaceEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun `test node builder`() {
val entryPoint = DefaultSpaceEntryPoint()
val nodeInputs = SpaceEntryPoint.Inputs(A_ROOM_ID)
val parentNode = TestParentNode.create { buildContext, plugins ->
SpaceNode(
SpaceFlowNode(
buildContext = buildContext,
plugins = plugins,
presenterFactory = { inputs ->
assertThat(inputs).isEqualTo(nodeInputs)
SpacePresenter(
inputs = inputs,
client = FakeMatrixClient(
spaceService = FakeSpaceService(
spaceRoomListResult = { FakeSpaceRoomList() },
)
),
seenInvitesStore = InMemorySeenInvitesStore(),
)
},
)
}
val callback = object : SpaceEntryPoint.Callback {
override fun onOpenRoom(roomId: RoomId, viaParameters: List<String>) {
lambdaError()
}
override fun onOpenRoom(roomId: RoomId, viaParameters: List<String>) = lambdaError()
}
val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null))
.inputs(nodeInputs)
.callback(callback)
.build()
assertThat(result).isInstanceOf(SpaceNode::class.java)
assertThat(result).isInstanceOf(SpaceFlowNode::class.java)
assertThat(result.plugins).contains(nodeInputs)
assertThat(result.plugins).contains(callback)
}

View File

@@ -0,0 +1,213 @@
/*
* 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.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.space.impl.leave
import com.google.common.truth.Truth.assertThat
import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.MatrixClient
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_SPACE_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList
import io.element.android.libraries.matrix.test.spaces.FakeSpaceService
import io.element.android.libraries.previewutils.room.aSpaceRoom
import io.element.android.tests.testutils.lambda.lambdaRecorder
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 {
@Test
fun `present - initial state`() = runTest {
val paginateResult = lambdaRecorder<Result<Unit>> {
Result.success(Unit)
}
val presenter = createLeaveSpacePresenter(
matrixClient = FakeMatrixClient(
spaceService = FakeSpaceService(
spaceRoomListResult = {
FakeSpaceRoomList(
paginateResult = paginateResult,
)
},
),
),
)
presenter.test {
val state = awaitItem()
assertThat(state.spaceName).isNull()
assertThat(state.selectableSpaceRooms).isEqualTo(AsyncAction.Uninitialized)
assertThat(state.leaveSpaceAction).isEqualTo(AsyncAction.Uninitialized)
advanceUntilIdle()
paginateResult.assertions().isCalledOnce()
}
}
@Test
fun `present - current space name`() = runTest {
val fakeSpaceRoomList = FakeSpaceRoomList()
val presenter = createLeaveSpacePresenter(
matrixClient = FakeMatrixClient(
spaceService = FakeSpaceService(
spaceRoomListResult = { fakeSpaceRoomList },
),
),
)
presenter.test {
val state = awaitItem()
advanceUntilIdle()
assertThat(state.spaceName).isNull()
val aSpace = aSpaceRoom(
name = A_SPACE_NAME
)
fakeSpaceRoomList.emitCurrentSpace(aSpace)
assertThat(awaitItem().spaceName).isEqualTo(A_SPACE_NAME)
}
}
@Test
fun `present - leave space and cancel`() = runTest {
val fakeSpaceRoomList = FakeSpaceRoomList(
paginateResult = { Result.success(Unit) },
)
val presenter = createLeaveSpacePresenter(
matrixClient = FakeMatrixClient(
spaceService = FakeSpaceService(
spaceRoomListResult = { fakeSpaceRoomList },
),
),
)
presenter.test {
val state = awaitItem()
advanceUntilIdle()
assertThat(state.leaveSpaceAction).isEqualTo(AsyncAction.Uninitialized)
state.eventSink(LeaveSpaceEvents.LeaveSpace)
val stateAfterStarting = awaitItem()
assertThat(stateAfterStarting.leaveSpaceAction).isInstanceOf(LeaveSpaceState::class.java)
val shown = stateAfterStarting.leaveSpaceAction as LeaveSpaceState
assertThat(shown.spaceName).isNull()
assertThat(shown.selectableSpaceRooms).isInstanceOf(AsyncData.Loading::class.java)
val stateAfterLoading = awaitItem()
val shownLoaded = stateAfterLoading.leaveSpaceAction as LeaveSpaceState
assertThat(shownLoaded.selectableSpaceRooms.dataOrNull()!!).isEmpty()
stateAfterLoading.eventSink(LeaveSpaceEvents.CloseError)
val stateAfterCancel = awaitItem()
assertThat(stateAfterCancel.leaveSpaceAction).isEqualTo(AsyncAction.Uninitialized)
}
}
@Test
fun `present - leave space and confirm`() = runTest {
val fakeSpaceRoomList = FakeSpaceRoomList(
paginateResult = { Result.success(Unit) },
)
val leaveRoomLambda = lambdaRecorder<Result<Unit>> {
Result.success(Unit)
}
val presenter = createLeaveSpacePresenter(
matrixClient = FakeMatrixClient(
spaceService = FakeSpaceService(
spaceRoomListResult = { fakeSpaceRoomList },
),
).apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
result = FakeBaseRoom(
leaveRoomLambda = leaveRoomLambda,
)
)
},
)
presenter.test {
val state = awaitItem()
advanceUntilIdle()
assertThat(state.leaveSpaceAction).isEqualTo(AsyncAction.Uninitialized)
state.eventSink(LeaveSpaceEvents.LeaveSpace)
val stateAfterStarting = awaitItem()
assertThat(stateAfterStarting.leaveSpaceAction).isInstanceOf(LeaveSpaceState::class.java)
val shown = stateAfterStarting.leaveSpaceAction as LeaveSpaceState
assertThat(shown.spaceName).isNull()
assertThat(shown.selectableSpaceRooms).isInstanceOf(AsyncData.Loading::class.java)
val stateAfterLoading = awaitItem()
val shownLoaded = stateAfterLoading.leaveSpaceAction as LeaveSpaceState
assertThat(shownLoaded.selectableSpaceRooms.dataOrNull()!!).isEmpty()
stateAfterLoading.eventSink(LeaveSpaceEvents.LeaveSpace)
val stateLoading = awaitItem()
assertThat(stateLoading.leaveSpaceAction).isEqualTo(AsyncAction.Loading)
val stateFinal = awaitItem()
assertThat(stateFinal.leaveSpaceAction).isEqualTo(AsyncAction.Success(Unit))
leaveRoomLambda.assertions().isCalledOnce()
}
}
@Test
fun `present - leave space, confirm then failure`() = runTest {
val fakeSpaceRoomList = FakeSpaceRoomList(
paginateResult = { Result.success(Unit) },
)
val leaveRoomLambda = lambdaRecorder<Result<Unit>> {
Result.failure(AN_EXCEPTION)
}
val presenter = createLeaveSpacePresenter(
matrixClient = FakeMatrixClient(
spaceService = FakeSpaceService(
spaceRoomListResult = { fakeSpaceRoomList },
),
).apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
result = FakeBaseRoom(
leaveRoomLambda = leaveRoomLambda,
)
)
},
)
presenter.test {
val state = awaitItem()
advanceUntilIdle()
assertThat(state.leaveSpaceAction).isEqualTo(AsyncAction.Uninitialized)
state.eventSink(LeaveSpaceEvents.LeaveSpace)
val stateAfterStarting = awaitItem()
assertThat(stateAfterStarting.leaveSpaceAction).isInstanceOf(LeaveSpaceState::class.java)
val shown = stateAfterStarting.leaveSpaceAction as LeaveSpaceState
assertThat(shown.spaceName).isNull()
assertThat(shown.selectableSpaceRooms).isInstanceOf(AsyncData.Loading::class.java)
val stateAfterLoading = awaitItem()
val shownLoaded = stateAfterLoading.leaveSpaceAction as LeaveSpaceState
assertThat(shownLoaded.selectableSpaceRooms.dataOrNull()!!).isEmpty()
stateAfterLoading.eventSink(LeaveSpaceEvents.LeaveSpace)
val stateLoading = awaitItem()
assertThat(stateLoading.leaveSpaceAction).isEqualTo(AsyncAction.Loading)
val stateError = awaitItem()
assertThat(stateError.leaveSpaceAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
leaveRoomLambda.assertions().isCalledOnce()
// Close error
stateError.eventSink(LeaveSpaceEvents.CloseError)
val stateFinal = awaitItem()
assertThat(stateFinal.leaveSpaceAction).isEqualTo(AsyncAction.Uninitialized)
}
}
private fun createLeaveSpacePresenter(
inputs: SpaceEntryPoint.Inputs = SpaceEntryPoint.Inputs(A_ROOM_ID),
matrixClient: MatrixClient = FakeMatrixClient(),
): LeaveSpacePresenter {
return LeaveSpacePresenter(
inputs = inputs,
matrixClient = matrixClient,
)
}
}

View File

@@ -7,7 +7,7 @@
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.space.impl
package io.element.android.features.space.impl.root
import com.google.common.truth.Truth.assertThat
import io.element.android.features.invite.api.SeenInvitesStore

View File

@@ -72,4 +72,5 @@ enum class AvatarSize(val dp: Dp) {
RoomPreviewHeader(64.dp),
RoomPreviewInviter(56.dp),
SpaceMember(24.dp),
LeaveSpaceRoom(32.dp),
}

View File

@@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.asSharedFlow
class RoomMembershipObserver {
data class RoomMembershipUpdate(
val roomId: RoomId,
val isSpace: Boolean,
val isUserInRoom: Boolean,
val change: MembershipChange,
)
@@ -22,12 +23,23 @@ class RoomMembershipObserver {
private val _updates = MutableSharedFlow<RoomMembershipUpdate>(extraBufferCapacity = 10)
val updates = _updates.asSharedFlow()
suspend fun notifyUserLeftRoom(roomId: RoomId, membershipBeforeLeft: CurrentUserMembership) {
suspend fun notifyUserLeftRoom(
roomId: RoomId,
isSpace: Boolean,
membershipBeforeLeft: CurrentUserMembership,
) {
val membershipChange = when (membershipBeforeLeft) {
CurrentUserMembership.INVITED -> MembershipChange.INVITATION_REJECTED
CurrentUserMembership.KNOCKED -> MembershipChange.KNOCK_RETRACTED
else -> MembershipChange.LEFT
}
_updates.emit(RoomMembershipUpdate(roomId, false, membershipChange))
_updates.emit(
RoomMembershipUpdate(
roomId = roomId,
isSpace = isSpace,
isUserInRoom = false,
change = membershipChange,
)
)
}
}

View File

@@ -157,7 +157,11 @@ class RustBaseRoom(
runCatchingExceptions {
innerRoom.leave()
}.onSuccess {
roomMembershipObserver.notifyUserLeftRoom(roomId, membershipBeforeLeft)
roomMembershipObserver.notifyUserLeftRoom(
roomId = roomId,
isSpace = roomInfoFlow.value.isSpace,
membershipBeforeLeft = membershipBeforeLeft,
)
}
}

View File

@@ -57,6 +57,7 @@ class RustBaseRoomTest {
leaveRoomAndObserveMembershipChange(roomMembershipObserver, rustBaseRoom) {
val membershipUpdate = awaitItem()
assertThat(membershipUpdate.roomId).isEqualTo(rustBaseRoom.roomId)
assertThat(membershipUpdate.isSpace).isFalse()
assertThat(membershipUpdate.isUserInRoom).isFalse()
assertThat(membershipUpdate.change).isEqualTo(MembershipChange.LEFT)
}
@@ -77,6 +78,7 @@ class RustBaseRoomTest {
leaveRoomAndObserveMembershipChange(roomMembershipObserver, rustBaseRoom) {
val membershipUpdate = awaitItem()
assertThat(membershipUpdate.roomId).isEqualTo(rustBaseRoom.roomId)
assertThat(membershipUpdate.isSpace).isFalse()
assertThat(membershipUpdate.isUserInRoom).isFalse()
assertThat(membershipUpdate.change).isEqualTo(MembershipChange.KNOCK_RETRACTED)
}
@@ -97,6 +99,7 @@ class RustBaseRoomTest {
leaveRoomAndObserveMembershipChange(roomMembershipObserver, rustBaseRoom) {
val membershipUpdate = awaitItem()
assertThat(membershipUpdate.roomId).isEqualTo(rustBaseRoom.roomId)
assertThat(membershipUpdate.isSpace).isFalse()
assertThat(membershipUpdate.isUserInRoom).isFalse()
assertThat(membershipUpdate.change).isEqualTo(MembershipChange.INVITATION_REJECTED)
}

View File

@@ -65,6 +65,8 @@ const val ANOTHER_MESSAGE = "Hello universe!"
const val A_CAPTION = "A media caption"
const val A_REASON = "A reason"
const val A_SPACE_NAME = "A space name"
const val A_REDACTION_REASON = "A redaction reason"
const val A_HOMESERVER_URL = "matrix.org"

View File

@@ -190,6 +190,7 @@
<string name="common_dark">"Dark"</string>
<string name="common_decryption_error">"Decryption error"</string>
<string name="common_description">"Description"</string>
<string name="common_deselect_all">"Deselect all"</string>
<string name="common_developer_options">"Developer options"</string>
<string name="common_device_id">"Device ID"</string>
<string name="common_direct_chat">"Direct chat"</string>
@@ -301,6 +302,7 @@ Reason: %1$s."</string>
<string name="common_security">"Security"</string>
<string name="common_seen_by">"Seen by"</string>
<string name="common_select_account">"Select an account"</string>
<string name="common_select_all">"Select all"</string>
<string name="common_send_to">"Send to"</string>
<string name="common_sending">"Sending…"</string>
<string name="common_sending_failed">"Sending failed"</string>
@@ -414,9 +416,6 @@ Are you sure you want to continue?"</string>
<string name="invite_friends_text">"Hey, talk to me on %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Rageshake to report bug"</string>
<string name="screen_bottom_sheet_leave_space_subtitle">"This will also remove you from all rooms in this space."</string>
<string name="screen_bottom_sheet_leave_space_subtitle_admin">"This will also remove you from all rooms in this space, including those youre the only administrator for:"</string>
<string name="screen_bottom_sheet_leave_space_title">"Leave %1$s?"</string>
<string name="screen_bug_report_a11y_screenshot">"Screenshot"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string>
<string name="screen_create_poll_options_section_title">"Options"</string>
@@ -460,6 +459,7 @@ Are you sure you want to continue?"</string>
<string name="screen_share_this_location_action">"Share this location"</string>
<string name="screen_space_list_description">"Spaces you have created or joined."</string>
<string name="screen_space_list_details">"%1$s • %2$s"</string>
<string name="screen_space_list_parent_space">"%1$s space"</string>
<string name="screen_space_list_title">"Spaces"</string>
<string name="screen_timeline_item_menu_send_failure_changed_identity">"Message not sent because %1$ss verified identity was reset."</string>
<string name="screen_timeline_item_menu_send_failure_unsigned_device">"Message not sent because %1$s has not verified all devices."</string>

View File

@@ -200,6 +200,12 @@
"screen\\.security_and_privacy\\..*"
]
},
{
"name" : ":features:space:impl",
"includeRegex" : [
"screen\\.leave_space\\..*"
]
},
{
"name" : ":features:userprofile:shared",
"includeRegex" : [