Click on avatar in moderation bottom sheet opens avatar preview (#6991)

* Make the avatar in the room member moderation bottom sheet open the avatar in the media viewer (#6962)

* Make the avatar in the room member moderation bottom sheet open the avatar in the media viewer.

* Fix issue with avatar overlay not dismissing the bottom sheet: the bottom sheet would eat all the touch events until the first click, which would somehow cancel this behaviour

* Fix lint issues

* Update screenshots

---------

Co-authored-by: bxdxnn <267911624+bxdxnn@users.noreply.github.com>
Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa
2026-06-08 16:32:13 +02:00
committed by GitHub
parent 786c53ac06
commit bea2903093
22 changed files with 111 additions and 39 deletions
@@ -190,6 +190,9 @@ class MessagesFlowNode(
@Parcelize @Parcelize
data object ThreadsList : NavTarget data object ThreadsList : NavTarget
@Parcelize
data class AvatarPreview(val name: String, val avatarUrl: String) : NavTarget
} }
private val callback: MessagesEntryPoint.Callback = callback() private val callback: MessagesEntryPoint.Callback = callback()
@@ -327,6 +330,10 @@ class MessagesFlowNode(
override fun navigateToDeveloperSettings() { override fun navigateToDeveloperSettings() {
callback.navigateToDeveloperSettings() callback.navigateToDeveloperSettings()
} }
override fun navigateToAvatarPreview(username: String, avatarUrl: String) {
overlay.show(NavTarget.AvatarPreview(username, avatarUrl))
}
} }
val inputs = MessagesNode.Inputs(focusedEventId = navTarget.focusedEventId) val inputs = MessagesNode.Inputs(focusedEventId = navTarget.focusedEventId)
createNode<MessagesNode>(buildContext, listOf(callback, inputs)) createNode<MessagesNode>(buildContext, listOf(callback, inputs))
@@ -562,6 +569,10 @@ class MessagesFlowNode(
override fun navigateToDeveloperSettings() { override fun navigateToDeveloperSettings() {
callback.navigateToDeveloperSettings() callback.navigateToDeveloperSettings()
} }
override fun navigateToAvatarPreview(username: String, avatarUrl: String) {
overlay.show(NavTarget.AvatarPreview(username, avatarUrl))
}
} }
createNode<ThreadedMessagesNode>(buildContext, listOf(inputs, callback)) createNode<ThreadedMessagesNode>(buildContext, listOf(inputs, callback))
} }
@@ -573,6 +584,31 @@ class MessagesFlowNode(
} }
createNode<ThreadsListNode>(buildContext, listOf(callback)) createNode<ThreadsListNode>(buildContext, listOf(callback))
} }
is NavTarget.AvatarPreview -> {
val callback = object : MediaViewerEntryPoint.Callback {
override fun onDone() {
overlay.hide()
}
override fun viewInTimeline(eventId: EventId) {
// Cannot happen
}
override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) {
// Cannot happen
}
}
val params = mediaViewerEntryPoint.createParamsForAvatar(
filename = navTarget.name,
avatarUrl = navTarget.avatarUrl,
)
mediaViewerEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = params,
callback = callback,
)
}
} }
} }
@@ -66,6 +66,7 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.alias.matches import io.element.android.libraries.matrix.api.room.alias.matches
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.ui.model.getBestName
import io.element.android.libraries.mediaplayer.api.MediaPlayer import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.a11y.hasExternalKeyboard import io.element.android.libraries.ui.utils.a11y.hasExternalKeyboard
@@ -136,6 +137,8 @@ class MessagesNode(
fun navigateToDeveloperSettings() fun navigateToDeveloperSettings()
fun navigateToThreadsList() fun navigateToThreadsList()
fun navigateToAvatarPreview(username: String, avatarUrl: String)
} }
override fun onBuilt() { override fun onBuilt() {
@@ -319,6 +322,11 @@ class MessagesNode(
else -> state.roomMemberModerationState.eventSink(RoomMemberModerationEvents.ProcessAction(action, target)) else -> state.roomMemberModerationState.eventSink(RoomMemberModerationEvents.ProcessAction(action, target))
} }
}, },
onAvatarClick = { user ->
user.avatarUrl?.let { url ->
callback.navigateToAvatarPreview(user.getBestName(), url)
}
},
modifier = Modifier, modifier = Modifier,
) )
@@ -67,6 +67,7 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.alias.matches import io.element.android.libraries.matrix.api.room.alias.matches
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.ui.model.getBestName
import io.element.android.libraries.ui.utils.a11y.hasExternalKeyboard import io.element.android.libraries.ui.utils.a11y.hasExternalKeyboard
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
@@ -138,6 +139,8 @@ class ThreadedMessagesNode(
fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean) fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean)
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
fun navigateToDeveloperSettings() fun navigateToDeveloperSettings()
fun navigateToAvatarPreview(username: String, avatarUrl: String)
} }
override fun onBuilt() { override fun onBuilt() {
@@ -315,6 +318,11 @@ class ThreadedMessagesNode(
else -> state.roomMemberModerationState.eventSink(RoomMemberModerationEvents.ProcessAction(action, target)) else -> state.roomMemberModerationState.eventSink(RoomMemberModerationEvents.ProcessAction(action, target))
} }
}, },
onAvatarClick = { user ->
user.avatarUrl?.let { url ->
callback.navigateToAvatarPreview(user.getBestName(), url)
}
},
modifier = Modifier, modifier = Modifier,
) )
@@ -258,6 +258,10 @@ class RoomDetailsFlowNode(
override fun navigateToInviteMembers() { override fun navigateToInviteMembers() {
backstack.push(NavTarget.InviteMembers) backstack.push(NavTarget.InviteMembers)
} }
override fun navigateToAvatarPreview(username: String, avatarUrl: String) {
overlay.show(NavTarget.AvatarPreview(username, avatarUrl))
}
} }
createNode<RoomMemberListNode>(buildContext, listOf(roomMemberListCallback)) createNode<RoomMemberListNode>(buildContext, listOf(roomMemberListCallback))
} }
@@ -27,6 +27,7 @@ import io.element.android.libraries.architecture.appyx.launchMolecule
import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.callback
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.model.getBestName
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
@@ -41,6 +42,7 @@ class RoomMemberListNode(
interface Callback : Plugin { interface Callback : Plugin {
fun navigateToRoomMemberDetails(roomMemberId: UserId) fun navigateToRoomMemberDetails(roomMemberId: UserId)
fun navigateToInviteMembers() fun navigateToInviteMembers()
fun navigateToAvatarPreview(username: String, avatarUrl: String)
} }
private val callback: Callback = callback() private val callback: Callback = callback()
@@ -82,6 +84,11 @@ class RoomMemberListNode(
else -> state.moderationState.eventSink(RoomMemberModerationEvents.ProcessAction(action, target)) else -> state.moderationState.eventSink(RoomMemberModerationEvents.ProcessAction(action, target))
} }
}, },
onAvatarClick = { user ->
user.avatarUrl?.let { url ->
callback.navigateToAvatarPreview(user.getBestName(), url)
}
},
modifier = Modifier, modifier = Modifier,
) )
} }
@@ -17,6 +17,7 @@ interface RoomMemberModerationRenderer {
fun Render( fun Render(
state: RoomMemberModerationState, state: RoomMemberModerationState,
onSelectAction: (ModerationAction, MatrixUser) -> Unit, onSelectAction: (ModerationAction, MatrixUser) -> Unit,
onAvatarClick: ((MatrixUser) -> Unit)?,
modifier: Modifier, modifier: Modifier,
) )
} }
@@ -25,10 +25,11 @@ class DefaultRoomMemberModerationRenderer : RoomMemberModerationRenderer {
override fun Render( override fun Render(
state: RoomMemberModerationState, state: RoomMemberModerationState,
onSelectAction: (ModerationAction, MatrixUser) -> Unit, onSelectAction: (ModerationAction, MatrixUser) -> Unit,
onAvatarClick: ((MatrixUser) -> Unit)?,
modifier: Modifier modifier: Modifier
) { ) {
if (state is InternalRoomMemberModerationState) { if (state is InternalRoomMemberModerationState) {
RoomMemberModerationView(state, onSelectAction, modifier) RoomMemberModerationView(modifier = modifier, state = state, onSelectAction = onSelectAction, onAvatarClick = onAvatarClick)
} else { } else {
SideEffect { SideEffect {
Timber.d("RoomMemberModerationRenderer: Render called with unsupported state: $state") Timber.d("RoomMemberModerationRenderer: Render called with unsupported state: $state")
@@ -12,6 +12,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
@@ -64,6 +65,7 @@ import timber.log.Timber
fun RoomMemberModerationView( fun RoomMemberModerationView(
state: InternalRoomMemberModerationState, state: InternalRoomMemberModerationState,
onSelectAction: (ModerationAction, MatrixUser) -> Unit, onSelectAction: (ModerationAction, MatrixUser) -> Unit,
onAvatarClick: ((MatrixUser) -> Unit)?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Box(modifier = modifier) { Box(modifier = modifier) {
@@ -73,6 +75,7 @@ fun RoomMemberModerationView(
user = selectedUser, user = selectedUser,
actions = state.actions, actions = state.actions,
onSelectAction = onSelectAction, onSelectAction = onSelectAction,
onAvatarClick = onAvatarClick,
onDismiss = { state.eventSink(InternalRoomMemberModerationEvents.Reset) }, onDismiss = { state.eventSink(InternalRoomMemberModerationEvents.Reset) },
) )
} }
@@ -214,6 +217,7 @@ private fun RoomMemberActionsBottomSheet(
user: MatrixUser, user: MatrixUser,
actions: ImmutableList<ModerationActionState>, actions: ImmutableList<ModerationActionState>,
onSelectAction: (ModerationAction, MatrixUser) -> Unit, onSelectAction: (ModerationAction, MatrixUser) -> Unit,
onAvatarClick: ((MatrixUser) -> Unit)? = null,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
@@ -240,10 +244,11 @@ private fun RoomMemberActionsBottomSheet(
modifier = Modifier modifier = Modifier
.padding(bottom = 24.dp) .padding(bottom = 24.dp)
.align(Alignment.CenterHorizontally) .align(Alignment.CenterHorizontally)
.clickable { .clickable(enabled = user.avatarUrl != null && onAvatarClick != null) {
coroutineScope.launch { coroutineScope.launch {
onSelectAction(ModerationAction.DisplayProfile, user)
bottomSheetState.hide() bottomSheetState.hide()
onAvatarClick?.invoke(user)
onDismiss()
} }
} }
) )
@@ -282,8 +287,8 @@ private fun RoomMemberActionsBottomSheet(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserProfile())), leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserProfile())),
onClick = { onClick = {
coroutineScope.launch { coroutineScope.launch {
onSelectAction(action, user)
bottomSheetState.hide() bottomSheetState.hide()
onSelectAction(action, user)
} }
}, },
enabled = actionState.isEnabled enabled = actionState.isEnabled
@@ -340,16 +345,18 @@ private fun RoomMemberActionsBottomSheet(
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun RoomMemberModerationViewPreview(@PreviewParameter(InternalRoomMemberModerationStateProvider::class) state: InternalRoomMemberModerationState) { internal fun RoomMemberModerationViewPreview(@PreviewParameter(InternalRoomMemberModerationStateProvider::class) state: InternalRoomMemberModerationState) {
val isDoingAction = listOf(state.kickUserAsyncAction, state.banUserAsyncAction, state.unbanUserAsyncAction).any { it is AsyncAction.Loading }
val modifier = if (isDoingAction) {
Modifier.fillMaxWidth().heightIn(min = 64.dp)
} else {
Modifier.fillMaxSize()
}
ElementPreview { ElementPreview {
Box( Box(modifier) {
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 64.dp)
) {
RoomMemberModerationView( RoomMemberModerationView(
state = state, state = state,
onSelectAction = { _, _ -> onSelectAction = { _, _ -> },
}, onAvatarClick = {},
) )
} }
} }