UX cleanup: room details (#2816)
* UX cleanup: room details screen Add new CTA buttons for Invite and Call actions * Update screenshots * Fix maestro --------- Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
parent
d86b7d24db
commit
46b22d7db7
34 changed files with 149 additions and 94 deletions
|
|
@ -86,6 +86,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
|||
import io.element.android.libraries.matrix.api.room.MessageEventType
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
|
||||
import io.element.android.libraries.matrix.ui.room.canCall
|
||||
import io.element.android.libraries.matrix.ui.room.canRedactOtherAsState
|
||||
import io.element.android.libraries.matrix.ui.room.canRedactOwnAsState
|
||||
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
|
||||
|
|
@ -158,9 +159,7 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
var canJoinCall by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
val canJoinCall by room.canCall(updateKey = syncUpdateFlow.value)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
// Remove the unread flag on entering but don't send read receipts
|
||||
|
|
@ -170,12 +169,6 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(syncUpdateFlow.value) {
|
||||
withContext(dispatchers.io) {
|
||||
canJoinCall = room.canUserJoinCall(room.sessionId).getOrDefault(false)
|
||||
}
|
||||
}
|
||||
|
||||
val inviteProgress = remember { mutableStateOf<AsyncData<Unit>>(AsyncData.Uninitialized) }
|
||||
var showReinvitePrompt by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(hasDismissedInviteDialog, composerState.hasFocus, syncUpdateFlow.value) {
|
||||
|
|
|
|||
|
|
@ -288,7 +288,7 @@ class MessagesPresenterTest {
|
|||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent()))
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
|
||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
assertThat(finalState.actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -298,10 +298,9 @@ class MessagesPresenterTest {
|
|||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(2)
|
||||
val initialState = awaitItem()
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent(eventId = null)))
|
||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
assertThat(initialState.actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
// Otherwise we would have some extra items here
|
||||
ensureAllEventsConsumed()
|
||||
}
|
||||
|
|
@ -335,7 +334,7 @@ class MessagesPresenterTest {
|
|||
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
|
||||
val replyMode = finalState.composerState.mode as MessageComposerMode.Reply
|
||||
assertThat(replyMode.attachmentThumbnailInfo).isNotNull()
|
||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
assertThat(finalState.actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -368,7 +367,7 @@ class MessagesPresenterTest {
|
|||
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
|
||||
val replyMode = finalState.composerState.mode as MessageComposerMode.Reply
|
||||
assertThat(replyMode.attachmentThumbnailInfo).isNotNull()
|
||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
assertThat(finalState.actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -394,7 +393,7 @@ class MessagesPresenterTest {
|
|||
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
|
||||
val replyMode = finalState.composerState.mode as MessageComposerMode.Reply
|
||||
assertThat(replyMode.attachmentThumbnailInfo).isNotNull()
|
||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
assertThat(finalState.actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -408,7 +407,7 @@ class MessagesPresenterTest {
|
|||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Edit, aMessageEvent()))
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Edit::class.java)
|
||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
assertThat(finalState.actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -732,7 +731,7 @@ class MessagesPresenterTest {
|
|||
assertThat(replyMode.attachmentThumbnailInfo).isNotNull()
|
||||
assertThat(replyMode.attachmentThumbnailInfo?.textContent)
|
||||
.isEqualTo("What type of food should we have at the party?")
|
||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
assertThat(finalState.actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -56,8 +56,9 @@ dependencies {
|
|||
api(projects.libraries.usersearch.api)
|
||||
api(projects.services.apperror.api)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(projects.features.leaveroom.api)
|
||||
implementation(projects.features.call)
|
||||
implementation(projects.features.createroom.api)
|
||||
implementation(projects.features.leaveroom.api)
|
||||
implementation(projects.features.userprofile.shared)
|
||||
implementation(projects.services.analytics.api)
|
||||
implementation(projects.features.poll.api)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
package io.element.android.features.roomdetails.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.poll.api.history.PollHistoryEntryPoint
|
||||
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
|
||||
import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditNode
|
||||
|
|
@ -42,10 +45,12 @@ import io.element.android.libraries.architecture.BackstackView
|
|||
import io.element.android.libraries.architecture.BaseFlowNode
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
|
@ -54,7 +59,9 @@ import kotlinx.parcelize.Parcelize
|
|||
class RoomDetailsFlowNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val pollHistoryEntryPoint: PollHistoryEntryPoint,
|
||||
private val room: MatrixRoom,
|
||||
) : BaseFlowNode<RoomDetailsFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = plugins.filterIsInstance<RoomDetailsEntryPoint.Params>().first().initialElement.toNavTarget(),
|
||||
|
|
@ -129,6 +136,14 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
|||
override fun openAdminSettings() {
|
||||
backstack.push(NavTarget.AdminSettings)
|
||||
}
|
||||
|
||||
override fun onJoinCall() {
|
||||
val inputs = CallType.RoomCall(
|
||||
sessionId = room.sessionId,
|
||||
roomId = room.roomId,
|
||||
)
|
||||
ElementCallActivity.start(context, inputs)
|
||||
}
|
||||
}
|
||||
createNode<RoomDetailsNode>(buildContext, listOf(roomDetailsCallback))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ class RoomDetailsNode @AssistedInject constructor(
|
|||
fun openAvatarPreview(name: String, url: String)
|
||||
fun openPollHistory()
|
||||
fun openAdminSettings()
|
||||
fun onJoinCall()
|
||||
}
|
||||
|
||||
private val callbacks = plugins<Callback>()
|
||||
|
|
@ -86,6 +87,10 @@ class RoomDetailsNode @AssistedInject constructor(
|
|||
callbacks.forEach { it.openPollHistory() }
|
||||
}
|
||||
|
||||
private fun onJoinCall() {
|
||||
callbacks.forEach { it.onJoinCall() }
|
||||
}
|
||||
|
||||
private fun CoroutineScope.onShareRoom(context: Context) = launch {
|
||||
room.getPermalink()
|
||||
.onSuccess { permalink ->
|
||||
|
|
@ -162,6 +167,7 @@ class RoomDetailsNode @AssistedInject constructor(
|
|||
openAvatarPreview = ::openAvatarPreview,
|
||||
openPollHistory = ::openPollHistory,
|
||||
openAdminSettings = this::openAdminSettings,
|
||||
onJoinCallClicked = ::onJoinCall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import io.element.android.libraries.matrix.api.room.StateEventType
|
|||
import io.element.android.libraries.matrix.api.room.powerlevels.canInvite
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.canSendState
|
||||
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
|
||||
import io.element.android.libraries.matrix.ui.room.canCall
|
||||
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
|
||||
import io.element.android.libraries.matrix.ui.room.isOwnUserAdmin
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
|
|
@ -86,11 +87,14 @@ class RoomDetailsPresenter @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
val syncUpdateTimestamp by room.syncUpdateFlow.collectAsState()
|
||||
|
||||
val membersState by room.membersStateFlow.collectAsState()
|
||||
val canInvite by getCanInvite(membersState)
|
||||
val canEditName by getCanSendState(membersState, StateEventType.ROOM_NAME)
|
||||
val canEditAvatar by getCanSendState(membersState, StateEventType.ROOM_AVATAR)
|
||||
val canEditTopic by getCanSendState(membersState, StateEventType.ROOM_TOPIC)
|
||||
val canJoinCall by room.canCall(updateKey = syncUpdateTimestamp)
|
||||
val dmMember by room.getDirectRoomMember(membersState)
|
||||
val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember)
|
||||
val roomType by getRoomType(dmMember)
|
||||
|
|
@ -138,6 +142,7 @@ class RoomDetailsPresenter @Inject constructor(
|
|||
canInvite = canInvite,
|
||||
canEdit = (canEditAvatar || canEditName || canEditTopic) && roomType == RoomDetailsType.Room,
|
||||
canShowNotificationSettings = canShowNotificationSettings.value,
|
||||
canCall = canJoinCall,
|
||||
roomType = roomType,
|
||||
roomMemberDetailsState = roomMemberDetailsState,
|
||||
leaveRoomState = leaveRoomState,
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ data class RoomDetailsState(
|
|||
val canEdit: Boolean,
|
||||
val canInvite: Boolean,
|
||||
val canShowNotificationSettings: Boolean,
|
||||
val canCall: Boolean,
|
||||
val leaveRoomState: LeaveRoomState,
|
||||
val roomNotificationSettings: RoomNotificationSettings?,
|
||||
val isFavorite: Boolean,
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState>
|
|||
// Also test the roomNotificationSettings ALL_MESSAGES in the same screenshot. Icon 'Mute' should be displayed
|
||||
roomNotificationSettings = aRoomNotificationSettings(mode = RoomNotificationMode.ALL_MESSAGES, isDefault = true)
|
||||
),
|
||||
aRoomDetailsState(canCall = false, canInvite = false),
|
||||
// Add other state here
|
||||
)
|
||||
}
|
||||
|
|
@ -89,6 +90,7 @@ fun aRoomDetailsState(
|
|||
canInvite: Boolean = false,
|
||||
canEdit: Boolean = false,
|
||||
canShowNotificationSettings: Boolean = true,
|
||||
canCall: Boolean = true,
|
||||
roomType: RoomDetailsType = RoomDetailsType.Room,
|
||||
roomMemberDetailsState: UserProfileState? = null,
|
||||
leaveRoomState: LeaveRoomState = aLeaveRoomState(),
|
||||
|
|
@ -107,6 +109,7 @@ fun aRoomDetailsState(
|
|||
canInvite = canInvite,
|
||||
canEdit = canEdit,
|
||||
canShowNotificationSettings = canShowNotificationSettings,
|
||||
canCall = canCall,
|
||||
roomType = roomType,
|
||||
roomMemberDetailsState = roomMemberDetailsState,
|
||||
leaveRoomState = leaveRoomState,
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
|
|
@ -100,6 +99,7 @@ fun RoomDetailsView(
|
|||
openAvatarPreview: (name: String, url: String) -> Unit,
|
||||
openPollHistory: () -> Unit,
|
||||
openAdminSettings: () -> Unit,
|
||||
onJoinCallClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun onShareMember() {
|
||||
|
|
@ -137,7 +137,9 @@ fun RoomDetailsView(
|
|||
)
|
||||
MainActionsSection(
|
||||
state = state,
|
||||
onShareRoom = onShareRoom
|
||||
onShareRoom = onShareRoom,
|
||||
onInvitePeople = invitePeople,
|
||||
onCall = onJoinCallClicked,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -188,20 +190,12 @@ fun RoomDetailsView(
|
|||
}
|
||||
|
||||
val displayMemberListItem = state.roomType is RoomDetailsType.Room
|
||||
val displayInviteMembersItem = state.canInvite
|
||||
if (displayMemberListItem || displayInviteMembersItem) {
|
||||
if (displayMemberListItem) {
|
||||
PreferenceCategory {
|
||||
if (displayMemberListItem) {
|
||||
MembersItem(
|
||||
memberCount = state.memberCount,
|
||||
openRoomMemberList = openRoomMemberList,
|
||||
)
|
||||
}
|
||||
if (displayInviteMembersItem) {
|
||||
InviteItem(
|
||||
invitePeople = invitePeople
|
||||
)
|
||||
}
|
||||
MembersItem(
|
||||
memberCount = state.memberCount,
|
||||
openRoomMemberList = openRoomMemberList,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -267,10 +261,12 @@ private fun RoomDetailsTopBar(
|
|||
private fun MainActionsSection(
|
||||
state: RoomDetailsState,
|
||||
onShareRoom: () -> Unit,
|
||||
onInvitePeople: () -> Unit,
|
||||
onCall: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
val roomNotificationSettings = state.roomNotificationSettings
|
||||
if (state.canShowNotificationSettings && roomNotificationSettings != null) {
|
||||
|
|
@ -292,9 +288,22 @@ private fun MainActionsSection(
|
|||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(20.dp))
|
||||
if (state.canCall) {
|
||||
MainActionButton(
|
||||
title = stringResource(CommonStrings.action_call),
|
||||
imageVector = CompoundIcons.VideoCall(),
|
||||
onClick = onCall,
|
||||
)
|
||||
}
|
||||
if (state.roomType is RoomDetailsType.Room && state.canInvite) {
|
||||
MainActionButton(
|
||||
title = stringResource(CommonStrings.action_invite),
|
||||
imageVector = CompoundIcons.UserAdd(),
|
||||
onClick = onInvitePeople,
|
||||
)
|
||||
}
|
||||
MainActionButton(
|
||||
title = stringResource(R.string.screen_room_details_share_room_title),
|
||||
title = stringResource(CommonStrings.action_share),
|
||||
imageVector = CompoundIcons.ShareAndroid(),
|
||||
onClick = onShareRoom
|
||||
)
|
||||
|
|
@ -410,17 +419,6 @@ private fun MembersItem(
|
|||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InviteItem(
|
||||
invitePeople: () -> Unit,
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.screen_room_details_invite_people_title)) },
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserAdd())),
|
||||
onClick = invitePeople,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PollsSection(
|
||||
openPollHistory: () -> Unit,
|
||||
|
|
@ -491,5 +489,6 @@ private fun ContentToPreview(state: RoomDetailsState) {
|
|||
openAvatarPreview = { _, _ -> },
|
||||
openPollHistory = {},
|
||||
openAdminSettings = {},
|
||||
onJoinCallClicked = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ class RoomDetailsViewTest {
|
|||
rule.setRoomDetailView(
|
||||
onShareRoom = callback,
|
||||
)
|
||||
rule.clickOn(R.string.screen_room_details_share_room_title)
|
||||
rule.clickOn(CommonStrings.action_share)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -112,9 +112,8 @@ class RoomDetailsViewTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Config(qualifiers = "h1024dp")
|
||||
@Test
|
||||
fun `click on invite people invokes expected callback`() {
|
||||
fun `click on invite invokes expected callback`() {
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setRoomDetailView(
|
||||
state = aRoomDetailsState(
|
||||
|
|
@ -123,7 +122,21 @@ class RoomDetailsViewTest {
|
|||
),
|
||||
invitePeople = callback,
|
||||
)
|
||||
rule.clickOn(R.string.screen_room_details_invite_people_title)
|
||||
rule.clickOn(CommonStrings.action_invite)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `click on call invokes expected callback`() {
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setRoomDetailView(
|
||||
state = aRoomDetailsState(
|
||||
eventSink = EventsRecorder(expectEvents = false),
|
||||
canInvite = true,
|
||||
),
|
||||
onJoinCallClicked = callback,
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_call)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -258,6 +271,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomD
|
|||
openAvatarPreview: (name: String, url: String) -> Unit = EnsureNeverCalledWithTwoParams(),
|
||||
openPollHistory: () -> Unit = EnsureNeverCalled(),
|
||||
openAdminSettings: () -> Unit = EnsureNeverCalled(),
|
||||
onJoinCallClicked: () -> Unit = EnsureNeverCalled(),
|
||||
) {
|
||||
setContent {
|
||||
RoomDetailsView(
|
||||
|
|
@ -272,6 +286,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomD
|
|||
openAvatarPreview = openAvatarPreview,
|
||||
openPollHistory = openPollHistory,
|
||||
openAdminSettings = openAdminSettings,
|
||||
onJoinCallClicked = onJoinCallClicked,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue