Display a confirmation dialog before creating a DM.
This commit is contained in:
committed by
Benoit Marty
parent
7b46c9c277
commit
bfcc741312
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.createroom.api
|
||||
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
|
||||
data class ConfirmingStartDmWithMatrixUser(
|
||||
val matrixUser: MatrixUser,
|
||||
) : AsyncAction.Confirming
|
||||
@@ -10,13 +10,19 @@ package io.element.android.features.createroom.api
|
||||
import androidx.compose.runtime.MutableState
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
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.user.MatrixUser
|
||||
|
||||
interface StartDMAction {
|
||||
/**
|
||||
* Try to find an existing DM with the given user, or create one if none exists.
|
||||
* @param userId The user to start a DM with.
|
||||
* @param matrixUser The user to start a DM with.
|
||||
* @param createIfDmDoesNotExist If true, create a DM if one does not exist. If false and the DM
|
||||
* does not exist, the action will fail with the value [ConfirmingStartDmWithMatrixUser].
|
||||
* @param actionState The state to update with the result of the action.
|
||||
*/
|
||||
suspend fun execute(userId: UserId, actionState: MutableState<AsyncAction<RoomId>>)
|
||||
suspend fun execute(
|
||||
matrixUser: MatrixUser,
|
||||
createIfDmDoesNotExist: Boolean,
|
||||
actionState: MutableState<AsyncAction<RoomId>>,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,14 +10,15 @@ package io.element.android.features.createroom.impl
|
||||
import androidx.compose.runtime.MutableState
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import im.vector.app.features.analytics.plan.CreatedRoom
|
||||
import io.element.android.features.createroom.api.ConfirmingStartDmWithMatrixUser
|
||||
import io.element.android.features.createroom.api.StartDMAction
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.StartDMResult
|
||||
import io.element.android.libraries.matrix.api.room.startDM
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -26,9 +27,13 @@ class DefaultStartDMAction @Inject constructor(
|
||||
private val matrixClient: MatrixClient,
|
||||
private val analyticsService: AnalyticsService,
|
||||
) : StartDMAction {
|
||||
override suspend fun execute(userId: UserId, actionState: MutableState<AsyncAction<RoomId>>) {
|
||||
override suspend fun execute(
|
||||
matrixUser: MatrixUser,
|
||||
createIfDmDoesNotExist: Boolean,
|
||||
actionState: MutableState<AsyncAction<RoomId>>,
|
||||
) {
|
||||
actionState.value = AsyncAction.Loading
|
||||
when (val result = matrixClient.startDM(userId)) {
|
||||
when (val result = matrixClient.startDM(matrixUser.userId, createIfDmDoesNotExist)) {
|
||||
is StartDMResult.Success -> {
|
||||
if (result.isNew) {
|
||||
analyticsService.capture(CreatedRoom(isDM = true))
|
||||
@@ -38,6 +43,9 @@ class DefaultStartDMAction @Inject constructor(
|
||||
is StartDMResult.Failure -> {
|
||||
actionState.value = AsyncAction.Failure(result.throwable)
|
||||
}
|
||||
StartDMResult.DmDoesNotExist -> {
|
||||
actionState.value = ConfirmingStartDmWithMatrixUser(matrixUser = matrixUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,11 @@ class CreateRoomRootPresenter @Inject constructor(
|
||||
fun handleEvents(event: CreateRoomRootEvents) {
|
||||
when (event) {
|
||||
is CreateRoomRootEvents.StartDM -> localCoroutineScope.launch {
|
||||
startDMAction.execute(event.matrixUser.userId, startDmActionState)
|
||||
startDMAction.execute(
|
||||
matrixUser = event.matrixUser,
|
||||
createIfDmDoesNotExist = startDmActionState.value is AsyncAction.Confirming,
|
||||
actionState = startDmActionState,
|
||||
)
|
||||
}
|
||||
CreateRoomRootEvents.CancelStartDM -> startDmActionState.value = AsyncAction.Uninitialized
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.createroom.api.ConfirmingStartDmWithMatrixUser
|
||||
import io.element.android.features.createroom.impl.R
|
||||
import io.element.android.features.createroom.impl.components.UserListView
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncActionView
|
||||
@@ -43,6 +44,7 @@ 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.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.ui.components.CreateDmConfirmationBottomSheet
|
||||
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
@@ -110,6 +112,19 @@ fun CreateRoomRootView(
|
||||
?: state.eventSink(CreateRoomRootEvents.CancelStartDM)
|
||||
},
|
||||
onErrorDismiss = { state.eventSink(CreateRoomRootEvents.CancelStartDM) },
|
||||
confirmationDialog = { data ->
|
||||
if (data is ConfirmingStartDmWithMatrixUser) {
|
||||
CreateDmConfirmationBottomSheet(
|
||||
matrixUser = data.matrixUser,
|
||||
onSendInvite = {
|
||||
state.eventSink(CreateRoomRootEvents.StartDM(data.matrixUser))
|
||||
},
|
||||
onDismiss = {
|
||||
state.eventSink(CreateRoomRootEvents.CancelStartDM)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,13 +10,14 @@ package io.element.android.features.createroom.impl
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import im.vector.app.features.analytics.plan.CreatedRoom
|
||||
import io.element.android.features.createroom.api.ConfirmingStartDmWithMatrixUser
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_THROWABLE
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import kotlinx.coroutines.test.runTest
|
||||
@@ -28,10 +29,12 @@ class DefaultStartDMActionTest {
|
||||
val matrixClient = FakeMatrixClient().apply {
|
||||
givenFindDmResult(A_ROOM_ID)
|
||||
}
|
||||
val action = createStartDMAction(matrixClient)
|
||||
val analyticsService = FakeAnalyticsService()
|
||||
val action = createStartDMAction(matrixClient, analyticsService)
|
||||
val state = mutableStateOf<AsyncAction<RoomId>>(AsyncAction.Uninitialized)
|
||||
action.execute(A_USER_ID, state)
|
||||
action.execute(aMatrixUser(), true, state)
|
||||
assertThat(state.value).isEqualTo(AsyncAction.Success(A_ROOM_ID))
|
||||
assertThat(analyticsService.capturedEvents).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -43,21 +46,38 @@ class DefaultStartDMActionTest {
|
||||
val analyticsService = FakeAnalyticsService()
|
||||
val action = createStartDMAction(matrixClient, analyticsService)
|
||||
val state = mutableStateOf<AsyncAction<RoomId>>(AsyncAction.Uninitialized)
|
||||
action.execute(A_USER_ID, state)
|
||||
action.execute(aMatrixUser(), true, state)
|
||||
assertThat(state.value).isEqualTo(AsyncAction.Success(A_ROOM_ID))
|
||||
assertThat(analyticsService.capturedEvents).containsExactly(CreatedRoom(isDM = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when dm is not found, and createIfDmDoesNotExist is false, assert dm is not created and state is updated to confirmation state`() = runTest {
|
||||
val matrixClient = FakeMatrixClient().apply {
|
||||
givenFindDmResult(null)
|
||||
givenCreateDmResult(Result.success(A_ROOM_ID))
|
||||
}
|
||||
val analyticsService = FakeAnalyticsService()
|
||||
val action = createStartDMAction(matrixClient, analyticsService)
|
||||
val state = mutableStateOf<AsyncAction<RoomId>>(AsyncAction.Uninitialized)
|
||||
val matrixUser = aMatrixUser()
|
||||
action.execute(matrixUser, false, state)
|
||||
assertThat(state.value).isEqualTo(ConfirmingStartDmWithMatrixUser(matrixUser))
|
||||
assertThat(analyticsService.capturedEvents).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when dm creation fails, assert state is updated with given error`() = runTest {
|
||||
val matrixClient = FakeMatrixClient().apply {
|
||||
givenFindDmResult(null)
|
||||
givenCreateDmResult(Result.failure(A_THROWABLE))
|
||||
}
|
||||
val action = createStartDMAction(matrixClient)
|
||||
val analyticsService = FakeAnalyticsService()
|
||||
val action = createStartDMAction(matrixClient, analyticsService)
|
||||
val state = mutableStateOf<AsyncAction<RoomId>>(AsyncAction.Uninitialized)
|
||||
action.execute(A_USER_ID, state)
|
||||
action.execute(aMatrixUser(), true, state)
|
||||
assertThat(state.value).isEqualTo(AsyncAction.Failure(A_THROWABLE))
|
||||
assertThat(analyticsService.capturedEvents).isEmpty()
|
||||
}
|
||||
|
||||
private fun createStartDMAction(
|
||||
|
||||
@@ -7,16 +7,19 @@
|
||||
|
||||
package io.element.android.features.createroom.impl.root
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.createroom.api.ConfirmingStartDmWithMatrixUser
|
||||
import io.element.android.features.createroom.api.StartDMAction
|
||||
import io.element.android.features.createroom.impl.userlist.FakeUserListPresenter
|
||||
import io.element.android.features.createroom.impl.userlist.FakeUserListPresenterFactory
|
||||
import io.element.android.features.createroom.impl.userlist.UserListDataStore
|
||||
import io.element.android.features.createroom.test.FakeStartDMAction
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
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.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
@@ -24,6 +27,9 @@ import io.element.android.libraries.matrix.test.A_THROWABLE
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.libraries.usersearch.test.FakeUserRepository
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.lambda.any
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -33,46 +39,130 @@ class CreateRoomRootPresenterTest {
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
@Test
|
||||
fun `present - start DM action complete scenario`() = runTest {
|
||||
val startDMAction = FakeStartDMAction()
|
||||
fun `present - start DM action failure scenario`() = runTest {
|
||||
val startDMFailureResult = AsyncAction.Failure(A_THROWABLE)
|
||||
val executeResult = lambdaRecorder<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, Unit> { _, _, actionState ->
|
||||
actionState.value = startDMFailureResult
|
||||
}
|
||||
val startDMAction = FakeStartDMAction(executeResult = executeResult)
|
||||
val presenter = createCreateRoomRootPresenter(startDMAction)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
||||
assertThat(initialState.startDmAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
|
||||
assertThat(initialState.applicationName).isEqualTo(aBuildMeta().applicationName)
|
||||
assertThat(initialState.userListState.selectedUsers).isEmpty()
|
||||
assertThat(initialState.userListState.isSearchActive).isFalse()
|
||||
assertThat(initialState.userListState.isMultiSelectionEnabled).isFalse()
|
||||
|
||||
val matrixUser = MatrixUser(UserId("@name:domain"))
|
||||
val startDMSuccessResult = AsyncAction.Success(A_ROOM_ID)
|
||||
val startDMFailureResult = AsyncAction.Failure(A_THROWABLE)
|
||||
|
||||
// Failure
|
||||
startDMAction.givenExecuteResult(startDMFailureResult)
|
||||
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
|
||||
assertThat(awaitItem().startDmAction).isInstanceOf(AsyncAction.Loading::class.java)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.startDmAction).isEqualTo(startDMFailureResult)
|
||||
executeResult.assertions().isCalledOnce().with(
|
||||
value(matrixUser),
|
||||
value(false),
|
||||
any(),
|
||||
)
|
||||
state.eventSink(CreateRoomRootEvents.CancelStartDM)
|
||||
}
|
||||
|
||||
// Success
|
||||
startDMAction.givenExecuteResult(startDMSuccessResult)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.startDmAction).isEqualTo(AsyncAction.Uninitialized)
|
||||
state.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
|
||||
assertThat(state.startDmAction.isUninitialized()).isTrue()
|
||||
}
|
||||
assertThat(awaitItem().startDmAction).isInstanceOf(AsyncAction.Loading::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - start DM action success scenario`() = runTest {
|
||||
val startDMSuccessResult = AsyncAction.Success(A_ROOM_ID)
|
||||
val executeResult = lambdaRecorder<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, Unit> { _, _, actionState ->
|
||||
actionState.value = startDMSuccessResult
|
||||
}
|
||||
val startDMAction = FakeStartDMAction(executeResult = executeResult)
|
||||
val presenter = createCreateRoomRootPresenter(startDMAction)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.startDmAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
|
||||
assertThat(initialState.applicationName).isEqualTo(aBuildMeta().applicationName)
|
||||
assertThat(initialState.userListState.selectedUsers).isEmpty()
|
||||
assertThat(initialState.userListState.isSearchActive).isFalse()
|
||||
assertThat(initialState.userListState.isMultiSelectionEnabled).isFalse()
|
||||
val matrixUser = MatrixUser(UserId("@name:domain"))
|
||||
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.startDmAction).isEqualTo(startDMSuccessResult)
|
||||
executeResult.assertions().isCalledOnce().with(
|
||||
value(matrixUser),
|
||||
value(false),
|
||||
any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - start DM action confirmation scenario - cancel`() = runTest {
|
||||
val matrixUser = MatrixUser(UserId("@name:domain"))
|
||||
val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser)
|
||||
val executeResult = lambdaRecorder<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, Unit> { _, _, actionState ->
|
||||
actionState.value = startDMConfirmationResult
|
||||
}
|
||||
val startDMAction = FakeStartDMAction(executeResult = executeResult)
|
||||
val presenter = createCreateRoomRootPresenter(startDMAction)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.startDmAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
|
||||
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
|
||||
val confirmingState = awaitItem()
|
||||
assertThat(confirmingState.startDmAction).isEqualTo(startDMConfirmationResult)
|
||||
executeResult.assertions().isCalledOnce().with(
|
||||
value(matrixUser),
|
||||
value(false),
|
||||
any(),
|
||||
)
|
||||
// Cancelling should not create the DM
|
||||
confirmingState.eventSink(CreateRoomRootEvents.CancelStartDM)
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.startDmAction.isUninitialized()).isTrue()
|
||||
executeResult.assertions().isCalledExactly(1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - start DM action confirmation scenario - confirm`() = runTest {
|
||||
val matrixUser = MatrixUser(UserId("@name:domain"))
|
||||
val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser)
|
||||
val executeResult = lambdaRecorder<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, Unit> { _, _, actionState ->
|
||||
actionState.value = startDMConfirmationResult
|
||||
}
|
||||
val startDMAction = FakeStartDMAction(executeResult = executeResult)
|
||||
val presenter = createCreateRoomRootPresenter(startDMAction)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.startDmAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
|
||||
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
|
||||
val confirmingState = awaitItem()
|
||||
assertThat(confirmingState.startDmAction).isEqualTo(startDMConfirmationResult)
|
||||
executeResult.assertions().isCalledOnce().with(
|
||||
value(matrixUser),
|
||||
value(false),
|
||||
any(),
|
||||
)
|
||||
// Start DM again should invoke the action with createIfDmDoesNotExist = true
|
||||
confirmingState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
|
||||
executeResult.assertions().isCalledExactly(2).withSequence(
|
||||
listOf(value(matrixUser), value(false), any()),
|
||||
listOf(value(matrixUser), value(true), any()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createCreateRoomRootPresenter(
|
||||
startDMAction: StartDMAction = FakeStartDMAction(),
|
||||
): CreateRoomRootPresenter {
|
||||
|
||||
@@ -18,5 +18,6 @@ dependencies {
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.matrix.test)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.tests.testutils)
|
||||
api(projects.features.createroom.api)
|
||||
}
|
||||
|
||||
@@ -11,20 +11,19 @@ import androidx.compose.runtime.MutableState
|
||||
import io.element.android.features.createroom.api.StartDMAction
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import kotlinx.coroutines.delay
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
|
||||
class FakeStartDMAction : StartDMAction {
|
||||
private var executeResult: AsyncAction<RoomId> = AsyncAction.Success(A_ROOM_ID)
|
||||
|
||||
fun givenExecuteResult(result: AsyncAction<RoomId>) {
|
||||
executeResult = result
|
||||
class FakeStartDMAction(
|
||||
private val executeResult: (MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>) -> Unit = { _, _, _ ->
|
||||
lambdaError()
|
||||
}
|
||||
|
||||
override suspend fun execute(userId: UserId, actionState: MutableState<AsyncAction<RoomId>>) {
|
||||
actionState.value = AsyncAction.Loading
|
||||
delay(1)
|
||||
actionState.value = executeResult
|
||||
) : StartDMAction {
|
||||
override suspend fun execute(
|
||||
matrixUser: MatrixUser,
|
||||
createIfDmDoesNotExist: Boolean,
|
||||
actionState: MutableState<AsyncAction<RoomId>>,
|
||||
) {
|
||||
executeResult(matrixUser, createIfDmDoesNotExist, actionState)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +119,11 @@ class UserProfilePresenter @AssistedInject constructor(
|
||||
}
|
||||
UserProfileEvents.StartDM -> {
|
||||
coroutineScope.launch {
|
||||
startDMAction.execute(userId, startDmActionState)
|
||||
startDMAction.execute(
|
||||
matrixUser = userProfile ?: MatrixUser(userId),
|
||||
createIfDmDoesNotExist = startDmActionState.value is AsyncAction.Confirming,
|
||||
actionState = startDmActionState,
|
||||
)
|
||||
}
|
||||
}
|
||||
UserProfileEvents.ClearStartDMState -> {
|
||||
|
||||
@@ -7,8 +7,13 @@
|
||||
|
||||
package io.element.android.features.userprofile.impl
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.ReceiveTurbine
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.createroom.api.ConfirmingStartDmWithMatrixUser
|
||||
import io.element.android.features.createroom.api.StartDMAction
|
||||
import io.element.android.features.createroom.test.FakeStartDMAction
|
||||
import io.element.android.features.userprofile.api.UserProfileEvents
|
||||
@@ -19,6 +24,7 @@ import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
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.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_THROWABLE
|
||||
@@ -30,6 +36,9 @@ import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.awaitLastSequentialItem
|
||||
import io.element.android.tests.testutils.lambda.any
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.test
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
@@ -229,37 +238,122 @@ class UserProfilePresenterTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - start DM action complete scenario`() = runTest {
|
||||
val startDMAction = FakeStartDMAction()
|
||||
fun `present - start DM action failure scenario`() = runTest {
|
||||
val startDMFailureResult = AsyncAction.Failure(A_THROWABLE)
|
||||
val executeResult = lambdaRecorder<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, Unit> { _, _, actionState ->
|
||||
actionState.value = startDMFailureResult
|
||||
}
|
||||
val startDMAction = FakeStartDMAction(executeResult = executeResult)
|
||||
val presenter = createUserProfilePresenter(startDMAction = startDMAction)
|
||||
presenter.test {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.startDmActionState).isInstanceOf(AsyncAction.Uninitialized::class.java)
|
||||
val startDMSuccessResult = AsyncAction.Success(A_ROOM_ID)
|
||||
val startDMFailureResult = AsyncAction.Failure(A_THROWABLE)
|
||||
|
||||
// Failure
|
||||
startDMAction.givenExecuteResult(startDMFailureResult)
|
||||
val matrixUser = MatrixUser(UserId("@alice:server.org"))
|
||||
initialState.eventSink(UserProfileEvents.StartDM)
|
||||
assertThat(awaitItem().startDmActionState).isInstanceOf(AsyncAction.Loading::class.java)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.startDmActionState).isEqualTo(startDMFailureResult)
|
||||
executeResult.assertions().isCalledOnce().with(
|
||||
value(matrixUser),
|
||||
value(false),
|
||||
any(),
|
||||
)
|
||||
state.eventSink(UserProfileEvents.ClearStartDMState)
|
||||
}
|
||||
|
||||
// Success
|
||||
startDMAction.givenExecuteResult(startDMSuccessResult)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.startDmActionState).isEqualTo(AsyncAction.Uninitialized)
|
||||
state.eventSink(UserProfileEvents.StartDM)
|
||||
assertThat(state.startDmActionState.isUninitialized()).isTrue()
|
||||
}
|
||||
assertThat(awaitItem().startDmActionState).isInstanceOf(AsyncAction.Loading::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - start DM action success scenario`() = runTest {
|
||||
val startDMSuccessResult = AsyncAction.Success(A_ROOM_ID)
|
||||
val executeResult = lambdaRecorder<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, Unit> { _, _, actionState ->
|
||||
actionState.value = startDMSuccessResult
|
||||
}
|
||||
val startDMAction = FakeStartDMAction(executeResult = executeResult)
|
||||
val presenter = createUserProfilePresenter(startDMAction = startDMAction)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.startDmActionState).isInstanceOf(AsyncAction.Uninitialized::class.java)
|
||||
val matrixUser = MatrixUser(UserId("@alice:server.org"))
|
||||
initialState.eventSink(UserProfileEvents.StartDM)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.startDmActionState).isEqualTo(startDMSuccessResult)
|
||||
executeResult.assertions().isCalledOnce().with(
|
||||
value(matrixUser),
|
||||
value(false),
|
||||
any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - start DM action confirmation scenario - cancel`() = runTest {
|
||||
val matrixUser = MatrixUser(UserId("@alice:server.org"))
|
||||
val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser)
|
||||
val executeResult = lambdaRecorder<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, Unit> { _, _, actionState ->
|
||||
actionState.value = startDMConfirmationResult
|
||||
}
|
||||
val startDMAction = FakeStartDMAction(executeResult = executeResult)
|
||||
val presenter = createUserProfilePresenter(startDMAction = startDMAction)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.startDmActionState).isInstanceOf(AsyncAction.Uninitialized::class.java)
|
||||
initialState.eventSink(UserProfileEvents.StartDM)
|
||||
val confirmingState = awaitItem()
|
||||
assertThat(confirmingState.startDmActionState).isEqualTo(startDMConfirmationResult)
|
||||
executeResult.assertions().isCalledOnce().with(
|
||||
value(matrixUser),
|
||||
value(false),
|
||||
any(),
|
||||
)
|
||||
// Cancelling should not create the DM
|
||||
confirmingState.eventSink(UserProfileEvents.ClearStartDMState)
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.startDmActionState.isUninitialized()).isTrue()
|
||||
executeResult.assertions().isCalledExactly(1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - start DM action confirmation scenario - confirm`() = runTest {
|
||||
val matrixUser = MatrixUser(UserId("@alice:server.org"))
|
||||
val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser)
|
||||
val executeResult = lambdaRecorder<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, Unit> { _, _, actionState ->
|
||||
actionState.value = startDMConfirmationResult
|
||||
}
|
||||
val startDMAction = FakeStartDMAction(executeResult = executeResult)
|
||||
val presenter = createUserProfilePresenter(startDMAction = startDMAction)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.startDmActionState).isInstanceOf(AsyncAction.Uninitialized::class.java)
|
||||
initialState.eventSink(UserProfileEvents.StartDM)
|
||||
val confirmingState = awaitItem()
|
||||
assertThat(confirmingState.startDmActionState).isEqualTo(startDMConfirmationResult)
|
||||
executeResult.assertions().isCalledOnce().with(
|
||||
value(matrixUser),
|
||||
value(false),
|
||||
any(),
|
||||
)
|
||||
// Start DM again should invoke the action with createIfDmDoesNotExist = true
|
||||
confirmingState.eventSink(UserProfileEvents.StartDM)
|
||||
executeResult.assertions().isCalledExactly(2).withSequence(
|
||||
listOf(value(matrixUser), value(false), any()),
|
||||
listOf(value(matrixUser), value(true), any()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - when user is verified, the value in the state is true`() = runTest {
|
||||
val client = createFakeMatrixClient(isUserVerified = true)
|
||||
|
||||
@@ -21,6 +21,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.createroom.api.ConfirmingStartDmWithMatrixUser
|
||||
import io.element.android.features.userprofile.api.UserProfileEvents
|
||||
import io.element.android.features.userprofile.api.UserProfileState
|
||||
import io.element.android.features.userprofile.shared.blockuser.BlockUserDialogs
|
||||
@@ -37,6 +38,7 @@ 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.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.ui.components.CreateDmConfirmationBottomSheet
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -95,6 +97,19 @@ fun UserProfileView(
|
||||
errorMessage = { stringResource(R.string.screen_start_chat_error_starting_chat) },
|
||||
onRetry = { state.eventSink(UserProfileEvents.StartDM) },
|
||||
onErrorDismiss = { state.eventSink(UserProfileEvents.ClearStartDMState) },
|
||||
confirmationDialog = { data ->
|
||||
if (data is ConfirmingStartDmWithMatrixUser) {
|
||||
CreateDmConfirmationBottomSheet(
|
||||
matrixUser = data.matrixUser,
|
||||
onSendInvite = {
|
||||
state.eventSink(UserProfileEvents.StartDM)
|
||||
},
|
||||
onDismiss = {
|
||||
state.eventSink(UserProfileEvents.ClearStartDMState)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,4 +59,6 @@ enum class AvatarSize(val dp: Dp) {
|
||||
KnockRequestBanner(32.dp),
|
||||
|
||||
MediaSender(32.dp),
|
||||
|
||||
DmCreationConfirmation(64.dp),
|
||||
}
|
||||
|
||||
@@ -12,21 +12,27 @@ import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
||||
/**
|
||||
* Try to find an existing DM with the given user, or create one if none exists.
|
||||
* Try to find an existing DM with the given user, or create one if none exists and [createIfDmDoesNotExist] is true.
|
||||
*/
|
||||
suspend fun MatrixClient.startDM(userId: UserId): StartDMResult {
|
||||
suspend fun MatrixClient.startDM(
|
||||
userId: UserId,
|
||||
createIfDmDoesNotExist: Boolean,
|
||||
): StartDMResult {
|
||||
val existingDM = findDM(userId)
|
||||
return if (existingDM != null) {
|
||||
StartDMResult.Success(existingDM, isNew = false)
|
||||
} else {
|
||||
} else if (createIfDmDoesNotExist) {
|
||||
createDM(userId).fold(
|
||||
{ StartDMResult.Success(it, isNew = true) },
|
||||
{ StartDMResult.Failure(it) }
|
||||
)
|
||||
} else {
|
||||
StartDMResult.DmDoesNotExist
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface StartDMResult {
|
||||
data class Success(val roomId: RoomId, val isNew: Boolean) : StartDMResult
|
||||
data object DmDoesNotExist : StartDMResult
|
||||
data class Failure(val throwable: Throwable) : StartDMResult
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
import io.element.android.libraries.designsystem.theme.components.IconSource
|
||||
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.R
|
||||
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||
import io.element.android.libraries.matrix.ui.model.getFullName
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
/**
|
||||
* Figma:
|
||||
* https://www.figma.com/design/dywzKQvHYxFD1Ncn4a5NkI/PSB-675%253A-Improve-invite-into-a-DM?node-id=12-36886
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CreateDmConfirmationBottomSheet(
|
||||
matrixUser: MatrixUser,
|
||||
onSendInvite: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
ModalBottomSheet(
|
||||
modifier = modifier,
|
||||
onDismissRequest = onDismiss,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Avatar(
|
||||
avatarData = matrixUser.getAvatarData(AvatarSize.DmCreationConfirmation),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.screen_bottom_sheet_create_dm_title),
|
||||
style = ElementTheme.typography.fontHeadingMdBold,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
// TODO Check if finally we do not want to remove string duplication
|
||||
text = stringResource(R.string.screen_bottom_sheet_create_dm_message_no_displayname, matrixUser.getFullName()),
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = onSendInvite,
|
||||
leadingIcon = IconSource.Vector(CompoundIcons.UserAdd()),
|
||||
text = stringResource(R.string.screen_bottom_sheet_create_dm_confirmation_button_title),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
TextButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = onDismiss,
|
||||
text = stringResource(CommonStrings.action_cancel),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun CreateDmConfirmationBottomSheetPreview() = ElementPreview {
|
||||
CreateDmConfirmationBottomSheet(
|
||||
matrixUser = aMatrixUser(),
|
||||
onSendInvite = {},
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
@@ -21,7 +21,7 @@ open class MatrixUserProvider : PreviewParameterProvider<MatrixUser> {
|
||||
|
||||
fun aMatrixUser(
|
||||
id: String = "@id_of_alice:server.org",
|
||||
displayName: String = "Alice",
|
||||
displayName: String? = "Alice",
|
||||
avatarUrl: String? = null,
|
||||
) = MatrixUser(
|
||||
userId = UserId(id),
|
||||
|
||||
@@ -21,3 +21,11 @@ fun MatrixUser.getAvatarData(size: AvatarSize) = AvatarData(
|
||||
fun MatrixUser.getBestName(): String {
|
||||
return displayName?.takeIf { it.isNotEmpty() } ?: userId.value
|
||||
}
|
||||
|
||||
fun MatrixUser.getFullName(): String {
|
||||
return if (displayName.isNullOrBlank()) {
|
||||
userId.value
|
||||
} else {
|
||||
"$displayName ($userId)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_bottom_sheet_create_dm_confirmation_button_title">"Send invite"</string>
|
||||
<string name="screen_bottom_sheet_create_dm_message">"Would you like to start a chat with %1$s (%2$s)?"</string>
|
||||
<string name="screen_bottom_sheet_create_dm_message_no_displayname">"Would you like to start a chat with %1$s?"</string>
|
||||
<string name="screen_bottom_sheet_create_dm_title">"Send invite?"</string>
|
||||
<string name="screen_invites_invited_you">"%1$s (%2$s) invited you"</string>
|
||||
</resources>
|
||||
|
||||
@@ -273,7 +273,8 @@
|
||||
{
|
||||
"name" : ":libraries:matrixui",
|
||||
"includeRegex" : [
|
||||
"screen_invites_invited_you"
|
||||
"screen_invites_invited_you",
|
||||
"screen\\.bottom_sheet\\.create_dm\\..*"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user