Merge pull request #4921 from element-hq/feature/bma/spaceAvatar

Iterate on avatar to be able to render Space avatar.
This commit is contained in:
Benoit Marty 2025-06-23 19:01:33 +02:00 committed by GitHub
commit 21e04e1fb3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 437 additions and 149 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.components.async.AsyncActionView
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.list.ListItemContent
import io.element.android.libraries.designsystem.modifiers.clearFocusOnTap
@ -219,6 +220,7 @@ private fun RoomNameWithAvatar(
) {
UnsavedAvatar(
avatarUri = avatarUri,
avatarType = AvatarType.Room(),
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.avatar.Avatar
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.SuperButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
@ -92,7 +93,11 @@ fun JoinRoomView(
vertical = 32.dp
),
topBar = {
JoinRoomTopBar(contentState = state.contentState, onBackClick = onBackClick)
JoinRoomTopBar(
contentState = state.contentState,
hideAvatarImage = state.hideAvatarsImages,
onBackClick = onBackClick,
)
},
content = {
JoinRoomContent(
@ -490,7 +495,11 @@ private fun DefaultLoadedContent(
RoomPreviewOrganism(
modifier = modifier,
avatar = {
Avatar(contentState.avatarData(AvatarSize.RoomHeader), hideImage = hideAvatarImage)
Avatar(
contentState.avatarData(AvatarSize.RoomHeader),
hideImage = hideAvatarImage,
avatarType = AvatarType.Room(),
)
},
title = {
if (contentState.name != null) {
@ -545,6 +554,7 @@ private fun DefaultLoadedContent(
@Composable
private fun JoinRoomTopBar(
contentState: ContentState,
hideAvatarImage: Boolean,
onBackClick: () -> Unit,
) {
TopAppBar(
@ -561,7 +571,11 @@ private fun JoinRoomTopBar(
modifier = titleModifier,
verticalAlignment = Alignment.CenterVertically
) {
Avatar(avatarData = contentState.avatarData(AvatarSize.TimelineRoom))
Avatar(
avatarData = contentState.avatarData(AvatarSize.TimelineRoom),
hideImage = hideAvatarImage,
avatarType = AvatarType.Room(),
)
Text(
modifier = Modifier.padding(horizontal = 8.dp),
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.libraries.androidutils.ui.hideKeyboard
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.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.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
@ -563,10 +564,12 @@ private fun RoomAvatarAndNameRow(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
) {
RoomAvatar(
Avatar(
avatarData = roomAvatar,
heroes = heroes,
isTombstoned = isTombstoned,
avatarType = AvatarType.Room(
heroes = heroes,
isTombstoned = isTombstoned,
),
)
Text(
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.AvatarData
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.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -98,6 +99,11 @@ private fun SuggestionItemView(
is ResolvedSuggestion.Member -> suggestion.roomMember.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) {
is ResolvedSuggestion.AtRoom -> stringResource(R.string.screen_room_mentions_at_room_title)
is ResolvedSuggestion.Member -> suggestion.roomMember.displayName
@ -108,9 +114,11 @@ private fun SuggestionItemView(
is ResolvedSuggestion.Member -> suggestion.roomMember.userId.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(
modifier = Modifier
.fillMaxWidth()

View file

@ -16,7 +16,8 @@ import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.preferences.impl.R
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.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
@ -97,9 +98,11 @@ fun EditDefaultNotificationSettingView(
Text(text = subtitle)
},
leadingContent = ListItemContent.Custom {
RoomAvatar(
Avatar(
avatarData = summary.avatarData,
heroes = summary.heroesAvatar,
avatarType = AvatarType.Room(
heroes = summary.heroesAvatar,
),
)
},
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.AsyncActionViewDefaults
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.modifiers.clearFocusOnTap
import io.element.android.libraries.designsystem.preview.ElementPreview
@ -101,6 +102,7 @@ fun EditUserProfileView(
displayName = state.displayName,
avatarUrl = state.userAvatarUrl,
avatarSize = AvatarSize.EditProfileDetails,
avatarType = AvatarType.User,
onAvatarClick = { onAvatarClick() },
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.molecules.MatrixBadgeRowMolecule
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.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.RoomAvatar
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.list.ListItemContent
@ -399,12 +400,14 @@ private fun RoomHeaderSection(
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
RoomAvatar(
Avatar(
avatarData = AvatarData(roomId.value, roomName, avatarUrl, AvatarSize.RoomHeader),
heroes = heroes.map { user ->
user.getAvatarData(size = AvatarSize.RoomHeader)
}.toPersistentList(),
isTombstoned = isTombstoned,
avatarType = AvatarType.Room(
heroes = heroes.map { user ->
user.getAvatarData(size = AvatarSize.RoomHeader)
}.toPersistentList(),
isTombstoned = isTombstoned,
),
modifier = Modifier
.clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) }
.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.AsyncActionViewDefaults
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.modifiers.clearFocusOnTap
import io.element.android.libraries.designsystem.preview.ElementPreview
@ -103,6 +104,7 @@ fun RoomDetailsEditView(
displayName = state.roomRawName,
avatarUrl = state.roomAvatarUrl,
avatarSize = AvatarSize.EditRoomDetails,
avatarType = AvatarType.Room(),
onAvatarClick = ::onAvatarClick,
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.libraries.designsystem.components.avatar.Avatar
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.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -248,7 +249,8 @@ private fun RoomDirectoryRoomRow(
) {
Avatar(
avatarData = roomDescription.avatarData(AvatarSize.RoomDirectoryItem),
modifier = Modifier.align(Alignment.CenterVertically)
avatarType = AvatarType.Room(),
modifier = Modifier.align(Alignment.CenterVertically),
)
Column(
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.libraries.core.extensions.orEmpty
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.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
@ -184,11 +185,13 @@ private fun RoomSummaryScaffoldRow(
.padding(horizontal = 16.dp, vertical = 11.dp)
.height(IntrinsicSize.Min),
) {
RoomAvatar(
Avatar(
avatarData = room.avatarData,
heroes = room.heroes,
isTombstoned = room.isTombstoned,
hideAvatarImage = hideAvatarImage,
avatarType = AvatarType.Room(
heroes = room.heroes,
isTombstoned = room.isTombstoned,
),
hideImage = hideAvatarImage,
)
Spacer(modifier = Modifier.width(16.dp))
Column(

View file

@ -9,43 +9,66 @@ 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.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImagePainter
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables
import timber.log.Timber
@Composable
fun Avatar(
avatarData: AvatarData,
modifier: Modifier = Modifier,
avatarType: AvatarType = AvatarType.User,
contentDescription: String? = null,
// If not null, will be used instead of the size from avatarData
forcedAvatarSize: Dp? = null,
// If true, will show initials even if avatarData.url is not null
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) {
InitialLetterAvatar(
avatarData = avatarData,
avatarType = AvatarType.User,
forcedAvatarSize = forcedAvatarSize,
modifier = modifier,
contentDescription = contentDescription,
@ -53,6 +76,7 @@ fun Avatar(
} else {
ImageAvatar(
avatarData = avatarData,
avatarType = AvatarType.User,
forcedAvatarSize = forcedAvatarSize,
modifier = modifier,
contentDescription = contentDescription,
@ -60,61 +84,6 @@ fun Avatar(
}
}
@Composable
private fun ImageAvatar(
avatarData: AvatarData,
forcedAvatarSize: Dp?,
modifier: Modifier = Modifier,
contentDescription: String? = null,
) {
val size = forcedAvatarSize ?: avatarData.size.dp
SubcomposeAsyncImage(
model = avatarData,
contentDescription = contentDescription,
contentScale = ContentScale.Crop,
modifier = modifier
.size(size)
.clip(CircleShape)
) {
val collectedState by painter.state.collectAsState()
when (val state = collectedState) {
is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent()
is AsyncImagePainter.State.Error -> {
SideEffect {
Timber.e(state.result.throwable, "Error loading avatar $state\n${state.result}")
}
InitialLetterAvatar(
avatarData = avatarData,
forcedAvatarSize = forcedAvatarSize,
contentDescription = contentDescription,
)
}
else -> InitialLetterAvatar(
avatarData = avatarData,
forcedAvatarSize = forcedAvatarSize,
contentDescription = contentDescription,
)
}
}
}
@Composable
private fun InitialLetterAvatar(
avatarData: AvatarData,
forcedAvatarSize: Dp?,
contentDescription: String?,
modifier: Modifier = Modifier,
) {
val avatarColors = AvatarColorsProvider.provide(avatarData.id)
TextAvatar(
text = avatarData.initialLetter,
size = forcedAvatarSize ?: avatarData.size.dp,
colors = avatarColors,
contentDescription = contentDescription,
modifier = modifier
)
}
@Preview(group = PreviewGroup.Avatars)
@Composable
internal fun AvatarPreview(@PreviewParameter(AvatarDataProvider::class) avatarData: AvatarData) =

View file

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

View file

@ -0,0 +1,22 @@
/*
* 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 AvatarType.avatarShape(): Shape {
return when (this) {
is AvatarType.Space -> RoundedCornerShape(cornerSize)
is AvatarType.Room,
AvatarType.User -> CircleShape
}
}

View file

@ -0,0 +1,28 @@
/*
* 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.runtime.Immutable
import androidx.compose.ui.unit.Dp
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Immutable
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,
val isTombstoned: Boolean = false,
) : AvatarType
}

View file

@ -0,0 +1,66 @@
/*
* 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.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp
import coil3.compose.AsyncImagePainter
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
import timber.log.Timber
@Composable
internal fun ImageAvatar(
avatarData: AvatarData,
avatarType: AvatarType,
forcedAvatarSize: Dp?,
modifier: Modifier = Modifier.Companion,
contentDescription: String? = null,
) {
val size = forcedAvatarSize ?: avatarData.size.dp
SubcomposeAsyncImage(
model = avatarData,
contentDescription = contentDescription,
contentScale = ContentScale.Companion.Crop,
modifier = modifier
.size(size)
.clip(avatarType.avatarShape())
) {
val collectedState by painter.state.collectAsState()
when (val state = collectedState) {
is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent()
is AsyncImagePainter.State.Error -> {
SideEffect {
Timber.Forest.e(
state.result.throwable,
"Error loading avatar $state\n${state.result}"
)
}
InitialLetterAvatar(
avatarData = avatarData,
avatarType = avatarType,
forcedAvatarSize = forcedAvatarSize,
contentDescription = contentDescription,
)
}
else -> InitialLetterAvatar(
avatarData = avatarData,
avatarType = avatarType,
forcedAvatarSize = forcedAvatarSize,
contentDescription = contentDescription,
)
}
}
}

View file

@ -0,0 +1,32 @@
/*
* 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.unit.Dp
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
@Composable
internal fun InitialLetterAvatar(
avatarData: AvatarData,
avatarType: AvatarType,
forcedAvatarSize: Dp?,
contentDescription: String?,
modifier: Modifier = Modifier.Companion,
) {
val avatarColors = AvatarColorsProvider.provide(avatarData.id)
TextAvatar(
text = avatarData.initialLetter,
size = forcedAvatarSize ?: avatarData.size.dp,
avatarType = avatarType,
colors = avatarColors,
contentDescription = contentDescription,
modifier = modifier
)
}

View file

@ -9,36 +9,46 @@ 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(
internal fun RoomAvatar(
avatarData: AvatarData,
heroes: ImmutableList<AvatarData>,
avatarType: AvatarType.Room,
modifier: Modifier = Modifier,
isTombstoned: Boolean = false,
hideAvatarImage: Boolean = false,
contentDescription: String? = null,
) {
when {
isTombstoned -> {
avatarType.isTombstoned -> {
TombstonedRoomAvatar(
size = avatarData.size,
modifier = modifier,
avatarType = avatarType,
contentDescription = contentDescription
)
}
avatarData.url != null || heroes.isEmpty() -> {
Avatar(
avatarData = avatarData,
modifier = modifier,
contentDescription = contentDescription,
hideImage = hideAvatarImage
)
avatarData.url != null || avatarType.heroes.isEmpty() -> {
if (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,
)
}
}
else -> {
AvatarCluster(
avatars = heroes,
avatars = avatarType.heroes,
modifier = modifier,
hideAvatarImages = hideAvatarImage,
contentDescription = contentDescription

View file

@ -0,0 +1,75 @@
/*
* 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,
hideAvatarImage: Boolean = false,
contentDescription: String? = null,
) {
when {
avatarType.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
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
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.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -34,12 +36,13 @@ internal fun TextAvatar(
size: Dp,
colors: AvatarColors,
contentDescription: String?,
avatarType: AvatarType,
modifier: Modifier = Modifier,
) {
Box(
modifier
.size(size)
.clip(CircleShape)
.clip(avatarType.avatarShape())
.background(color = colors.background)
) {
val fontSize = size.toSp() / 2
@ -64,13 +67,25 @@ internal fun TextAvatar(
@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,
)
Row(
modifier = Modifier.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
listOf(
AvatarType.User,
AvatarType.Room(),
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,8 +16,9 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
@Composable
fun TombstonedRoomAvatar(
internal fun TombstonedRoomAvatar(
size: AvatarSize,
avatarType: AvatarType,
modifier: Modifier = Modifier,
contentDescription: String? = null,
) {
@ -29,7 +30,8 @@ fun TombstonedRoomAvatar(
foreground = ElementTheme.colors.iconTertiary
),
modifier = modifier,
contentDescription = contentDescription
avatarType = avatarType,
contentDescription = contentDescription,
)
}
@ -38,6 +40,7 @@ fun TombstonedRoomAvatar(
internal fun TombstonedRoomAvatarPreview() = ElementPreview {
TombstonedRoomAvatar(
size = AvatarSize.RoomListItem,
avatarType = AvatarType.Room(),
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.AvatarData
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.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
@ -49,6 +50,7 @@ fun EditableAvatarView(
displayName: String?,
avatarUrl: Uri?,
avatarSize: AvatarSize,
avatarType: AvatarType,
onAvatarClick: () -> Unit,
modifier: Modifier = Modifier,
) {
@ -74,12 +76,14 @@ fun EditableAvatarView(
null, "mxc" -> {
Avatar(
avatarData = AvatarData(matrixId, displayName, avatarUrl?.toString(), size = avatarSize),
avatarType = avatarType,
modifier = Modifier.fillMaxSize(),
)
}
else -> {
UnsavedAvatar(
avatarUri = avatarUrl,
avatarType = avatarType,
modifier = Modifier.fillMaxSize(),
)
}
@ -116,6 +120,7 @@ internal fun EditableAvatarViewPreview(
displayName = "A room",
avatarUrl = uri,
avatarSize = AvatarSize.EditRoomDetails,
avatarType = AvatarType.User,
onAvatarClick = {},
)
}

View file

@ -28,8 +28,9 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
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.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.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
@ -53,10 +54,12 @@ fun SelectedRoom(
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
RoomAvatar(
Avatar(
avatarData = roomInfo.getAvatarData(AvatarSize.SelectedRoom),
heroes = roomInfo.heroes.map { it.getAvatarData(AvatarSize.SelectedRoom) }.toImmutableList(),
isTombstoned = roomInfo.isTombstoned,
avatarType = AvatarType.Room(
heroes = roomInfo.heroes.map { it.getAvatarData(AvatarSize.SelectedRoom) }.toImmutableList(),
isTombstoned = roomInfo.isTombstoned,
),
)
Text(
// 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 androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
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.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AddAPhoto
import androidx.compose.material3.MaterialTheme
@ -27,6 +28,8 @@ import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.avatar.avatarShape
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
@ -40,11 +43,12 @@ import io.element.android.libraries.designsystem.theme.temporaryColorBgSpecial
@Composable
fun UnsavedAvatar(
avatarUri: Uri?,
avatarType: AvatarType,
modifier: Modifier = Modifier,
) {
val commonModifier = modifier
.size(70.dp)
.clip(CircleShape)
.clip(avatarType.avatarShape())
if (avatarUri != null) {
val context = LocalContext.current
@ -75,8 +79,13 @@ fun UnsavedAvatar(
@PreviewsDayNight
@Composable
internal fun UnsavedAvatarPreview() = ElementPreview {
Row {
UnsavedAvatar(null)
UnsavedAvatar(Uri.EMPTY)
Row(
modifier = Modifier.padding(8.dp),
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.unit.dp
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.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.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -214,12 +215,14 @@ private fun RoomSummaryView(
.heightIn(56.dp),
verticalAlignment = Alignment.CenterVertically
) {
RoomAvatar(
Avatar(
avatarData = roomInfo.getAvatarData(size = AvatarSize.RoomSelectRoomListItem),
heroes = roomInfo.heroes.map { user ->
user.getAvatarData(size = AvatarSize.RoomSelectRoomListItem)
}.toPersistentList(),
isTombstoned = roomInfo.isTombstoned,
avatarType = AvatarType.Room(
heroes = roomInfo.heroes.map { user ->
user.getAvatarData(size = AvatarSize.RoomSelectRoomListItem)
}.toPersistentList(),
isTombstoned = roomInfo.isTombstoned,
),
)
Column(
modifier = Modifier

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2f238177f5bf6e2964aa7f1d661af45c9bf09507d1a7b34dc749603e69056af1
size 36330
oid sha256:e20e9a7264a6784fbaf6525e73a07318abd27bcf025541b880fd27ac0cca4ba4
size 69466

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1ec7b0c98849e856978f3f5601680fa9a72597340aeca40fb03e5c232c3d651f
size 36002
oid sha256:991318dc36c1e6b86a753241638c36adafb896f852f4d5d0e15dd251fa025311
size 68974