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 0bbb107dea
commit 5dddda64d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 396 additions and 56 deletions

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,
)
}
}