Merge pull request #7232 from element-hq/feature/fga/display_user_status

WIP : display user status
This commit is contained in:
ganfra
2026-07-23 15:10:19 +02:00
committed by GitHub
153 changed files with 625 additions and 305 deletions
@@ -63,6 +63,8 @@ import io.element.android.libraries.designsystem.theme.roomListRoomName
import io.element.android.libraries.designsystem.theme.unreadIndicator import io.element.android.libraries.designsystem.theme.unreadIndicator
import io.element.android.libraries.matrix.api.notification.CallIntent import io.element.android.libraries.matrix.api.notification.CallIntent
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.ui.components.DisplayNameWithStatus
import io.element.android.libraries.matrix.ui.components.InviteSenderView import io.element.android.libraries.matrix.ui.components.InviteSenderView
import io.element.android.libraries.matrix.ui.model.InviteSender import io.element.android.libraries.matrix.ui.model.InviteSender
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@@ -126,7 +128,8 @@ internal fun RoomSummaryRow(
NameAndTimestampRow( NameAndTimestampRow(
name = room.name, name = room.name,
timestamp = room.timestamp, timestamp = room.timestamp,
isHighlighted = room.isHighlighted isHighlighted = room.isHighlighted,
dmUserStatus = room.dmUserStatus,
) )
MessagePreviewAndIndicatorRow(room = room, showUnreadCount = showUnreadCount) MessagePreviewAndIndicatorRow(room = room, showUnreadCount = showUnreadCount)
} }
@@ -142,7 +145,8 @@ internal fun RoomSummaryRow(
NameAndTimestampRow( NameAndTimestampRow(
name = room.name, name = room.name,
timestamp = null, timestamp = null,
isHighlighted = room.isHighlighted isHighlighted = room.isHighlighted,
dmUserStatus = null,
) )
if (room.canonicalAlias != null) { if (room.canonicalAlias != null) {
Text( Text(
@@ -218,22 +222,21 @@ private fun NameAndTimestampRow(
name: String?, name: String?,
timestamp: String?, timestamp: String?,
isHighlighted: Boolean, isHighlighted: Boolean,
dmUserStatus: DisplayedStatus?,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Row( Row(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
horizontalArrangement = spacedBy(16.dp) horizontalArrangement = spacedBy(16.dp)
) { ) {
Text( val displayName = name?.toSafeLength(ellipsize = true) ?: stringResource(id = CommonStrings.common_no_room_name)
modifier = Modifier DisplayNameWithStatus(
.weight(1f) name = displayName,
.clipToBounds(), status = dmUserStatus,
modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyLgMedium, style = ElementTheme.typography.fontBodyLgMedium,
text = name?.toSafeLength(ellipsize = true) ?: stringResource(id = CommonStrings.common_no_room_name), nameColor = ElementTheme.colors.roomListRoomName,
fontStyle = FontStyle.Italic.takeIf { name == null }, nameFontStyle = FontStyle.Italic.takeIf { name == null },
color = ElementTheme.colors.roomListRoomName,
maxLines = 1,
overflow = TextOverflow.Ellipsis
) )
// Timestamp // Timestamp
Text( Text(
@@ -21,6 +21,7 @@ import io.element.android.libraries.matrix.api.room.CallIntentConsensus
import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.roomlist.LatestEventValue import io.element.android.libraries.matrix.api.roomlist.LatestEventValue
import io.element.android.libraries.matrix.api.roomlist.RoomSummary import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.ui.model.dmUserStatus
import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.toInviteSender import io.element.android.libraries.matrix.ui.model.toInviteSender
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@@ -76,6 +77,7 @@ class RoomListRoomSummaryFactory(
}.toImmutableList(), }.toImmutableList(),
isTombstoned = roomInfo.successorRoom != null, isTombstoned = roomInfo.successorRoom != null,
isSpace = roomInfo.isSpace, isSpace = roomInfo.isSpace,
dmUserStatus = roomInfo.dmUserStatus(),
) )
} }
@@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.notification.CallIntent import io.element.android.libraries.matrix.api.notification.CallIntent
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.ui.model.InviteSender import io.element.android.libraries.matrix.ui.model.InviteSender
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@@ -42,6 +43,7 @@ data class RoomListRoomSummary(
val isTombstoned: Boolean, val isTombstoned: Boolean,
val heroes: ImmutableList<AvatarData>, val heroes: ImmutableList<AvatarData>,
val isSpace: Boolean, val isSpace: Boolean,
val dmUserStatus: DisplayedStatus?,
) { ) {
val isHighlighted = userDefinedNotificationMode != RoomNotificationMode.MUTE && val isHighlighted = userDefinedNotificationMode != RoomNotificationMode.MUTE &&
(numberOfUnreadNotifications > 0 || numberOfUnreadMentions > 0) || (numberOfUnreadNotifications > 0 || numberOfUnreadMentions > 0) ||
@@ -20,6 +20,7 @@ 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.core.UserId
import io.element.android.libraries.matrix.api.notification.CallIntent import io.element.android.libraries.matrix.api.notification.CallIntent
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.ui.model.InviteSender import io.element.android.libraries.matrix.ui.model.InviteSender
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@@ -182,6 +183,7 @@ internal fun aRoomListRoomSummary(
heroes: List<AvatarData> = emptyList(), heroes: List<AvatarData> = emptyList(),
isTombstoned: Boolean = false, isTombstoned: Boolean = false,
isSpace: Boolean = false, isSpace: Boolean = false,
dmUserStatus: DisplayedStatus? = null,
) = RoomListRoomSummary( ) = RoomListRoomSummary(
id = id, id = id,
roomId = RoomId(id), roomId = RoomId(id),
@@ -204,5 +206,6 @@ internal fun aRoomListRoomSummary(
canonicalAlias = canonicalAlias, canonicalAlias = canonicalAlias,
heroes = heroes.toImmutableList(), heroes = heroes.toImmutableList(),
isTombstoned = isTombstoned, isTombstoned = isTombstoned,
isSpace = isSpace isSpace = isSpace,
dmUserStatus = dmUserStatus,
) )
@@ -12,6 +12,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME import io.element.android.libraries.matrix.test.A_ROOM_NAME
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@@ -87,6 +88,7 @@ internal fun createRoomListRoomSummary(
timestamp: String? = null, timestamp: String? = null,
isTombstoned: Boolean = false, isTombstoned: Boolean = false,
isSpace: Boolean = false, isSpace: Boolean = false,
dmUserStatus: DisplayedStatus? = null,
) = RoomListRoomSummary( ) = RoomListRoomSummary(
id = A_ROOM_ID.value, id = A_ROOM_ID.value,
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
@@ -109,5 +111,6 @@ internal fun createRoomListRoomSummary(
isDm = false, isDm = false,
heroes = heroes.toImmutableList(), heroes = heroes.toImmutableList(),
isTombstoned = isTombstoned, isTombstoned = isTombstoned,
isSpace = isSpace isSpace = isSpace,
dmUserStatus = dmUserStatus,
) )
@@ -84,6 +84,7 @@ import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsSta
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.event.EventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.ui.messages.reply.map import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.matrix.ui.model.dmUserStatus
import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
@@ -319,6 +320,7 @@ class MessagesPresenter(
appName = buildMeta.applicationName, appName = buildMeta.applicationName,
pinnedMessagesBannerState = pinnedMessagesBannerState, pinnedMessagesBannerState = pinnedMessagesBannerState,
dmUserVerificationState = dmUserVerificationState, dmUserVerificationState = dmUserVerificationState,
dmUserStatus = roomInfo.dmUserStatus(),
roomMemberModerationState = roomMemberModerationState, roomMemberModerationState = roomMemberModerationState,
topBarSharedHistoryIcon = topBarSharedHistoryIcon, topBarSharedHistoryIcon = topBarSharedHistoryIcon,
successorRoom = roomInfo.successorRoom, successorRoom = roomInfo.successorRoom,
@@ -27,6 +27,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
data class MessagesState( data class MessagesState(
@@ -53,6 +54,7 @@ data class MessagesState(
val appName: String, val appName: String,
val pinnedMessagesBannerState: PinnedMessagesBannerState, val pinnedMessagesBannerState: PinnedMessagesBannerState,
val dmUserVerificationState: IdentityState?, val dmUserVerificationState: IdentityState?,
val dmUserStatus: DisplayedStatus?,
val roomMemberModerationState: RoomMemberModerationState, val roomMemberModerationState: RoomMemberModerationState,
/** Type of "shared history" icon to show in the top bar. */ /** Type of "shared history" icon to show in the top bar. */
val topBarSharedHistoryIcon: SharedHistoryIcon, val topBarSharedHistoryIcon: SharedHistoryIcon,
@@ -50,6 +50,7 @@ import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
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.user.DisplayedStatus
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown
import io.element.android.libraries.textcomposer.model.aTextEditorStateRich import io.element.android.libraries.textcomposer.model.aTextEditorStateRich
@@ -128,6 +129,7 @@ fun aMessagesState(
hasUnreadThreads = false, hasUnreadThreads = false,
), ),
isCurrentlySharingLiveLocationInRoom: Boolean = false, isCurrentlySharingLiveLocationInRoom: Boolean = false,
dmUserStatus: DisplayedStatus? = null,
eventSink: (MessagesEvent) -> Unit = {}, eventSink: (MessagesEvent) -> Unit = {},
) = MessagesState( ) = MessagesState(
roomId = RoomId("!id:domain"), roomId = RoomId("!id:domain"),
@@ -158,6 +160,7 @@ fun aMessagesState(
successorRoom = successorRoom, successorRoom = successorRoom,
threads = threads, threads = threads,
showLiveLocationShareBanner = isCurrentlySharingLiveLocationInRoom, showLiveLocationShareBanner = isCurrentlySharingLiveLocationInRoom,
dmUserStatus = dmUserStatus,
eventSink = eventSink, eventSink = eventSink,
) )
@@ -244,6 +244,7 @@ fun MessagesView(
heroes = state.heroes, heroes = state.heroes,
dmUserIdentityState = state.dmUserVerificationState, dmUserIdentityState = state.dmUserVerificationState,
sharedHistoryIcon = state.topBarSharedHistoryIcon, sharedHistoryIcon = state.topBarSharedHistoryIcon,
dmUserStatus = state.dmUserStatus,
onBackClick = { hidingKeyboard { onBackClick() } }, onBackClick = { hidingKeyboard { onBackClick() } },
onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } }, onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } },
menuActions = { menuActions = {
@@ -41,6 +41,8 @@ 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.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.getBestName
import io.element.android.libraries.matrix.ui.components.DisplayNameWithStatus
import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.slashcommands.api.SlashCommandSuggestion import io.element.android.libraries.slashcommands.api.SlashCommandSuggestion
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
@@ -112,12 +114,16 @@ private fun SuggestionItemView(
is ResolvedSuggestion.Member -> AvatarType.User is ResolvedSuggestion.Member -> AvatarType.User
is ResolvedSuggestion.Command -> null is ResolvedSuggestion.Command -> null
} }
val title = when (suggestion) { val title: String? = when (suggestion) {
is ResolvedSuggestion.AtRoom -> stringResource(R.string.screen_room_mentions_at_room_title) is ResolvedSuggestion.AtRoom -> stringResource(R.string.screen_room_mentions_at_room_title)
is ResolvedSuggestion.Member -> suggestion.roomMember.displayName is ResolvedSuggestion.Member -> suggestion.roomMember.getBestName()
is ResolvedSuggestion.Alias -> suggestion.roomName is ResolvedSuggestion.Alias -> suggestion.roomName
is ResolvedSuggestion.Command -> suggestion.command.command is ResolvedSuggestion.Command -> suggestion.command.command
} }
val status = when (suggestion) {
is ResolvedSuggestion.Member -> suggestion.roomMember.displayedStatus
else -> null
}
val details = when (suggestion) { val details = when (suggestion) {
is ResolvedSuggestion.AtRoom, is ResolvedSuggestion.AtRoom,
is ResolvedSuggestion.Member, is ResolvedSuggestion.Member,
@@ -148,11 +154,11 @@ private fun SuggestionItemView(
horizontalArrangement = Arrangement.spacedBy(4.dp) horizontalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
title?.let { title?.let {
Text( DisplayNameWithStatus(
text = it, name = title,
status = status,
style = ElementTheme.typography.fontBodyLgRegular, style = ElementTheme.typography.fontBodyLgRegular,
maxLines = 1, nameColor = ElementTheme.colors.textPrimary,
overflow = TextOverflow.Ellipsis,
) )
} }
details?.let { details?.let {
@@ -191,6 +197,7 @@ internal fun SuggestionsPickerViewPreview() {
role = RoomMember.Role.User, role = RoomMember.Role.User,
membershipChangeReason = null, membershipChangeReason = null,
isServiceMember = false, isServiceMember = false,
displayedStatus = null,
) )
val anAlias = remember { RoomAlias("#room:domain.org") } val anAlias = remember { RoomAlias("#room:domain.org") }
SuggestionsPickerView( SuggestionsPickerView(
@@ -383,7 +383,12 @@ fun aThreadListItem(
fun aThreadListItemEvent( fun aThreadListItemEvent(
threadId: ThreadId = ThreadId("\$a-thread-id"), threadId: ThreadId = ThreadId("\$a-thread-id"),
senderId: UserId = UserId("@a-user-id:server"), senderId: UserId = UserId("@a-user-id:server"),
senderProfile: ProfileDetails = ProfileDetails.Ready(displayName = USER_NAME_ALICE, displayNameAmbiguous = false, avatarUrl = null), senderProfile: ProfileDetails = ProfileDetails.Ready(
displayName = USER_NAME_ALICE,
displayNameAmbiguous = false,
avatarUrl = null,
displayedStatus = null,
),
isOwn: Boolean = false, isOwn: Boolean = false,
content: EventContent = MessageContent( content: EventContent = MessageContent(
body = "Hello world!", body = "Hello world!",
@@ -166,6 +166,7 @@ internal fun MessageShieldViewPreview() {
displayName = USER_NAME_ALICE, displayName = USER_NAME_ALICE,
displayNameAmbiguous = false, displayNameAmbiguous = false,
avatarUrl = null, avatarUrl = null,
displayedStatus = null,
), ),
) )
) )
@@ -315,7 +315,12 @@ private fun inReplyToInvalidContent(): InReplyToDetails.Ready = InReplyToDetails
) )
), ),
textContent = "A text content", textContent = "A text content",
senderProfile = ProfileDetails.Ready(displayName = "Sender", displayNameAmbiguous = false, avatarUrl = null) senderProfile = ProfileDetails.Ready(
displayName = "Sender",
displayNameAmbiguous = false,
avatarUrl = null,
displayedStatus = null
)
) )
private fun inReplyToTextContent(): InReplyToDetails.Ready = InReplyToDetails.Ready( private fun inReplyToTextContent(): InReplyToDetails.Ready = InReplyToDetails.Ready(
@@ -332,5 +337,10 @@ private fun inReplyToTextContent(): InReplyToDetails.Ready = InReplyToDetails.Re
) )
), ),
textContent = "A text content", textContent = "A text content",
senderProfile = ProfileDetails.Ready(displayName = "Sender", displayNameAmbiguous = false, avatarUrl = null) senderProfile = ProfileDetails.Ready(
displayName = "Sender",
displayNameAmbiguous = false,
avatarUrl = null,
displayedStatus = null
)
) )
@@ -962,6 +962,7 @@ internal fun TimelineItemEventRowWithThreadSummaryPreview() = ElementPreview {
displayName = USER_NAME_ALICE, displayName = USER_NAME_ALICE,
avatarUrl = null, avatarUrl = null,
displayNameAmbiguous = false, displayNameAmbiguous = false,
displayedStatus = null,
), ),
timestamp = 0L, timestamp = 0L,
) )
@@ -1140,6 +1141,7 @@ internal fun ThreadSummaryViewPreview() {
displayName = USER_NAME_ALICE, displayName = USER_NAME_ALICE,
avatarUrl = null, avatarUrl = null,
displayNameAmbiguous = true, displayNameAmbiguous = true,
displayedStatus = null,
), ),
timestamp = 0L, timestamp = 0L,
) )
@@ -26,7 +26,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
@@ -46,9 +45,10 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ROOM_NAME import io.element.android.libraries.designsystem.preview.ROOM_NAME
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.ui.components.DisplayNameWithStatus
import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@@ -65,6 +65,7 @@ internal fun MessagesViewTopBar(
heroes: ImmutableList<AvatarData>, heroes: ImmutableList<AvatarData>,
dmUserIdentityState: IdentityState?, dmUserIdentityState: IdentityState?,
sharedHistoryIcon: SharedHistoryIcon, sharedHistoryIcon: SharedHistoryIcon,
dmUserStatus: DisplayedStatus?,
onRoomDetailsClick: () -> Unit, onRoomDetailsClick: () -> Unit,
onBackClick: () -> Unit, onBackClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -91,6 +92,7 @@ internal fun MessagesViewTopBar(
roomAvatar = roomAvatar, roomAvatar = roomAvatar,
isTombstoned = isTombstoned, isTombstoned = isTombstoned,
heroes = heroes, heroes = heroes,
dmUserStatus = dmUserStatus,
modifier = titleModifier modifier = titleModifier
) )
@@ -144,6 +146,7 @@ private fun RoomAvatarAndNameRow(
roomAvatar: AvatarData, roomAvatar: AvatarData,
heroes: ImmutableList<AvatarData>, heroes: ImmutableList<AvatarData>,
isTombstoned: Boolean, isTombstoned: Boolean,
dmUserStatus: DisplayedStatus?,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Row( Row(
@@ -157,14 +160,13 @@ private fun RoomAvatarAndNameRow(
isTombstoned = isTombstoned, isTombstoned = isTombstoned,
), ),
) )
Text( DisplayNameWithStatus(
modifier = Modifier name = roomName ?: stringResource(CommonStrings.common_no_room_name),
.padding(start = 8.dp), status = dmUserStatus,
text = roomName ?: stringResource(CommonStrings.common_no_room_name), modifier = Modifier.padding(start = 8.dp),
style = ElementTheme.typography.fontBodyLgMedium, style = ElementTheme.typography.fontBodyLgMedium,
fontStyle = FontStyle.Italic.takeIf { roomName == null }, nameColor = ElementTheme.colors.textPrimary,
maxLines = 1, nameFontStyle = FontStyle.Italic.takeIf { roomName == null },
overflow = TextOverflow.Ellipsis
) )
} }
} }
@@ -184,6 +186,7 @@ internal fun MessagesViewTopBarPreview() = ElementPreview {
roomCallState: RoomCallState = RoomCallState.Unavailable, roomCallState: RoomCallState = RoomCallState.Unavailable,
dmUserIdentityState: IdentityState? = null, dmUserIdentityState: IdentityState? = null,
sharedHistoryIcon: SharedHistoryIcon = SharedHistoryIcon.NONE, sharedHistoryIcon: SharedHistoryIcon = SharedHistoryIcon.NONE,
dmUserStatus: DisplayedStatus? = null,
displayThreads: Boolean = false, displayThreads: Boolean = false,
) = MessagesViewTopBar( ) = MessagesViewTopBar(
roomName = roomName, roomName = roomName,
@@ -192,6 +195,7 @@ internal fun MessagesViewTopBarPreview() = ElementPreview {
heroes = heroes, heroes = heroes,
dmUserIdentityState = dmUserIdentityState, dmUserIdentityState = dmUserIdentityState,
sharedHistoryIcon = sharedHistoryIcon, sharedHistoryIcon = sharedHistoryIcon,
dmUserStatus = dmUserStatus,
onRoomDetailsClick = {}, onRoomDetailsClick = {},
onBackClick = {}, onBackClick = {},
menuActions = { menuActions = {
@@ -246,5 +250,11 @@ internal fun MessagesViewTopBarPreview() = ElementPreview {
AMessagesViewTopBar( AMessagesViewTopBar(
displayThreads = true, displayThreads = true,
) )
HorizontalDivider()
AMessagesViewTopBar(
roomName = "A DM with a very very very long name",
dmUserIdentityState = IdentityState.Verified,
dmUserStatus = DisplayedStatus.InCall(0L)
)
} }
} }
@@ -41,9 +41,9 @@ sealed interface EmojiPickerSheetState {
} }
enum class PredefinedUserStatus(val emoji: String, @StringRes val labelRes: Int) { enum class PredefinedUserStatus(val emoji: String, @StringRes val labelRes: Int) {
IN_A_MEETING("💬", R.string.common_user_status_in_a_meeting), IN_A_MEETING("💬", R.string.screen_settings_user_status_in_a_meeting),
FOCUS_TIME("💡", R.string.common_user_status_focus_time), FOCUS_TIME("💡", R.string.screen_settings_user_status_focus_time),
ON_THE_ROAD("🚙", R.string.common_user_status_on_the_road), ON_THE_ROAD("🚙", R.string.screen_settings_user_status_on_the_road),
BE_RIGHT_BACK("☕", R.string.common_user_status_be_right_back), BE_RIGHT_BACK("☕", R.string.screen_settings_user_status_be_right_back),
AWAY("🌴", R.string.common_user_status_away), AWAY("🌴", R.string.screen_settings_user_status_away),
} }
@@ -132,7 +132,7 @@ private fun EmptyStatusRow(
ListItem( ListItem(
headlineContent = { headlineContent = {
Text( Text(
text = stringResource(R.string.screen_preferences_user_status_placeholder), text = stringResource(R.string.screen_settings_user_status_placeholder),
modifier = Modifier.padding(vertical = 16.dp), modifier = Modifier.padding(vertical = 16.dp),
) )
}, },
@@ -150,7 +150,7 @@ private fun CurrentStatusRow(
) { ) {
val (emoji, text) = when (displayedStatus) { val (emoji, text) = when (displayedStatus) {
is DisplayedStatus.UserSet -> displayedStatus.status.emoji to displayedStatus.status.text is DisplayedStatus.UserSet -> displayedStatus.status.emoji to displayedStatus.status.text
is DisplayedStatus.InCall -> "🎧" to stringResource(R.string.common_user_status_on_a_call) is DisplayedStatus.InCall -> "🎧" to stringResource(CommonStrings.common_on_a_call)
} }
ListItem( ListItem(
headlineContent = { Text(text = text, modifier = Modifier.padding(vertical = 16.dp)) }, headlineContent = { Text(text = text, modifier = Modifier.padding(vertical = 16.dp)) },
@@ -225,7 +225,7 @@ private fun UserStatusPickerBottomSheet(
} }
ListItem( ListItem(
headlineContent = { headlineContent = {
Text(text = stringResource(R.string.common_user_status_custom)) Text(text = stringResource(R.string.screen_settings_user_status_custom))
}, },
leadingContent = ListItemContent.Custom { EmojiText("✏️") }, leadingContent = ListItemContent.Custom { EmojiText("✏️") },
onClick = { onClick = {
@@ -265,7 +265,7 @@ private fun CustomStatusInputRow(
TextField( TextField(
modifier = Modifier.focusRequester(focusRequester), modifier = Modifier.focusRequester(focusRequester),
state = textFieldState, state = textFieldState,
placeholder = stringResource(R.string.screen_preferences_user_status_custom_hint), placeholder = stringResource(R.string.screen_settings_user_status_custom_hint),
inputTransformation = InputTransformation.maxLength(CUSTOM_STATUS_TEXT_MAX_LENGTH), inputTransformation = InputTransformation.maxLength(CUSTOM_STATUS_TEXT_MAX_LENGTH),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
onKeyboardAction = { if (hasChanges) onConfirm() }, onKeyboardAction = { if (hasChanges) onConfirm() },
@@ -101,6 +101,14 @@ If you proceed, some of your settings may change."</string>
<string name="screen_notification_settings_system_notifications_action_required_content_link">"system settings"</string> <string name="screen_notification_settings_system_notifications_action_required_content_link">"system settings"</string>
<string name="screen_notification_settings_system_notifications_turned_off">"System notifications turned off"</string> <string name="screen_notification_settings_system_notifications_turned_off">"System notifications turned off"</string>
<string name="screen_notification_settings_title">"Notifications"</string> <string name="screen_notification_settings_title">"Notifications"</string>
<string name="screen_settings_user_status_away">"Away"</string>
<string name="screen_settings_user_status_be_right_back">"Be right back"</string>
<string name="screen_settings_user_status_custom">"Custom…"</string>
<string name="screen_settings_user_status_custom_hint">"Custom status…"</string>
<string name="screen_settings_user_status_focus_time">"Focus time"</string>
<string name="screen_settings_user_status_in_a_meeting">"In a meeting"</string>
<string name="screen_settings_user_status_on_the_road">"On the road"</string>
<string name="screen_settings_user_status_placeholder">"What\'s your status?"</string>
<string name="theme_black">"Black"</string> <string name="theme_black">"Black"</string>
<string name="theme_dark">"Dark"</string> <string name="theme_dark">"Dark"</string>
<string name="theme_light">"Light"</string> <string name="theme_light">"Light"</string>
@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2026 Element Creations Ltd.
~
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
~ Please see LICENSE files in the repository root for full details.
-->
<resources>
<string name="common_user_status_in_a_meeting">In a meeting</string>
<string name="common_user_status_focus_time">Focus time</string>
<string name="common_user_status_on_the_road">On the road</string>
<string name="common_user_status_be_right_back">Be right back</string>
<string name="common_user_status_away">Away</string>
<string name="common_user_status_on_a_call">On a call</string>
<string name="common_user_status_custom">Custom…</string>
<string name="screen_preferences_user_status_placeholder">What\'s your status?</string>
<string name="screen_preferences_user_status_custom_hint">Custom status…</string>
</resources>
@@ -26,6 +26,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.RoomNotificationSettings import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@@ -75,6 +76,7 @@ fun aDmRoomMember(
role: RoomMember.Role = RoomMember.Role.User, role: RoomMember.Role = RoomMember.Role.User,
membershipChangeReason: String? = null, membershipChangeReason: String? = null,
isServiceMember: Boolean = false, isServiceMember: Boolean = false,
displayedStatus: DisplayedStatus? = null,
) = RoomMember( ) = RoomMember(
userId = userId, userId = userId,
displayName = displayName, displayName = displayName,
@@ -86,6 +88,7 @@ fun aDmRoomMember(
role = role, role = role,
membershipChangeReason = membershipChangeReason, membershipChangeReason = membershipChangeReason,
isServiceMember = isServiceMember, isServiceMember = isServiceMember,
displayedStatus = displayedStatus,
) )
fun aRoomDetailsState( fun aRoomDetailsState(
@@ -178,12 +181,13 @@ fun aDmRoomDetailsState(
roomName: String = "Daniel", roomName: String = "Daniel",
isEncrypted: Boolean = true, isEncrypted: Boolean = true,
dmRoomMemberVerificationState: UserProfileVerificationState = UserProfileVerificationState.UNKNOWN, dmRoomMemberVerificationState: UserProfileVerificationState = UserProfileVerificationState.UNKNOWN,
displayedStatus: DisplayedStatus? = null,
) = aRoomDetailsState( ) = aRoomDetailsState(
roomName = roomName, roomName = roomName,
isPublic = false, isPublic = false,
isEncrypted = isEncrypted, isEncrypted = isEncrypted,
canInvite = true, canInvite = true,
roomType = RoomDetailsType.Dm(otherMember = aDmRoomMember(isIgnored = isDmMemberIgnored)), roomType = RoomDetailsType.Dm(otherMember = aDmRoomMember(isIgnored = isDmMemberIgnored, displayedStatus = displayedStatus)),
dmOtherMemberDetailsState = aUserProfileState( dmOtherMemberDetailsState = aUserProfileState(
isBlocked = AsyncData.Success(isDmMemberIgnored), isBlocked = AsyncData.Success(isDmMemberIgnored),
verificationState = dmRoomMemberVerificationState, verificationState = dmRoomMemberVerificationState,
@@ -37,6 +37,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
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
import im.vector.app.features.analytics.plan.Interaction import im.vector.app.features.analytics.plan.Interaction
@@ -88,6 +89,7 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.getBestName import io.element.android.libraries.matrix.api.room.getBestName
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.toText
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@@ -489,11 +491,23 @@ private fun RoomHeaderSection(
} }
.testTag(TestTags.roomDetailAvatar) .testTag(TestTags.roomDetailAvatar)
) )
TitleAndSubtitle( Spacer(modifier = Modifier.height(24.dp))
title = roomName, Text(
subtitle = roomAlias?.value, text = roomName,
onSubtitleClick = onSubtitleClick, style = ElementTheme.typography.fontHeadingLgBold,
textAlign = TextAlign.Center,
) )
if (roomAlias != null) {
Spacer(modifier = Modifier.height(12.dp))
Text(
modifier = Modifier.niceClickable { onSubtitleClick(roomAlias.value) },
text = roomAlias.value,
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
)
}
Spacer(modifier = Modifier.height(12.dp))
} }
} }
@@ -531,37 +545,34 @@ private fun DmHeaderSection(
} }
.testTag(TestTags.roomDetailAvatar) .testTag(TestTags.roomDetailAvatar)
) )
TitleAndSubtitle(
title = roomName,
subtitle = otherMember.userId.value,
onSubtitleClick = onSubtitleClick,
)
}
}
@Composable
private fun TitleAndSubtitle(
title: String,
subtitle: String?,
onSubtitleClick: (String) -> Unit,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Text( Text(
text = title, text = roomName,
style = ElementTheme.typography.fontHeadingLgBold, style = ElementTheme.typography.fontHeadingLgBold,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
if (subtitle != null) { val userStatus = otherMember.displayedStatus?.toText()
Spacer(modifier = Modifier.height(6.dp)) if (userStatus != null) {
Spacer(modifier = Modifier.height(12.dp))
Text( Text(
modifier = Modifier.niceClickable { onSubtitleClick(subtitle) }, text = userStatus,
text = subtitle, style = ElementTheme.typography.fontBodyLgMedium,
style = ElementTheme.typography.fontBodyLgRegular, maxLines = 1,
color = ElementTheme.colors.textSecondary, color = ElementTheme.colors.textSecondary,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
) )
} }
Spacer(modifier = Modifier.height(12.dp))
Text(
modifier = Modifier.niceClickable { onSubtitleClick(otherMember.userId.value) },
text = otherMember.userId.value,
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(12.dp))
} }
} }
@@ -28,6 +28,7 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMemberListState> { internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMemberListState> {
@@ -129,6 +130,7 @@ fun aRoomMember(
role: RoomMember.Role = RoomMember.Role.User, role: RoomMember.Role = RoomMember.Role.User,
membershipChangeReason: String? = null, membershipChangeReason: String? = null,
isServiceMember: Boolean = false, isServiceMember: Boolean = false,
displayedStatus: DisplayedStatus? = null,
) = RoomMember( ) = RoomMember(
userId = userId, userId = userId,
displayName = displayName, displayName = displayName,
@@ -140,6 +142,7 @@ fun aRoomMember(
role = role, role = role,
membershipChangeReason = membershipChangeReason, membershipChangeReason = membershipChangeReason,
isServiceMember = isServiceMember, isServiceMember = isServiceMember,
displayedStatus = displayedStatus,
) )
fun aRoomMemberList() = persistentListOf( fun aRoomMemberList() = persistentListOf(
@@ -128,6 +128,7 @@ class RoomMemberDetailsPresenter(
return userProfileState.copy( return userProfileState.copy(
userName = roomUserName ?: userProfileState.userName, userName = roomUserName ?: userProfileState.userName,
avatarUrl = roomUserAvatar ?: userProfileState.avatarUrl, avatarUrl = roomUserAvatar ?: userProfileState.avatarUrl,
displayedStatus = roomMember?.displayedStatus ?: userProfileState.displayedStatus,
verificationState = verificationState, verificationState = verificationState,
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
eventSink = ::handleEvent, eventSink = ::handleEvent,
@@ -16,6 +16,7 @@ import io.element.android.features.roommembermoderation.api.RoomMemberModeration
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@@ -82,6 +83,7 @@ fun anAlice() = MatrixUser(
userId = UserId(value = "@alice:server.org"), userId = UserId(value = "@alice:server.org"),
displayName = USER_NAME_ALICE, displayName = USER_NAME_ALICE,
avatarUrl = null, avatarUrl = null,
displayedStatus = DisplayedStatus.InCall(0L),
) )
fun aRoomMembersModerationState( fun aRoomMembersModerationState(
@@ -57,6 +57,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.getBestName import io.element.android.libraries.matrix.ui.model.getBestName
import io.element.android.libraries.matrix.ui.model.toText
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -264,9 +265,23 @@ private fun RoomMemberActionsBottomSheet(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier modifier = Modifier
.padding(start = 16.dp, end = 16.dp, bottom = 8.dp) .padding(start = 16.dp, end = 16.dp, bottom = 12.dp)
.fillMaxWidth() .fillMaxWidth()
) )
val userStatus = user.displayedStatus?.toText()
if (userStatus != null) {
Text(
text = userStatus,
style = ElementTheme.typography.fontBodyLgMedium,
maxLines = 1,
color = ElementTheme.colors.textSecondary,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(start = 16.dp, end = 16.dp, bottom = 12.dp)
.fillMaxWidth()
)
}
// Show user ID only if it's different from the display name // Show user ID only if it's different from the display name
if (bestName != user.userId.value) { if (bestName != user.userId.value) {
Text( Text(
@@ -13,6 +13,7 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.RoomId 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.core.UserId
import io.element.android.libraries.matrix.api.user.DisplayedStatus
data class UserProfileState( data class UserProfileState(
val userId: UserId, val userId: UserId,
@@ -26,6 +27,7 @@ data class UserProfileState(
val dmRoomId: RoomId?, val dmRoomId: RoomId?,
val canCall: Boolean, val canCall: Boolean,
val snackbarMessage: SnackbarMessage?, val snackbarMessage: SnackbarMessage?,
val displayedStatus: DisplayedStatus?,
val eventSink: (UserProfileEvents) -> Unit val eventSink: (UserProfileEvents) -> Unit
) { ) {
enum class ConfirmationDialog { enum class ConfirmationDialog {
@@ -145,6 +145,7 @@ class UserProfilePresenter(
userId = userId, userId = userId,
userName = userProfile?.displayName, userName = userProfile?.displayName,
avatarUrl = userProfile?.avatarUrl, avatarUrl = userProfile?.avatarUrl,
displayedStatus = userProfile?.displayedStatus,
isBlocked = isBlocked.value, isBlocked = isBlocked.value,
verificationState = UserProfileVerificationState.UNKNOWN, verificationState = UserProfileVerificationState.UNKNOWN,
startDmActionState = startDmActionState.value, startDmActionState = startDmActionState.value,
@@ -42,6 +42,8 @@ import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.OutlinedButton import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.ui.model.toText
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@@ -56,6 +58,7 @@ fun UserProfileHeaderSection(
openAvatarPreview: (url: String) -> Unit, openAvatarPreview: (url: String) -> Unit,
onUserIdClick: () -> Unit, onUserIdClick: () -> Unit,
withdrawVerificationClick: () -> Unit, withdrawVerificationClick: () -> Unit,
displayedStatus: DisplayedStatus?,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Column( Column(
@@ -90,7 +93,16 @@ fun UserProfileHeaderSection(
style = ElementTheme.typography.fontHeadingLgBold, style = ElementTheme.typography.fontHeadingLgBold,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(12.dp))
}
displayedStatus?.let { status ->
Text(
text = status.toText(),
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(12.dp))
} }
Text( Text(
modifier = Modifier.niceClickable { onUserIdClick() }, modifier = Modifier.niceClickable { onUserIdClick() },
@@ -99,6 +111,7 @@ fun UserProfileHeaderSection(
color = ElementTheme.colors.textSecondary, color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
Spacer(modifier = Modifier.height(12.dp))
when (verificationState) { when (verificationState) {
UserProfileVerificationState.UNKNOWN, UserProfileVerificationState.UNVERIFIED -> Unit UserProfileVerificationState.UNKNOWN, UserProfileVerificationState.UNVERIFIED -> Unit
UserProfileVerificationState.VERIFIED -> { UserProfileVerificationState.VERIFIED -> {
@@ -143,6 +156,7 @@ internal fun UserProfileHeaderSectionPreview() = ElementPreview {
userId = UserId("@alice:example.com"), userId = UserId("@alice:example.com"),
userName = USER_NAME_ALICE, userName = USER_NAME_ALICE,
verificationState = UserProfileVerificationState.VERIFIED, verificationState = UserProfileVerificationState.VERIFIED,
displayedStatus = DisplayedStatus.InCall(0L),
openAvatarPreview = {}, openAvatarPreview = {},
onUserIdClick = {}, onUserIdClick = {},
withdrawVerificationClick = {}, withdrawVerificationClick = {},
@@ -157,6 +171,7 @@ internal fun UserProfileHeaderSectionWithVerificationViolationPreview() = Elemen
userId = UserId("@alice:example.com"), userId = UserId("@alice:example.com"),
userName = USER_NAME_ALICE, userName = USER_NAME_ALICE,
verificationState = UserProfileVerificationState.VERIFICATION_VIOLATION, verificationState = UserProfileVerificationState.VERIFICATION_VIOLATION,
displayedStatus = DisplayedStatus.InCall(0L),
openAvatarPreview = {}, openAvatarPreview = {},
onUserIdClick = {}, onUserIdClick = {},
withdrawVerificationClick = {}, withdrawVerificationClick = {},
@@ -18,6 +18,7 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.RoomId 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.core.UserId
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUser
open class UserProfileStateProvider : PreviewParameterProvider<UserProfileState> { open class UserProfileStateProvider : PreviewParameterProvider<UserProfileState> {
@@ -48,6 +49,7 @@ fun aUserProfileState(
dmRoomId: RoomId? = null, dmRoomId: RoomId? = null,
canCall: Boolean = false, canCall: Boolean = false,
snackbarMessage: SnackbarMessage? = null, snackbarMessage: SnackbarMessage? = null,
displayedStatus: DisplayedStatus? = null,
eventSink: (UserProfileEvents) -> Unit = {}, eventSink: (UserProfileEvents) -> Unit = {},
) = UserProfileState( ) = UserProfileState(
userId = userId, userId = userId,
@@ -61,5 +63,6 @@ fun aUserProfileState(
dmRoomId = dmRoomId, dmRoomId = dmRoomId,
canCall = canCall, canCall = canCall,
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
displayedStatus = displayedStatus,
eventSink = eventSink, eventSink = eventSink,
) )
@@ -78,6 +78,7 @@ fun UserProfileView(
userId = state.userId, userId = state.userId,
userName = state.userName, userName = state.userName,
verificationState = state.verificationState, verificationState = state.verificationState,
displayedStatus = state.displayedStatus,
openAvatarPreview = { avatarUrl -> openAvatarPreview = { avatarUrl ->
openAvatarPreview(state.userName ?: state.userId.value, avatarUrl) openAvatarPreview(state.userName ?: state.userId.value, avatarUrl)
}, },
@@ -25,7 +25,7 @@ fun MatrixBadgeRowMolecule(
) { ) {
FlowRow( FlowRow(
modifier = modifier modifier = modifier
.padding(start = 16.dp, end = 16.dp, top = 8.dp), .padding(start = 16.dp, end = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
@@ -10,6 +10,7 @@ package io.element.android.libraries.matrix.api.room
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
data class RoomMember( data class RoomMember(
@@ -23,6 +24,7 @@ data class RoomMember(
val role: Role, val role: Role,
val membershipChangeReason: String?, val membershipChangeReason: String?,
val isServiceMember: Boolean, val isServiceMember: Boolean,
val displayedStatus: DisplayedStatus?,
) { ) {
/** /**
* Role of the RoomMember, based on its [powerLevel]. * Role of the RoomMember, based on its [powerLevel].
@@ -10,6 +10,7 @@ package io.element.android.libraries.matrix.api.timeline.item.event
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.DisplayedStatus
@Immutable @Immutable
sealed interface ProfileDetails { sealed interface ProfileDetails {
@@ -20,7 +21,8 @@ sealed interface ProfileDetails {
data class Ready( data class Ready(
val displayName: String?, val displayName: String?,
val displayNameAmbiguous: Boolean, val displayNameAmbiguous: Boolean,
val avatarUrl: String? val avatarUrl: String?,
val displayedStatus: DisplayedStatus?,
) : ProfileDetails ) : ProfileDetails
data class Error( data class Error(
@@ -38,6 +38,7 @@ class ProfileTimelineDetailsTest {
displayName = null, displayName = null,
displayNameAmbiguous = false, displayNameAmbiguous = false,
avatarUrl = null, avatarUrl = null,
displayedStatus = null,
).getDisambiguatedDisplayName(aUserId) ).getDisambiguatedDisplayName(aUserId)
).isEqualTo(A_USER_ID) ).isEqualTo(A_USER_ID)
} }
@@ -49,6 +50,7 @@ class ProfileTimelineDetailsTest {
displayName = "Alice", displayName = "Alice",
displayNameAmbiguous = false, displayNameAmbiguous = false,
avatarUrl = null, avatarUrl = null,
displayedStatus = null,
).getDisambiguatedDisplayName(aUserId) ).getDisambiguatedDisplayName(aUserId)
).isEqualTo("Alice") ).isEqualTo("Alice")
} }
@@ -60,6 +62,7 @@ class ProfileTimelineDetailsTest {
displayName = "Alice", displayName = "Alice",
displayNameAmbiguous = true, displayNameAmbiguous = true,
avatarUrl = null, avatarUrl = null,
displayedStatus = null,
).getDisambiguatedDisplayName(aUserId) ).getDisambiguatedDisplayName(aUserId)
).isEqualTo("Alice ($A_USER_ID)") ).isEqualTo("Alice ($A_USER_ID)")
} }
@@ -30,6 +30,8 @@ object RoomMemberMapper {
role = mapRole(roomMember.suggestedRoleForPowerLevel, powerLevel), role = mapRole(roomMember.suggestedRoleForPowerLevel, powerLevel),
membershipChangeReason = roomMember.membershipChangeReason, membershipChangeReason = roomMember.membershipChangeReason,
isServiceMember = roomMember.isServiceMember, isServiceMember = roomMember.isServiceMember,
// TODO map this value when available
displayedStatus = null,
) )
} }
@@ -74,7 +74,9 @@ fun RustProfileDetails.map(): ProfileDetails {
is RustProfileDetails.Ready -> ProfileDetails.Ready( is RustProfileDetails.Ready -> ProfileDetails.Ready(
displayName = displayName, displayName = displayName,
displayNameAmbiguous = displayNameAmbiguous, displayNameAmbiguous = displayNameAmbiguous,
avatarUrl = avatarUrl avatarUrl = avatarUrl,
// TODO map this value when available
displayedStatus = null,
) )
} }
} }
@@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.test.room
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
fun aRoomMember( fun aRoomMember(
@@ -24,6 +25,7 @@ fun aRoomMember(
role: RoomMember.Role = RoomMember.Role.User, role: RoomMember.Role = RoomMember.Role.User,
membershipChangeReason: String? = null, membershipChangeReason: String? = null,
isServiceMember: Boolean = false, isServiceMember: Boolean = false,
displayedStatus: DisplayedStatus? = null,
) = RoomMember( ) = RoomMember(
userId = userId, userId = userId,
displayName = displayName, displayName = displayName,
@@ -35,6 +37,7 @@ fun aRoomMember(
role = role, role = role,
membershipChangeReason = membershipChangeReason, membershipChangeReason = membershipChangeReason,
isServiceMember = isServiceMember, isServiceMember = isServiceMember,
displayedStatus = displayedStatus,
) )
fun aRoomMemberList() = persistentListOf( fun aRoomMemberList() = persistentListOf(
@@ -33,6 +33,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.SendHandlePro
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemDebugInfoProvider import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemDebugInfoProvider
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.A_USER_NAME
@@ -84,11 +85,13 @@ fun anEventTimelineItem(
fun aProfileDetails( fun aProfileDetails(
displayName: String? = A_USER_NAME, displayName: String? = A_USER_NAME,
displayNameAmbiguous: Boolean = false, displayNameAmbiguous: Boolean = false,
avatarUrl: String? = null avatarUrl: String? = null,
displayedStatus: DisplayedStatus? = null,
): ProfileDetails = ProfileDetails.Ready( ): ProfileDetails = ProfileDetails.Ready(
displayName = displayName, displayName = displayName,
displayNameAmbiguous = displayNameAmbiguous, displayNameAmbiguous = displayNameAmbiguous,
avatarUrl = avatarUrl, avatarUrl = avatarUrl,
displayedStatus = displayedStatus,
) )
fun aProfileChangeMessageContent( fun aProfileChangeMessageContent(
@@ -19,6 +19,7 @@ import androidx.compose.runtime.Immutable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
@@ -50,7 +51,7 @@ fun CheckableUserRow(
UserRow( UserRow(
modifier = rowModifier, modifier = rowModifier,
avatarData = data.avatarData, avatarData = data.avatarData,
name = data.name, name = AnnotatedString(data.name),
subtext = data.subtext, subtext = data.subtext,
enabled = enabled, enabled = enabled,
) )
@@ -0,0 +1,63 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.ui.model.toEmojiText
/**
* Component to display side by side a display name and an optional status.
*/
@Composable
fun DisplayNameWithStatus(
name: String,
status: DisplayedStatus?,
style: TextStyle,
modifier: Modifier = Modifier,
nameColor: Color = Color.Unspecified,
nameFontStyle: FontStyle? = null,
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = name,
modifier = Modifier
.weight(1f, fill = false)
.clipToBounds(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = style,
color = nameColor,
fontStyle = nameFontStyle,
)
if (status != null) {
Spacer(Modifier.width(4.dp))
Text(
text = status.toEmojiText(),
style = style,
color = ElementTheme.colors.textSecondary,
)
}
}
}
@@ -18,7 +18,6 @@ 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
@@ -58,13 +57,11 @@ fun MatrixUserHeader(
verticalArrangement = Arrangement.spacedBy(2.dp) verticalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
// Name // Name
Text( DisplayNameWithStatus(
modifier = Modifier.clipToBounds(), name = matrixUser.getBestName(),
text = matrixUser.getBestName(), status = matrixUser.displayedStatus,
maxLines = 1, nameColor = ElementTheme.colors.textPrimary,
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()) {
@@ -21,6 +21,7 @@ import io.element.android.libraries.designsystem.preview.USER_NAME_SUSIE
import io.element.android.libraries.designsystem.preview.USER_NAME_VICTOR import io.element.android.libraries.designsystem.preview.USER_NAME_VICTOR
import io.element.android.libraries.designsystem.preview.USER_NAME_WALTER import io.element.android.libraries.designsystem.preview.USER_NAME_WALTER
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
open class MatrixUserProvider : PreviewParameterProvider<MatrixUser> { open class MatrixUserProvider : PreviewParameterProvider<MatrixUser> {
@@ -28,6 +29,7 @@ open class MatrixUserProvider : PreviewParameterProvider<MatrixUser> {
get() = sequenceOf( get() = sequenceOf(
aMatrixUser(), aMatrixUser(),
aMatrixUser(displayName = null), aMatrixUser(displayName = null),
aMatrixUser(displayedStatus = DisplayedStatus.InCall(0L)),
) )
} }
@@ -43,10 +45,12 @@ fun aMatrixUser(
id: String? = null, id: String? = null,
displayName: String? = USER_NAME_ALICE, displayName: String? = USER_NAME_ALICE,
avatarUrl: String? = null, avatarUrl: String? = null,
displayedStatus: DisplayedStatus? = null,
) = MatrixUser( ) = MatrixUser(
userId = UserId(id ?: "@${displayName?.lowercase()?.replace(" ", "_") ?: "id"}:server.org"), userId = UserId(id ?: "@${displayName?.lowercase()?.replace(" ", "_") ?: "id"}:server.org"),
displayName = displayName, displayName = displayName,
avatarUrl = avatarUrl, avatarUrl = avatarUrl,
displayedStatus = displayedStatus,
) )
fun aMatrixUserList() = listOf( fun aMatrixUserList() = listOf(
@@ -13,6 +13,7 @@ import androidx.compose.ui.Modifier
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
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -29,7 +30,14 @@ fun MatrixUserRow(
trailingContent: @Composable (() -> Unit)? = null, trailingContent: @Composable (() -> Unit)? = null,
) = UserRow( ) = UserRow(
avatarData = matrixUser.getAvatarData(avatarSize), avatarData = matrixUser.getAvatarData(avatarSize),
name = matrixUser.getBestName(), nameContent = {
DisplayNameWithStatus(
name = matrixUser.getBestName(),
status = matrixUser.displayedStatus,
nameColor = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontBodyLgRegular,
)
},
subtext = if (matrixUser.displayName.isNullOrEmpty()) null else matrixUser.userId.value, subtext = if (matrixUser.displayName.isNullOrEmpty()) null else matrixUser.userId.value,
modifier = modifier, modifier = modifier,
verticalSpaceWidth = verticalSpaceWidth, verticalSpaceWidth = verticalSpaceWidth,
@@ -18,6 +18,7 @@ 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.draw.clipToBounds
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -30,7 +31,37 @@ import io.element.android.libraries.designsystem.theme.components.Text
@Composable @Composable
internal fun UserRow( internal fun UserRow(
avatarData: AvatarData, avatarData: AvatarData,
name: String, name: AnnotatedString,
subtext: String?,
modifier: Modifier = Modifier,
enabled: Boolean = true,
verticalSpaceWidth: Dp = 12.dp,
trailingContent: @Composable (() -> Unit)? = null,
) {
UserRow(
avatarData = avatarData,
nameContent = {
Text(
modifier = Modifier.clipToBounds(),
text = name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = if (enabled) ElementTheme.colors.textPrimary else ElementTheme.colors.textDisabled,
style = ElementTheme.typography.fontBodyLgRegular,
)
},
subtext = subtext,
modifier = modifier,
enabled = enabled,
verticalSpaceWidth = verticalSpaceWidth,
trailingContent = trailingContent,
)
}
@Composable
internal fun UserRow(
avatarData: AvatarData,
nameContent: @Composable () -> Unit,
subtext: String?, subtext: String?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
@@ -52,14 +83,7 @@ internal fun UserRow(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) { ) {
// Name // Name
Text( nameContent()
modifier = Modifier.clipToBounds(),
text = name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = if (enabled) ElementTheme.colors.textPrimary else ElementTheme.colors.textDisabled,
style = ElementTheme.typography.fontBodyLgRegular,
)
// Id // Id
subtext?.let { subtext?.let {
Text( Text(
@@ -13,6 +13,7 @@ import dev.zacsweers.metro.SingleIn
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.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.drop
@@ -31,4 +32,8 @@ class RoomMemberProfilesCache {
fun getDisplayName(userId: UserId): String? { fun getDisplayName(userId: UserId): String? {
return cache.value[userId]?.disambiguatedDisplayName return cache.value[userId]?.disambiguatedDisplayName
} }
fun getDisplayedStatus(userId: UserId): DisplayedStatus? {
return cache.value[userId]?.displayedStatus
}
} }
@@ -167,4 +167,5 @@ fun aProfileDetailsReady(
displayName = displayName, displayName = displayName,
displayNameAmbiguous = displayNameAmbiguous, displayNameAmbiguous = displayNameAmbiguous,
avatarUrl = avatarUrl, avatarUrl = avatarUrl,
displayedStatus = null,
) )
@@ -24,6 +24,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
import io.element.android.libraries.matrix.ui.model.toText
// https://www.figma.com/file/Ni6Ii8YKtmXCKYNE90cC67/Timeline-(new)?type=design&node-id=917-80169&mode=design&t=A0CJCBbMqR8NOwUQ-0 // https://www.figma.com/file/Ni6Ii8YKtmXCKYNE90cC67/Timeline-(new)?type=design&node-id=917-80169&mode=design&t=A0CJCBbMqR8NOwUQ-0
@Composable @Composable
@@ -54,6 +55,19 @@ fun SenderName(
SecondaryText(text = senderId.value, mode = senderNameMode) SecondaryText(text = senderId.value, mode = senderNameMode)
} }
} }
val userStatus = senderProfile.displayedStatus
if (senderNameMode is SenderNameMode.Timeline && userStatus != null) {
Text(
modifier = Modifier
.clipToBounds()
.alignByBaseline(),
text = userStatus.toText(),
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
} }
} }
} }
@@ -74,13 +88,16 @@ private fun RowScope.MainText(
SenderNameMode.ActionList, SenderNameMode.ActionList,
SenderNameMode.Reply -> Modifier SenderNameMode.Reply -> Modifier
} }
val color = when (mode) { val color = when (mode) {
is SenderNameMode.Timeline -> mode.mainColor is SenderNameMode.Timeline -> mode.mainColor
SenderNameMode.ActionList, SenderNameMode.ActionList,
SenderNameMode.Reply -> ElementTheme.colors.textPrimary SenderNameMode.Reply -> ElementTheme.colors.textPrimary
} }
Text( Text(
modifier = modifier.clipToBounds(), modifier = modifier
.clipToBounds()
.weight(1f, fill = false),
text = text, text = text,
style = style, style = style,
color = color, color = color,
@@ -12,6 +12,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
import io.element.android.libraries.matrix.api.user.DisplayedStatus
data class SenderNameData( data class SenderNameData(
val userId: UserId, val userId: UserId,
@@ -47,12 +48,14 @@ open class SenderNameDataProvider : PreviewParameterProvider<SenderNameData> {
private fun aSenderNameData( private fun aSenderNameData(
senderNameMode: SenderNameMode, senderNameMode: SenderNameMode,
displayNameAmbiguous: Boolean = false, displayNameAmbiguous: Boolean = false,
displayedStatus: DisplayedStatus? = null,
) = SenderNameData( ) = SenderNameData(
userId = UserId("@alice:${senderNameMode.javaClass.simpleName.lowercase()}"), userId = UserId("@alice:${senderNameMode.javaClass.simpleName.lowercase()}"),
profileDetails = ProfileDetails.Ready( profileDetails = ProfileDetails.Ready(
displayName = "Alice ${senderNameMode.javaClass.simpleName}", displayName = "Alice ${senderNameMode.javaClass.simpleName}",
displayNameAmbiguous = displayNameAmbiguous, displayNameAmbiguous = displayNameAmbiguous,
avatarUrl = null avatarUrl = null,
displayedStatus = displayedStatus,
), ),
senderNameMode = senderNameMode, senderNameMode = senderNameMode,
) )
@@ -0,0 +1,24 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.model
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.ui.strings.CommonStrings
fun DisplayedStatus.toEmojiText(): String = when (this) {
is DisplayedStatus.UserSet -> status.emoji
is DisplayedStatus.InCall -> "🎧"
}
@Composable
fun DisplayedStatus.toText(): String = when (this) {
is DisplayedStatus.UserSet -> "${status.emoji} ${status.text}"
is DisplayedStatus.InCall -> "🎧 ${stringResource(CommonStrings.common_on_a_call)}"
}
@@ -13,6 +13,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomInfo import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.user.DisplayedStatus
fun RoomInfo.getAvatarData(size: AvatarSize) = AvatarData( fun RoomInfo.getAvatarData(size: AvatarSize) = AvatarData(
id = id.value, id = id.value,
@@ -45,3 +46,14 @@ fun RoomInfo.roleOf(userId: UserId): RoomMember.Role {
val powerLevel = powerLevelOf(userId = userId) val powerLevel = powerLevelOf(userId = userId)
return RoomMember.Role.forPowerLevel(powerLevel) return RoomMember.Role.forPowerLevel(powerLevel)
} }
/**
* Return the [DisplayedStatus] of the other member in a DM.
*/
fun RoomInfo.dmUserStatus(): DisplayedStatus? {
return if (isDm) {
heroes.firstOrNull()?.displayedStatus
} else {
null
}
}
@@ -21,6 +21,7 @@ import io.element.android.libraries.designsystem.preview.USER_NAME_WALTER
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
fun aRoomMember( fun aRoomMember(
@@ -34,6 +35,7 @@ fun aRoomMember(
role: RoomMember.Role = RoomMember.Role.User, role: RoomMember.Role = RoomMember.Role.User,
membershipChangeReason: String? = null, membershipChangeReason: String? = null,
isServiceMember: Boolean = false, isServiceMember: Boolean = false,
displayedStatus: DisplayedStatus? = null,
) = RoomMember( ) = RoomMember(
userId = userId, userId = userId,
displayName = displayName, displayName = displayName,
@@ -45,6 +47,7 @@ fun aRoomMember(
role = role, role = role,
membershipChangeReason = membershipChangeReason, membershipChangeReason = membershipChangeReason,
isServiceMember = isServiceMember, isServiceMember = isServiceMember,
displayedStatus = displayedStatus,
) )
fun aRoomMemberList() = persistentListOf( fun aRoomMemberList() = persistentListOf(
@@ -39,11 +39,15 @@ class MentionSpan(
private var typeface: Typeface = Typeface.DEFAULT private var typeface: Typeface = Typeface.DEFAULT
private var measuredTextWidth = 0 private var measuredTextWidth = 0
private var measuredTrailingWidth = 0
// The formatted display text, will be set by the formatter // The formatted display text, will be set by the formatter
var displayText: CharSequence = "" var displayText: CharSequence = ""
private set private set
var trailingText: String? = null
private set
/** /**
* Updates the visual properties of this span. * Updates the visual properties of this span.
*/ */
@@ -80,6 +84,10 @@ class MentionSpan(
displayText = formatter.formatDisplayText(type) displayText = formatter.formatDisplayText(type)
} }
fun updateTrailingText(trailingText: String?) {
this.trailingText = trailingText
}
override fun getSize( override fun getSize(
paint: Paint, paint: Paint,
text: CharSequence?, text: CharSequence?,
@@ -91,7 +99,8 @@ class MentionSpan(
textPaint.typeface = typeface textPaint.typeface = typeface
// Measure the full text width without truncation // Measure the full text width without truncation
measuredTextWidth = textPaint.measureText(displayText, 0, displayText.length).roundToInt() measuredTextWidth = textPaint.measureText(displayText, 0, displayText.length).roundToInt()
return measuredTextWidth + startPadding + endPadding measuredTrailingWidth = trailingText?.let { textPaint.measureText(" $it").roundToInt() } ?: 0
return measuredTextWidth + measuredTrailingWidth + startPadding + endPadding
} }
override fun draw( override fun draw(
@@ -106,7 +115,8 @@ class MentionSpan(
paint: Paint paint: Paint
) { ) {
val availableWidth = (canvas.width - x).coerceAtLeast(0f) val availableWidth = (canvas.width - x).coerceAtLeast(0f)
val measuredWidth = measuredTextWidth + startPadding + endPadding
val measuredWidth = measuredTextWidth + measuredTrailingWidth + startPadding + endPadding
val pillWidth = minOf(availableWidth, measuredWidth.toFloat()) val pillWidth = minOf(availableWidth, measuredWidth.toFloat())
backgroundPaint.color = backgroundColor backgroundPaint.color = backgroundColor
@@ -118,18 +128,18 @@ class MentionSpan(
textPaint.color = textColor textPaint.color = textColor
textPaint.typeface = typeface textPaint.typeface = typeface
val availableWidthForText = availableWidth - startPadding - endPadding val availableWidthForText = availableWidth - startPadding - endPadding - measuredTrailingWidth
val textToDraw = if (measuredTextWidth > availableWidthForText) { val textToDraw = if (measuredTextWidth > availableWidthForText) {
TextUtils.ellipsize( TextUtils.ellipsize(displayText, textPaint, availableWidthForText, TextUtils.TruncateAt.END)
displayText,
textPaint,
availableWidthForText,
TextUtils.TruncateAt.END
)
} else { } else {
displayText displayText
} }
canvas.drawText(textToDraw, 0, textToDraw.length, x + startPadding, y.toFloat(), textPaint) canvas.drawText(textToDraw, 0, textToDraw.length, x + startPadding, y.toFloat(), textPaint)
trailingText?.let {
val drawnTextWidth = minOf(availableWidthForText, measuredTextWidth.toFloat())
canvas.drawText(" $it", x + startPadding + drawnTextWidth, y.toFloat(), textPaint)
}
} }
} }
@@ -18,6 +18,7 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.RoomNamesCache import io.element.android.libraries.matrix.ui.messages.RoomNamesCache
import io.element.android.libraries.matrix.ui.model.toEmojiText
interface MentionSpanUpdater { interface MentionSpanUpdater {
fun updateMentionSpans(text: CharSequence): CharSequence fun updateMentionSpans(text: CharSequence): CharSequence
@@ -48,6 +49,13 @@ class DefaultMentionSpanUpdater(
for (mentionSpan in text.getMentionSpans()) { for (mentionSpan in text.getMentionSpans()) {
mentionSpan.updateTheme(theme) mentionSpan.updateTheme(theme)
mentionSpan.updateDisplayText(formatter) mentionSpan.updateDisplayText(formatter)
mentionSpan.updateTrailingText(
if (mentionSpan.type is MentionType.User) {
roomMemberProfilesCache.getDisplayedStatus(mentionSpan.type.userId)?.toEmojiText()
} else {
null
}
)
} }
return text return text
} }
@@ -23,6 +23,7 @@
<string name="a11y_join_call">"Join call"</string> <string name="a11y_join_call">"Join call"</string>
<string name="a11y_jump_to_bottom">"Jump to bottom"</string> <string name="a11y_jump_to_bottom">"Jump to bottom"</string>
<string name="a11y_jump_to_unread">"Jump to unread"</string> <string name="a11y_jump_to_unread">"Jump to unread"</string>
<string name="a11y_jump_to_unread_messages">"Jump to first unread message"</string>
<string name="a11y_move_the_map_to_my_location">"Move the map to my location"</string> <string name="a11y_move_the_map_to_my_location">"Move the map to my location"</string>
<string name="a11y_notifications_mentions_only">"Mentions only"</string> <string name="a11y_notifications_mentions_only">"Mentions only"</string>
<string name="a11y_notifications_muted">"Muted"</string> <string name="a11y_notifications_muted">"Muted"</string>
@@ -143,6 +144,7 @@
<string name="action_manage_account_and_devices">"Manage account &amp; devices"</string> <string name="action_manage_account_and_devices">"Manage account &amp; devices"</string>
<string name="action_manage_devices">"Manage devices"</string> <string name="action_manage_devices">"Manage devices"</string>
<string name="action_manage_rooms">"Manage rooms"</string> <string name="action_manage_rooms">"Manage rooms"</string>
<string name="action_mark_as_read">"Mark as read"</string>
<string name="action_message">"Message"</string> <string name="action_message">"Message"</string>
<string name="action_minimize">"Minimise"</string> <string name="action_minimize">"Minimise"</string>
<string name="action_next">"Next"</string> <string name="action_next">"Next"</string>
@@ -515,6 +517,8 @@ Are you sure you want to continue?"</string>
<item quantity="other">"%1$d people"</item> <item quantity="other">"%1$d people"</item>
</plurals> </plurals>
<string name="screen_live_location_sheet_title">"On the map"</string> <string name="screen_live_location_sheet_title">"On the map"</string>
<string name="screen_local_network_opt_in_subtitle">"Your homeserver is on your local network. To connect, Element needs permission to reach devices on this network."</string>
<string name="screen_local_network_opt_in_title">"Allow access to local network"</string>
<string name="screen_media_picker_error_failed_selection">"Failed selecting media, please try again."</string> <string name="screen_media_picker_error_failed_selection">"Failed selecting media, please try again."</string>
<string name="screen_pinned_timeline_empty_state_description">"Press on a message and choose “%1$s” to include here."</string> <string name="screen_pinned_timeline_empty_state_description">"Press on a message and choose “%1$s” to include here."</string>
<string name="screen_pinned_timeline_empty_state_headline">"Pin important messages so that they can be easily discovered"</string> <string name="screen_pinned_timeline_empty_state_headline">"Pin important messages so that they can be easily discovered"</string>
@@ -6,12 +6,8 @@
--> -->
<resources> <resources>
<string name="action_mark_as_read">"Mark as read"</string>
<string name="a11y_jump_to_unread_messages">"Jump to first unread message"</string>
<string name="a11y_state_expanded">"Expanded"</string> <string name="a11y_state_expanded">"Expanded"</string>
<string name="a11y_state_collapsed">"Collapsed"</string> <string name="a11y_state_collapsed">"Collapsed"</string>
<string name="screen_local_network_opt_in_title">Allow access to local network</string>
<string name="screen_local_network_opt_in_subtitle">Your homeserver is on your local network. To connect, Element needs permission to reach other devices on this network.</string>
<string name="a11y_active">Active</string> <string name="a11y_active">Active</string>
<string name="a11y_inactive">Inactive</string> <string name="a11y_inactive">Inactive</string>
</resources> </resources>

Some files were not shown because too many files have changed in this diff Show More