Browse Source

[Room Details] Leave room (#296)

* Add leave room functionality to the Room Details screen

* Add snackbar message throught `SnackbarDistpacher`
test/jme/compound-poc
Jorge Martin Espinosa 1 year ago committed by GitHub
parent
commit
3aea24380a
  1. 7
      app/src/main/kotlin/io/element/android/x/di/AppModule.kt
  2. 1
      appnav/build.gradle.kts
  3. 72
      appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt
  4. 13
      appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
  5. 18
      appnav/src/main/kotlin/io/element/android/appnav/RoomFlowNode.kt
  6. 1
      changelog.d/286.feature
  7. 4
      features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt
  8. 5
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPoint.kt
  9. 6
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt
  10. 39
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt
  11. 27
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt
  12. 4
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt
  13. 46
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt
  14. 138
      features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt
  15. 1
      features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListEvents.kt
  16. 19
      features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt
  17. 3
      features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt
  18. 6
      features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt
  19. 24
      features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt
  20. 35
      features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt
  21. 2
      features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt
  22. 4
      libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ConfirmationDialog.kt
  23. 72
      libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/SnackbarDispatcher.kt
  24. 3
      libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt
  25. 4
      libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt
  26. 40
      libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMembershipObserver.kt
  27. 8
      libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
  28. 8
      libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt
  29. 7
      libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt
  30. 5
      libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt
  31. 9
      libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt
  32. 4
      samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt

7
app/src/main/kotlin/io/element/android/x/di/AppModule.kt

@ -22,6 +22,7 @@ import dagger.Module @@ -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 { @@ -78,4 +79,10 @@ object AppModule {
diffUpdateDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
)
}
@Provides
@SingleIn(AppScope::class)
fun provideSnackbarDispatcher(): SnackbarDispatcher {
return SnackbarDispatcher()
}
}

1
appnav/build.gradle.kts

@ -45,6 +45,7 @@ dependencies { @@ -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)

72
appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt

@ -0,0 +1,72 @@ @@ -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))
}
}

13
appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt

@ -46,13 +46,17 @@ import io.element.android.libraries.architecture.bindings @@ -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( @@ -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<LoggedInFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.RoomList,
@ -87,6 +93,11 @@ class LoggedInFlowNode @AssistedInject constructor( @@ -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( @@ -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<MatrixUIBindings>().notLoggedInImageLoaderFactory()
@ -106,6 +118,7 @@ class LoggedInFlowNode @AssistedInject constructor( @@ -106,6 +118,7 @@ class LoggedInFlowNode @AssistedInject constructor(
plugins<LifecycleCallback>().forEach { it.onFlowReleased(inputs.matrixClient) }
appNavigationStateService.onLeavingSpace()
appNavigationStateService.onLeavingSession()
loggedInFlowProcessor.stopObserving()
}
)
}

18
appnav/src/main/kotlin/io/element/android/appnav/RoomFlowNode.kt

@ -18,6 +18,7 @@ package io.element.android.appnav @@ -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 @@ -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( @@ -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<RoomFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Messages,
@ -68,6 +76,7 @@ class RoomFlowNode @AssistedInject constructor( @@ -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( @@ -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( @@ -97,7 +113,7 @@ class RoomFlowNode @AssistedInject constructor(
})
}
NavTarget.RoomDetails -> {
roomDetailsEntryPoint.createNode(this, buildContext)
roomDetailsEntryPoint.createNode(this, buildContext, emptyList())
}
}
}

1
changelog.d/286.feature

@ -0,0 +1 @@ @@ -0,0 +1 @@
Add leave room functionality to the Room Details screen.

4
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 @@ -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<Plugin>): Node
}

5
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 @@ -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 @@ -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<RoomDetailsFlowNode>(buildContext)
override fun createNode(parentNode: Node, buildContext: BuildContext, plugins: List<Plugin>): Node {
return parentNode.createNode<RoomDetailsFlowNode>(buildContext, plugins)
}
}

6
features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt

@ -16,4 +16,8 @@ @@ -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
}

39
features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt

@ -21,22 +21,31 @@ import androidx.compose.runtime.LaunchedEffect @@ -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<RoomDetailsState> {
@Composable
override fun present(): RoomDetailsState {
// fun handleEvents(event: RoomDetailsEvent) {}
val coroutineScope = rememberCoroutineScope()
var leaveRoomWarning by remember {
mutableStateOf<LeaveRoomWarning?>(null)
}
var error by remember {
mutableStateOf<RoomDetailsError?>(null)
}
var memberCount: Async<Int> by remember { mutableStateOf(Async.Loading()) }
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
@ -47,6 +56,28 @@ class RoomDetailsPresenter @Inject constructor( @@ -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( @@ -56,7 +87,9 @@ class RoomDetailsPresenter @Inject constructor(
roomTopic = room.topic,
memberCount = memberCount,
isEncrypted = room.isEncrypted,
// eventSink = ::handleEvents
displayLeaveRoomWarning = leaveRoomWarning,
error = error,
eventSink = ::handleEvents
)
}
}

27
features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt

@ -17,6 +17,9 @@ @@ -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( @@ -26,5 +29,27 @@ data class RoomDetailsState(
val roomTopic: String?,
val memberCount: Async<Int>,
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<Int>): LeaveRoomWarning {
return when {
!isPublic -> PrivateRoom
(memberCount as? Async.Success<Int>)?.state == 1 -> LastUserInRoom
else -> Generic
}
}
}
}
sealed interface RoomDetailsError {
object AlertGeneric : RoomDetailsError
}

4
features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt

@ -43,5 +43,7 @@ fun aRoomDetailsState() = RoomDetailsState( @@ -43,5 +43,7 @@ fun aRoomDetailsState() = RoomDetailsState(
"|| MAI iki/Marketing...",
memberCount = Async.Success(32),
isEncrypted = true,
// eventSink = {}
displayLeaveRoomWarning = null,
error = null,
eventSink = {}
)

46
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 @@ -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 @@ -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( @@ -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) { @@ -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) =

138
features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt

@ -20,21 +20,37 @@ import app.cash.molecule.RecompositionClock @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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( @@ -104,6 +214,7 @@ fun aMatrixRoom(
avatarUrl: String? = "https://matrix.org/avatar.jpg",
members: List<RoomMember> = emptyList(),
isEncrypted: Boolean = true,
isPublic: Boolean = true,
) = FakeMatrixRoom(
roomId = roomId,
name = name,
@ -112,4 +223,23 @@ fun aMatrixRoom( @@ -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,
)

1
features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListEvents.kt

@ -20,5 +20,4 @@ sealed interface RoomListEvents { @@ -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
}

19
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 @@ -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( @@ -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<RoomListState> {
private val roomMembershipObserver: RoomMembershipObserver = client.roomMembershipObserver()
@Composable
override fun present(): RoomListState {
val matrixUser: MutableState<MatrixUser?> = remember {
@ -86,19 +91,11 @@ class RoomListPresenter @Inject constructor( @@ -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( @@ -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
)
}

3
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 @@ -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( @@ -26,7 +27,7 @@ data class RoomListState(
val matrixUser: MatrixUser?,
val roomList: ImmutableList<RoomListRoomSummary>,
val filter: String,
val presentVerificationSuccessfulMessage: Boolean,
val displayVerificationPrompt: Boolean,
val snackbarMessage: SnackbarMessage?,
val eventSink: (RoomListEvents) -> Unit
)

6
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 @@ -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<RoomListState> {
override val values: Sequence<RoomListState>
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( @@ -39,7 +41,7 @@ internal fun aRoomListState() = RoomListState(
roomList = aRoomListRoomSummaryList(),
filter = "filter",
eventSink = {},
presentVerificationSuccessfulMessage = false,
snackbarMessage = null,
displayVerificationPrompt = false,
)

24
features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt

@ -40,10 +40,11 @@ import androidx.compose.material3.SnackbarHostState @@ -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 @@ -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( @@ -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,
)
}
}
}

35
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 @@ -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 { @@ -49,6 +49,7 @@ class RoomListPresenterTests {
createDateFormatter(),
FakeRoomLastMessageFormatter(),
FakeSessionVerificationService(),
SnackbarDispatcher(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -75,6 +76,7 @@ class RoomListPresenterTests { @@ -75,6 +76,7 @@ class RoomListPresenterTests {
createDateFormatter(),
FakeRoomLastMessageFormatter(),
FakeSessionVerificationService(),
SnackbarDispatcher(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -95,6 +97,7 @@ class RoomListPresenterTests { @@ -95,6 +97,7 @@ class RoomListPresenterTests {
createDateFormatter(),
FakeRoomLastMessageFormatter(),
FakeSessionVerificationService(),
SnackbarDispatcher(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -119,6 +122,7 @@ class RoomListPresenterTests { @@ -119,6 +122,7 @@ class RoomListPresenterTests {
createDateFormatter(),
FakeRoomLastMessageFormatter(),
FakeSessionVerificationService(),
SnackbarDispatcher(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -148,6 +152,7 @@ class RoomListPresenterTests { @@ -148,6 +152,7 @@ class RoomListPresenterTests {
createDateFormatter(),
FakeRoomLastMessageFormatter(),
FakeSessionVerificationService(),
SnackbarDispatcher(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -182,6 +187,7 @@ class RoomListPresenterTests { @@ -182,6 +187,7 @@ class RoomListPresenterTests {
createDateFormatter(),
FakeRoomLastMessageFormatter(),
FakeSessionVerificationService(),
SnackbarDispatcher(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -230,6 +236,7 @@ class RoomListPresenterTests { @@ -230,6 +236,7 @@ class RoomListPresenterTests {
givenIsReady(true)
givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
},
SnackbarDispatcher(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -242,32 +249,6 @@ class RoomListPresenterTests { @@ -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)

2
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 @@ -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

4
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 @@ -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( @@ -60,7 +60,7 @@ fun ConfirmationDialog(
modifier = modifier,
onDismissRequest = onDismiss,
title = {
Text(text = title)
if (title != null) { Text(text = title) }
},
text = {
Text(content)

72
libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/SnackbarDispatcher.kt

@ -0,0 +1,72 @@ @@ -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<SnackbarMessage?>(null)
val snackbarMessage: Flow<SnackbarMessage?> = 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 = {},
)

3
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 @@ -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 { @@ -43,4 +44,6 @@ interface MatrixClient : Closeable {
): Result<ByteArray>
fun onSlidingSyncUpdate()
fun roomMembershipObserver(): RoomMembershipObserver
}

4
libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt

@ -32,8 +32,10 @@ interface MatrixRoom: Closeable { @@ -32,8 +32,10 @@ interface MatrixRoom: Closeable {
val topic: String?
val avatarUrl: String?
val isEncrypted: Boolean
val isPublic: Boolean
suspend fun members() : List<RoomMember>
suspend fun memberCount(): Int
fun syncUpdateFlow(): Flow<Long>
@ -53,4 +55,6 @@ interface MatrixRoom: Closeable { @@ -53,4 +55,6 @@ interface MatrixRoom: Closeable {
suspend fun replyMessage(eventId: EventId, message: String): Result<Unit>
suspend fun redactEvent(eventId: EventId, reason: String? = null): Result<Unit>
fun leave(): Result<Unit>
}

40
libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMembershipObserver.kt

@ -0,0 +1,40 @@ @@ -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<RoomMembershipUpdate>(replay = 1)
val updates = _updates.asSharedFlow()
fun notifyUserLeftRoom(roomId: RoomId) {
_updates.tryEmit(RoomMembershipUpdate(roomId, false, MembershipChange.LEFT))
}
}

8
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 @@ -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( @@ -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( @@ -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( @@ -150,7 +154,7 @@ class RustMatrixClient constructor(
slidingSyncRoom = slidingSyncRoom,
innerRoom = fullRoom,
coroutineScope = coroutineScope,
coroutineDispatchers = dispatchers
coroutineDispatchers = dispatchers,
)
}
@ -243,6 +247,8 @@ class RustMatrixClient constructor( @@ -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(":", "_")

8
libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt

@ -22,7 +22,9 @@ import dagger.Provides @@ -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 { @@ -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()
}
}

7
libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt

@ -129,6 +129,9 @@ class RustMatrixRoom( @@ -129,6 +129,9 @@ class RustMatrixRoom(
override val alternativeAliases: List<String>
get() = innerRoom.alternativeAliases()
override val isPublic: Boolean
get() = innerRoom.isPublic()
override suspend fun fetchMembers(): Result<Unit> = withContext(coroutineDispatchers.io) {
runCatching {
innerRoom.fetchMembers()
@ -179,4 +182,8 @@ class RustMatrixRoom( @@ -179,4 +182,8 @@ class RustMatrixRoom(
innerRoom.redact(eventId.value, reason, transactionId)
}
}
override fun leave(): Result<Unit> {
return runCatching { innerRoom.leave() }
}
}

5
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 @@ -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( @@ -81,4 +82,8 @@ class FakeMatrixClient(
override fun sessionVerificationService(): SessionVerificationService = sessionVerificationService
override fun onSlidingSyncUpdate() {}
override fun roomMembershipObserver(): RoomMembershipObserver {
return RoomMembershipObserver(A_SESSION_ID)
}
}

9
libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt

@ -37,6 +37,7 @@ class FakeMatrixRoom( @@ -37,6 +37,7 @@ class FakeMatrixRoom(
override val isEncrypted: Boolean = false,
override val alias: String? = null,
override val alternativeAliases: List<String> = emptyList(),
override val isPublic: Boolean = true,
private val members: List<RoomMember> = emptyList(),
private val matrixTimeline: MatrixTimeline = FakeMatrixTimeline(),
) : MatrixRoom {
@ -46,6 +47,8 @@ class FakeMatrixRoom( @@ -46,6 +47,8 @@ class FakeMatrixRoom(
var areMembersFetched: Boolean = false
private set
private var leaveRoomError: Throwable? = null
override fun syncUpdateFlow(): Flow<Long> {
return emptyFlow()
}
@ -114,8 +117,14 @@ class FakeMatrixRoom( @@ -114,8 +117,14 @@ class FakeMatrixRoom(
return Result.success(Unit)
}
override fun leave(): Result<Unit> = leaveRoomError?.let { Result.failure(it) } ?: Result.success(Unit)
override fun close() = Unit
fun givenLeaveRoomError(throwable: Throwable?) {
this.leaveRoomError = throwable
}
fun givenFetchMemberResult(result: Result<Unit>) {
fetchMemberResult = result
}

4
samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt

@ -26,6 +26,7 @@ import io.element.android.features.roomlist.impl.RoomListView @@ -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( @@ -47,7 +48,8 @@ class RoomListScreen(
matrixClient,
DefaultLastMessageTimestampFormatter(dateTimeProvider, dateFormatters),
DefaultRoomLastMessageFormatter(context, matrixClient),
sessionVerificationService
sessionVerificationService,
SnackbarDispatcher(),
)
@Composable

Loading…
Cancel
Save