Add manage mode to space view for removing child rooms, wip.

This commit is contained in:
ganfra
2026-01-13 22:08:48 +01:00
parent 168ea3e0e4
commit 78b4895254
11 changed files with 263 additions and 21 deletions

View File

@@ -8,6 +8,7 @@
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
sealed interface SpaceEvents {
@@ -19,4 +20,12 @@ sealed interface SpaceEvents {
data class ShowTopicViewer(val topic: String) : SpaceEvents
data object HideTopicViewer : SpaceEvents
// Manage mode events
data object EnterManageMode : SpaceEvents
data object ExitManageMode : SpaceEvents
data class ToggleRoomSelection(val roomId: RoomId) : SpaceEvents
data object ConfirmRoomRemoval : SpaceEvents
data object RemoveSelectedRooms : SpaceEvents
data object ClearRemoveAction : SpaceEvents
}

View File

@@ -40,6 +40,7 @@ import io.element.android.libraries.matrix.api.room.join.JoinRoom
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
import io.element.android.libraries.matrix.api.spaces.SpaceService
import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -48,6 +49,8 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlin.jvm.optionals.getOrNull
@@ -62,6 +65,7 @@ class SpacePresenter(
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val featureFlagService: FeatureFlagService,
private val spaceService: SpaceService,
) : Presenter<SpaceState> {
private var children by mutableStateOf<ImmutableList<SpaceRoom>>(persistentListOf())
@@ -104,6 +108,11 @@ class SpacePresenter(
var topicViewerState: TopicViewerState by remember { mutableStateOf(TopicViewerState.Hidden) }
// Manage mode state
var isManageMode by remember { mutableStateOf(false) }
var selectedRoomIds by remember { mutableStateOf<Set<RoomId>>(emptySet()) }
var removeRoomsAction by remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
LaunchedEffect(children) {
// Remove joined children from the join actions
val joinedChildren = children
@@ -138,6 +147,46 @@ class SpacePresenter(
}
SpaceEvents.HideTopicViewer -> topicViewerState = TopicViewerState.Hidden
is SpaceEvents.ShowTopicViewer -> topicViewerState = TopicViewerState.Shown(event.topic)
// Manage mode events
SpaceEvents.EnterManageMode -> {
isManageMode = true
selectedRoomIds = emptySet()
}
SpaceEvents.ExitManageMode -> {
isManageMode = false
selectedRoomIds = emptySet()
}
is SpaceEvents.ToggleRoomSelection -> {
selectedRoomIds = if (event.roomId in selectedRoomIds) {
selectedRoomIds - event.roomId
} else {
selectedRoomIds + event.roomId
}
}
SpaceEvents.RemoveSelectedRooms -> {
removeRoomsAction = AsyncAction.ConfirmingNoParams
}
SpaceEvents.ConfirmRoomRemoval -> {
localCoroutineScope.launch {
removeRoomsAction = AsyncAction.Loading
val spaceId = spaceRoomList.roomId
val results = selectedRoomIds.map { roomId ->
async { spaceService.removeChildFromSpace(spaceId, roomId) }
}
val hasError = results.awaitAll().any { it.isFailure }
if (hasError) {
removeRoomsAction = AsyncAction.Failure(Exception("Failed to remove some rooms"))
} else {
removeRoomsAction = AsyncAction.Success(Unit)
isManageMode = false
selectedRoomIds = emptySet()
}
}
}
SpaceEvents.ClearRemoveAction -> {
removeRoomsAction = AsyncAction.Uninitialized
}
}
}
return SpaceState(
@@ -150,6 +199,10 @@ class SpacePresenter(
acceptDeclineInviteState = acceptDeclineInviteState,
topicViewerState = topicViewerState,
canAccessSpaceSettings = canAccessSpaceSettings,
isManageMode = isManageMode,
selectedRoomIds = selectedRoomIds.toImmutableSet(),
canManageRooms = permissions.canManageRooms,
removeRoomsAction = removeRoomsAction,
eventSink = ::handleEvent,
)
}

View File

@@ -27,12 +27,20 @@ data class SpaceState(
val acceptDeclineInviteState: AcceptDeclineInviteState,
val topicViewerState: TopicViewerState,
val canAccessSpaceSettings: Boolean,
val isManageMode: Boolean,
val selectedRoomIds: ImmutableSet<RoomId>,
val canManageRooms: Boolean,
val removeRoomsAction: AsyncAction<Unit>,
val eventSink: (SpaceEvents) -> Unit
) {
fun isJoining(spaceId: RoomId): Boolean = joinActions[spaceId] == AsyncAction.Loading
val hasAnyFailure: Boolean = joinActions.values.any {
it is AsyncAction.Failure
}
val showManageRoomsAction: Boolean = canManageRooms && children.isNotEmpty()
val selectedCount: Int = selectedRoomIds.size
val isRemoveButtonEnabled: Boolean = selectedRoomIds.isNotEmpty()
}
@Immutable

View File

@@ -39,7 +39,26 @@ open class SpaceStateProvider : PreviewParameterProvider<SpaceState> {
aSpaceState(
topicViewerState = TopicViewerState.Shown(topic = "Space description goes here." + LoremIpsum(20).values.first()),
),
// Add other states here
// Manage mode states
aSpaceState(
parentSpace = aParentSpace(),
children = aListOfSpaceRooms(),
isManageMode = true,
selectedRoomIds = emptySet(),
),
aSpaceState(
parentSpace = aParentSpace(),
children = aListOfSpaceRooms(),
isManageMode = true,
selectedRoomIds = setOf(RoomId("!spaceId0:example.com"), RoomId("!spaceId1:example.com")),
),
aSpaceState(
parentSpace = aParentSpace(),
children = aListOfSpaceRooms(),
isManageMode = true,
selectedRoomIds = setOf(RoomId("!spaceId0:example.com")),
removeRoomsAction = AsyncAction.ConfirmingNoParams,
),
)
}
@@ -54,6 +73,10 @@ fun aSpaceState(
acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(),
topicViewerState: TopicViewerState = TopicViewerState.Hidden,
canAccessSpaceSettings: Boolean = true,
isManageMode: Boolean = false,
selectedRoomIds: Set<RoomId> = emptySet(),
canManageRooms: Boolean = true,
removeRoomsAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (SpaceEvents) -> Unit = { },
) = SpaceState(
currentSpace = parentSpace,
@@ -65,6 +88,10 @@ fun aSpaceState(
acceptDeclineInviteState = acceptDeclineInviteState,
topicViewerState = topicViewerState,
canAccessSpaceSettings = canAccessSpaceSettings,
isManageMode = isManageMode,
selectedRoomIds = selectedRoomIds.toImmutableSet(),
canManageRooms = canManageRooms,
removeRoomsAction = removeRoomsAction,
eventSink = eventSink,
)

View File

@@ -40,7 +40,10 @@ 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.architecture.AsyncAction
import io.element.android.libraries.designsystem.atomic.molecules.InviteButtonsRowMolecule
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.ClickableLinkText
import io.element.android.libraries.designsystem.components.SimpleModalBottomSheet
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
@@ -56,9 +59,11 @@ 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.Checkbox
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
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.TextButton
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
@@ -88,15 +93,26 @@ fun SpaceView(
Scaffold(
modifier = modifier,
topBar = {
SpaceViewTopBar(
currentSpace = state.currentSpace,
canAccessSpaceSettings = state.canAccessSpaceSettings,
onBackClick = onBackClick,
onLeaveSpaceClick = onLeaveSpaceClick,
onShareSpace = onShareSpace,
onSettingsClick = onSettingsClick,
onViewMembersClick = onViewMembersClick,
)
if (state.isManageMode) {
ManageModeTopBar(
selectedCount = state.selectedCount,
isRemoveButtonEnabled = state.isRemoveButtonEnabled,
onCancelClick = { state.eventSink(SpaceEvents.ExitManageMode) },
onRemoveClick = { state.eventSink(SpaceEvents.RemoveSelectedRooms) },
)
} else {
SpaceViewTopBar(
currentSpace = state.currentSpace,
canAccessSpaceSettings = state.canAccessSpaceSettings,
showManageRoomsAction = state.showManageRoomsAction,
onBackClick = onBackClick,
onLeaveSpaceClick = onLeaveSpaceClick,
onShareSpace = onShareSpace,
onSettingsClick = onSettingsClick,
onViewMembersClick = onViewMembersClick,
onManageRoomsClick = { state.eventSink(SpaceEvents.EnterManageMode) },
)
}
},
content = { padding ->
Box(
@@ -104,7 +120,13 @@ fun SpaceView(
) {
SpaceViewContent(
state = state,
onRoomClick = onRoomClick,
onRoomClick = { spaceRoom ->
if (state.isManageMode) {
state.eventSink(SpaceEvents.ToggleRoomSelection(spaceRoom.roomId))
} else {
onRoomClick(spaceRoom)
}
},
onTopicClick = { topic ->
state.eventSink(SpaceEvents.ShowTopicViewer(topic))
}
@@ -125,6 +147,14 @@ fun SpaceView(
}
)
}
// Confirmation dialog for removing rooms
RemoveRoomsConfirmationDialog(
removeRoomsAction = state.removeRoomsAction,
selectedCount = state.selectedCount,
onConfirm = { state.eventSink(SpaceEvents.ConfirmRoomRemoval) },
onDismiss = { state.eventSink(SpaceEvents.ClearRemoveAction) },
)
}
@Composable
@@ -200,6 +230,7 @@ private fun SpaceViewContent(
) { index, spaceRoom ->
val isInvitation = spaceRoom.state == CurrentUserMembership.INVITED
val isCurrentlyJoining = state.isJoining(spaceRoom.roomId)
val isSelected = spaceRoom.roomId in state.selectedRoomIds
SpaceRoomItemView(
spaceRoom = spaceRoom,
showUnreadIndicator = isInvitation && spaceRoom.roomId !in state.seenSpaceInvites,
@@ -210,17 +241,30 @@ private fun SpaceViewContent(
onLongClick = {
// TODO
},
trailingAction = spaceRoom.trailingAction(isCurrentlyJoining = isCurrentlyJoining) {
state.eventSink(SpaceEvents.Join(spaceRoom))
},
bottomAction = spaceRoom.inviteButtons(
onAcceptClick = {
state.eventSink(SpaceEvents.AcceptInvite(spaceRoom))
},
onDeclineClick = {
state.eventSink(SpaceEvents.DeclineInvite(spaceRoom))
trailingAction = if (state.isManageMode) {
{
Checkbox(
checked = isSelected,
onCheckedChange = null,
)
}
)
} else {
spaceRoom.trailingAction(isCurrentlyJoining = isCurrentlyJoining) {
state.eventSink(SpaceEvents.Join(spaceRoom))
}
},
bottomAction = if (state.isManageMode) {
null
} else {
spaceRoom.inviteButtons(
onAcceptClick = {
state.eventSink(SpaceEvents.AcceptInvite(spaceRoom))
},
onDeclineClick = {
state.eventSink(SpaceEvents.DeclineInvite(spaceRoom))
}
)
}
)
if (index != state.children.lastIndex) {
HorizontalDivider()
@@ -259,11 +303,13 @@ private fun LoadingMoreIndicator(
private fun SpaceViewTopBar(
currentSpace: SpaceRoom?,
canAccessSpaceSettings: Boolean,
showManageRoomsAction: Boolean,
onBackClick: () -> Unit,
onLeaveSpaceClick: () -> Unit,
onSettingsClick: () -> Unit,
onShareSpace: () -> Unit,
onViewMembersClick: () -> Unit,
onManageRoomsClick: () -> Unit,
modifier: Modifier = Modifier,
) {
TopAppBar(
@@ -313,6 +359,16 @@ private fun SpaceViewTopBar(
onShareSpace()
}
)
if (showManageRoomsAction) {
SpaceMenuItem(
titleRes = CommonStrings.action_manage_rooms,
icon = CompoundIcons.Edit(),
onClick = {
showMenu = false
onManageRoomsClick()
}
)
}
if (canAccessSpaceSettings) {
SpaceMenuItem(
titleRes = CommonStrings.common_settings,
@@ -337,6 +393,39 @@ private fun SpaceViewTopBar(
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ManageModeTopBar(
selectedCount: Int,
isRemoveButtonEnabled: Boolean,
onCancelClick: () -> Unit,
onRemoveClick: () -> Unit,
modifier: Modifier = Modifier,
) {
TopAppBar(
modifier = modifier,
navigationIcon = {
BackButton(
onClick = onCancelClick,
imageVector = CompoundIcons.Close()
)
},
title = {
Text(
text = "$selectedCount selected",
style = ElementTheme.typography.fontBodyLgMedium,
)
},
actions = {
TextButton(
text = stringResource(CommonStrings.action_remove),
onClick = onRemoveClick,
enabled = isRemoveButtonEnabled,
)
},
)
}
@Composable
private fun SpaceMenuItem(
@StringRes titleRes: Int,
@@ -425,6 +514,34 @@ private fun SpaceRoom.inviteButtons(
}
}
@Composable
private fun RemoveRoomsConfirmationDialog(
removeRoomsAction: AsyncAction<Unit>,
selectedCount: Int,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
when (removeRoomsAction) {
AsyncAction.ConfirmingNoParams -> {
ConfirmationDialog(
title = "Remove $selectedCount rooms from space?",
content = "Removing a room will not affect the room access. To change the access go to Room info > Privacy & security.",
submitText = stringResource(CommonStrings.action_remove),
onSubmitClick = onConfirm,
onDismiss = onDismiss,
destructiveSubmit = true,
)
}
else -> {
AsyncActionView(
async = removeRoomsAction,
onSuccess = { onDismiss() },
onErrorDismiss = onDismiss,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun SpaceViewPreview(

View File

@@ -11,6 +11,7 @@ import io.element.android.features.roomdetailsedit.api.RoomDetailsEditPermission
import io.element.android.features.roomdetailsedit.api.roomDetailsEditPermissions
import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyPermissions
import io.element.android.features.securityandprivacy.api.securityAndPrivacyPermissions
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions
import io.element.android.libraries.matrix.api.room.powerlevels.canEditRolesAndPermissions
@@ -19,6 +20,7 @@ data class SpaceSettingsPermissions(
val editDetailsPermissions: RoomDetailsEditPermissions,
val canEditRolesAndPermissions: Boolean,
val securityAndPrivacyPermissions: SecurityAndPrivacyPermissions,
val canManageRooms: Boolean,
) {
fun hasAny(joinRule: JoinRule?): Boolean {
return editDetailsPermissions.hasAny ||
@@ -31,6 +33,7 @@ data class SpaceSettingsPermissions(
editDetailsPermissions = RoomDetailsEditPermissions.DEFAULT,
canEditRolesAndPermissions = false,
securityAndPrivacyPermissions = SecurityAndPrivacyPermissions.DEFAULT,
canManageRooms = false,
)
}
}
@@ -40,5 +43,6 @@ fun RoomPermissions.spaceSettingsPermissions(): SpaceSettingsPermissions {
editDetailsPermissions = roomDetailsEditPermissions(),
canEditRolesAndPermissions = canEditRolesAndPermissions(),
securityAndPrivacyPermissions = securityAndPrivacyPermissions(),
canManageRooms = canOwnUserSendState(StateEventType.SpaceChild),
)
}

View File

@@ -28,6 +28,7 @@ import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.join.JoinRoom
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
import io.element.android.libraries.matrix.api.spaces.SpaceService
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
@@ -36,6 +37,7 @@ import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
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.EventsRecorder
import io.element.android.tests.testutils.lambda.lambdaRecorder
@@ -365,6 +367,7 @@ class SpacePresenterTest {
),
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
spaceSettingsEnabled: Boolean = false,
spaceService: FakeSpaceService = FakeSpaceService(),
): SpacePresenter {
return SpacePresenter(
client = client,
@@ -379,6 +382,7 @@ class SpacePresenterTest {
FeatureFlags.SpaceSettings.key to spaceSettingsEnabled,
)
),
spaceService = spaceService,
)
}
}