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:
parent
0bbb107dea
commit
5dddda64d1
38 changed files with 396 additions and 56 deletions
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>>,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 = { _, _ -> }
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue