From 9064481b4c8f56af7630922a5f69c887c3304813 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Wed, 5 Apr 2023 15:36:41 +0200 Subject: [PATCH] [Room Details] Leave room (#296) * Add leave room functionality to the Room Details screen * Add snackbar message throught `SnackbarDistpacher` --- .../io/element/android/x/di/AppModule.kt | 7 + appnav/build.gradle.kts | 1 + .../android/appnav/LoggedInEventProcessor.kt | 72 +++++++++ .../android/appnav/LoggedInFlowNode.kt | 13 ++ .../io/element/android/appnav/RoomFlowNode.kt | 18 ++- changelog.d/286.feature | 1 + .../roomdetails/api/RoomDetailsEntryPoint.kt | 4 +- .../impl/DefaultRoomDetailsEntryPoint.kt | 5 +- .../roomdetails/impl/RoomDetailsEvent.kt | 6 +- .../roomdetails/impl/RoomDetailsPresenter.kt | 39 ++++- .../roomdetails/impl/RoomDetailsState.kt | 27 +++- .../impl/RoomDetailsStateProvider.kt | 4 +- .../roomdetails/impl/RoomDetailsView.kt | 46 +++++- .../roomdetails/RoomDetailsPresenterTests.kt | 138 +++++++++++++++++- .../features/roomlist/impl/RoomListEvents.kt | 1 - .../roomlist/impl/RoomListPresenter.kt | 19 ++- .../features/roomlist/impl/RoomListState.kt | 3 +- .../roomlist/impl/RoomListStateProvider.kt | 6 +- .../features/roomlist/impl/RoomListView.kt | 24 +-- .../roomlist/impl/RoomListPresenterTests.kt | 35 +---- .../impl/VerifySelfSessionPresenterTests.kt | 2 + .../components/dialogs/ConfirmationDialog.kt | 4 +- .../designsystem/utils/SnackbarDispatcher.kt | 72 +++++++++ .../libraries/matrix/api/MatrixClient.kt | 3 + .../libraries/matrix/api/room/MatrixRoom.kt | 4 + .../matrix/api/room/RoomMembershipObserver.kt | 40 +++++ .../libraries/matrix/impl/RustMatrixClient.kt | 8 +- .../matrix/impl/di/SessionMatrixModule.kt | 8 + .../matrix/impl/room/RustMatrixRoom.kt | 7 + .../libraries/matrix/test/FakeMatrixClient.kt | 5 + .../matrix/test/room/FakeMatrixRoom.kt | 9 ++ .../android/samples/minimal/RoomListScreen.kt | 4 +- 32 files changed, 564 insertions(+), 71 deletions(-) create mode 100644 appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt create mode 100644 changelog.d/286.feature create mode 100644 libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/SnackbarDispatcher.kt create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMembershipObserver.kt diff --git a/app/src/main/kotlin/io/element/android/x/di/AppModule.kt b/app/src/main/kotlin/io/element/android/x/di/AppModule.kt index c553f3f7f0..4a9ee85fc8 100644 --- a/app/src/main/kotlin/io/element/android/x/di/AppModule.kt +++ b/app/src/main/kotlin/io/element/android/x/di/AppModule.kt @@ -22,6 +22,7 @@ import dagger.Module import dagger.Provides import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.SingleIn @@ -78,4 +79,10 @@ object AppModule { diffUpdateDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() ) } + + @Provides + @SingleIn(AppScope::class) + fun provideSnackbarDispatcher(): SnackbarDispatcher { + return SnackbarDispatcher() + } } diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts index ea4c2d9a94..1cfcc40510 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -45,6 +45,7 @@ dependencies { implementation(projects.libraries.matrix.api) implementation(projects.libraries.designsystem) implementation(projects.libraries.matrixui) + implementation(projects.libraries.uiStrings) implementation(projects.features.verifysession.api) implementation(projects.features.roomdetails.api) implementation(projects.tests.uitests) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt new file mode 100644 index 0000000000..f1d87330e1 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.appnav + +import io.element.android.libraries.designsystem.utils.SnackbarDispatcher +import io.element.android.libraries.designsystem.utils.SnackbarMessage +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver +import io.element.android.libraries.matrix.api.verification.SessionVerificationService +import io.element.android.libraries.matrix.api.verification.VerificationFlowState +import io.element.android.libraries.ui.strings.R +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.coroutines.coroutineContext + +class LoggedInEventProcessor @Inject constructor( + private val snackbarDispatcher: SnackbarDispatcher, + roomMembershipObserver: RoomMembershipObserver, + sessionVerificationService: SessionVerificationService, +) { + + private var observingJob: Job? = null + + private val displayLeftRoomMessage = roomMembershipObserver.updates + .map { !it.isUserInRoom } + + private val displayVerificationSuccessfulMessage = sessionVerificationService.verificationFlowState + .map { it == VerificationFlowState.Finished } + + fun observeEvents(coroutineScope: CoroutineScope) { + observingJob = coroutineScope.launch { + displayLeftRoomMessage.onEach { + displayMessage(R.string.common_current_user_left_room) + }.launchIn(this) + + displayVerificationSuccessfulMessage + .drop(1) + .onEach { + displayMessage(R.string.common_verification_complete) + }.launchIn(this) + } + } + + fun stopObserving() { + observingJob?.cancel() + observingJob = null + } + + private suspend fun displayMessage(message: Int) { + snackbarDispatcher.post(SnackbarMessage(message)) + } +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index 4b2d653d79..1028c6f16d 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -46,13 +46,17 @@ import io.element.android.libraries.architecture.bindings import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.inputs import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.di.AppScope import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.MAIN_SPACE import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.ui.di.MatrixUIBindings import io.element.android.services.appnavstate.api.AppNavigationStateService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.parcelize.Parcelize +import kotlin.coroutines.coroutineContext @ContributesNode(AppScope::class) class LoggedInFlowNode @AssistedInject constructor( @@ -63,6 +67,8 @@ class LoggedInFlowNode @AssistedInject constructor( private val createRoomEntryPoint: CreateRoomEntryPoint, private val appNavigationStateService: AppNavigationStateService, private val verifySessionEntryPoint: VerifySessionEntryPoint, + private val coroutineScope: CoroutineScope, + snackbarDispatcher: SnackbarDispatcher, ) : BackstackNode( backstack = BackStack( initialElement = NavTarget.RoomList, @@ -87,6 +93,11 @@ class LoggedInFlowNode @AssistedInject constructor( ) : NodeInputs private val inputs: Inputs = inputs() + private val loggedInFlowProcessor = LoggedInEventProcessor( + snackbarDispatcher, + inputs.matrixClient.roomMembershipObserver(), + inputs.matrixClient.sessionVerificationService(), + ) override fun onBuilt() { super.onBuilt() @@ -99,6 +110,7 @@ class LoggedInFlowNode @AssistedInject constructor( appNavigationStateService.onNavigateToSession(inputs.matrixClient.sessionId) // TODO We do not support Space yet, so directly navigate to main space appNavigationStateService.onNavigateToSpace(MAIN_SPACE) + loggedInFlowProcessor.observeEvents(coroutineScope) }, onDestroy = { val imageLoaderFactory = bindings().notLoggedInImageLoaderFactory() @@ -106,6 +118,7 @@ class LoggedInFlowNode @AssistedInject constructor( plugins().forEach { it.onFlowReleased(inputs.matrixClient) } appNavigationStateService.onLeavingSpace() appNavigationStateService.onLeavingSession() + loggedInFlowProcessor.stopObserving() } ) } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RoomFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RoomFlowNode.kt index 14b90b0064..69c02d500e 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RoomFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RoomFlowNode.kt @@ -18,6 +18,7 @@ package io.element.android.appnav import android.os.Parcelable import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Modifier import com.bumble.appyx.core.composable.Children import com.bumble.appyx.core.lifecycle.subscribe @@ -38,7 +39,12 @@ import io.element.android.libraries.architecture.animation.rememberDefaultTransi import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.services.appnavstate.api.AppNavigationStateService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.parcelize.Parcelize import timber.log.Timber @@ -49,6 +55,8 @@ class RoomFlowNode @AssistedInject constructor( private val messagesEntryPoint: MessagesEntryPoint, private val roomDetailsEntryPoint: RoomDetailsEntryPoint, private val appNavigationStateService: AppNavigationStateService, + roomMembershipObserver: RoomMembershipObserver, + coroutineScope: CoroutineScope, ) : BackstackNode( backstack = BackStack( initialElement = NavTarget.Messages, @@ -68,6 +76,7 @@ class RoomFlowNode @AssistedInject constructor( ) : NodeInputs private val inputs: Inputs = inputs() + private val timeline = inputs.room.timeline() private val roomFlowPresenter = RoomFlowPresenter(inputs.room) @@ -85,6 +94,13 @@ class RoomFlowNode @AssistedInject constructor( appNavigationStateService.onLeavingRoom() } ) + + roomMembershipObserver.updates + .filter { update -> update.roomId == inputs.room.roomId && !update.isUserInRoom } + .onEach { + navigateUp() + } + .launchIn(coroutineScope) } override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { @@ -97,7 +113,7 @@ class RoomFlowNode @AssistedInject constructor( }) } NavTarget.RoomDetails -> { - roomDetailsEntryPoint.createNode(this, buildContext) + roomDetailsEntryPoint.createNode(this, buildContext, emptyList()) } } } diff --git a/changelog.d/286.feature b/changelog.d/286.feature new file mode 100644 index 0000000000..0a193367fd --- /dev/null +++ b/changelog.d/286.feature @@ -0,0 +1 @@ +Add leave room functionality to the Room Details screen. diff --git a/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt b/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt index e56cc7e705..560e9d5dd7 100644 --- a/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt +++ b/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt @@ -18,9 +18,9 @@ package io.element.android.features.roomdetails.api import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin import io.element.android.libraries.architecture.FeatureEntryPoint interface RoomDetailsEntryPoint : FeatureEntryPoint { - fun createNode(parentNode: Node, buildContext: BuildContext): Node - + fun createNode(parentNode: Node, buildContext: BuildContext, plugins: List): Node } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPoint.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPoint.kt index a52575606c..eebdbea062 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPoint.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPoint.kt @@ -18,6 +18,7 @@ package io.element.android.features.roomdetails.impl import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin import com.squareup.anvil.annotations.ContributesBinding import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint import io.element.android.libraries.architecture.createNode @@ -26,7 +27,7 @@ import javax.inject.Inject @ContributesBinding(AppScope::class) class DefaultRoomDetailsEntryPoint @Inject constructor() : RoomDetailsEntryPoint { - override fun createNode(parentNode: Node, buildContext: BuildContext): Node { - return parentNode.createNode(buildContext) + override fun createNode(parentNode: Node, buildContext: BuildContext, plugins: List): Node { + return parentNode.createNode(buildContext, plugins) } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt index ad6b4ea78f..3ef87d17e0 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt @@ -16,4 +16,8 @@ package io.element.android.features.roomdetails.impl -sealed interface RoomDetailsEvent +sealed interface RoomDetailsEvent { + data class LeaveRoom(val needsConfirmation: Boolean) : RoomDetailsEvent + object ClearLeaveRoomWarning : RoomDetailsEvent + object ClearError : RoomDetailsEvent +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt index 80ae4426d9..48dce4609c 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt @@ -21,22 +21,31 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject class RoomDetailsPresenter @Inject constructor( private val room: MatrixRoom, + private val roomMembershipObserver: RoomMembershipObserver, ) : Presenter { @Composable override fun present(): RoomDetailsState { -// fun handleEvents(event: RoomDetailsEvent) {} - + val coroutineScope = rememberCoroutineScope() + var leaveRoomWarning by remember { + mutableStateOf(null) + } + var error by remember { + mutableStateOf(null) + } var memberCount: Async by remember { mutableStateOf(Async.Loading()) } LaunchedEffect(Unit) { withContext(Dispatchers.IO) { @@ -47,6 +56,28 @@ class RoomDetailsPresenter @Inject constructor( ) } } + fun handleEvents(event: RoomDetailsEvent) { + when (event) { + is RoomDetailsEvent.LeaveRoom -> { + if (event.needsConfirmation) { + leaveRoomWarning = LeaveRoomWarning.computeLeaveRoomWarning(room.isPublic, memberCount) + } else { + coroutineScope.launch(Dispatchers.IO) { + room.leave() + .onSuccess { + roomMembershipObserver.notifyUserLeftRoom(room.roomId) + }.onFailure { + error = RoomDetailsError.AlertGeneric + } + leaveRoomWarning = null + } + } + } + is RoomDetailsEvent.ClearLeaveRoomWarning -> leaveRoomWarning = null + RoomDetailsEvent.ClearError -> error = null + } + } + return RoomDetailsState( roomId = room.roomId.value, @@ -56,7 +87,9 @@ class RoomDetailsPresenter @Inject constructor( roomTopic = room.topic, memberCount = memberCount, isEncrypted = room.isEncrypted, -// eventSink = ::handleEvents + displayLeaveRoomWarning = leaveRoomWarning, + error = error, + eventSink = ::handleEvents ) } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt index 78ee70529d..a9d3dc2fb1 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt @@ -17,6 +17,9 @@ package io.element.android.features.roomdetails.impl import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.isLoading + +import io.element.android.libraries.matrix.api.room.MatrixRoom data class RoomDetailsState( val roomId: String, @@ -26,5 +29,27 @@ data class RoomDetailsState( val roomTopic: String?, val memberCount: Async, val isEncrypted: Boolean, -// val eventSink: (RoomDetailsEvent) -> Unit + val displayLeaveRoomWarning: LeaveRoomWarning?, + val error: RoomDetailsError?, + val eventSink: (RoomDetailsEvent) -> Unit ) + +sealed class LeaveRoomWarning { + object Generic : LeaveRoomWarning() + object PrivateRoom : LeaveRoomWarning() + object LastUserInRoom : LeaveRoomWarning() + + companion object { + fun computeLeaveRoomWarning(isPublic: Boolean, memberCount: Async): LeaveRoomWarning { + return when { + !isPublic -> PrivateRoom + (memberCount as? Async.Success)?.state == 1 -> LastUserInRoom + else -> Generic + } + } + } +} + +sealed interface RoomDetailsError { + object AlertGeneric : RoomDetailsError +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt index c91db4d3a8..2629d76aed 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt @@ -43,5 +43,7 @@ fun aRoomDetailsState() = RoomDetailsState( "|| MAI iki/Marketing...", memberCount = Async.Success(32), isEncrypted = true, -// eventSink = {} + displayLeaveRoomWarning = null, + error = null, + eventSink = {} ) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt index e4d4ac5609..d5b2a4a9e4 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt @@ -49,6 +49,8 @@ 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 import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferenceText import io.element.android.libraries.designsystem.preview.ElementPreviewDark @@ -57,6 +59,7 @@ import io.element.android.libraries.designsystem.theme.LocalColors 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 +import io.element.android.libraries.ui.strings.R as StringR @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -101,7 +104,24 @@ fun RoomDetailsView( SecuritySection() } - OtherActionsSection() + OtherActionsSection(onLeaveRoom = { + state.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true)) + }) + + if (state.displayLeaveRoomWarning != null) { + ConfirmLeaveRoomDialog( + leaveRoomWarning = state.displayLeaveRoomWarning, + onConfirmLeave = { state.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false)) }, + onDismiss = { state.eventSink(RoomDetailsEvent.ClearLeaveRoomWarning) } + ) + } + + if (state.error != null) { + ErrorDialog( + content = stringResource(StringR.string.error_unknown), + onDismiss = { state.eventSink(RoomDetailsEvent.ClearError) } + ) + } } } } @@ -189,16 +209,38 @@ internal fun SecuritySection(modifier: Modifier = Modifier) { } @Composable -internal fun OtherActionsSection(modifier: Modifier = Modifier) { +internal fun OtherActionsSection(onLeaveRoom: () -> Unit, modifier: Modifier = Modifier) { PreferenceCategory(showDivider = false, modifier = modifier) { PreferenceText( title = stringResource(R.string.screen_room_details_leave_room_title), icon = ImageVector.vectorResource(R.drawable.ic_door_open), tintColor = LocalColors.current.textActionCritical, + onClick = onLeaveRoom, ) } } +@Composable +internal fun ConfirmLeaveRoomDialog( + leaveRoomWarning: LeaveRoomWarning, + onConfirmLeave: () -> Unit, + onDismiss: () -> Unit +) { + val content = stringResource( + when (leaveRoomWarning) { + LeaveRoomWarning.PrivateRoom -> StringR.string.leave_room_alert_private_subtitle + LeaveRoomWarning.LastUserInRoom -> StringR.string.leave_room_alert_empty_subtitle + LeaveRoomWarning.Generic -> StringR.string.leave_room_alert_subtitle + } + ) + ConfirmationDialog( + content = content, + submitText = stringResource(StringR.string.action_leave), + onSubmitClicked = onConfirmLeave, + onDismiss = onDismiss, + ) +} + @Preview @Composable fun RoomDetailsLightPreview(@PreviewParameter(RoomDetailsStateProvider::class) state: RoomDetailsState) = diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt index 7679e1eb7d..41404b7354 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt @@ -20,21 +20,37 @@ import app.cash.molecule.RecompositionClock import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth +import io.element.android.features.roomdetails.impl.LeaveRoomWarning +import io.element.android.features.roomdetails.impl.RoomDetailsEvent import io.element.android.features.roomdetails.impl.RoomDetailsPresenter import io.element.android.libraries.architecture.Async import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver +import io.element.android.libraries.matrix.api.room.RoomMembershipState +import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_NAME +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.take import kotlinx.coroutines.test.runTest import org.junit.Test +@ExperimentalCoroutinesApi class RoomDetailsPresenterTests { + + private val roomMembershipObserver = RoomMembershipObserver(A_SESSION_ID) + @Test fun `present - initial state is created from room info`() = runTest { val room = aMatrixRoom() - val presenter = RoomDetailsPresenter(room) + val presenter = RoomDetailsPresenter(room, roomMembershipObserver) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -53,7 +69,7 @@ class RoomDetailsPresenterTests { @Test fun `present - room member count is calculated asynchronously`() = runTest { val room = aMatrixRoom() - val presenter = RoomDetailsPresenter(room) + val presenter = RoomDetailsPresenter(room, roomMembershipObserver) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -68,7 +84,7 @@ class RoomDetailsPresenterTests { @Test fun `present - initial state with no room name`() = runTest { val room = aMatrixRoom(name = null) - val presenter = RoomDetailsPresenter(room) + val presenter = RoomDetailsPresenter(room, roomMembershipObserver) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -84,7 +100,7 @@ class RoomDetailsPresenterTests { val room = aMatrixRoom(name = null).apply { givenFetchMemberResult(Result.failure(Throwable())) } - val presenter = RoomDetailsPresenter(room) + val presenter = RoomDetailsPresenter(room, roomMembershipObserver) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -94,6 +110,100 @@ class RoomDetailsPresenterTests { cancelAndIgnoreRemainingEvents() } } + + @Test + fun `present - Leave with confirmation on private room shows a specific warning`() = runTest { + val room = aMatrixRoom(isPublic = false) + val presenter = RoomDetailsPresenter(room, roomMembershipObserver) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + // Allow room member count to load + skipItems(1) + + initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true)) + val confirmationState = awaitItem() + Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.PrivateRoom) + } + } + + @Test + fun `present - Leave with confirmation on empty room shows a specific warning`() = runTest { + val room = aMatrixRoom(members = listOf(aRoomMember())) + val presenter = RoomDetailsPresenter(room, roomMembershipObserver) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + // Allow room member count to load + skipItems(1) + + initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true)) + val confirmationState = awaitItem() + Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.LastUserInRoom) + } + } + + @Test + fun `present - Leave with confirmation shows a generic warning`() = runTest { + val room = aMatrixRoom() + val presenter = RoomDetailsPresenter(room, roomMembershipObserver) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + // Allow room member count to load + skipItems(1) + + initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true)) + val confirmationState = awaitItem() + Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.Generic) + } + } + + @Test + fun `present - Leave without confirmation leaves the room`() = runTest { + val room = aMatrixRoom() + val presenter = RoomDetailsPresenter(room, roomMembershipObserver) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + // Allow room member count to load + skipItems(1) + + initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false)) + + cancelAndIgnoreRemainingEvents() + } + + // Membership observer should receive a 'left room' change + roomMembershipObserver.updates.take(1) + .onEach { update -> Truth.assertThat(update.change).isEqualTo(MembershipChange.LEFT) } + .collect() + } + + @Test + fun `present - ClearError removes any error present`() = runTest { + val room = aMatrixRoom().apply { + givenLeaveRoomError(Throwable()) + } + val presenter = RoomDetailsPresenter(room, roomMembershipObserver) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + // Allow room member count to load + skipItems(1) + + initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false)) + val errorState = awaitItem() + Truth.assertThat(errorState.error).isNotNull() + errorState.eventSink(RoomDetailsEvent.ClearError) + Truth.assertThat(awaitItem().error).isNull() + } + } } fun aMatrixRoom( @@ -104,6 +214,7 @@ fun aMatrixRoom( avatarUrl: String? = "https://matrix.org/avatar.jpg", members: List = emptyList(), isEncrypted: Boolean = true, + isPublic: Boolean = true, ) = FakeMatrixRoom( roomId = roomId, name = name, @@ -112,4 +223,23 @@ fun aMatrixRoom( avatarUrl = avatarUrl, members = members, isEncrypted = isEncrypted, + isPublic = isPublic, +) + +fun aRoomMember( + userId: UserId = A_USER_ID, + displayName: String? = null, + avatarUrl: String? = null, + membership: RoomMembershipState = RoomMembershipState.JOIN, + isNameAmbiguous: Boolean = false, + powerLevel: Long = 0L, + normalizedPowerLevel: Long = 0L +) = RoomMember( + userId = userId.value, + displayName = displayName, + avatarUrl = avatarUrl, + membership = membership, + isNameAmbiguous = isNameAmbiguous, + powerLevel = powerLevel, + normalizedPowerLevel = normalizedPowerLevel, ) diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListEvents.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListEvents.kt index 47c34d6a5c..299c670eb4 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListEvents.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListEvents.kt @@ -20,5 +20,4 @@ sealed interface RoomListEvents { data class UpdateFilter(val newFilter: String) : RoomListEvents data class UpdateVisibleRange(val range: IntRange) : RoomListEvents object DismissRequestVerificationPrompt : RoomListEvents - object ClearSuccessfulVerificationMessage : RoomListEvents } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt index 7d9a62f2d7..ac37dfe30d 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt @@ -34,12 +34,14 @@ import io.element.android.libraries.core.extensions.orEmpty import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.utils.SnackbarDispatcher +import io.element.android.libraries.designsystem.utils.handleSnackbarMessage import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomSummary import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus -import io.element.android.libraries.matrix.api.verification.VerificationFlowState import io.element.android.libraries.matrix.ui.model.MatrixUser import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -56,8 +58,11 @@ class RoomListPresenter @Inject constructor( private val lastMessageTimestampFormatter: LastMessageTimestampFormatter, private val roomLastMessageFormatter: RoomLastMessageFormatter, private val sessionVerificationService: SessionVerificationService, + private val snackbarDispatcher: SnackbarDispatcher, ) : Presenter { + private val roomMembershipObserver: RoomMembershipObserver = client.roomMembershipObserver() + @Composable override fun present(): RoomListState { val matrixUser: MutableState = remember { @@ -86,19 +91,11 @@ class RoomListPresenter @Inject constructor( derivedStateOf { sessionVerifiedStatus == SessionVerifiedStatus.NotVerified && !verificationPromptDismissed } } - // Current verification flow status, if any (initial, requesting, accepted, etc.) - val currentVerificationFlowStatus by sessionVerificationService.verificationFlowState.collectAsState() - // We only care about the 'Finished' state to display the 'verification success' message - val presentVerificationSuccessfulMessage = remember { - derivedStateOf { currentVerificationFlowStatus == VerificationFlowState.Finished } - } - fun handleEvents(event: RoomListEvents) { when (event) { is RoomListEvents.UpdateFilter -> filter = event.newFilter is RoomListEvents.UpdateVisibleRange -> updateVisibleRange(event.range) RoomListEvents.DismissRequestVerificationPrompt -> verificationPromptDismissed = true - RoomListEvents.ClearSuccessfulVerificationMessage -> sessionVerificationService.reset() } } @@ -106,12 +103,14 @@ class RoomListPresenter @Inject constructor( filteredRoomSummaries.value = updateFilteredRoomSummaries(roomSummaries, filter) } + val snackbarMessage = handleSnackbarMessage(snackbarDispatcher) + return RoomListState( matrixUser = matrixUser.value, roomList = filteredRoomSummaries.value, filter = filter, - presentVerificationSuccessfulMessage = presentVerificationSuccessfulMessage.value, displayVerificationPrompt = displayVerificationPrompt, + snackbarMessage = snackbarMessage, eventSink = ::handleEvents ) } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt index 122c5e5506..a14ef74e94 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt @@ -18,6 +18,7 @@ package io.element.android.features.roomlist.impl import androidx.compose.runtime.Immutable import io.element.android.features.roomlist.impl.model.RoomListRoomSummary +import io.element.android.libraries.designsystem.utils.SnackbarMessage import io.element.android.libraries.matrix.ui.model.MatrixUser import kotlinx.collections.immutable.ImmutableList @@ -26,7 +27,7 @@ data class RoomListState( val matrixUser: MatrixUser?, val roomList: ImmutableList, val filter: String, - val presentVerificationSuccessfulMessage: Boolean, val displayVerificationPrompt: Boolean, + val snackbarMessage: SnackbarMessage?, val eventSink: (RoomListEvents) -> Unit ) diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt index 68db631675..d1ca647d62 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt @@ -20,17 +20,19 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryPlaceholders import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.utils.SnackbarMessage import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.ui.model.MatrixUser import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import io.element.android.libraries.ui.strings.R as StringR open class RoomListStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( aRoomListState(), aRoomListState().copy(displayVerificationPrompt = true), - aRoomListState().copy(presentVerificationSuccessfulMessage = true), + aRoomListState().copy(snackbarMessage = SnackbarMessage(StringR.string.common_verification_complete)), ) } @@ -39,7 +41,7 @@ internal fun aRoomListState() = RoomListState( roomList = aRoomListRoomSummaryList(), filter = "filter", eventSink = {}, - presentVerificationSuccessfulMessage = false, + snackbarMessage = null, displayVerificationPrompt = false, ) diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt index 8d31b17a14..1f49d8db0f 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt @@ -40,10 +40,11 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -67,6 +68,7 @@ import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.utils.LogCompositions import io.element.android.libraries.matrix.api.core.RoomId +import kotlinx.coroutines.launch import io.element.android.libraries.designsystem.R as DrawableR import io.element.android.libraries.ui.strings.R as StringR @@ -130,14 +132,18 @@ fun RoomListContent( } val snackbarHostState = remember { SnackbarHostState() } - val verificationCompleteMessage = stringResource(StringR.string.common_verification_complete) - LaunchedEffect(state.presentVerificationSuccessfulMessage) { - if (state.presentVerificationSuccessfulMessage) { - snackbarHostState.showSnackbar( - message = verificationCompleteMessage, - duration = SnackbarDuration.Short, - ) - state.eventSink(RoomListEvents.ClearSuccessfulVerificationMessage) + val snackbarMessageText = if (state.snackbarMessage != null ) { + stringResource(state.snackbarMessage.messageResId) + } else null + val coroutineScope = rememberCoroutineScope() + if (snackbarMessageText != null) { + SideEffect { + coroutineScope.launch { + snackbarHostState.showSnackbar( + message = snackbarMessageText, + duration = SnackbarDuration.Short, + ) + } } } diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt index 1e8c15bf6d..3f3e43e2e7 100644 --- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt @@ -24,8 +24,8 @@ import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus -import io.element.android.libraries.matrix.api.verification.VerificationFlowState import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_ROOM_ID @@ -49,6 +49,7 @@ class RoomListPresenterTests { createDateFormatter(), FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), + SnackbarDispatcher(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -75,6 +76,7 @@ class RoomListPresenterTests { createDateFormatter(), FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), + SnackbarDispatcher(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -95,6 +97,7 @@ class RoomListPresenterTests { createDateFormatter(), FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), + SnackbarDispatcher(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -119,6 +122,7 @@ class RoomListPresenterTests { createDateFormatter(), FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), + SnackbarDispatcher(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -148,6 +152,7 @@ class RoomListPresenterTests { createDateFormatter(), FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), + SnackbarDispatcher(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -182,6 +187,7 @@ class RoomListPresenterTests { createDateFormatter(), FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), + SnackbarDispatcher(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -230,6 +236,7 @@ class RoomListPresenterTests { givenIsReady(true) givenVerifiedStatus(SessionVerifiedStatus.NotVerified) }, + SnackbarDispatcher(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -242,32 +249,6 @@ class RoomListPresenterTests { } } - @Test - fun `present - presentVerificationSuccessfulMessage & ClearVerificationSuccesfulMessage`() = runTest { - val roomSummaryDataSource = FakeRoomSummaryDataSource() - val presenter = RoomListPresenter( - FakeMatrixClient( - sessionId = A_SESSION_ID, - roomSummaryDataSource = roomSummaryDataSource - ), - createDateFormatter(), - FakeRoomLastMessageFormatter(), - FakeSessionVerificationService().apply { - givenIsReady(true) - givenVerificationFlowState(VerificationFlowState.Finished) - }, - ) - moleculeFlow(RecompositionClock.Immediate) { - presenter.present() - }.test { - skipItems(1) - val displayMessageItem = awaitItem() - Truth.assertThat(displayMessageItem.presentVerificationSuccessfulMessage).isTrue() - displayMessageItem.eventSink(RoomListEvents.ClearSuccessfulVerificationMessage) - Truth.assertThat(awaitItem().presentVerificationSuccessfulMessage).isFalse() - } - } - private fun createDateFormatter(): LastMessageTimestampFormatter { return FakeLastMessageTimestampFormatter().apply { givenFormat(A_FORMATTED_DATE) diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt index 6c79c1b762..4007f4c8cf 100644 --- a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt +++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt @@ -27,9 +27,11 @@ import io.element.android.libraries.architecture.Async import io.element.android.libraries.matrix.api.verification.VerificationFlowState import io.element.android.libraries.matrix.api.verification.VerificationEmoji import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Test +@ExperimentalCoroutinesApi class VerifySelfSessionPresenterTests { @Test diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ConfirmationDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ConfirmationDialog.kt index ff434c9748..07a85e0645 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ConfirmationDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ConfirmationDialog.kt @@ -37,11 +37,11 @@ import io.element.android.libraries.ui.strings.R as StringR @Composable fun ConfirmationDialog( - title: String, content: String, onSubmitClicked: () -> Unit, onDismiss: () -> Unit, modifier: Modifier = Modifier, + title: String? = null, submitText: String = stringResource(id = StringR.string.action_ok), cancelText: String = stringResource(id = StringR.string.action_cancel), thirdButtonText: String? = null, @@ -60,7 +60,7 @@ fun ConfirmationDialog( modifier = modifier, onDismissRequest = onDismiss, title = { - Text(text = title) + if (title != null) { Text(text = title) } }, text = { Text(content) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/SnackbarDispatcher.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/SnackbarDispatcher.kt new file mode 100644 index 0000000000..1131777398 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/SnackbarDispatcher.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.designsystem.utils + +import androidx.annotation.StringRes +import androidx.compose.material3.SnackbarDuration +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class SnackbarDispatcher { + private val mutex = Mutex() + + private val snackbarState = MutableStateFlow(null) + val snackbarMessage: Flow = snackbarState + + suspend fun post(message: SnackbarMessage) { + mutex.withLock { + snackbarState.update { message } + } + } + + suspend fun clear() { + mutex.withLock { + snackbarState.update { null } + } + } +} + +@Composable +fun handleSnackbarMessage( + snackbarDispatcher: SnackbarDispatcher +): SnackbarMessage? { + val snackbarMessage by snackbarDispatcher.snackbarMessage.collectAsState(initial = null) + LaunchedEffect(snackbarMessage) { + if (snackbarMessage != null) { + launch(Dispatchers.Main) { + snackbarDispatcher.clear() + } + } + } + return snackbarMessage +} + +data class SnackbarMessage( + @StringRes val messageResId: Int, + val duration: SnackbarDuration = SnackbarDuration.Short, + @StringRes val actionResId: Int? = null, + val action: () -> Unit = {}, +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index 9ce5502843..8a991771a5 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.media.MediaResolver import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource import io.element.android.libraries.matrix.api.verification.SessionVerificationService import java.io.Closeable @@ -43,4 +44,6 @@ interface MatrixClient : Closeable { ): Result fun onSlidingSyncUpdate() + + fun roomMembershipObserver(): RoomMembershipObserver } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt index bd76d95b35..60ff09a15b 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt @@ -32,8 +32,10 @@ interface MatrixRoom: Closeable { val topic: String? val avatarUrl: String? val isEncrypted: Boolean + val isPublic: Boolean suspend fun members() : List + suspend fun memberCount(): Int fun syncUpdateFlow(): Flow @@ -53,4 +55,6 @@ interface MatrixRoom: Closeable { suspend fun replyMessage(eventId: EventId, message: String): Result suspend fun redactEvent(eventId: EventId, reason: String? = null): Result + + fun leave(): Result } 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 new file mode 100644 index 0000000000..42b0996bb1 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMembershipObserver.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.matrix.api.room + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +class RoomMembershipObserver( + private val sessionId: SessionId, +) { + data class RoomMembershipUpdate( + val roomId: RoomId, + val isUserInRoom: Boolean, + val change: MembershipChange, + ) + + private val _updates = MutableSharedFlow(replay = 1) + val updates = _updates.asSharedFlow() + + fun notifyUserLeftRoom(roomId: RoomId) { + _updates.tryEmit(RoomMembershipUpdate(roomId, false, MembershipChange.LEFT)) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 378fec56c7..2fe34bfa55 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -25,6 +25,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.impl.media.RustMediaResolver +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.impl.room.RustMatrixRoom import io.element.android.libraries.matrix.impl.room.RustRoomSummaryDataSource import io.element.android.libraries.matrix.impl.sync.SlidingSyncObserverProxy @@ -89,6 +90,7 @@ class RustMatrixClient constructor( requiredState = listOf( RequiredState(key = "m.room.avatar", value = ""), RequiredState(key = "m.room.encryption", value = ""), + RequiredState(key = "m.room.join_rules", value = ""), ) ) .filters(slidingSyncFilters) @@ -128,6 +130,8 @@ class RustMatrixClient constructor( private val mediaResolver = RustMediaResolver(this) private val isSyncing = AtomicBoolean(false) + private val roomMembershipObserver = RoomMembershipObserver(sessionId) + init { client.setDelegate(clientDelegate) rustRoomSummaryDataSource.init() @@ -150,7 +154,7 @@ class RustMatrixClient constructor( slidingSyncRoom = slidingSyncRoom, innerRoom = fullRoom, coroutineScope = coroutineScope, - coroutineDispatchers = dispatchers + coroutineDispatchers = dispatchers, ) } @@ -243,6 +247,8 @@ class RustMatrixClient constructor( } } + override fun roomMembershipObserver(): RoomMembershipObserver = roomMembershipObserver + private fun File.deleteSessionDirectory(userID: String): Boolean { // Rust sanitises the user ID replacing invalid characters with an _ val sanitisedUserID = userID.replace(":", "_") diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt index 3079c75dc8..b49050cdc2 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt @@ -22,7 +22,9 @@ import dagger.Provides import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SingleIn import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.verification.SessionVerificationService +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver @Module @ContributesTo(SessionScope::class) @@ -32,4 +34,10 @@ object SessionMatrixModule { fun providesRustSessionVerificationService(matrixClient: MatrixClient): SessionVerificationService { return matrixClient.sessionVerificationService() } + + @Provides + @SingleIn(SessionScope::class) + fun provideRoomMembershipObserver(matrixClient: MatrixClient): RoomMembershipObserver { + return matrixClient.roomMembershipObserver() + } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt index 291aa57392..fd5a63cb3b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt @@ -129,6 +129,9 @@ class RustMatrixRoom( override val alternativeAliases: List get() = innerRoom.alternativeAliases() + override val isPublic: Boolean + get() = innerRoom.isPublic() + override suspend fun fetchMembers(): Result = withContext(coroutineDispatchers.io) { runCatching { innerRoom.fetchMembers() @@ -179,4 +182,8 @@ class RustMatrixRoom( innerRoom.redact(eventId.value, reason, transactionId) } } + + override fun leave(): Result { + return runCatching { innerRoom.leave() } + } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index 9272e794b4..998c7cdea4 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -21,6 +21,7 @@ import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.media.MediaResolver import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.test.media.FakeMediaResolver @@ -81,4 +82,8 @@ class FakeMatrixClient( override fun sessionVerificationService(): SessionVerificationService = sessionVerificationService override fun onSlidingSyncUpdate() {} + + override fun roomMembershipObserver(): RoomMembershipObserver { + return RoomMembershipObserver(A_SESSION_ID) + } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt index b8ea695f47..1c6d580a3c 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt @@ -37,6 +37,7 @@ class FakeMatrixRoom( override val isEncrypted: Boolean = false, override val alias: String? = null, override val alternativeAliases: List = emptyList(), + override val isPublic: Boolean = true, private val members: List = emptyList(), private val matrixTimeline: MatrixTimeline = FakeMatrixTimeline(), ) : MatrixRoom { @@ -46,6 +47,8 @@ class FakeMatrixRoom( var areMembersFetched: Boolean = false private set + private var leaveRoomError: Throwable? = null + override fun syncUpdateFlow(): Flow { return emptyFlow() } @@ -114,8 +117,14 @@ class FakeMatrixRoom( return Result.success(Unit) } + override fun leave(): Result = leaveRoomError?.let { Result.failure(it) } ?: Result.success(Unit) + override fun close() = Unit + fun givenLeaveRoomError(throwable: Throwable?) { + this.leaveRoomError = throwable + } + fun givenFetchMemberResult(result: Result) { fetchMemberResult = result } diff --git a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt index 6c95dee210..5dce2feafe 100644 --- a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt +++ b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt @@ -26,6 +26,7 @@ import io.element.android.features.roomlist.impl.RoomListView import io.element.android.libraries.dateformatter.impl.DateFormatters import io.element.android.libraries.dateformatter.impl.DefaultLastMessageTimestampFormatter import io.element.android.libraries.dateformatter.impl.LocalDateTimeProvider +import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId import kotlinx.coroutines.launch @@ -47,7 +48,8 @@ class RoomListScreen( matrixClient, DefaultLastMessageTimestampFormatter(dateTimeProvider, dateFormatters), DefaultRoomLastMessageFormatter(context, matrixClient), - sessionVerificationService + sessionVerificationService, + SnackbarDispatcher(), ) @Composable