UX cleanup: DM details screen (#2820)

* UX cleanup: user profile.

- Move send DM to a CTA button.
- Add 'Call' CTA button too when there is a DM with that user and a call is possible.
- Add missing tests.

* Update screenshots

* Add tests for clicking on the avatar

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
Jorge Martin Espinosa
2024-05-08 16:05:02 +02:00
committed by GitHub
parent 17f5b0fe77
commit 2e93d842c7
38 changed files with 396 additions and 56 deletions

1
changelog.d/2818.misc Normal file
View File

@@ -0,0 +1 @@
UX cleanup: user profile. Move send DM to a call to action button, add 'Call' CTA too.

View File

@@ -188,6 +188,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
override fun onStartDM(roomId: RoomId) {
plugins<RoomDetailsEntryPoint.Callback>().forEach { it.onOpenRoom(roomId) }
}
override fun onStartCall(roomId: RoomId) {
ElementCallActivity.start(context, CallType.RoomCall(roomId = roomId, sessionId = room.sessionId))
}
}
val plugins = listOf(RoomMemberDetailsNode.RoomMemberDetailsInput(navTarget.roomMemberId), callback)
createNode<RoomMemberDetailsNode>(buildContext, plugins)

View File

@@ -76,6 +76,10 @@ class RoomMemberDetailsNode @AssistedInject constructor(
callback.onStartDM(roomId)
}
fun onStartCall(roomId: RoomId) {
callback.onStartCall(roomId)
}
val state = presenter.present()
LaunchedEffect(state.startDmActionState) {
@@ -89,7 +93,8 @@ class RoomMemberDetailsNode @AssistedInject constructor(
modifier = modifier,
goBack = this::navigateUp,
onShareUser = ::onShareUser,
onDMStarted = ::onStartDM,
onDmStarted = ::onStartDM,
onStartCall = ::onStartCall,
openAvatarPreview = callback::openAvatarPreview,
)
}

View File

@@ -71,6 +71,9 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
var userProfile by remember { mutableStateOf<MatrixUser?>(null) }
val startDmActionState: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val isBlocked: MutableState<AsyncData<Boolean>> = remember { mutableStateOf(AsyncData.Uninitialized) }
val isCurrentUser = remember { client.isMe(roomMemberId) }
val dmRoomId by userProfilePresenterHelper.getDmRoomId()
val canCall by userProfilePresenterHelper.getCanCall(dmRoomId)
LaunchedEffect(Unit) {
client.ignoredUsersFlow
.map { ignoredUsers -> roomMemberId in ignoredUsers }
@@ -158,7 +161,9 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
isBlocked = isBlocked.value,
startDmActionState = startDmActionState.value,
displayConfirmationDialog = confirmationDialog,
isCurrentUser = client.isMe(roomMemberId),
isCurrentUser = isCurrentUser,
dmRoomId = dmRoomId,
canCall = canCall,
eventSink = ::handleEvents
)
}

View File

@@ -70,6 +70,8 @@ class RoomMemberDetailsPresenterTests {
assertThat(initialState.userName).isEqualTo(roomMember.displayName)
assertThat(initialState.avatarUrl).isEqualTo(roomMember.avatarUrl)
assertThat(initialState.isBlocked).isEqualTo(AsyncData.Success(roomMember.isIgnored))
assertThat(initialState.dmRoomId).isEqualTo(A_ROOM_ID)
assertThat(initialState.canCall).isFalse()
skipItems(1)
val loadedState = awaitItem()
assertThat(loadedState.userName).isEqualTo("A custom name")

View File

@@ -46,6 +46,7 @@ dependencies {
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.mediaviewer.api)
implementation(projects.features.call)
api(projects.features.userprofile.api)
api(projects.features.userprofile.shared)
implementation(libs.coil.compose)

View File

@@ -16,6 +16,7 @@
package io.element.android.features.userprofile.impl
import android.content.Context
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@@ -28,6 +29,8 @@ import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.call.CallType
import io.element.android.features.call.ui.ElementCallActivity
import io.element.android.features.userprofile.api.UserProfileEntryPoint
import io.element.android.features.userprofile.impl.root.UserProfileNode
import io.element.android.features.userprofile.shared.UserProfileNodeHelper
@@ -37,9 +40,11 @@ import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
import kotlinx.parcelize.Parcelize
@@ -48,6 +53,8 @@ import kotlinx.parcelize.Parcelize
class UserProfileFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
@ApplicationContext private val context: Context,
private val sessionIdHolder: CurrentSessionIdHolder,
) : BaseFlowNode<UserProfileFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
@@ -75,6 +82,10 @@ class UserProfileFlowNode @AssistedInject constructor(
override fun onStartDM(roomId: RoomId) {
plugins<UserProfileEntryPoint.Callback>().forEach { it.onOpenRoom(roomId) }
}
override fun onStartCall(roomId: RoomId) {
ElementCallActivity.start(context, CallType.RoomCall(sessionId = sessionIdHolder.current, roomId = roomId))
}
}
val params = UserProfileNode.UserProfileInputs(userId = inputs<UserProfileEntryPoint.Params>().userId)
createNode<UserProfileNode>(buildContext, listOf(callback, params))

View File

@@ -89,7 +89,8 @@ class UserProfileNode @AssistedInject constructor(
modifier = modifier,
goBack = this::navigateUp,
onShareUser = ::onShareUser,
onDMStarted = ::onStartDM,
onDmStarted = ::onStartDM,
onStartCall = callback::onStartCall,
openAvatarPreview = callback::openAvatarPreview,
)
}

View File

@@ -66,6 +66,8 @@ class UserProfilePresenter @AssistedInject constructor(
var userProfile by remember { mutableStateOf<MatrixUser?>(null) }
val startDmActionState: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val isBlocked: MutableState<AsyncData<Boolean>> = remember { mutableStateOf(AsyncData.Uninitialized) }
val dmRoomId by userProfilePresenterHelper.getDmRoomId()
val canCall by userProfilePresenterHelper.getCanCall(dmRoomId)
LaunchedEffect(Unit) {
client.ignoredUsersFlow
.map { ignoredUsers -> userId in ignoredUsers }
@@ -118,6 +120,8 @@ class UserProfilePresenter @AssistedInject constructor(
startDmActionState = startDmActionState.value,
displayConfirmationDialog = confirmationDialog,
isCurrentUser = client.isMe(userId),
dmRoomId = dmRoomId,
canCall = canCall,
eventSink = ::handleEvents
)
}

View File

@@ -64,6 +64,8 @@ class UserProfilePresenterTests {
assertThat(initialState.userName).isEqualTo(matrixUser.displayName)
assertThat(initialState.avatarUrl).isEqualTo(matrixUser.avatarUrl)
assertThat(initialState.isBlocked).isEqualTo(AsyncData.Success(false))
assertThat(initialState.dmRoomId).isEqualTo(A_ROOM_ID)
assertThat(initialState.canCall).isFalse()
}
}

View File

@@ -49,7 +49,12 @@ fun UserProfileHeaderSection(
openAvatarPreview: (url: String) -> Unit,
modifier: Modifier = Modifier
) {
Column(modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(modifier = Modifier.size(70.dp)) {
Avatar(
avatarData = AvatarData(userId.value, userName, avatarUrl, AvatarSize.UserHeader),
@@ -65,6 +70,7 @@ fun UserProfileHeaderSection(
modifier = Modifier.clipToBounds(),
text = userName,
style = ElementTheme.typography.fontHeadingLgBold,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(6.dp))
}

View File

@@ -29,11 +29,32 @@ import io.element.android.libraries.designsystem.components.button.MainActionBut
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun UserProfileMainActionsSection(onShareUser: () -> Unit, modifier: Modifier = Modifier) {
fun UserProfileMainActionsSection(
isCurrentUser: Boolean,
canCall: Boolean,
onShareUser: () -> Unit,
onStartDM: () -> Unit,
onCall: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier.fillMaxWidth().padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
if (!isCurrentUser) {
MainActionButton(
title = stringResource(CommonStrings.action_message),
imageVector = CompoundIcons.Chat(),
onClick = onStartDM,
)
}
if (canCall) {
MainActionButton(
title = stringResource(CommonStrings.action_call),
imageVector = CompoundIcons.VideoCall(),
onClick = onCall,
)
}
MainActionButton(
title = stringResource(CommonStrings.action_share),
imageVector = CompoundIcons.ShareAndroid(),

View File

@@ -32,6 +32,7 @@ class UserProfileNodeHelper(
interface Callback : NodeInputs {
fun openAvatarPreview(username: String, avatarUrl: String)
fun onStartDM(roomId: RoomId)
fun onStartCall(roomId: RoomId)
}
fun onShareUser(

View File

@@ -16,9 +16,14 @@
package io.element.android.features.userprofile.shared
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.produceState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.bool.orFalse
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -27,6 +32,24 @@ class UserProfilePresenterHelper(
private val userId: UserId,
private val client: MatrixClient,
) {
@Composable
fun getDmRoomId(): State<RoomId?> {
return produceState<RoomId?>(initialValue = null) {
value = client.findDM(userId)
}
}
@Composable
fun getCanCall(roomId: RoomId?): State<Boolean> {
return produceState(initialValue = false, roomId) {
value = if (client.isMe(userId)) {
false
} else {
roomId?.let { client.getRoom(it)?.canUserJoinCall(client.sessionId)?.getOrNull() == true }.orFalse()
}
}
}
fun blockUser(
scope: CoroutineScope,
isBlockedState: MutableState<AsyncData<Boolean>>,

View File

@@ -29,6 +29,8 @@ data class UserProfileState(
val startDmActionState: AsyncAction<RoomId>,
val displayConfirmationDialog: ConfirmationDialog?,
val isCurrentUser: Boolean,
val dmRoomId: RoomId?,
val canCall: Boolean,
val eventSink: (UserProfileEvents) -> Unit
) {
enum class ConfirmationDialog {

View File

@@ -32,6 +32,8 @@ open class UserProfileStateProvider : PreviewParameterProvider<UserProfileState>
aUserProfileState(displayConfirmationDialog = UserProfileState.ConfirmationDialog.Unblock),
aUserProfileState(isBlocked = AsyncData.Loading(true)),
aUserProfileState(startDmActionState = AsyncAction.Loading),
aUserProfileState(canCall = true),
aUserProfileState(dmRoomId = null),
// Add other states here
)
}
@@ -44,6 +46,8 @@ fun aUserProfileState(
startDmActionState: AsyncAction<RoomId> = AsyncAction.Uninitialized,
displayConfirmationDialog: UserProfileState.ConfirmationDialog? = null,
isCurrentUser: Boolean = false,
dmRoomId: RoomId? = null,
canCall: Boolean = false,
eventSink: (UserProfileEvents) -> Unit = {},
) = UserProfileState(
userId = userId,
@@ -53,5 +57,7 @@ fun aUserProfileState(
startDmActionState = startDmActionState,
displayConfirmationDialog = displayConfirmationDialog,
isCurrentUser = isCurrentUser,
dmRoomId = dmRoomId,
canCall = canCall,
eventSink = eventSink,
)

View File

@@ -16,6 +16,7 @@
package io.element.android.features.userprofile.shared
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
@@ -29,20 +30,14 @@ import androidx.compose.ui.Modifier
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.userprofile.shared.blockuser.BlockUserDialogs
import io.element.android.features.userprofile.shared.blockuser.BlockUserSection
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListItemStyle
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.ui.strings.CommonStrings
@@ -52,11 +47,13 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun UserProfileView(
state: UserProfileState,
onShareUser: () -> Unit,
onDMStarted: (RoomId) -> Unit,
onDmStarted: (RoomId) -> Unit,
onStartCall: (RoomId) -> Unit,
goBack: () -> Unit,
openAvatarPreview: (username: String, url: String) -> Unit,
modifier: Modifier = Modifier,
) {
BackHandler { goBack() }
Scaffold(
modifier = modifier,
topBar = {
@@ -78,12 +75,17 @@ fun UserProfileView(
},
)
UserProfileMainActionsSection(onShareUser = onShareUser)
UserProfileMainActionsSection(
isCurrentUser = state.isCurrentUser,
canCall = state.canCall,
onShareUser = onShareUser,
onStartDM = { state.eventSink(UserProfileEvents.StartDM) },
onCall = { state.dmRoomId?.let { onStartCall(it) } }
)
Spacer(modifier = Modifier.height(26.dp))
if (!state.isCurrentUser) {
StartDMSection(onStartDMClicked = { state.eventSink(UserProfileEvents.StartDM) })
BlockUserSection(state)
BlockUserDialogs(state)
}
@@ -94,7 +96,7 @@ fun UserProfileView(
progressText = stringResource(CommonStrings.common_starting_chat),
)
},
onSuccess = onDMStarted,
onSuccess = onDmStarted,
errorMessage = { stringResource(R.string.screen_start_chat_error_starting_chat) },
onRetry = { state.eventSink(UserProfileEvents.StartDM) },
onErrorDismiss = { state.eventSink(UserProfileEvents.ClearStartDMState) },
@@ -103,18 +105,6 @@ fun UserProfileView(
}
}
@Composable
private fun StartDMSection(
onStartDMClicked: () -> Unit,
) {
ListItem(
headlineContent = { Text(stringResource(CommonStrings.common_direct_chat)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Chat())),
style = ListItemStyle.Primary,
onClick = onStartDMClicked,
)
}
@PreviewsDayNight
@Composable
internal fun UserProfileViewPreview(
@@ -124,7 +114,8 @@ internal fun UserProfileViewPreview(
state = state,
onShareUser = {},
goBack = {},
onDMStarted = {},
onDmStarted = {},
onStartCall = {},
openAvatarPreview = { _, _ -> }
)
}

View File

@@ -0,0 +1,235 @@
/*
* Copyright (c) 2024 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.features.userprofile
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.userprofile.shared.R
import io.element.android.features.userprofile.shared.UserProfileEvents
import io.element.android.features.userprofile.shared.UserProfileState
import io.element.android.features.userprofile.shared.UserProfileView
import io.element.android.features.userprofile.shared.aUserProfileState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.ensureCalledOnceWithTwoParams
import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.pressBackKey
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class UserProfileViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `on back key press - the expected callback is called`() = runTest {
ensureCalledOnce { callback ->
rule.setUserProfileView(
goBack = callback,
)
rule.pressBackKey()
}
}
@Test
fun `on back button click - the expected callback is called`() = runTest {
ensureCalledOnce { callback ->
rule.setUserProfileView(
goBack = callback,
)
rule.pressBack()
}
}
@Test
fun `on avatar clicked - the expected callback is called`() = runTest {
ensureCalledOnceWithTwoParams(A_USER_NAME, AN_AVATAR_URL) { callback ->
rule.setUserProfileView(
state = aUserProfileState(userName = A_USER_NAME, avatarUrl = AN_AVATAR_URL),
openAvatarPreview = callback,
)
rule.onNode(hasTestTag(TestTags.memberDetailAvatar.value)).performClick()
}
}
@Test
fun `on avatar clicked with no avatar - nothing happens`() = runTest {
val callback = EnsureNeverCalledWithTwoParams<String, String>()
rule.setUserProfileView(
state = aUserProfileState(userName = A_USER_NAME, avatarUrl = null),
openAvatarPreview = callback,
)
rule.onNode(hasTestTag(TestTags.memberDetailAvatar.value)).performClick()
}
@Test
fun `on Share clicked - the expected callback is called`() = runTest {
ensureCalledOnce { callback ->
rule.setUserProfileView(
onShareUser = callback,
)
rule.clickOn(CommonStrings.action_share)
}
}
@Test
fun `on Message clicked - the StartDm event is emitted`() = runTest {
val eventsRecorder = EventsRecorder<UserProfileEvents>()
rule.setUserProfileView(
state = aUserProfileState(
dmRoomId = A_ROOM_ID,
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_message)
eventsRecorder.assertSingle(UserProfileEvents.StartDM)
}
@Test
fun `on Call clicked - the expected callback is called`() = runTest {
ensureCalledOnceWithParam(A_ROOM_ID) { callback ->
rule.setUserProfileView(
state = aUserProfileState(
dmRoomId = A_ROOM_ID,
canCall = true,
),
onStartCall = callback,
)
rule.clickOn(CommonStrings.action_call)
}
}
@Test
fun `on Block user clicked - a BlockUser event is emitted with needsConfirmation`() = runTest {
val eventsRecorder = EventsRecorder<UserProfileEvents>()
rule.setUserProfileView(
state = aUserProfileState(
eventSink = eventsRecorder,
),
)
rule.clickOn(R.string.screen_dm_details_block_user)
eventsRecorder.assertSingle(UserProfileEvents.BlockUser(needsConfirmation = true))
}
@Test
fun `on confirming block user - a BlockUser event is emitted without needsConfirmation`() = runTest {
val eventsRecorder = EventsRecorder<UserProfileEvents>()
rule.setUserProfileView(
state = aUserProfileState(
displayConfirmationDialog = UserProfileState.ConfirmationDialog.Block,
eventSink = eventsRecorder,
),
)
rule.clickOn(R.string.screen_dm_details_block_alert_action)
eventsRecorder.assertSingle(UserProfileEvents.BlockUser(needsConfirmation = false))
}
@Test
fun `on canceling blocking a user - a ClearConfirmationDialog event is emitted`() = runTest {
val eventsRecorder = EventsRecorder<UserProfileEvents>()
rule.setUserProfileView(
state = aUserProfileState(
displayConfirmationDialog = UserProfileState.ConfirmationDialog.Block,
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(UserProfileEvents.ClearConfirmationDialog)
}
@Test
fun `on Unblock user clicked - an UnblockUser event is emitted with needsConfirmation`() = runTest {
val eventsRecorder = EventsRecorder<UserProfileEvents>()
rule.setUserProfileView(
state = aUserProfileState(
isBlocked = AsyncData.Success(true),
eventSink = eventsRecorder,
),
)
rule.clickOn(R.string.screen_dm_details_unblock_user)
eventsRecorder.assertSingle(UserProfileEvents.UnblockUser(needsConfirmation = true))
}
@Test
fun `on confirming Unblock user - an UnblockUser event is emitted without needsConfirmation`() = runTest {
val eventsRecorder = EventsRecorder<UserProfileEvents>()
rule.setUserProfileView(
state = aUserProfileState(
isBlocked = AsyncData.Success(true),
displayConfirmationDialog = UserProfileState.ConfirmationDialog.Unblock,
eventSink = eventsRecorder,
),
)
rule.clickOn(R.string.screen_dm_details_unblock_alert_action)
eventsRecorder.assertSingle(UserProfileEvents.UnblockUser(needsConfirmation = false))
}
@Test
fun `on canceling unblocking a user - a ClearConfirmationDialog event is emitted`() = runTest {
val eventsRecorder = EventsRecorder<UserProfileEvents>()
rule.setUserProfileView(
state = aUserProfileState(
isBlocked = AsyncData.Success(true),
displayConfirmationDialog = UserProfileState.ConfirmationDialog.Unblock,
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(UserProfileEvents.ClearConfirmationDialog)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setUserProfileView(
state: UserProfileState = aUserProfileState(
eventSink = EventsRecorder(expectEvents = false),
),
onShareUser: () -> Unit = EnsureNeverCalled(),
onDmStarted: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
onStartCall: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
goBack: () -> Unit = EnsureNeverCalled(),
openAvatarPreview: (String, String) -> Unit = EnsureNeverCalledWithTwoParams(),
) {
setContent {
UserProfileView(
state = state,
onShareUser = onShareUser,
onDmStarted = onDmStarted,
onStartCall = onStartCall,
goBack = goBack,
openAvatarPreview = openAvatarPreview,
)
}
}

View File

@@ -73,6 +73,7 @@
<string name="action_load_more">"Load more"</string>
<string name="action_manage_account">"Manage account"</string>
<string name="action_manage_devices">"Manage devices"</string>
<string name="action_message">"Message"</string>
<string name="action_next">"Next"</string>
<string name="action_no">"No"</string>
<string name="action_not_now">"Not now"</string>

View File

@@ -105,3 +105,9 @@ fun <T, R> ensureCalledOnceWithParam(param: T, block: (callback: EnsureCalledOnc
block(callback)
callback.assertSuccess()
}
fun <P1, P2> ensureCalledOnceWithTwoParams(param1: P1, param2: P2, block: (callback: EnsureCalledOnceWithTwoParams<P1, P2>) -> Unit) {
val callback = EnsureCalledOnceWithTwoParams(param1, param2)
block(callback)
callback.assertSuccess()
}