Merge pull request #4891 from element-hq/feature/fga/tombstoned-room-decoration

Change : add tombstoned room decoration
This commit is contained in:
ganfra 2025-06-18 15:08:38 +02:00 committed by GitHub
commit b2e17622ed
48 changed files with 449 additions and 274 deletions

View file

@ -152,11 +152,8 @@ class MessagesPresenter @AssistedInject constructor(
val userEventPermissions by userEventPermissions(syncUpdateFlow.value) val userEventPermissions by userEventPermissions(syncUpdateFlow.value)
val roomName: AsyncData<String> by remember { val roomAvatar by remember {
derivedStateOf { roomInfo.name?.let { AsyncData.Success(it) } ?: AsyncData.Uninitialized } derivedStateOf { roomInfo.avatarData() }
}
val roomAvatar: AsyncData<AvatarData> by remember {
derivedStateOf { AsyncData.Success(roomInfo.avatarData()) }
} }
val heroes by remember { val heroes by remember {
derivedStateOf { roomInfo.heroes().toPersistentList() } derivedStateOf { roomInfo.heroes().toPersistentList() }
@ -245,7 +242,7 @@ class MessagesPresenter @AssistedInject constructor(
return MessagesState( return MessagesState(
roomId = room.roomId, roomId = room.roomId,
roomName = roomName, roomName = roomInfo.name,
roomAvatar = roomAvatar, roomAvatar = roomAvatar,
heroes = heroes, heroes = heroes,
composerState = composerState, composerState = composerState,
@ -292,7 +289,7 @@ class MessagesPresenter @AssistedInject constructor(
return AvatarData( return AvatarData(
id = id.value, id = id.value,
name = name, name = name,
url = avatarUrl ?: room.info().avatarUrl, url = avatarUrl,
size = AvatarSize.TimelineRoom size = AvatarSize.TimelineRoom
) )
} }

View file

@ -32,8 +32,8 @@ import kotlinx.collections.immutable.ImmutableList
@Immutable @Immutable
data class MessagesState( data class MessagesState(
val roomId: RoomId, val roomId: RoomId,
val roomName: AsyncData<String>, val roomName: String?,
val roomAvatar: AsyncData<AvatarData>, val roomAvatar: AvatarData,
val heroes: ImmutableList<AvatarData>, val heroes: ImmutableList<AvatarData>,
val userEventPermissions: UserEventPermissions, val userEventPermissions: UserEventPermissions,
val composerState: MessageComposerState, val composerState: MessageComposerState,
@ -59,4 +59,6 @@ data class MessagesState(
val roomMemberModerationState: RoomMemberModerationState, val roomMemberModerationState: RoomMemberModerationState,
val successorRoom: SuccessorRoom?, val successorRoom: SuccessorRoom?,
val eventSink: (MessagesEvents) -> Unit val eventSink: (MessagesEvents) -> Unit
) ) {
val isTombstoned = successorRoom != null
}

View file

@ -58,10 +58,7 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
aMessagesState(composerState = aMessageComposerState(showAttachmentSourcePicker = true)), aMessagesState(composerState = aMessageComposerState(showAttachmentSourcePicker = true)),
aMessagesState(userEventPermissions = aUserEventPermissions(canSendMessage = false)), aMessagesState(userEventPermissions = aUserEventPermissions(canSendMessage = false)),
aMessagesState(showReinvitePrompt = true), aMessagesState(showReinvitePrompt = true),
aMessagesState( aMessagesState(roomName = null),
roomName = AsyncData.Uninitialized,
roomAvatar = AsyncData.Uninitialized,
),
aMessagesState(composerState = aMessageComposerState(showTextFormatting = true)), aMessagesState(composerState = aMessageComposerState(showTextFormatting = true)),
aMessagesState( aMessagesState(
enableVoiceMessages = true, enableVoiceMessages = true,
@ -86,15 +83,15 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
currentPinnedMessageIndex = 0, currentPinnedMessageIndex = 0,
), ),
), ),
aMessagesState(roomName = AsyncData.Success("A DM with a very looong name"), dmUserVerificationState = IdentityState.Verified), aMessagesState(roomName = "A DM with a very looong name", dmUserVerificationState = IdentityState.Verified),
aMessagesState(roomName = AsyncData.Success("A DM with a very looong name"), dmUserVerificationState = IdentityState.VerificationViolation), aMessagesState(roomName = "A DM with a very looong name", dmUserVerificationState = IdentityState.VerificationViolation),
aMessagesState(successorRoom = SuccessorRoom(RoomId("!id:domain"), null)), aMessagesState(successorRoom = SuccessorRoom(RoomId("!id:domain"), null)),
) )
} }
fun aMessagesState( fun aMessagesState(
roomName: AsyncData<String> = AsyncData.Success("Room name"), roomName: String? = "Room name",
roomAvatar: AsyncData<AvatarData> = AsyncData.Success(AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom)), roomAvatar: AvatarData = AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom),
userEventPermissions: UserEventPermissions = aUserEventPermissions(), userEventPermissions: UserEventPermissions = aUserEventPermissions(),
composerState: MessageComposerState = aMessageComposerState( composerState: MessageComposerState = aMessageComposerState(
textEditorState = aTextEditorStateRich(initialText = "Hello", initialFocus = true), textEditorState = aTextEditorStateRich(initialText = "Hello", initialFocus = true),

View file

@ -83,10 +83,8 @@ import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorVi
import io.element.android.features.roomcall.api.RoomCallState import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.androidutils.ui.hideKeyboard import io.element.android.libraries.androidutils.ui.hideKeyboard
import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertMolecule import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule
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.RoomAvatar
import io.element.android.libraries.designsystem.components.avatar.CompositeAvatar
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
@ -194,8 +192,9 @@ fun MessagesView(
Column { Column {
ConnectivityIndicatorView(isOnline = state.hasNetworkConnection) ConnectivityIndicatorView(isOnline = state.hasNetworkConnection)
MessagesViewTopBar( MessagesViewTopBar(
roomName = state.roomName.dataOrNull(), roomName = state.roomName,
roomAvatar = state.roomAvatar.dataOrNull(), roomAvatar = state.roomAvatar,
isTombstoned = state.isTombstoned,
heroes = state.heroes, heroes = state.heroes,
roomCallState = state.roomCallState, roomCallState = state.roomCallState,
dmUserIdentityState = state.dmUserVerificationState, dmUserIdentityState = state.dmUserVerificationState,
@ -450,8 +449,8 @@ private fun MessagesViewComposerBottomSheetContents(
} }
}), }),
roomId = state.roomId, roomId = state.roomId,
roomName = state.roomName.dataOrNull(), roomName = state.roomName,
roomAvatarData = state.roomAvatar.dataOrNull(), roomAvatarData = state.roomAvatar,
suggestions = state.composerState.suggestions, suggestions = state.composerState.suggestions,
onSelectSuggestion = { onSelectSuggestion = {
state.composerState.eventSink(MessageComposerEvents.InsertSuggestion(it)) state.composerState.eventSink(MessageComposerEvents.InsertSuggestion(it))
@ -491,7 +490,8 @@ private fun MessagesViewComposerBottomSheetContents(
@Composable @Composable
private fun MessagesViewTopBar( private fun MessagesViewTopBar(
roomName: String?, roomName: String?,
roomAvatar: AvatarData?, roomAvatar: AvatarData,
isTombstoned: Boolean,
heroes: ImmutableList<AvatarData>, heroes: ImmutableList<AvatarData>,
roomCallState: RoomCallState, roomCallState: RoomCallState,
dmUserIdentityState: IdentityState?, dmUserIdentityState: IdentityState?,
@ -513,19 +513,13 @@ private fun MessagesViewTopBar(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
val titleModifier = Modifier.weight(1f, fill = false) val titleModifier = Modifier.weight(1f, fill = false)
if (roomName != null && roomAvatar != null) { RoomAvatarAndNameRow(
RoomAvatarAndNameRow( roomName = roomName,
roomName = roomName, roomAvatar = roomAvatar,
roomAvatar = roomAvatar, isTombstoned = isTombstoned,
heroes = heroes, heroes = heroes,
modifier = titleModifier modifier = titleModifier
) )
} else {
IconTitlePlaceholdersRowMolecule(
iconSize = AvatarSize.TimelineRoom.dp,
modifier = titleModifier
)
}
when (dmUserIdentityState) { when (dmUserIdentityState) {
IdentityState.Verified -> { IdentityState.Verified -> {
@ -559,23 +553,26 @@ private fun MessagesViewTopBar(
@Composable @Composable
private fun RoomAvatarAndNameRow( private fun RoomAvatarAndNameRow(
roomName: String, roomName: String?,
roomAvatar: AvatarData, roomAvatar: AvatarData,
heroes: ImmutableList<AvatarData>, heroes: ImmutableList<AvatarData>,
isTombstoned: Boolean,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Row( Row(
modifier = modifier, modifier = modifier,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
CompositeAvatar( RoomAvatar(
avatarData = roomAvatar, avatarData = roomAvatar,
heroes = heroes, heroes = heroes,
isTombstoned = isTombstoned,
) )
Text( Text(
modifier = Modifier.padding(horizontal = 8.dp), modifier = Modifier.padding(horizontal = 8.dp),
text = roomName, text = roomName ?: stringResource(CommonStrings.common_no_room_name),
style = ElementTheme.typography.fontBodyLgMedium, style = ElementTheme.typography.fontBodyLgMedium,
fontStyle = FontStyle.Italic.takeIf { roomName == null },
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
@ -586,9 +583,9 @@ private fun RoomAvatarAndNameRow(
private fun CantSendMessageBanner() { private fun CantSendMessageBanner() {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(ElementTheme.colors.bgSubtleSecondary) .background(ElementTheme.colors.bgSubtleSecondary)
.padding(16.dp), .padding(16.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
) { ) {

View file

@ -27,6 +27,7 @@ import io.element.android.features.messages.impl.R
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
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.designsystem.components.avatar.anAvatarData
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
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
@ -45,7 +46,7 @@ import kotlinx.collections.immutable.persistentListOf
fun SuggestionsPickerView( fun SuggestionsPickerView(
roomId: RoomId, roomId: RoomId,
roomName: String?, roomName: String?,
roomAvatarData: AvatarData?, roomAvatarData: AvatarData,
suggestions: ImmutableList<ResolvedSuggestion>, suggestions: ImmutableList<ResolvedSuggestion>,
onSelectSuggestion: (ResolvedSuggestion) -> Unit, onSelectSuggestion: (ResolvedSuggestion) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -155,7 +156,7 @@ internal fun SuggestionsPickerViewPreview() {
SuggestionsPickerView( SuggestionsPickerView(
roomId = RoomId("!room:matrix.org"), roomId = RoomId("!room:matrix.org"),
roomName = "Room", roomName = "Room",
roomAvatarData = null, roomAvatarData = anAvatarData(),
suggestions = persistentListOf( suggestions = persistentListOf(
ResolvedSuggestion.AtRoom, ResolvedSuggestion.AtRoom,
ResolvedSuggestion.Member(roomMember), ResolvedSuggestion.Member(roomMember),

View file

@ -119,9 +119,9 @@ class MessagesPresenterTest {
presenter.testWithLifecycleOwner { presenter.testWithLifecycleOwner {
val initialState = consumeItemsUntilTimeout().last() val initialState = consumeItemsUntilTimeout().last()
assertThat(initialState.roomId).isEqualTo(A_ROOM_ID) assertThat(initialState.roomId).isEqualTo(A_ROOM_ID)
assertThat(initialState.roomName).isEqualTo(AsyncData.Success("")) assertThat(initialState.roomName).isEqualTo("")
assertThat(initialState.roomAvatar) assertThat(initialState.roomAvatar)
.isEqualTo(AsyncData.Success(AvatarData(id = A_ROOM_ID.value, name = "", url = AN_AVATAR_URL, size = AvatarSize.TimelineRoom))) .isEqualTo(AvatarData(id = A_ROOM_ID.value, name = "", url = AN_AVATAR_URL, size = AvatarSize.TimelineRoom))
assertThat(initialState.userEventPermissions.canSendMessage).isTrue() assertThat(initialState.userEventPermissions.canSendMessage).isTrue()
assertThat(initialState.userEventPermissions.canRedactOwn).isTrue() assertThat(initialState.userEventPermissions.canRedactOwn).isTrue()
assertThat(initialState.hasNetworkConnection).isTrue() assertThat(initialState.hasNetworkConnection).isTrue()

View file

@ -110,7 +110,7 @@ class MessagesViewTest {
state = state, state = state,
onRoomDetailsClick = callback, onRoomDetailsClick = callback,
) )
rule.onNodeWithText(state.roomName.dataOrNull().orEmpty(), useUnmergedTree = true).performClick() rule.onNodeWithText(state.roomName.orEmpty(), useUnmergedTree = true).performClick()
} }
} }

View file

@ -16,7 +16,7 @@ import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.R
import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.avatar.CompositeAvatar import io.element.android.libraries.designsystem.components.avatar.RoomAvatar
import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferencePage import io.element.android.libraries.designsystem.components.preferences.PreferencePage
@ -97,7 +97,7 @@ fun EditDefaultNotificationSettingView(
Text(text = subtitle) Text(text = subtitle)
}, },
leadingContent = ListItemContent.Custom { leadingContent = ListItemContent.Custom {
CompositeAvatar( RoomAvatar(
avatarData = summary.avatarData, avatarData = summary.avatarData,
heroes = summary.heroesAvatar, heroes = summary.heroesAvatar,
) )

View file

@ -210,6 +210,7 @@ class RoomDetailsPresenter @Inject constructor(
canShowSecurityAndPrivacy = canShowSecurityAndPrivacy, canShowSecurityAndPrivacy = canShowSecurityAndPrivacy,
hasMemberVerificationViolations = hasMemberVerificationViolations, hasMemberVerificationViolations = hasMemberVerificationViolations,
canReportRoom = canReportRoom, canReportRoom = canReportRoom,
isTombstoned = roomInfo.successorRoom != null,
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
} }

View file

@ -49,6 +49,7 @@ data class RoomDetailsState(
val canShowSecurityAndPrivacy: Boolean, val canShowSecurityAndPrivacy: Boolean,
val hasMemberVerificationViolations: Boolean, val hasMemberVerificationViolations: Boolean,
val canReportRoom: Boolean, val canReportRoom: Boolean,
val isTombstoned: Boolean,
val eventSink: (RoomDetailsEvent) -> Unit val eventSink: (RoomDetailsEvent) -> Unit
) { ) {
val roomBadges = buildList { val roomBadges = buildList {

View file

@ -53,6 +53,7 @@ open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState>
aRoomDetailsState(knockRequestsCount = null, canShowKnockRequests = true), aRoomDetailsState(knockRequestsCount = null, canShowKnockRequests = true),
aRoomDetailsState(knockRequestsCount = 4, canShowKnockRequests = true), aRoomDetailsState(knockRequestsCount = 4, canShowKnockRequests = true),
aRoomDetailsState(hasMemberVerificationViolations = true), aRoomDetailsState(hasMemberVerificationViolations = true),
aRoomDetailsState(isTombstoned = true),
aDmRoomDetailsState(dmRoomMemberVerificationState = UserProfileVerificationState.VERIFIED), aDmRoomDetailsState(dmRoomMemberVerificationState = UserProfileVerificationState.VERIFIED),
aDmRoomDetailsState(dmRoomMemberVerificationState = UserProfileVerificationState.VERIFICATION_VIOLATION), aDmRoomDetailsState(dmRoomMemberVerificationState = UserProfileVerificationState.VERIFICATION_VIOLATION),
// Add other state here // Add other state here
@ -118,6 +119,7 @@ fun aRoomDetailsState(
canShowSecurityAndPrivacy: Boolean = true, canShowSecurityAndPrivacy: Boolean = true,
hasMemberVerificationViolations: Boolean = false, hasMemberVerificationViolations: Boolean = false,
canReportRoom: Boolean = true, canReportRoom: Boolean = true,
isTombstoned: Boolean = false,
eventSink: (RoomDetailsEvent) -> Unit = {}, eventSink: (RoomDetailsEvent) -> Unit = {},
) = RoomDetailsState( ) = RoomDetailsState(
roomId = roomId, roomId = roomId,
@ -148,6 +150,7 @@ fun aRoomDetailsState(
canShowSecurityAndPrivacy = canShowSecurityAndPrivacy, canShowSecurityAndPrivacy = canShowSecurityAndPrivacy,
hasMemberVerificationViolations = hasMemberVerificationViolations, hasMemberVerificationViolations = hasMemberVerificationViolations,
canReportRoom = canReportRoom, canReportRoom = canReportRoom,
isTombstoned = isTombstoned,
eventSink = eventSink, eventSink = eventSink,
) )

View file

@ -48,8 +48,8 @@ import io.element.android.libraries.designsystem.atomic.molecules.MatrixBadgeRow
import io.element.android.libraries.designsystem.components.ClickableLinkText import io.element.android.libraries.designsystem.components.ClickableLinkText
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.designsystem.components.avatar.CompositeAvatar
import io.element.android.libraries.designsystem.components.avatar.DmAvatars import io.element.android.libraries.designsystem.components.avatar.DmAvatars
import io.element.android.libraries.designsystem.components.avatar.RoomAvatar
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.button.MainActionButton import io.element.android.libraries.designsystem.components.button.MainActionButton
import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.list.ListItemContent
@ -138,6 +138,7 @@ fun RoomDetailsView(
roomName = state.roomName, roomName = state.roomName,
roomAlias = state.roomAlias, roomAlias = state.roomAlias,
heroes = state.heroes, heroes = state.heroes,
isTombstoned = state.isTombstoned,
openAvatarPreview = { avatarUrl -> openAvatarPreview = { avatarUrl ->
openAvatarPreview(state.roomName, avatarUrl) openAvatarPreview(state.roomName, avatarUrl)
}, },
@ -380,6 +381,7 @@ private fun RoomHeaderSection(
roomName: String, roomName: String,
roomAlias: RoomAlias?, roomAlias: RoomAlias?,
heroes: ImmutableList<MatrixUser>, heroes: ImmutableList<MatrixUser>,
isTombstoned: Boolean,
openAvatarPreview: (url: String) -> Unit, openAvatarPreview: (url: String) -> Unit,
onSubtitleClick: (String) -> Unit, onSubtitleClick: (String) -> Unit,
) { ) {
@ -389,11 +391,12 @@ private fun RoomHeaderSection(
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
CompositeAvatar( RoomAvatar(
avatarData = AvatarData(roomId.value, roomName, avatarUrl, AvatarSize.RoomHeader), avatarData = AvatarData(roomId.value, roomName, avatarUrl, AvatarSize.RoomHeader),
heroes = heroes.map { user -> heroes = heroes.map { user ->
user.getAvatarData(size = AvatarSize.RoomHeader) user.getAvatarData(size = AvatarSize.RoomHeader)
}.toPersistentList(), }.toPersistentList(),
isTombstoned = isTombstoned,
modifier = Modifier modifier = Modifier
.clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) } .clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) }
.testTag(TestTags.roomDetailAvatar) .testTag(TestTags.roomDetailAvatar)

View file

@ -44,7 +44,7 @@ import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryProvid
import io.element.android.features.roomlist.impl.model.RoomSummaryDisplayType import io.element.android.features.roomlist.impl.model.RoomSummaryDisplayType
import io.element.android.libraries.core.extensions.orEmpty import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
import io.element.android.libraries.designsystem.components.avatar.CompositeAvatar import io.element.android.libraries.designsystem.components.avatar.RoomAvatar
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
import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.Button
@ -121,7 +121,7 @@ internal fun RoomSummaryRow(
timestamp = room.timestamp, timestamp = room.timestamp,
isHighlighted = room.isHighlighted isHighlighted = room.isHighlighted
) )
LastMessageAndIndicatorRow(room = room) MessagePreviewAndIndicatorRow(room = room)
} }
} }
RoomSummaryDisplayType.KNOCKED -> { RoomSummaryDisplayType.KNOCKED -> {
@ -184,10 +184,11 @@ private fun RoomSummaryScaffoldRow(
.padding(horizontal = 16.dp, vertical = 11.dp) .padding(horizontal = 16.dp, vertical = 11.dp)
.height(IntrinsicSize.Min), .height(IntrinsicSize.Min),
) { ) {
CompositeAvatar( RoomAvatar(
avatarData = room.avatarData, avatarData = room.avatarData,
heroes = room.heroes, heroes = room.heroes,
hideAvatarImages = hideAvatarImage, isTombstoned = room.isTombstoned,
hideAvatarImage = hideAvatarImage,
) )
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(16.dp))
Column( Column(
@ -255,7 +256,7 @@ private fun InviteSubtitle(
} }
@Composable @Composable
private fun LastMessageAndIndicatorRow( private fun MessagePreviewAndIndicatorRow(
room: RoomListRoomSummary, room: RoomListRoomSummary,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -263,12 +264,15 @@ private fun LastMessageAndIndicatorRow(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
horizontalArrangement = spacedBy(28.dp) horizontalArrangement = spacedBy(28.dp)
) { ) {
// Last Message val messagePreview = if (room.isTombstoned) {
val attributedLastMessage = room.lastMessage as? AnnotatedString stringResource(R.string.screen_roomlist_tombstoned_room_description)
?: AnnotatedString(room.lastMessage.orEmpty().toString()) } else {
room.lastMessage.orEmpty()
}
val annotatedMessagePreview = messagePreview as? AnnotatedString ?: AnnotatedString(text = messagePreview.toString())
Text( Text(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
text = attributedLastMessage, text = annotatedMessagePreview,
color = ElementTheme.roomListRoomMessage(), color = ElementTheme.roomListRoomMessage(),
style = ElementTheme.typography.fontBodyMdRegular, style = ElementTheme.typography.fontBodyMdRegular,
minLines = 2, minLines = 2,

View file

@ -67,6 +67,7 @@ class RoomListRoomSummaryFactory @Inject constructor(
heroes = roomInfo.heroes.map { user -> heroes = roomInfo.heroes.map { user ->
user.getAvatarData(size = AvatarSize.RoomListItem) user.getAvatarData(size = AvatarSize.RoomListItem)
}.toImmutableList(), }.toImmutableList(),
isTombstoned = roomInfo.successorRoom != null,
) )
} }
} }

View file

@ -36,6 +36,7 @@ data class RoomListRoomSummary(
val isDm: Boolean, val isDm: Boolean,
val isFavorite: Boolean, val isFavorite: Boolean,
val inviteSender: InviteSender?, val inviteSender: InviteSender?,
val isTombstoned: Boolean,
val heroes: ImmutableList<AvatarData>, val heroes: ImmutableList<AvatarData>,
) { ) {
val isHighlighted = userDefinedNotificationMode != RoomNotificationMode.MUTE && val isHighlighted = userDefinedNotificationMode != RoomNotificationMode.MUTE &&

View file

@ -110,6 +110,11 @@ open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSu
name = "A knocked room with alias", name = "A knocked room with alias",
canonicalAlias = RoomAlias("#knockable:matrix.org"), canonicalAlias = RoomAlias("#knockable:matrix.org"),
displayType = RoomSummaryDisplayType.KNOCKED, displayType = RoomSummaryDisplayType.KNOCKED,
),
aRoomListRoomSummary(
name = "A tombstoned room",
displayType = RoomSummaryDisplayType.ROOM,
isTombstoned = true,
) )
), ),
).flatten() ).flatten()
@ -145,6 +150,7 @@ internal fun aRoomListRoomSummary(
displayType: RoomSummaryDisplayType = RoomSummaryDisplayType.ROOM, displayType: RoomSummaryDisplayType = RoomSummaryDisplayType.ROOM,
canonicalAlias: RoomAlias? = null, canonicalAlias: RoomAlias? = null,
heroes: List<AvatarData> = emptyList(), heroes: List<AvatarData> = emptyList(),
isTombstoned: Boolean = false,
) = RoomListRoomSummary( ) = RoomListRoomSummary(
id = id, id = id,
roomId = RoomId(id), roomId = RoomId(id),
@ -165,4 +171,5 @@ internal fun aRoomListRoomSummary(
displayType = displayType, displayType = displayType,
canonicalAlias = canonicalAlias, canonicalAlias = canonicalAlias,
heroes = heroes.toImmutableList(), heroes = heroes.toImmutableList(),
isTombstoned = isTombstoned,
) )

View file

@ -84,6 +84,7 @@ internal fun createRoomListRoomSummary(
displayType: RoomSummaryDisplayType = RoomSummaryDisplayType.ROOM, displayType: RoomSummaryDisplayType = RoomSummaryDisplayType.ROOM,
heroes: List<AvatarData> = emptyList(), heroes: List<AvatarData> = emptyList(),
timestamp: String? = null, timestamp: String? = null,
isTombstoned: Boolean = false,
) = RoomListRoomSummary( ) = RoomListRoomSummary(
id = A_ROOM_ID.value, id = A_ROOM_ID.value,
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
@ -104,4 +105,5 @@ internal fun createRoomListRoomSummary(
inviteSender = null, inviteSender = null,
isDm = false, isDm = false,
heroes = heroes.toPersistentList(), heroes = heroes.toPersistentList(),
isTombstoned = isTombstoned,
) )

View file

@ -370,7 +370,7 @@ fun Modifier.avatarBloom(
val initialsBitmap = initialsBitmap( val initialsBitmap = initialsBitmap(
width = BloomDefaults.ENCODE_SIZE_PX.toDp(), width = BloomDefaults.ENCODE_SIZE_PX.toDp(),
height = BloomDefaults.ENCODE_SIZE_PX.toDp(), height = BloomDefaults.ENCODE_SIZE_PX.toDp(),
text = avatarData.initial, text = avatarData.initialLetter,
textColor = avatarColors.foreground, textColor = avatarColors.foreground,
backgroundColor = avatarColors.background, backgroundColor = avatarColors.background,
) )

View file

@ -7,9 +7,7 @@
package io.element.android.libraries.designsystem.components.avatar package io.element.android.libraries.designsystem.components.avatar
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
@ -21,21 +19,16 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
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 androidx.compose.ui.unit.sp
import coil3.compose.AsyncImagePainter import coil3.compose.AsyncImagePainter
import coil3.compose.SubcomposeAsyncImage import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent import coil3.compose.SubcomposeAsyncImageContent
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.text.toSp
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.designsystem.utils.CommonDrawables
import timber.log.Timber import timber.log.Timber
@ -50,21 +43,18 @@ fun Avatar(
// If true, will show initials even if avatarData.url is not null // If true, will show initials even if avatarData.url is not null
hideImage: Boolean = false, hideImage: Boolean = false,
) { ) {
val commonModifier = modifier
.size(forcedAvatarSize ?: avatarData.size.dp)
.clip(CircleShape)
if (avatarData.url.isNullOrBlank() || hideImage) { if (avatarData.url.isNullOrBlank() || hideImage) {
InitialsAvatar( InitialLetterAvatar(
avatarData = avatarData, avatarData = avatarData,
forcedAvatarSize = forcedAvatarSize, forcedAvatarSize = forcedAvatarSize,
modifier = commonModifier, modifier = modifier,
contentDescription = contentDescription, contentDescription = contentDescription,
) )
} else { } else {
ImageAvatar( ImageAvatar(
avatarData = avatarData, avatarData = avatarData,
forcedAvatarSize = forcedAvatarSize, forcedAvatarSize = forcedAvatarSize,
modifier = commonModifier, modifier = modifier,
contentDescription = contentDescription, contentDescription = contentDescription,
) )
} }
@ -77,11 +67,14 @@ private fun ImageAvatar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentDescription: String? = null, contentDescription: String? = null,
) { ) {
val size = forcedAvatarSize ?: avatarData.size.dp
SubcomposeAsyncImage( SubcomposeAsyncImage(
model = avatarData, model = avatarData,
contentDescription = contentDescription, contentDescription = contentDescription,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = modifier modifier = modifier
.size(size)
.clip(CircleShape)
) { ) {
val collectedState by painter.state.collectAsState() val collectedState by painter.state.collectAsState()
when (val state = collectedState) { when (val state = collectedState) {
@ -90,13 +83,13 @@ private fun ImageAvatar(
SideEffect { SideEffect {
Timber.e(state.result.throwable, "Error loading avatar $state\n${state.result}") Timber.e(state.result.throwable, "Error loading avatar $state\n${state.result}")
} }
InitialsAvatar( InitialLetterAvatar(
avatarData = avatarData, avatarData = avatarData,
forcedAvatarSize = forcedAvatarSize, forcedAvatarSize = forcedAvatarSize,
contentDescription = contentDescription, contentDescription = contentDescription,
) )
} }
else -> InitialsAvatar( else -> InitialLetterAvatar(
avatarData = avatarData, avatarData = avatarData,
forcedAvatarSize = forcedAvatarSize, forcedAvatarSize = forcedAvatarSize,
contentDescription = contentDescription, contentDescription = contentDescription,
@ -106,33 +99,20 @@ private fun ImageAvatar(
} }
@Composable @Composable
private fun InitialsAvatar( private fun InitialLetterAvatar(
avatarData: AvatarData, avatarData: AvatarData,
forcedAvatarSize: Dp?, forcedAvatarSize: Dp?,
contentDescription: String?, contentDescription: String?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val avatarColors = AvatarColorsProvider.provide(avatarData.id) val avatarColors = AvatarColorsProvider.provide(avatarData.id)
Box( TextAvatar(
modifier.background(color = avatarColors.background) text = avatarData.initialLetter,
) { size = forcedAvatarSize ?: avatarData.size.dp,
val fontSize = (forcedAvatarSize ?: avatarData.size.dp).toSp() / 2 colors = avatarColors,
val originalFont = ElementTheme.typography.fontHeadingMdBold contentDescription = contentDescription,
val ratio = fontSize.value / originalFont.fontSize.value modifier = modifier
val lineHeight = originalFont.lineHeight * ratio )
Text(
modifier = Modifier
.clearAndSetSemantics {
contentDescription?.let {
this.contentDescription = it
}
}
.align(Alignment.Center),
text = avatarData.initial,
style = originalFont.copy(fontSize = fontSize, lineHeight = lineHeight, letterSpacing = 0.sp),
color = avatarColors.foreground,
)
}
} }
@Preview(group = PreviewGroup.Avatars) @Preview(group = PreviewGroup.Avatars)

View file

@ -0,0 +1,127 @@
/*
* Copyright 2024 New Vector 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.designsystem.components.avatar
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
import java.util.Collections
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
private const val MAX_AVATAR_COUNT = 4
@Composable
fun AvatarCluster(
avatars: ImmutableList<AvatarData>,
modifier: Modifier = Modifier,
hideAvatarImages: Boolean = false,
contentDescription: String? = null,
) {
val limitedAvatars = avatars.take(MAX_AVATAR_COUNT)
val numberOfAvatars = limitedAvatars.size
if (numberOfAvatars == 4) {
// Swap 2 and 3 so that the 4th avatar is at the bottom right
Collections.swap(limitedAvatars, 2, 3)
}
when (numberOfAvatars) {
0 -> {
error("Unsupported number of avatars: 0")
}
1 -> {
Avatar(
avatarData = limitedAvatars[0],
modifier = modifier,
contentDescription = contentDescription,
hideImage = hideAvatarImages
)
}
else -> {
val size = limitedAvatars.first().size
val angle = 2 * Math.PI / numberOfAvatars
val offsetRadius = when (numberOfAvatars) {
2 -> size.dp.value / 4.2
3 -> size.dp.value / 4.0
4 -> size.dp.value / 3.1
else -> error("Unsupported number of heroes: $numberOfAvatars")
}
val heroAvatarSize = when (numberOfAvatars) {
2 -> size.dp / 2.2f
3 -> size.dp / 2.4f
4 -> size.dp / 2.2f
else -> error("Unsupported number of heroes: $numberOfAvatars")
}
val angleOffset = when (numberOfAvatars) {
2 -> PI
3 -> 7 * PI / 6
4 -> 13 * PI / 4
else -> error("Unsupported number of heroes: $numberOfAvatars")
}
Box(
modifier = modifier
.size(size.dp)
.semantics {
this.contentDescription = contentDescription.orEmpty()
},
contentAlignment = Alignment.Center,
) {
limitedAvatars.forEachIndexed { index, heroAvatar ->
val xOffset = (offsetRadius * cos(angle * index.toDouble() + angleOffset)).dp
val yOffset = (offsetRadius * sin(angle * index.toDouble() + angleOffset)).dp
Box(
modifier = Modifier
.size(heroAvatarSize)
.offset(
x = xOffset,
y = yOffset,
)
) {
Avatar(
avatarData = heroAvatar,
forcedAvatarSize = heroAvatarSize,
hideImage = hideAvatarImages,
)
}
}
}
}
}
}
@Preview(group = PreviewGroup.Avatars)
@Composable
internal fun AvatarClusterPreview() = ElementThemedPreview {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
for (ngOfAvatars in 1..5) {
AvatarCluster(
avatars = List(ngOfAvatars) { anAvatarData(it) }.toPersistentList(),
)
}
}
}
private fun anAvatarData(i: Int) = anAvatarData(
id = ('A' + i).toString(),
name = ('A' + i).toString()
)

View file

@ -18,7 +18,7 @@ data class AvatarData(
val url: String? = null, val url: String? = null,
val size: AvatarSize, val size: AvatarSize,
) { ) {
val initial by lazy { val initialLetter by lazy {
// For roomIds, use "#" as initial // For roomIds, use "#" as initial
(name?.takeIf { it.isNotBlank() } ?: id.takeIf { !it.startsWith("!") } ?: "#") (name?.takeIf { it.isNotBlank() } ?: id.takeIf { !it.startsWith("!") } ?: "#")
.let { dn -> .let { dn ->

View file

@ -1,140 +0,0 @@
/*
* Copyright 2024 New Vector 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.designsystem.components.avatar
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
import java.util.Collections
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
@Composable
fun CompositeAvatar(
avatarData: AvatarData,
heroes: ImmutableList<AvatarData>,
modifier: Modifier = Modifier,
hideAvatarImages: Boolean = false,
contentDescription: String? = null,
) {
if (avatarData.url != null || heroes.isEmpty()) {
Avatar(
avatarData = avatarData,
modifier = modifier,
contentDescription = contentDescription,
hideImage = hideAvatarImages
)
} else {
val limitedHeroes = heroes.take(4)
val numberOfHeroes = limitedHeroes.size
if (numberOfHeroes == 4) {
// Swap 2 and 3 so that the 4th hero is at the bottom right
Collections.swap(limitedHeroes, 2, 3)
}
when (numberOfHeroes) {
0 -> {
error("Unsupported number of heroes: 0")
}
1 -> {
Avatar(
avatarData = heroes[0],
modifier = modifier,
contentDescription = contentDescription,
hideImage = hideAvatarImages
)
}
else -> {
val angle = 2 * Math.PI / numberOfHeroes
val offsetRadius = when (numberOfHeroes) {
2 -> avatarData.size.dp.value / 4.2
3 -> avatarData.size.dp.value / 4.0
4 -> avatarData.size.dp.value / 3.1
else -> error("Unsupported number of heroes: $numberOfHeroes")
}
val heroAvatarSize = when (numberOfHeroes) {
2 -> avatarData.size.dp / 2.2f
3 -> avatarData.size.dp / 2.4f
4 -> avatarData.size.dp / 2.2f
else -> error("Unsupported number of heroes: $numberOfHeroes")
}
val angleOffset = when (numberOfHeroes) {
2 -> PI
3 -> 7 * PI / 6
4 -> 13 * PI / 4
else -> error("Unsupported number of heroes: $numberOfHeroes")
}
Box(
modifier = modifier
.size(avatarData.size.dp)
.semantics {
this.contentDescription = contentDescription.orEmpty()
},
contentAlignment = Alignment.Center,
) {
limitedHeroes.forEachIndexed { index, heroAvatar ->
val xOffset = (offsetRadius * cos(angle * index.toDouble() + angleOffset)).dp
val yOffset = (offsetRadius * sin(angle * index.toDouble() + angleOffset)).dp
Box(
modifier = Modifier
.size(heroAvatarSize)
.offset(
x = xOffset,
y = yOffset,
)
) {
Avatar(
avatarData = heroAvatar,
forcedAvatarSize = heroAvatarSize,
hideImage = hideAvatarImages,
)
}
}
}
}
}
}
}
@Preview(group = PreviewGroup.Avatars)
@Composable
internal fun CompositeAvatarPreview() = ElementThemedPreview {
val mainAvatar = anAvatarData(
id = "Zac",
name = "Zac",
size = AvatarSize.RoomListItem,
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
repeat(6) { nbOfHeroes ->
CompositeAvatar(
avatarData = mainAvatar,
heroes = List(nbOfHeroes) { aHeroAvatarData(it) }.toPersistentList(),
)
}
}
}
private fun aHeroAvatarData(i: Int) = anAvatarData(
id = ('A' + i).toString(),
name = ('A' + i).toString()
)

View file

@ -0,0 +1,48 @@
/*
* Copyright 2025 New Vector 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.designsystem.components.avatar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import kotlinx.collections.immutable.ImmutableList
@Composable
fun RoomAvatar(
avatarData: AvatarData,
heroes: ImmutableList<AvatarData>,
modifier: Modifier = Modifier,
isTombstoned: Boolean = false,
hideAvatarImage: Boolean = false,
contentDescription: String? = null,
) {
when {
isTombstoned -> {
TombstonedRoomAvatar(
size = avatarData.size,
modifier = modifier,
contentDescription = contentDescription
)
}
avatarData.url != null || heroes.isEmpty() -> {
Avatar(
avatarData = avatarData,
modifier = modifier,
contentDescription = contentDescription,
hideImage = hideAvatarImage
)
}
else -> {
AvatarCluster(
avatars = heroes,
modifier = modifier,
hideAvatarImages = hideAvatarImage,
contentDescription = contentDescription
)
}
}
}

View file

@ -0,0 +1,76 @@
/*
* Copyright 2025 New Vector 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.designsystem.components.avatar
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.compound.theme.AvatarColors
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.text.toSp
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
internal fun TextAvatar(
text: String,
size: Dp,
colors: AvatarColors,
contentDescription: String?,
modifier: Modifier = Modifier,
) {
Box(
modifier
.size(size)
.clip(CircleShape)
.background(color = colors.background)
) {
val fontSize = size.toSp() / 2
val originalFont = ElementTheme.typography.fontHeadingMdBold
val ratio = fontSize.value / originalFont.fontSize.value
val lineHeight = originalFont.lineHeight * ratio
Text(
modifier = Modifier
.clearAndSetSemantics {
contentDescription?.let {
this.contentDescription = it
}
}
.align(Alignment.Center),
text = text,
style = originalFont.copy(fontSize = fontSize, lineHeight = lineHeight, letterSpacing = 0.sp),
color = colors.foreground,
)
}
}
@Preview(group = PreviewGroup.Avatars)
@Composable
internal fun TextAvatarPreview() = ElementPreview {
TextAvatar(
text = "AB",
size = 40.dp,
colors = AvatarColors(
background = ElementTheme.colors.bgSubtlePrimary,
foreground = ElementTheme.colors.iconPrimary,
),
contentDescription = null,
)
}

View file

@ -0,0 +1,43 @@
/*
* Copyright 2025 New Vector 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.designsystem.components.avatar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import io.element.android.compound.theme.AvatarColors
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
@Composable
fun TombstonedRoomAvatar(
size: AvatarSize,
modifier: Modifier = Modifier,
contentDescription: String? = null,
) {
TextAvatar(
text = "!",
size = size.dp,
colors = AvatarColors(
background = ElementTheme.colors.bgSubtlePrimary,
foreground = ElementTheme.colors.iconTertiary
),
modifier = modifier,
contentDescription = contentDescription
)
}
@Preview(group = PreviewGroup.Avatars)
@Composable
internal fun TombstonedRoomAvatarPreview() = ElementPreview {
TombstonedRoomAvatar(
size = AvatarSize.RoomListItem,
contentDescription = null,
)
}

View file

@ -14,30 +14,30 @@ class AvatarDataTest {
@Test @Test
fun `initial with text should get the first char, uppercased`() { fun `initial with text should get the first char, uppercased`() {
val data = AvatarData("id", "test", null, AvatarSize.InviteSender) val data = AvatarData("id", "test", null, AvatarSize.InviteSender)
assertThat(data.initial).isEqualTo("T") assertThat(data.initialLetter).isEqualTo("T")
} }
@Test @Test
fun `initial with leading whitespace should get the first non-whitespace char, uppercased`() { fun `initial with leading whitespace should get the first non-whitespace char, uppercased`() {
val data = AvatarData("id", " test", null, AvatarSize.InviteSender) val data = AvatarData("id", " test", null, AvatarSize.InviteSender)
assertThat(data.initial).isEqualTo("T") assertThat(data.initialLetter).isEqualTo("T")
} }
@Test @Test
fun `initial with long emoji should get the full emoji`() { fun `initial with long emoji should get the full emoji`() {
val data = AvatarData("id", "\uD83C\uDFF3\uFE0F\u200D\uD83C\uDF08 Test", null, AvatarSize.InviteSender) val data = AvatarData("id", "\uD83C\uDFF3\uFE0F\u200D\uD83C\uDF08 Test", null, AvatarSize.InviteSender)
assertThat(data.initial).isEqualTo("\uD83C\uDFF3\uFE0F\u200D\uD83C\uDF08") assertThat(data.initialLetter).isEqualTo("\uD83C\uDFF3\uFE0F\u200D\uD83C\uDF08")
} }
@Test @Test
fun `initial with short emoji should get the emoji`() { fun `initial with short emoji should get the emoji`() {
val data = AvatarData("id", "✂ Test", null, AvatarSize.InviteSender) val data = AvatarData("id", "✂ Test", null, AvatarSize.InviteSender)
assertThat(data.initial).isEqualTo("") assertThat(data.initialLetter).isEqualTo("")
} }
@Test @Test
fun `initial with a single letter should take that letter`() { fun `initial with a single letter should take that letter`() {
val data = AvatarData("id", "T", null, AvatarSize.InviteSender) val data = AvatarData("id", "T", null, AvatarSize.InviteSender)
assertThat(data.initial).isEqualTo("T") assertThat(data.initialLetter).isEqualTo("T")
} }
} }

View file

@ -30,10 +30,12 @@ fun aSelectRoomInfo(
canonicalAlias: RoomAlias? = null, canonicalAlias: RoomAlias? = null,
avatarUrl: String? = null, avatarUrl: String? = null,
heroes: ImmutableList<MatrixUser> = persistentListOf(), heroes: ImmutableList<MatrixUser> = persistentListOf(),
isTombstoned: Boolean = false,
) = SelectRoomInfo( ) = SelectRoomInfo(
roomId = roomId, roomId = roomId,
name = name, name = name,
canonicalAlias = canonicalAlias, canonicalAlias = canonicalAlias,
avatarUrl = avatarUrl, avatarUrl = avatarUrl,
heroes = heroes, heroes = heroes,
isTombstoned = isTombstoned,
) )

View file

@ -29,7 +29,7 @@ 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
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.CompositeAvatar import io.element.android.libraries.designsystem.components.avatar.RoomAvatar
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
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
@ -53,9 +53,10 @@ fun SelectedRoom(
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
CompositeAvatar( RoomAvatar(
avatarData = roomInfo.getAvatarData(AvatarSize.SelectedRoom), avatarData = roomInfo.getAvatarData(AvatarSize.SelectedRoom),
heroes = roomInfo.heroes.map { it.getAvatarData(AvatarSize.SelectedRoom) }.toImmutableList(), heroes = roomInfo.heroes.map { it.getAvatarData(AvatarSize.SelectedRoom) }.toImmutableList(),
isTombstoned = roomInfo.isTombstoned,
) )
Text( Text(
// If name is null, we do not have space to render "No room name", so just use `#` here. // If name is null, we do not have space to render "No room name", so just use `#` here.

View file

@ -21,6 +21,7 @@ data class SelectRoomInfo(
val canonicalAlias: RoomAlias?, val canonicalAlias: RoomAlias?,
val avatarUrl: String?, val avatarUrl: String?,
val heroes: ImmutableList<MatrixUser>, val heroes: ImmutableList<MatrixUser>,
val isTombstoned: Boolean,
) { ) {
fun getAvatarData(size: AvatarSize) = AvatarData( fun getAvatarData(size: AvatarSize) = AvatarData(
id = roomId.value, id = roomId.value,
@ -36,4 +37,5 @@ fun RoomSummary.toSelectRoomInfo() = SelectRoomInfo(
avatarUrl = info.avatarUrl, avatarUrl = info.avatarUrl,
heroes = info.heroes, heroes = info.heroes,
canonicalAlias = info.canonicalAlias, canonicalAlias = info.canonicalAlias,
isTombstoned = info.successorRoom != null,
) )

View file

@ -33,7 +33,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
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.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.CompositeAvatar import io.element.android.libraries.designsystem.components.avatar.RoomAvatar
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
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
@ -214,11 +214,12 @@ private fun RoomSummaryView(
.heightIn(56.dp), .heightIn(56.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
CompositeAvatar( RoomAvatar(
avatarData = roomInfo.getAvatarData(size = AvatarSize.RoomSelectRoomListItem), avatarData = roomInfo.getAvatarData(size = AvatarSize.RoomSelectRoomListItem),
heroes = roomInfo.heroes.map { user -> heroes = roomInfo.heroes.map { user ->
user.getAvatarData(size = AvatarSize.RoomSelectRoomListItem) user.getAvatarData(size = AvatarSize.RoomSelectRoomListItem)
}.toPersistentList() }.toPersistentList(),
isTombstoned = roomInfo.isTombstoned,
) )
Column( Column(
modifier = Modifier modifier = Modifier

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:19f6f6dcdf49e9ffd2fbb0ea81c3fe0374faf79cecccc812ea1e1d8c05578671 oid sha256:8823f1cca4a240779ade39486854332689d114efe2d2213a9ab0720113c7f04b
size 22344 size 22353

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:12266ee1fc3477aec0f47e731294ccd2f93ecf4a3a1fada6cf4cb8ba6f7430ac oid sha256:49916050cd93d9b4aa4a90aa3ecd19bec6095e8b7cb472a3543867c0ce6f9ffa
size 22661 size 22783

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:79e6ab4ac13eeb03cb02843a122b519a6410aeaad40e5f272d0b09f2547af6e7 oid sha256:45ead771fc133b1c992d090f35c93f0de4166b52966b818d8a401e75e5b63caf
size 62973 size 62982

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:62475d0e6b37cff42980135438faeccebe74bc110f517c12616351be8469c448 oid sha256:4493799d889522a540435c10f0bf3642a438c38d6b219491afff4a72a169ce1c
size 55208 size 58416

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3241f342c62665d1a343d085ec9174e0e6555219ce51e74f4199bea74f641183 oid sha256:5864ca4671dee1caef50d0444ffdeb0c7d2a5d9ed28ebc64937efbc469f6031a
size 65429 size 65288

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f4209f327f3a7cb3fbb0de25230604857e9d0e39b7ca0b2bec948656f08b2e08 oid sha256:b44213fdccb6b194466d3daa7474b77378f96ab9b35559c0ba392dde49ecf175
size 54804 size 58055

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:60571dc08867db9ad2f6528e95cc65df0eb57b9840bf8e96e6e1fbd802ad3a26 oid sha256:f3cc4924fbbb2fea5937bf481b1322da14e1d2c8ea2cf4351fd6d0d222357738
size 38778 size 41304

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:69d9aaf46305802861df8be0341abd73068376893bfd7aef43d7b8f3251350dd oid sha256:60571dc08867db9ad2f6528e95cc65df0eb57b9840bf8e96e6e1fbd802ad3a26
size 38735 size 38778

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:cabfc7c5bf6f9b612e933cad211b8261b236cd981488cfa081e0226940626339 oid sha256:63c75fdca8960f015caaa11bbb970c6e7621e87afbe7761d23db875664c55cd1
size 39499 size 42360

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:cf96db08ac0382185c34863b38603fe55eb3aab691fa3c42b6d73e4167ce8f82 oid sha256:cabfc7c5bf6f9b612e933cad211b8261b236cd981488cfa081e0226940626339
size 39383 size 39499

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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