Introduce AvatarType to be able to render space avatars

This commit is contained in:
Benoit Marty 2025-06-23 17:08:14 +02:00
parent d524c23b8d
commit 8df920a3ac
22 changed files with 337 additions and 78 deletions

View file

@ -40,6 +40,7 @@ import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize
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.async.AsyncActionViewDefaults import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults
import io.element.android.libraries.designsystem.components.avatar.AvatarType
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.list.ListItemContent import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.modifiers.clearFocusOnTap import io.element.android.libraries.designsystem.modifiers.clearFocusOnTap
@ -219,6 +220,7 @@ private fun RoomNameWithAvatar(
) { ) {
UnsavedAvatar( UnsavedAvatar(
avatarUri = avatarUri, avatarUri = avatarUri,
avatarType = AvatarType.Room(),
modifier = Modifier.clickable(onClick = onAvatarClick), modifier = Modifier.clickable(onClick = onAvatarClick),
) )

View file

@ -53,6 +53,7 @@ import io.element.android.libraries.designsystem.components.BigIcon
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.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
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.AvatarType
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.SuperButton import io.element.android.libraries.designsystem.components.button.SuperButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
@ -92,7 +93,11 @@ fun JoinRoomView(
vertical = 32.dp vertical = 32.dp
), ),
topBar = { topBar = {
JoinRoomTopBar(contentState = state.contentState, onBackClick = onBackClick) JoinRoomTopBar(
contentState = state.contentState,
hideAvatarImage = state.hideAvatarsImages,
onBackClick = onBackClick,
)
}, },
content = { content = {
JoinRoomContent( JoinRoomContent(
@ -490,7 +495,11 @@ private fun DefaultLoadedContent(
RoomPreviewOrganism( RoomPreviewOrganism(
modifier = modifier, modifier = modifier,
avatar = { avatar = {
Avatar(contentState.avatarData(AvatarSize.RoomHeader), hideImage = hideAvatarImage) Avatar(
contentState.avatarData(AvatarSize.RoomHeader),
hideImage = hideAvatarImage,
avatarType = AvatarType.Room(),
)
}, },
title = { title = {
if (contentState.name != null) { if (contentState.name != null) {
@ -545,6 +554,7 @@ private fun DefaultLoadedContent(
@Composable @Composable
private fun JoinRoomTopBar( private fun JoinRoomTopBar(
contentState: ContentState, contentState: ContentState,
hideAvatarImage: Boolean,
onBackClick: () -> Unit, onBackClick: () -> Unit,
) { ) {
TopAppBar( TopAppBar(
@ -561,7 +571,11 @@ private fun JoinRoomTopBar(
modifier = titleModifier, modifier = titleModifier,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Avatar(avatarData = contentState.avatarData(AvatarSize.TimelineRoom)) Avatar(
avatarData = contentState.avatarData(AvatarSize.TimelineRoom),
hideImage = hideAvatarImage,
avatarType = AvatarType.Room(),
)
Text( Text(
modifier = Modifier.padding(horizontal = 8.dp), modifier = Modifier.padding(horizontal = 8.dp),
text = contentState.name, text = contentState.name,

View file

@ -83,8 +83,9 @@ 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.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.RoomAvatar import io.element.android.libraries.designsystem.components.avatar.AvatarType
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
@ -563,10 +564,12 @@ private fun RoomAvatarAndNameRow(
modifier = modifier, modifier = modifier,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
RoomAvatar( Avatar(
avatarData = roomAvatar, avatarData = roomAvatar,
heroes = heroes, avatarType = AvatarType.Room(
isTombstoned = isTombstoned, heroes = heroes,
isTombstoned = isTombstoned,
),
) )
Text( Text(
modifier = Modifier.padding(horizontal = 8.dp), modifier = Modifier.padding(horizontal = 8.dp),

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.AvatarType
import io.element.android.libraries.designsystem.components.avatar.anAvatarData 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
@ -98,6 +99,11 @@ private fun SuggestionItemView(
is ResolvedSuggestion.Member -> suggestion.roomMember.getAvatarData(avatarSize) is ResolvedSuggestion.Member -> suggestion.roomMember.getAvatarData(avatarSize)
is ResolvedSuggestion.Alias -> suggestion.getAvatarData(avatarSize) is ResolvedSuggestion.Alias -> suggestion.getAvatarData(avatarSize)
} }
val avatarType = when (suggestion) {
is ResolvedSuggestion.Alias -> AvatarType.Room()
ResolvedSuggestion.AtRoom,
is ResolvedSuggestion.Member -> AvatarType.User
}
val title = when (suggestion) { val title = 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.displayName
@ -109,7 +115,12 @@ private fun SuggestionItemView(
is ResolvedSuggestion.Alias -> suggestion.roomAlias.value is ResolvedSuggestion.Alias -> suggestion.roomAlias.value
} }
Avatar(avatarData = avatarData, modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 12.dp)) Avatar(
avatarData = avatarData,
avatarType = avatarType,
modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 12.dp),
)
Column( Column(
modifier = Modifier modifier = Modifier

View file

@ -16,7 +16,8 @@ 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.RoomAvatar import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarType
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,9 +98,11 @@ fun EditDefaultNotificationSettingView(
Text(text = subtitle) Text(text = subtitle)
}, },
leadingContent = ListItemContent.Custom { leadingContent = ListItemContent.Custom {
RoomAvatar( Avatar(
avatarData = summary.avatarData, avatarData = summary.avatarData,
heroes = summary.heroesAvatar, avatarType = AvatarType.Room(
heroes = summary.heroesAvatar,
),
) )
}, },
onClick = { onClick = {

View file

@ -32,6 +32,7 @@ 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.async.AsyncActionViewDefaults import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults
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.AvatarType
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.modifiers.clearFocusOnTap import io.element.android.libraries.designsystem.modifiers.clearFocusOnTap
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
@ -101,6 +102,7 @@ fun EditUserProfileView(
displayName = state.displayName, displayName = state.displayName,
avatarUrl = state.userAvatarUrl, avatarUrl = state.userAvatarUrl,
avatarSize = AvatarSize.EditProfileDetails, avatarSize = AvatarSize.EditProfileDetails,
avatarType = AvatarType.User,
onAvatarClick = { onAvatarClick() }, onAvatarClick = { onAvatarClick() },
modifier = Modifier.align(Alignment.CenterHorizontally), modifier = Modifier.align(Alignment.CenterHorizontally),
) )

View file

@ -48,10 +48,11 @@ import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.atomic.atoms.MatrixBadgeAtom import io.element.android.libraries.designsystem.atomic.atoms.MatrixBadgeAtom
import io.element.android.libraries.designsystem.atomic.molecules.MatrixBadgeRowMolecule import io.element.android.libraries.designsystem.atomic.molecules.MatrixBadgeRowMolecule
import io.element.android.libraries.designsystem.components.ClickableLinkText import io.element.android.libraries.designsystem.components.ClickableLinkText
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.AvatarType
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
@ -399,12 +400,14 @@ private fun RoomHeaderSection(
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
RoomAvatar( Avatar(
avatarData = AvatarData(roomId.value, roomName, avatarUrl, AvatarSize.RoomHeader), avatarData = AvatarData(roomId.value, roomName, avatarUrl, AvatarSize.RoomHeader),
heroes = heroes.map { user -> avatarType = AvatarType.Room(
user.getAvatarData(size = AvatarSize.RoomHeader) heroes = heroes.map { user ->
}.toPersistentList(), user.getAvatarData(size = AvatarSize.RoomHeader)
isTombstoned = isTombstoned, }.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

@ -34,6 +34,7 @@ import io.element.android.features.roomdetails.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.async.AsyncActionViewDefaults import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults
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.AvatarType
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.modifiers.clearFocusOnTap import io.element.android.libraries.designsystem.modifiers.clearFocusOnTap
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
@ -103,6 +104,7 @@ fun RoomDetailsEditView(
displayName = state.roomRawName, displayName = state.roomRawName,
avatarUrl = state.roomAvatarUrl, avatarUrl = state.roomAvatarUrl,
avatarSize = AvatarSize.EditRoomDetails, avatarSize = AvatarSize.EditRoomDetails,
avatarType = AvatarType.Room(),
onAvatarClick = ::onAvatarClick, onAvatarClick = ::onAvatarClick,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )

View file

@ -40,6 +40,7 @@ import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.features.roomdirectory.impl.R import io.element.android.features.roomdirectory.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.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
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
@ -248,7 +249,8 @@ private fun RoomDirectoryRoomRow(
) { ) {
Avatar( Avatar(
avatarData = roomDescription.avatarData(AvatarSize.RoomDirectoryItem), avatarData = roomDescription.avatarData(AvatarSize.RoomDirectoryItem),
modifier = Modifier.align(Alignment.CenterVertically) avatarType = AvatarType.Room(),
modifier = Modifier.align(Alignment.CenterVertically),
) )
Column( Column(
modifier = Modifier modifier = Modifier

View file

@ -44,7 +44,8 @@ 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.RoomAvatar import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarType
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
@ -184,11 +185,13 @@ private fun RoomSummaryScaffoldRow(
.padding(horizontal = 16.dp, vertical = 11.dp) .padding(horizontal = 16.dp, vertical = 11.dp)
.height(IntrinsicSize.Min), .height(IntrinsicSize.Min),
) { ) {
RoomAvatar( Avatar(
avatarData = room.avatarData, avatarData = room.avatarData,
heroes = room.heroes, avatarType = AvatarType.Room(
isTombstoned = room.isTombstoned, heroes = room.heroes,
hideAvatarImage = hideAvatarImage, isTombstoned = room.isTombstoned,
),
hideImage = hideAvatarImage,
) )
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(16.dp))
Column( Column(

View file

@ -10,7 +10,6 @@ package io.element.android.libraries.designsystem.components.avatar
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
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.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -37,15 +36,50 @@ import timber.log.Timber
fun Avatar( fun Avatar(
avatarData: AvatarData, avatarData: AvatarData,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
avatarType: AvatarType = AvatarType.User,
contentDescription: String? = null, contentDescription: String? = null,
// If not null, will be used instead of the size from avatarData // If not null, will be used instead of the size from avatarData
forcedAvatarSize: Dp? = null, forcedAvatarSize: Dp? = null,
// 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,
) {
when (avatarType) {
is AvatarType.Room -> RoomAvatar(
avatarData = avatarData,
avatarType = avatarType,
modifier = modifier,
hideAvatarImage = hideImage,
contentDescription = contentDescription,
)
AvatarType.User -> UserAvatar(
avatarData = avatarData,
modifier = modifier,
contentDescription = contentDescription,
forcedAvatarSize = forcedAvatarSize,
hideImage = hideImage,
)
is AvatarType.Space -> SpaceAvatar(
avatarData = avatarData,
avatarType = avatarType,
modifier = modifier,
hideAvatarImage = hideImage,
contentDescription = contentDescription,
)
}
}
@Composable
private fun UserAvatar(
avatarData: AvatarData,
modifier: Modifier = Modifier,
contentDescription: String? = null,
forcedAvatarSize: Dp? = null,
hideImage: Boolean = false,
) { ) {
if (avatarData.url.isNullOrBlank() || hideImage) { if (avatarData.url.isNullOrBlank() || hideImage) {
InitialLetterAvatar( InitialLetterAvatar(
avatarData = avatarData, avatarData = avatarData,
avatarType = AvatarType.User,
forcedAvatarSize = forcedAvatarSize, forcedAvatarSize = forcedAvatarSize,
modifier = modifier, modifier = modifier,
contentDescription = contentDescription, contentDescription = contentDescription,
@ -53,6 +87,7 @@ fun Avatar(
} else { } else {
ImageAvatar( ImageAvatar(
avatarData = avatarData, avatarData = avatarData,
avatarType = AvatarType.User,
forcedAvatarSize = forcedAvatarSize, forcedAvatarSize = forcedAvatarSize,
modifier = modifier, modifier = modifier,
contentDescription = contentDescription, contentDescription = contentDescription,
@ -61,8 +96,9 @@ fun Avatar(
} }
@Composable @Composable
private fun ImageAvatar( internal fun ImageAvatar(
avatarData: AvatarData, avatarData: AvatarData,
avatarType: AvatarType,
forcedAvatarSize: Dp?, forcedAvatarSize: Dp?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentDescription: String? = null, contentDescription: String? = null,
@ -74,7 +110,7 @@ private fun ImageAvatar(
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = modifier modifier = modifier
.size(size) .size(size)
.clip(CircleShape) .clip(avatarShape(avatarType))
) { ) {
val collectedState by painter.state.collectAsState() val collectedState by painter.state.collectAsState()
when (val state = collectedState) { when (val state = collectedState) {
@ -85,12 +121,14 @@ private fun ImageAvatar(
} }
InitialLetterAvatar( InitialLetterAvatar(
avatarData = avatarData, avatarData = avatarData,
avatarType = avatarType,
forcedAvatarSize = forcedAvatarSize, forcedAvatarSize = forcedAvatarSize,
contentDescription = contentDescription, contentDescription = contentDescription,
) )
} }
else -> InitialLetterAvatar( else -> InitialLetterAvatar(
avatarData = avatarData, avatarData = avatarData,
avatarType = avatarType,
forcedAvatarSize = forcedAvatarSize, forcedAvatarSize = forcedAvatarSize,
contentDescription = contentDescription, contentDescription = contentDescription,
) )
@ -99,8 +137,9 @@ private fun ImageAvatar(
} }
@Composable @Composable
private fun InitialLetterAvatar( internal fun InitialLetterAvatar(
avatarData: AvatarData, avatarData: AvatarData,
avatarType: AvatarType,
forcedAvatarSize: Dp?, forcedAvatarSize: Dp?,
contentDescription: String?, contentDescription: String?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -109,6 +148,7 @@ private fun InitialLetterAvatar(
TextAvatar( TextAvatar(
text = avatarData.initialLetter, text = avatarData.initialLetter,
size = forcedAvatarSize ?: avatarData.size.dp, size = forcedAvatarSize ?: avatarData.size.dp,
avatarType = avatarType,
colors = avatarColors, colors = avatarColors,
contentDescription = contentDescription, contentDescription = contentDescription,
modifier = modifier modifier = modifier

View file

@ -31,9 +31,10 @@ import kotlin.math.sin
private const val MAX_AVATAR_COUNT = 4 private const val MAX_AVATAR_COUNT = 4
@Composable @Composable
fun AvatarCluster( internal fun AvatarCluster(
avatars: ImmutableList<AvatarData>, avatars: ImmutableList<AvatarData>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
avatarType: AvatarType = AvatarType.User,
hideAvatarImages: Boolean = false, hideAvatarImages: Boolean = false,
contentDescription: String? = null, contentDescription: String? = null,
) { ) {
@ -50,6 +51,7 @@ fun AvatarCluster(
1 -> { 1 -> {
Avatar( Avatar(
avatarData = limitedAvatars[0], avatarData = limitedAvatars[0],
avatarType = avatarType,
modifier = modifier, modifier = modifier,
contentDescription = contentDescription, contentDescription = contentDescription,
hideImage = hideAvatarImages hideImage = hideAvatarImages
@ -78,10 +80,10 @@ fun AvatarCluster(
} }
Box( Box(
modifier = modifier modifier = modifier
.size(size.dp) .size(size.dp)
.semantics { .semantics {
this.contentDescription = contentDescription.orEmpty() this.contentDescription = contentDescription.orEmpty()
}, },
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
limitedAvatars.forEachIndexed { index, heroAvatar -> limitedAvatars.forEachIndexed { index, heroAvatar ->
@ -89,15 +91,16 @@ fun AvatarCluster(
val yOffset = (offsetRadius * sin(angle * index.toDouble() + angleOffset)).dp val yOffset = (offsetRadius * sin(angle * index.toDouble() + angleOffset)).dp
Box( Box(
modifier = Modifier modifier = Modifier
.size(heroAvatarSize) .size(heroAvatarSize)
.offset( .offset(
x = xOffset, x = xOffset,
y = yOffset, y = yOffset,
) )
) { ) {
Avatar( Avatar(
avatarData = heroAvatar, avatarData = heroAvatar,
forcedAvatarSize = heroAvatarSize, forcedAvatarSize = heroAvatarSize,
avatarType = avatarType,
hideImage = hideAvatarImages, hideImage = hideAvatarImages,
) )
} }

View file

@ -0,0 +1,24 @@
/*
* 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.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Shape
@Composable
fun avatarShape(
avatarType: AvatarType,
): Shape {
return when (avatarType) {
is AvatarType.Space -> RoundedCornerShape(avatarType.cornerSize)
is AvatarType.Room,
AvatarType.User -> CircleShape
}
}

View file

@ -0,0 +1,25 @@
/*
* Copyright 2023, 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.ui.unit.Dp
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
sealed interface AvatarType {
data object User : AvatarType
data class Room(
val isTombstoned: Boolean = false,
val heroes: ImmutableList<AvatarData> = persistentListOf(),
) : AvatarType
data class Space(
val cornerSize: Dp,
) : AvatarType
}

View file

@ -9,36 +9,46 @@ package io.element.android.libraries.designsystem.components.avatar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import kotlinx.collections.immutable.ImmutableList
@Composable @Composable
fun RoomAvatar( internal fun RoomAvatar(
avatarData: AvatarData, avatarData: AvatarData,
heroes: ImmutableList<AvatarData>, avatarType: AvatarType.Room,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isTombstoned: Boolean = false,
hideAvatarImage: Boolean = false, hideAvatarImage: Boolean = false,
contentDescription: String? = null, contentDescription: String? = null,
) { ) {
when { when {
isTombstoned -> { avatarType.isTombstoned -> {
TombstonedRoomAvatar( TombstonedRoomAvatar(
size = avatarData.size, size = avatarData.size,
modifier = modifier, modifier = modifier,
avatarType = avatarType,
contentDescription = contentDescription contentDescription = contentDescription
) )
} }
avatarData.url != null || heroes.isEmpty() -> { avatarData.url != null || avatarType.heroes.isEmpty() -> {
Avatar( if (avatarData.url.isNullOrBlank() || hideAvatarImage) {
avatarData = avatarData, InitialLetterAvatar(
modifier = modifier, avatarData = avatarData,
contentDescription = contentDescription, avatarType = avatarType,
hideImage = hideAvatarImage modifier = modifier,
) contentDescription = contentDescription,
forcedAvatarSize = null,
)
} else {
ImageAvatar(
avatarData = avatarData,
avatarType = avatarType,
forcedAvatarSize = null,
modifier = modifier,
contentDescription = contentDescription,
)
}
} }
else -> { else -> {
AvatarCluster( AvatarCluster(
avatars = heroes, avatars = avatarType.heroes,
modifier = modifier, modifier = modifier,
hideAvatarImages = hideAvatarImage, hideAvatarImages = hideAvatarImage,
contentDescription = contentDescription contentDescription = contentDescription

View file

@ -0,0 +1,74 @@
/*
* 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.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
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 io.element.android.libraries.designsystem.utils.CommonDrawables
@Composable
fun SpaceAvatar(
avatarData: AvatarData,
avatarType: AvatarType.Space,
modifier: Modifier = Modifier,
isTombstoned: Boolean = false,
hideAvatarImage: Boolean = false,
contentDescription: String? = null,
) {
when {
isTombstoned -> TombstonedRoomAvatar(
size = avatarData.size,
avatarType = avatarType,
modifier = modifier,
contentDescription = contentDescription,
)
avatarData.url.isNullOrBlank() || hideAvatarImage -> InitialLetterAvatar(
avatarData = avatarData,
avatarType = avatarType,
modifier = modifier,
contentDescription = contentDescription,
forcedAvatarSize = null,
)
else -> ImageAvatar(
avatarData = avatarData,
avatarType = avatarType,
forcedAvatarSize = null,
modifier = modifier,
contentDescription = contentDescription,
)
}
}
@Preview(group = PreviewGroup.Avatars)
@Composable
internal fun SpaceAvatarPreview() =
ElementThemedPreview(
drawableFallbackForImages = CommonDrawables.sample_avatar,
) {
Row(
modifier = Modifier.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
SpaceAvatar(
avatarData = anAvatarData(),
avatarType = AvatarType.Space(cornerSize = 16.dp),
)
SpaceAvatar(
avatarData = anAvatarData(),
avatarType = AvatarType.Space(cornerSize = 16.dp),
isTombstoned = true,
)
}
}

View file

@ -8,9 +8,11 @@
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.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
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
@ -34,12 +36,13 @@ internal fun TextAvatar(
size: Dp, size: Dp,
colors: AvatarColors, colors: AvatarColors,
contentDescription: String?, contentDescription: String?,
avatarType: AvatarType,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Box( Box(
modifier modifier
.size(size) .size(size)
.clip(CircleShape) .clip(avatarShape(avatarType))
.background(color = colors.background) .background(color = colors.background)
) { ) {
val fontSize = size.toSp() / 2 val fontSize = size.toSp() / 2
@ -64,13 +67,25 @@ internal fun TextAvatar(
@Preview(group = PreviewGroup.Avatars) @Preview(group = PreviewGroup.Avatars)
@Composable @Composable
internal fun TextAvatarPreview() = ElementPreview { internal fun TextAvatarPreview() = ElementPreview {
TextAvatar( Row(
text = "AB", modifier = Modifier.padding(8.dp),
size = 40.dp, horizontalArrangement = Arrangement.spacedBy(8.dp),
colors = AvatarColors( ) {
background = ElementTheme.colors.bgSubtlePrimary, listOf(
foreground = ElementTheme.colors.iconPrimary, AvatarType.User,
), AvatarType.Room(),
contentDescription = null, AvatarType.Space(8.dp),
) ).forEach { avatarType ->
TextAvatar(
text = "AB",
size = 40.dp,
colors = AvatarColors(
background = ElementTheme.colors.bgSubtlePrimary,
foreground = ElementTheme.colors.iconPrimary,
),
avatarType = avatarType,
contentDescription = null,
)
}
} }
}

View file

@ -16,9 +16,10 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup import io.element.android.libraries.designsystem.preview.PreviewGroup
@Composable @Composable
fun TombstonedRoomAvatar( internal fun TombstonedRoomAvatar(
size: AvatarSize, size: AvatarSize,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
avatarType: AvatarType,
contentDescription: String? = null, contentDescription: String? = null,
) { ) {
TextAvatar( TextAvatar(
@ -29,7 +30,8 @@ fun TombstonedRoomAvatar(
foreground = ElementTheme.colors.iconTertiary foreground = ElementTheme.colors.iconTertiary
), ),
modifier = modifier, modifier = modifier,
contentDescription = contentDescription avatarType = avatarType,
contentDescription = contentDescription,
) )
} }
@ -38,6 +40,7 @@ fun TombstonedRoomAvatar(
internal fun TombstonedRoomAvatarPreview() = ElementPreview { internal fun TombstonedRoomAvatarPreview() = ElementPreview {
TombstonedRoomAvatar( TombstonedRoomAvatar(
size = AvatarSize.RoomListItem, size = AvatarSize.RoomListItem,
avatarType = AvatarType.Room(),
contentDescription = null, contentDescription = null,
) )
} }

View file

@ -35,6 +35,7 @@ import io.element.android.compound.tokens.generated.CompoundIcons
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.AvatarType
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
@ -49,6 +50,7 @@ fun EditableAvatarView(
displayName: String?, displayName: String?,
avatarUrl: Uri?, avatarUrl: Uri?,
avatarSize: AvatarSize, avatarSize: AvatarSize,
avatarType: AvatarType,
onAvatarClick: () -> Unit, onAvatarClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -74,12 +76,14 @@ fun EditableAvatarView(
null, "mxc" -> { null, "mxc" -> {
Avatar( Avatar(
avatarData = AvatarData(matrixId, displayName, avatarUrl?.toString(), size = avatarSize), avatarData = AvatarData(matrixId, displayName, avatarUrl?.toString(), size = avatarSize),
avatarType = avatarType,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} }
else -> { else -> {
UnsavedAvatar( UnsavedAvatar(
avatarUri = avatarUrl, avatarUri = avatarUrl,
avatarType = avatarType,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} }
@ -116,6 +120,7 @@ internal fun EditableAvatarViewPreview(
displayName = "A room", displayName = "A room",
avatarUrl = uri, avatarUrl = uri,
avatarSize = AvatarSize.EditRoomDetails, avatarSize = AvatarSize.EditRoomDetails,
avatarType = AvatarType.User,
onAvatarClick = {}, onAvatarClick = {},
) )
} }

View file

@ -28,8 +28,9 @@ 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.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.components.avatar.Avatar
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.RoomAvatar import io.element.android.libraries.designsystem.components.avatar.AvatarType
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,10 +54,12 @@ fun SelectedRoom(
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
RoomAvatar( Avatar(
avatarData = roomInfo.getAvatarData(AvatarSize.SelectedRoom), avatarData = roomInfo.getAvatarData(AvatarSize.SelectedRoom),
heroes = roomInfo.heroes.map { it.getAvatarData(AvatarSize.SelectedRoom) }.toImmutableList(), avatarType = AvatarType.Room(
isTombstoned = roomInfo.isTombstoned, 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

@ -9,10 +9,11 @@ package io.element.android.libraries.matrix.ui.components
import android.net.Uri import android.net.Uri
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AddAPhoto import androidx.compose.material.icons.outlined.AddAPhoto
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -27,6 +28,8 @@ import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import coil3.request.ImageRequest import coil3.request.ImageRequest
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.components.avatar.avatarShape
import io.element.android.libraries.designsystem.components.avatar.AvatarType
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
@ -40,11 +43,12 @@ import io.element.android.libraries.designsystem.theme.temporaryColorBgSpecial
@Composable @Composable
fun UnsavedAvatar( fun UnsavedAvatar(
avatarUri: Uri?, avatarUri: Uri?,
avatarType: AvatarType,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val commonModifier = modifier val commonModifier = modifier
.size(70.dp) .size(70.dp)
.clip(CircleShape) .clip(avatarShape(avatarType))
if (avatarUri != null) { if (avatarUri != null) {
val context = LocalContext.current val context = LocalContext.current
@ -75,8 +79,13 @@ fun UnsavedAvatar(
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun UnsavedAvatarPreview() = ElementPreview { internal fun UnsavedAvatarPreview() = ElementPreview {
Row { Row(
UnsavedAvatar(null) modifier = Modifier.padding(8.dp),
UnsavedAvatar(Uri.EMPTY) horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
UnsavedAvatar(null, AvatarType.User)
UnsavedAvatar(Uri.EMPTY, AvatarType.User)
UnsavedAvatar(null, AvatarType.Space(8.dp))
UnsavedAvatar(Uri.EMPTY, AvatarType.Space(8.dp))
} }
} }

View file

@ -32,8 +32,9 @@ 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 io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.components.avatar.Avatar
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.RoomAvatar import io.element.android.libraries.designsystem.components.avatar.AvatarType
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,12 +215,14 @@ private fun RoomSummaryView(
.heightIn(56.dp), .heightIn(56.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
RoomAvatar( Avatar(
avatarData = roomInfo.getAvatarData(size = AvatarSize.RoomSelectRoomListItem), avatarData = roomInfo.getAvatarData(size = AvatarSize.RoomSelectRoomListItem),
heroes = roomInfo.heroes.map { user -> avatarType = AvatarType.Room(
user.getAvatarData(size = AvatarSize.RoomSelectRoomListItem) heroes = roomInfo.heroes.map { user ->
}.toPersistentList(), user.getAvatarData(size = AvatarSize.RoomSelectRoomListItem)
isTombstoned = roomInfo.isTombstoned, }.toPersistentList(),
isTombstoned = roomInfo.isTombstoned,
),
) )
Column( Column(
modifier = Modifier modifier = Modifier