diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt index 5444b5c465..c557d6e1c2 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt @@ -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 diff --git a/features/rageshake/impl/src/main/res/values/localazy.xml b/features/rageshake/impl/src/main/res/values/localazy.xml index 9c18d37a3b..f6d93c4114 100644 --- a/features/rageshake/impl/src/main/res/values/localazy.xml +++ b/features/rageshake/impl/src/main/res/values/localazy.xml @@ -14,5 +14,7 @@ "Send screenshot" "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." "%1$s crashed the last time it was used. Would you like to share a crash report with us?" + "If you are having issues with notifications, uploading the notification settings can help us pinpoint the root cause." + "Send notification settings" "View logs" diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPoint.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPoint.kt index 1ef8275b27..8591978417 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPoint.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPoint.kt @@ -33,7 +33,7 @@ class DefaultSpaceEntryPoint : SpaceEntryPoint { } override fun build(): Node { - return parentNode.createNode(buildContext, plugins = plugins.toList()) + return parentNode.createNode(buildContext, plugins = plugins.toList()) } } } diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceFlowNode.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceFlowNode.kt new file mode 100644 index 0000000000..3fec810cb2 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceFlowNode.kt @@ -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, +) : BaseFlowNode( + backstack = BackStack( + initialElement = NavTarget.Root, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins, +) { + private val inputs: SpaceEntryPoint.Inputs = inputs() + private val callback = plugins.filterIsInstance().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(buildContext, listOf(inputs)) + } + NavTarget.Root -> { + val callback = object : SpaceNode.Callback { + override fun onOpenRoom(roomId: RoomId, viaParameters: List) { + callback.onOpenRoom(roomId, viaParameters) + } + + override fun onLeaveSpace() { + backstack.push(NavTarget.Leave) + } + } + createNode(buildContext, listOf(inputs, callback)) + } + } + } + + @Composable + override fun View(modifier: Modifier) = BackstackView() +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceEvents.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceEvents.kt new file mode 100644 index 0000000000..3c963a0bf5 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceEvents.kt @@ -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 +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceNode.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceNode.kt similarity index 75% rename from features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceNode.kt rename to features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceNode.kt index db49b7eb49..0973092994 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceNode.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceNode.kt @@ -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, - presenterFactory: SpacePresenter.Factory, + presenterFactory: LeaveSpacePresenter.Factory, ) : Node(buildContext, plugins = plugins) { private val inputs: SpaceEntryPoint.Inputs = inputs() - private val callback = plugins.filterIsInstance().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 ) } diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpacePresenter.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpacePresenter.kt new file mode 100644 index 0000000000..21e87e95e3 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpacePresenter.kt @@ -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 { + @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.Uninitialized) + } + val selectedRoomIds = remember { + mutableStateOf>(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>>( + 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>, + @Suppress("unused") selectedRoomIds: Set, + ) = 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() + } + } +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceState.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceState.kt new file mode 100644 index 0000000000..e0862bae49 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceState.kt @@ -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>, + val leaveSpaceAction: AsyncAction, + 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() +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceStateProvider.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceStateProvider.kt new file mode 100644 index 0000000000..0e1424c7b8 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceStateProvider.kt @@ -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 { + override val values: Sequence + 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> = AsyncData.Uninitialized, + leaveSpaceAction: AsyncAction = 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, +) diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceView.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceView.kt new file mode 100644 index 0000000000..bccacd0e47 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceView.kt @@ -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 = {}, + ) +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/SelectableSpaceRoom.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/SelectableSpaceRoom.kt new file mode 100644 index 0000000000..6247a9e48f --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/SelectableSpaceRoom.kt @@ -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, +) diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceEvents.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceEvents.kt similarity index 83% rename from features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceEvents.kt rename to features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceEvents.kt index 848dac3ebc..b978ae8010 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceEvents.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceEvents.kt @@ -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 diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceNode.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceNode.kt new file mode 100644 index 0000000000..706777bfa7 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceNode.kt @@ -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, + presenterFactory: SpacePresenter.Factory, + private val matrixClient: MatrixClient, +) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun onOpenRoom(roomId: RoomId, viaParameters: List) + fun onLeaveSpace() + } + + private val inputs: SpaceEntryPoint.Inputs = inputs() + private val callback = plugins.filterIsInstance().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 + ) + } +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpacePresenter.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt similarity index 98% rename from features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpacePresenter.kt rename to features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt index b46dd41e5c..3d4bcc8fdd 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpacePresenter.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt @@ -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 diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceState.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceState.kt similarity index 92% rename from features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceState.kt rename to features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceState.kt index ad822283ca..820123e32f 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceState.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceState.kt @@ -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 diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceStateProvider.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceStateProvider.kt similarity index 97% rename from features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceStateProvider.kt rename to features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceStateProvider.kt index cf2fcf92b5..881fc20af2 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceStateProvider.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceStateProvider.kt @@ -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 { aSpaceState( hasMoreToLoad = false, children = aListOfSpaceRooms() - ) + ), // Add other states here ) } diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceView.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt similarity index 71% rename from features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceView.kt rename to features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt index f1f8356701..e8d5a8f2c3 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceView.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt @@ -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 = {}, ) } diff --git a/features/space/impl/src/main/res/values/localazy.xml b/features/space/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..016682b46c --- /dev/null +++ b/features/space/impl/src/main/res/values/localazy.xml @@ -0,0 +1,13 @@ + + + + "Leave %1$d room and space" + "Leave %1$d rooms and space" + + "Select the rooms you’d like to leave which you\'re not the only administrator for:" + + "You will not be removed from the following room because you\'re the only administrator:" + "You will not be removed from the following rooms because you\'re the only administrator:" + + "Leave %1$s?" + diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPointTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPointTest.kt index cafc825f6a..0410944dbb 100644 --- a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPointTest.kt +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPointTest.kt @@ -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) { - lambdaError() - } + override fun onOpenRoom(roomId: RoomId, viaParameters: List) = 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) } diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/leave/LeaveSpacePresenterTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/leave/LeaveSpacePresenterTest.kt new file mode 100644 index 0000000000..81866001cc --- /dev/null +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/leave/LeaveSpacePresenterTest.kt @@ -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.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.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.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, + ) + } +} diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/SpacePresenterTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt similarity index 99% rename from features/space/impl/src/test/kotlin/io/element/android/features/space/impl/SpacePresenterTest.kt rename to features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt index 0bcd1303ae..1f096ff2b6 100644 --- a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/SpacePresenterTest.kt +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt @@ -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 diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt index 72cf62c76a..c142fa0d39 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt @@ -72,4 +72,5 @@ enum class AvatarSize(val dp: Dp) { RoomPreviewHeader(64.dp), RoomPreviewInviter(56.dp), SpaceMember(24.dp), + LeaveSpaceRoom(32.dp), } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMembershipObserver.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMembershipObserver.kt index 19d7fdaaf2..a4a718b219 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMembershipObserver.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMembershipObserver.kt @@ -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(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, + ) + ) } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustBaseRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustBaseRoom.kt index 1ca5915c71..8697818f0a 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustBaseRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustBaseRoom.kt @@ -157,7 +157,11 @@ class RustBaseRoom( runCatchingExceptions { innerRoom.leave() }.onSuccess { - roomMembershipObserver.notifyUserLeftRoom(roomId, membershipBeforeLeft) + roomMembershipObserver.notifyUserLeftRoom( + roomId = roomId, + isSpace = roomInfoFlow.value.isSpace, + membershipBeforeLeft = membershipBeforeLeft, + ) } } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RustBaseRoomTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RustBaseRoomTest.kt index e24eca53cb..50d6c348b5 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RustBaseRoomTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RustBaseRoomTest.kt @@ -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) } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt index ca0db64285..ed18a5ebd9 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt @@ -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" diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 2e678e6c28..9e3d8f6035 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -190,6 +190,7 @@ "Dark" "Decryption error" "Description" + "Deselect all" "Developer options" "Device ID" "Direct chat" @@ -301,6 +302,7 @@ Reason: %1$s." "Security" "Seen by" "Select an account" + "Select all" "Send to" "Sending…" "Sending failed" @@ -414,9 +416,6 @@ Are you sure you want to continue?" "Hey, talk to me on %1$s: %2$s" "%1$s Android" "Rageshake to report bug" - "This will also remove you from all rooms in this space." - "This will also remove you from all rooms in this space, including those you’re the only administrator for:" - "Leave %1$s?" "Screenshot" "%1$s: %2$s" "Options" @@ -460,6 +459,7 @@ Are you sure you want to continue?" "Share this location" "Spaces you have created or joined." "%1$s • %2$s" + "%1$s space" "Spaces" "Message not sent because %1$s’s verified identity was reset." "Message not sent because %1$s has not verified all devices." diff --git a/tools/localazy/config.json b/tools/localazy/config.json index 4264b35229..2afebbef4d 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -200,6 +200,12 @@ "screen\\.security_and_privacy\\..*" ] }, + { + "name" : ":features:space:impl", + "includeRegex" : [ + "screen\\.leave_space\\..*" + ] + }, { "name" : ":features:userprofile:shared", "includeRegex" : [