Merge pull request #7826 from element-hq/feature/fga/user-status-update

Update user status UI
This commit is contained in:
ganfra
2026-10-01 17:33:33 +02:00
committed by GitHub
9 changed files with 82 additions and 101 deletions
@@ -9,9 +9,11 @@
package io.element.android.features.home.impl.components package io.element.android.features.home.impl.components
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.indication
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
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.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.WindowInsetsSides
@@ -43,18 +45,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.appconfig.RoomListConfig import io.element.android.appconfig.RoomListConfig
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
@@ -296,11 +290,15 @@ private fun NavigationIcon(
onAccountSwitch: (SessionId) -> Unit, onAccountSwitch: (SessionId) -> Unit,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
// Reserve the status emoji slot for all the accounts, else the width of the navigation icon,
// and so the position of the title, would change while scrolling the pager.
val reserveStatusEmojiSlot = currentUserAndNeighbors.any { it.displayedStatus != null }
if (currentUserAndNeighbors.size == 1) { if (currentUserAndNeighbors.size == 1) {
AccountIcon( AccountIcon(
matrixUser = currentUserAndNeighbors.single(), matrixUser = currentUserAndNeighbors.single(),
isCurrentAccount = true, isCurrentAccount = true,
showAvatarIndicator = showAvatarIndicator, showAvatarIndicator = showAvatarIndicator,
reserveStatusEmojiSlot = reserveStatusEmojiSlot,
onClick = onClick, onClick = onClick,
) )
} else { } else {
@@ -321,6 +319,7 @@ private fun NavigationIcon(
matrixUser = currentUserAndNeighbors[page], matrixUser = currentUserAndNeighbors[page],
isCurrentAccount = page == 1, isCurrentAccount = page == 1,
showAvatarIndicator = page == 1 && showAvatarIndicator, showAvatarIndicator = page == 1 && showAvatarIndicator,
reserveStatusEmojiSlot = reserveStatusEmojiSlot,
onClick = if (page == 1) { onClick = if (page == 1) {
onClick onClick
} else { } else {
@@ -336,18 +335,28 @@ private fun AccountIcon(
matrixUser: MatrixUser, matrixUser: MatrixUser,
isCurrentAccount: Boolean, isCurrentAccount: Boolean,
showAvatarIndicator: Boolean, showAvatarIndicator: Boolean,
reserveStatusEmojiSlot: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val testTag = if (isCurrentAccount) Modifier.testTag(TestTags.homeScreenSettings) else Modifier val testTag = if (isCurrentAccount) Modifier.testTag(TestTags.homeScreenSettings) else Modifier
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
Box( Row(
modifier = modifier modifier = modifier
.then(testTag) .then(testTag)
.minimumInteractiveComponentSize()
.clickable( .clickable(
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = onClick, onClick = onClick,
// The ripple is rendered on the avatar only, see below.
indication = null,
),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.minimumInteractiveComponentSize()
.indication(
interactionSource = interactionSource,
indication = ripple(bounded = false), indication = ripple(bounded = false),
), ),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
@@ -357,19 +366,9 @@ private fun AccountIcon(
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar) matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
} }
} }
val statusEmoji = matrixUser.displayedStatus?.toEmojiText()
val avatarModifier = if (statusEmoji != null) {
Modifier.eraseStatusEmojiBackground(
parentSize = AvatarSize.CurrentUserTopBar.dp,
layoutDirection = LocalLayoutDirection.current,
)
} else {
Modifier
}
Avatar( Avatar(
avatarData = avatarData, avatarData = avatarData,
avatarType = AvatarType.User, avatarType = AvatarType.User,
modifier = avatarModifier,
contentDescription = if (isCurrentAccount) { contentDescription = if (isCurrentAccount) {
if (showAvatarIndicator) { if (showAvatarIndicator) {
stringResource(CommonStrings.a11y_settings_with_required_action) stringResource(CommonStrings.a11y_settings_with_required_action)
@@ -380,51 +379,30 @@ private fun AccountIcon(
null null
}, },
) )
if (statusEmoji != null) {
StatusEmojiBadge(
emoji = statusEmoji,
modifier = Modifier.align(Alignment.BottomEnd),
)
}
if (showAvatarIndicator) { if (showAvatarIndicator) {
RedIndicatorAtom( RedIndicatorAtom(
modifier = Modifier.align(Alignment.TopEnd) modifier = Modifier.align(Alignment.TopEnd)
) )
} }
} }
} if (reserveStatusEmojiSlot) {
StatusEmoji(
private val statusEmojiBadgeSize = 20.dp emoji = matrixUser.displayedStatus?.toEmojiText(),
private val statusEmojiBadgeRadius = statusEmojiBadgeSize / 2 modifier = Modifier.offset(x = statusEmojiStartOffset),
private val statusEmojiBadgeOffset = 8.dp
private fun Modifier.eraseStatusEmojiBackground(
parentSize: Dp,
layoutDirection: LayoutDirection,
): Modifier = this
.graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen
}
.drawWithContent {
drawContent()
drawCircle(
color = Color.Black,
center = Offset(
x = if (layoutDirection == LayoutDirection.Ltr) {
(parentSize - statusEmojiBadgeRadius + statusEmojiBadgeOffset).toPx()
} else {
(statusEmojiBadgeRadius - statusEmojiBadgeOffset).toPx()
},
y = size.height - statusEmojiBadgeRadius.toPx(),
),
radius = statusEmojiBadgeRadius.toPx(),
blendMode = BlendMode.Clear,
) )
} }
}
}
private val statusEmojiSize = 20.dp
// The avatar is centered inside a 48dp touch target, so there is already 8dp of free space
// on its trailing side. Compensate it to end up with a 4dp gap after the avatar itself.
private val statusEmojiStartOffset = 4.dp - (48.dp - AvatarSize.CurrentUserTopBar.dp) / 2
@Composable @Composable
private fun StatusEmojiBadge( private fun StatusEmoji(
emoji: String, emoji: String?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val density = LocalDensity.current val density = LocalDensity.current
@@ -433,11 +411,10 @@ private fun StatusEmojiBadge(
Density(density = density.density, fontScale = 1f) Density(density = density.density, fontScale = 1f)
} }
Box( Box(
modifier = modifier modifier = modifier.size(statusEmojiSize),
.offset(x = statusEmojiBadgeOffset)
.size(statusEmojiBadgeSize),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
if (emoji != null) {
CompositionLocalProvider(LocalDensity provides fixedFontScaleDensity) { CompositionLocalProvider(LocalDensity provides fixedFontScaleDensity) {
Text( Text(
text = emoji, text = emoji,
@@ -446,6 +423,7 @@ private fun StatusEmojiBadge(
} }
} }
} }
}
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -57,11 +58,13 @@ fun MatrixUserHeader(
verticalArrangement = Arrangement.spacedBy(2.dp) verticalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
// Name // Name
DisplayNameWithStatus( Text(
name = matrixUser.getBestName(), modifier = Modifier.clipToBounds(),
status = matrixUser.displayedStatus, text = matrixUser.getBestName(),
nameColor = ElementTheme.colors.textPrimary, maxLines = 1,
style = ElementTheme.typography.fontHeadingMdRegular, style = ElementTheme.typography.fontHeadingMdRegular,
overflow = TextOverflow.Ellipsis,
color = ElementTheme.colors.textPrimary,
) )
// Id // Id
if (matrixUser.displayName.isNullOrEmpty().not()) { if (matrixUser.displayName.isNullOrEmpty().not()) {