Browse Source

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>
pull/2826/head
Jorge Martin Espinosa 4 months ago committed by GitHub
parent
commit
5dddda64d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      changelog.d/2818.misc
  2. 4
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt
  3. 7
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt
  4. 7
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt
  5. 2
      features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt
  6. 1
      features/userprofile/impl/build.gradle.kts
  7. 11
      features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/UserProfileFlowNode.kt
  8. 3
      features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfileNode.kt
  9. 4
      features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt
  10. 2
      features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTests.kt
  11. 8
      features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileHeaderSection.kt
  12. 23
      features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileMainActionsSection.kt
  13. 1
      features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileNodeHelper.kt
  14. 23
      features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfilePresenterHelper.kt
  15. 2
      features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileState.kt
  16. 6
      features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileStateProvider.kt
  17. 37
      features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt
  18. 235
      features/userprofile/shared/src/test/kotlin/io/element/android/features/userprofile/UserProfileViewTest.kt
  19. 1
      libraries/ui-strings/src/main/res/values/localazy.xml
  20. 6
      tests/testutils/src/main/kotlin/io/element/android/tests/testutils/EnsureCalledOnce.kt
  21. 4
      tests/uitests/src/test/snapshots/images/ui_S_t[f.userprofile.shared_UserProfileView_null_UserProfileView-Day-0_1_null_0,NEXUS_5,1.0,en].png
  22. 4
      tests/uitests/src/test/snapshots/images/ui_S_t[f.userprofile.shared_UserProfileView_null_UserProfileView-Day-0_1_null_1,NEXUS_5,1.0,en].png
  23. 4
      tests/uitests/src/test/snapshots/images/ui_S_t[f.userprofile.shared_UserProfileView_null_UserProfileView-Day-0_1_null_2,NEXUS_5,1.0,en].png
  24. 4
      tests/uitests/src/test/snapshots/images/ui_S_t[f.userprofile.shared_UserProfileView_null_UserProfileView-Day-0_1_null_3,NEXUS_5,1.0,en].png
  25. 4
      tests/uitests/src/test/snapshots/images/ui_S_t[f.userprofile.shared_UserProfileView_null_UserProfileView-Day-0_1_null_4,NEXUS_5,1.0,en].png
  26. 4
      tests/uitests/src/test/snapshots/images/ui_S_t[f.userprofile.shared_UserProfileView_null_UserProfileView-Day-0_1_null_5,NEXUS_5,1.0,en].png
  27. 4
      tests/uitests/src/test/snapshots/images/ui_S_t[f.userprofile.shared_UserProfileView_null_UserProfileView-Day-0_1_null_6,NEXUS_5,1.0,en].png
  28. 3
      tests/uitests/src/test/snapshots/images/ui_S_t[f.userprofile.shared_UserProfileView_null_UserProfileView-Day-0_1_null_7,NEXUS_5,1.0,en].png
  29. 3
      tests/uitests/src/test/snapshots/images/ui_S_t[f.userprofile.shared_UserProfileView_null_UserProfileView-Day-0_1_null_8,NEXUS_5,1.0,en].png
  30. 4
      tests/uitests/src/test/snapshots/images/ui_S_t[f.userprofile.shared_UserProfileView_null_UserProfileView-Night-0_2_null_0,NEXUS_5,1.0,en].png
  31. 4
      tests/uitests/src/test/snapshots/images/ui_S_t[f.userprofile.shared_UserProfileView_null_UserProfileView-Night-0_2_null_1,NEXUS_5,1.0,en].png
  32. 4
      tests/uitests/src/test/snapshots/images/ui_S_t[f.userprofile.shared_UserProfileView_null_UserProfileView-Night-0_2_null_2,NEXUS_5,1.0,en].png
  33. 4
      tests/uitests/src/test/snapshots/images/ui_S_t[f.userprofile.shared_UserProfileView_null_UserProfileView-Night-0_2_null_3,NEXUS_5,1.0,en].png
  34. 4
      tests/uitests/src/test/snapshots/images/ui_S_t[f.userprofile.shared_UserProfileView_null_UserProfileView-Night-0_2_null_4,NEXUS_5,1.0,en].png
  35. 4
      tests/uitests/src/test/snapshots/images/ui_S_t[f.userprofile.shared_UserProfileView_null_UserProfileView-Night-0_2_null_5,NEXUS_5,1.0,en].png
  36. 4
      tests/uitests/src/test/snapshots/images/ui_S_t[f.userprofile.shared_UserProfileView_null_UserProfileView-Night-0_2_null_6,NEXUS_5,1.0,en].png
  37. 3
      tests/uitests/src/test/snapshots/images/ui_S_t[f.userprofile.shared_UserProfileView_null_UserProfileView-Night-0_2_null_7,NEXUS_5,1.0,en].png
  38. 3
      tests/uitests/src/test/snapshots/images/ui_S_t[f.userprofile.shared_UserProfileView_null_UserProfileView-Night-0_2_null_8,NEXUS_5,1.0,en].png

1
changelog.d/2818.misc

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

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

@ -188,6 +188,10 @@ class RoomDetailsFlowNode @AssistedInject constructor( @@ -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)

7
features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt

@ -76,6 +76,10 @@ class RoomMemberDetailsNode @AssistedInject constructor( @@ -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( @@ -89,7 +93,8 @@ class RoomMemberDetailsNode @AssistedInject constructor(
modifier = modifier,
goBack = this::navigateUp,
onShareUser = ::onShareUser,
onDMStarted = ::onStartDM,
onDmStarted = ::onStartDM,
onStartCall = ::onStartCall,
openAvatarPreview = callback::openAvatarPreview,
)
}

7
features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt

@ -71,6 +71,9 @@ class RoomMemberDetailsPresenter @AssistedInject constructor( @@ -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( @@ -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
)
}

2
features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt

@ -70,6 +70,8 @@ class RoomMemberDetailsPresenterTests { @@ -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")

1
features/userprofile/impl/build.gradle.kts

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

11
features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/UserProfileFlowNode.kt

@ -16,6 +16,7 @@ @@ -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 @@ -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 @@ -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 @@ -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( @@ -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))

3
features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfileNode.kt

@ -89,7 +89,8 @@ class UserProfileNode @AssistedInject constructor( @@ -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,
)
}

4
features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt

@ -66,6 +66,8 @@ class UserProfilePresenter @AssistedInject constructor( @@ -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( @@ -118,6 +120,8 @@ class UserProfilePresenter @AssistedInject constructor(
startDmActionState = startDmActionState.value,
displayConfirmationDialog = confirmationDialog,
isCurrentUser = client.isMe(userId),
dmRoomId = dmRoomId,
canCall = canCall,
eventSink = ::handleEvents
)
}

2
features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTests.kt

@ -64,6 +64,8 @@ class UserProfilePresenterTests { @@ -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()
}
}

8
features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileHeaderSection.kt

@ -49,7 +49,12 @@ fun UserProfileHeaderSection( @@ -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( @@ -65,6 +70,7 @@ fun UserProfileHeaderSection(
modifier = Modifier.clipToBounds(),
text = userName,
style = ElementTheme.typography.fontHeadingLgBold,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(6.dp))
}

23
features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileMainActionsSection.kt

@ -29,11 +29,32 @@ import io.element.android.libraries.designsystem.components.button.MainActionBut @@ -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(),

1
features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileNodeHelper.kt

@ -32,6 +32,7 @@ class UserProfileNodeHelper( @@ -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(

23
features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfilePresenterHelper.kt

@ -16,9 +16,14 @@ @@ -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( @@ -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>>,

2
features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileState.kt

@ -29,6 +29,8 @@ data class UserProfileState( @@ -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 {

6
features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileStateProvider.kt

@ -32,6 +32,8 @@ open class UserProfileStateProvider : PreviewParameterProvider<UserProfileState> @@ -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( @@ -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( @@ -53,5 +57,7 @@ fun aUserProfileState(
startDmActionState = startDmActionState,
displayConfirmationDialog = displayConfirmationDialog,
isCurrentUser = isCurrentUser,
dmRoomId = dmRoomId,
canCall = canCall,
eventSink = eventSink,
)

37
features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt

@ -16,6 +16,7 @@ @@ -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 @@ -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 @@ -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( @@ -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( @@ -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( @@ -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( @@ -124,7 +114,8 @@ internal fun UserProfileViewPreview(
state = state,
onShareUser = {},
goBack = {},
onDMStarted = {},
onDmStarted = {},
onStartCall = {},
openAvatarPreview = { _, _ -> }
)
}

235
features/userprofile/shared/src/test/kotlin/io/element/android/features/userprofile/UserProfileViewTest.kt

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

1
libraries/ui-strings/src/main/res/values/localazy.xml

@ -73,6 +73,7 @@ @@ -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>

6
tests/testutils/src/main/kotlin/io/element/android/tests/testutils/EnsureCalledOnce.kt

@ -105,3 +105,9 @@ fun <T, R> ensureCalledOnceWithParam(param: T, block: (callback: EnsureCalledOnc @@ -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()
}

4
tests/uitests/src/test/snapshots/images/ui_S_t[f.userprofile.shared_UserProfileView_null_UserProfileView-Day-0_1_null_0,NEXUS_5,1.0,en].png

@ -1,3 +1,3 @@ @@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8fc6369578a3684c1a88e904848bcb16a7e4cde46c887938db909563c6c54d2d
size 23031
oid sha256:96414480d9e361319abf7543324927300f841633f628da68901d6291b8cfd675
size 22667

4
tests/uitests/src/test/snapshots/images/ui_S_t[f.userprofile.shared_UserProfileView_null_UserProfileView-Day-0_1_null_1,NEXUS_5,1.0,en].png

@ -1,3 +1,3 @@ @@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:59e3eccfa962e9d667aaa326ab3258214334db558983bc251543835585d78453
size 20930
oid sha256:7388e54cc1fccc87a3257b099aa832f851aabace145cc09f965431653fc0cf35
size 20661

4
tests/uitests/src/test/snapshots/images/ui_S_t[f.userprofile.shared_UserProfileView_null_UserProfileView-Day-0_1_null_2,NEXUS_5,1.0,en].png

@ -1,3 +1,3 @@ @@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:07418c91e8efee98f7b9e7d069bf3aef2b674164dad0795719f97ad6b214225b
size 23550
oid sha256:a5ca4c3f9c7376656357f07a6b1777bca92808eeb972f9f7d03bdac89a6c5867
size 22987

4
tests/uitests/src/test/snapshots/images/ui_S_t[f.userprofile.shared_UserProfileView_null_UserProfileView-Day-0_1_null_3,NEXUS_5,1.0,en].png

@ -1,3 +1,3 @@ @@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:90fe61eb831db5e1f1eb213fcdd690f06f2e547b8ad864680288d529d379d30e
size 43651
oid sha256:2348853735daae624c99658d832382fd834a770b40fde0004089f6378ecb954c
size 41498

4
tests/uitests/src/test/snapshots/images/ui_S_t[f.userprofile.shared_UserProfileView_null_UserProfileView-Day-0_1_null_4,NEXUS_5,1.0,en].png

@ -1,3 +1,3 @@ @@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a6b0328e4f5a06b59ac5b73f4230bd950bed6847af275b9e6851ffdb9403742b
size 35523
oid sha256:364172de34c530ce2cb4799e0b5ac5aa810068794bab10c9f04f72f709ebd8fa
size 32482

4
tests/uitests/src/test/snapshots/images/ui_S_t[f.userprofile.shared_UserProfileView_null_UserProfileView-Day-0_1_null_5,NEXUS_5,1.0,en].png

@ -1,3 +1,3 @@ @@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7536568491a41526b48354b60db41e47d40b6677e5b38f464c57715499514171
size 24193
oid sha256:1a956800e49a48da34a49f7ec4c2647641a1c64af70e4ccbcdbdac0fb0cc5616
size 23513

4
tests/uitests/src/test/snapshots/images/ui_S_t[f.userprofile.shared_UserProfileView_null_UserProfileView-Day-0_1_null_6,NEXUS_5,1.0,en].png

@ -1,3 +1,3 @@ @@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:902bd8ddad85ee2ea52898a910baf0c710880d94c36f56e22916bf4d15f4469c
size 25864
oid sha256:01308cc0d4361e2685a16ce02526a9efdc4f81b86fdb982b016e090bcafa9bbb
size 23486

3
tests/uitests/src/test/snapshots/images/ui_S_t[f.userprofile.shared_UserProfileView_null_UserProfileView-Day-0_1_null_7,NEXUS_5,1.0,en].png

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ab8949d70455a9b3ef5d6414b788cb1cddec95fc238df8277dfd41c405a4060c
size 23581

3
tests/uitests/src/test/snapshots/images/ui_S_t[f.userprofile.shared_UserProfileView_null_UserProfileView-Day-0_1_null_8,NEXUS_5,1.0,en].png

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:96414480d9e361319abf7543324927300f841633f628da68901d6291b8cfd675
size 22667

4
tests/uitests/src/test/snapshots/images/ui_S_t[f.userprofile.shared_UserProfileView_null_UserProfileView-Night-0_2_null_0,NEXUS_5,1.0,en].png

@ -1,3 +1,3 @@ @@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1744d73991a0987c53d214041a26eaf7a3ce8a2ff4c90739f65f8641c4af921d
size 22351
oid sha256:4a6807e02706f29b5b748cfbbb13f9e1437e38438da1c81fa568709e5f88d7a4
size 22041

4
tests/uitests/src/test/snapshots/images/ui_S_t[f.userprofile.shared_UserProfileView_null_UserProfileView-Night-0_2_null_1,NEXUS_5,1.0,en].png

@ -1,3 +1,3 @@ @@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4598d748f84146e66b547a5d9310c7e67477e1eb66d1c58bfb23f28807b7f54d
size 20263
oid sha256:b9b5cf8ae7992a8ef83056daa2fb8ebff0c60310ec675efa0e45faa58c0e58ad
size 20049

4
tests/uitests/src/test/snapshots/images/ui_S_t[f.userprofile.shared_UserProfileView_null_UserProfileView-Night-0_2_null_2,NEXUS_5,1.0,en].png

@ -1,3 +1,3 @@ @@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3d448afa778c03771454d5493f15748dce3eeb7a7b35cd33cb38bb5a06646f5d
size 22550
oid sha256:e256df9ae5a854a906a21868ecdf21a5d2ce37577a69a1da557949562d1da45d
size 22193

4
tests/uitests/src/test/snapshots/images/ui_S_t[f.userprofile.shared_UserProfileView_null_UserProfileView-Night-0_2_null_3,NEXUS_5,1.0,en].png

@ -1,3 +1,3 @@ @@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e000e964df93279e8325219a8a9ae74d1f66c533d418fabb88a208bc1165dbed
size 38724
oid sha256:aefcccd96088200097cd12a934b27df29819044ab315f3e0a3e1df9228336669
size 36801

4
tests/uitests/src/test/snapshots/images/ui_S_t[f.userprofile.shared_UserProfileView_null_UserProfileView-Night-0_2_null_4,NEXUS_5,1.0,en].png

@ -1,3 +1,3 @@ @@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bf1d5e3367a9684cd4fd8ffebd4c38475fad0a6a6fa122df19281c1a3bf7349f
size 30814
oid sha256:561230a37864cced2286168b6bfe7c9e461177c056386dc08e538643d58b15a6
size 28162

4
tests/uitests/src/test/snapshots/images/ui_S_t[f.userprofile.shared_UserProfileView_null_UserProfileView-Night-0_2_null_5,NEXUS_5,1.0,en].png

@ -1,3 +1,3 @@ @@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:15c3812a12a76e5701bc1f487703ee32d6920f3c2d790f5b1d1a4b468da28000
size 23047
oid sha256:125d38504d4500506fcb9d85eb9ce057620e683674efb2e6b33356764a14a1af
size 22677

4
tests/uitests/src/test/snapshots/images/ui_S_t[f.userprofile.shared_UserProfileView_null_UserProfileView-Night-0_2_null_6,NEXUS_5,1.0,en].png

@ -1,3 +1,3 @@ @@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a3fefee021f7c3bc6a8b950a129fd7549b05416ff72b32b2b24a4317555f43d8
size 22916
oid sha256:5600ca86ffac9b33c320432dfb75b6242d7726ef39de0bc0e227b166565fa91f
size 20622

3
tests/uitests/src/test/snapshots/images/ui_S_t[f.userprofile.shared_UserProfileView_null_UserProfileView-Night-0_2_null_7,NEXUS_5,1.0,en].png

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8cc9c9f6cdf542c4817dfcf54f43ebd3e8c7fc47d36a50e60be28642dfbdf8ae
size 22868

3
tests/uitests/src/test/snapshots/images/ui_S_t[f.userprofile.shared_UserProfileView_null_UserProfileView-Night-0_2_null_8,NEXUS_5,1.0,en].png

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4a6807e02706f29b5b748cfbbb13f9e1437e38438da1c81fa568709e5f88d7a4
size 22041
Loading…
Cancel
Save