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 7bde9bc0c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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 roomName: AsyncData<String> by remember {
derivedStateOf { roomInfo.name?.let { AsyncData.Success(it) } ?: AsyncData.Uninitialized }
}
val roomAvatar: AsyncData<AvatarData> by remember {
derivedStateOf { AsyncData.Success(roomInfo.avatarData()) }
val roomAvatar by remember {
derivedStateOf { roomInfo.avatarData() }
}
val heroes by remember {
derivedStateOf { roomInfo.heroes().toPersistentList() }
@ -245,7 +242,7 @@ class MessagesPresenter @AssistedInject constructor(
return MessagesState(
roomId = room.roomId,
roomName = roomName,
roomName = roomInfo.name,
roomAvatar = roomAvatar,
heroes = heroes,
composerState = composerState,
@ -292,7 +289,7 @@ class MessagesPresenter @AssistedInject constructor(
return AvatarData(
id = id.value,
name = name,
url = avatarUrl ?: room.info().avatarUrl,
url = avatarUrl,
size = AvatarSize.TimelineRoom
)
}

View file

@ -32,8 +32,8 @@ import kotlinx.collections.immutable.ImmutableList
@Immutable
data class MessagesState(
val roomId: RoomId,
val roomName: AsyncData<String>,
val roomAvatar: AsyncData<AvatarData>,
val roomName: String?,
val roomAvatar: AvatarData,
val heroes: ImmutableList<AvatarData>,
val userEventPermissions: UserEventPermissions,
val composerState: MessageComposerState,
@ -59,4 +59,6 @@ data class MessagesState(
val roomMemberModerationState: RoomMemberModerationState,
val successorRoom: SuccessorRoom?,
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(userEventPermissions = aUserEventPermissions(canSendMessage = false)),
aMessagesState(showReinvitePrompt = true),
aMessagesState(
roomName = AsyncData.Uninitialized,
roomAvatar = AsyncData.Uninitialized,
),
aMessagesState(roomName = null),
aMessagesState(composerState = aMessageComposerState(showTextFormatting = true)),
aMessagesState(
enableVoiceMessages = true,
@ -86,15 +83,15 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
currentPinnedMessageIndex = 0,
),
),
aMessagesState(roomName = AsyncData.Success("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.Verified),
aMessagesState(roomName = "A DM with a very looong name", dmUserVerificationState = IdentityState.VerificationViolation),
aMessagesState(successorRoom = SuccessorRoom(RoomId("!id:domain"), null)),
)
}
fun aMessagesState(
roomName: AsyncData<String> = AsyncData.Success("Room name"),
roomAvatar: AsyncData<AvatarData> = AsyncData.Success(AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom)),
roomName: String? = "Room name",
roomAvatar: AvatarData = AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom),
userEventPermissions: UserEventPermissions = aUserEventPermissions(),
composerState: MessageComposerState = aMessageComposerState(
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.libraries.androidutils.ui.hideKeyboard
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.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.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
@ -194,8 +192,9 @@ fun MessagesView(
Column {
ConnectivityIndicatorView(isOnline = state.hasNetworkConnection)
MessagesViewTopBar(
roomName = state.roomName.dataOrNull(),
roomAvatar = state.roomAvatar.dataOrNull(),
roomName = state.roomName,
roomAvatar = state.roomAvatar,
isTombstoned = state.isTombstoned,
heroes = state.heroes,
roomCallState = state.roomCallState,
dmUserIdentityState = state.dmUserVerificationState,
@ -450,8 +449,8 @@ private fun MessagesViewComposerBottomSheetContents(
}
}),
roomId = state.roomId,
roomName = state.roomName.dataOrNull(),
roomAvatarData = state.roomAvatar.dataOrNull(),
roomName = state.roomName,
roomAvatarData = state.roomAvatar,
suggestions = state.composerState.suggestions,
onSelectSuggestion = {
state.composerState.eventSink(MessageComposerEvents.InsertSuggestion(it))
@ -491,7 +490,8 @@ private fun MessagesViewComposerBottomSheetContents(
@Composable
private fun MessagesViewTopBar(
roomName: String?,
roomAvatar: AvatarData?,
roomAvatar: AvatarData,
isTombstoned: Boolean,
heroes: ImmutableList<AvatarData>,
roomCallState: RoomCallState,
dmUserIdentityState: IdentityState?,
@ -513,19 +513,13 @@ private fun MessagesViewTopBar(
verticalAlignment = Alignment.CenterVertically,
) {
val titleModifier = Modifier.weight(1f, fill = false)
if (roomName != null && roomAvatar != null) {
RoomAvatarAndNameRow(
roomName = roomName,
roomAvatar = roomAvatar,
heroes = heroes,
modifier = titleModifier
)
} else {
IconTitlePlaceholdersRowMolecule(
iconSize = AvatarSize.TimelineRoom.dp,
modifier = titleModifier
)
}
RoomAvatarAndNameRow(
roomName = roomName,
roomAvatar = roomAvatar,
isTombstoned = isTombstoned,
heroes = heroes,
modifier = titleModifier
)
when (dmUserIdentityState) {
IdentityState.Verified -> {
@ -559,23 +553,26 @@ private fun MessagesViewTopBar(
@Composable
private fun RoomAvatarAndNameRow(
roomName: String,
roomName: String?,
roomAvatar: AvatarData,
heroes: ImmutableList<AvatarData>,
isTombstoned: Boolean,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
) {
CompositeAvatar(
RoomAvatar(
avatarData = roomAvatar,
heroes = heroes,
isTombstoned = isTombstoned,
)
Text(
modifier = Modifier.padding(horizontal = 8.dp),
text = roomName,
text = roomName ?: stringResource(CommonStrings.common_no_room_name),
style = ElementTheme.typography.fontBodyLgMedium,
fontStyle = FontStyle.Italic.takeIf { roomName == null },
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@ -586,9 +583,9 @@ private fun RoomAvatarAndNameRow(
private fun CantSendMessageBanner() {
Row(
modifier = Modifier
.fillMaxWidth()
.background(ElementTheme.colors.bgSubtleSecondary)
.padding(16.dp),
.fillMaxWidth()
.background(ElementTheme.colors.bgSubtleSecondary)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
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.AvatarData
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.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
@ -45,7 +46,7 @@ import kotlinx.collections.immutable.persistentListOf
fun SuggestionsPickerView(
roomId: RoomId,
roomName: String?,
roomAvatarData: AvatarData?,
roomAvatarData: AvatarData,
suggestions: ImmutableList<ResolvedSuggestion>,
onSelectSuggestion: (ResolvedSuggestion) -> Unit,
modifier: Modifier = Modifier,
@ -155,7 +156,7 @@ internal fun SuggestionsPickerViewPreview() {
SuggestionsPickerView(
roomId = RoomId("!room:matrix.org"),
roomName = "Room",
roomAvatarData = null,
roomAvatarData = anAvatarData(),
suggestions = persistentListOf(
ResolvedSuggestion.AtRoom,
ResolvedSuggestion.Member(roomMember),

View file

@ -119,9 +119,9 @@ class MessagesPresenterTest {
presenter.testWithLifecycleOwner {
val initialState = consumeItemsUntilTimeout().last()
assertThat(initialState.roomId).isEqualTo(A_ROOM_ID)
assertThat(initialState.roomName).isEqualTo(AsyncData.Success(""))
assertThat(initialState.roomName).isEqualTo("")
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.canRedactOwn).isTrue()
assertThat(initialState.hasNetworkConnection).isTrue()

View file

@ -110,7 +110,7 @@ class MessagesViewTest {
state = state,
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 io.element.android.features.preferences.impl.R
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.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
@ -97,7 +97,7 @@ fun EditDefaultNotificationSettingView(
Text(text = subtitle)
},
leadingContent = ListItemContent.Custom {
CompositeAvatar(
RoomAvatar(
avatarData = summary.avatarData,
heroes = summary.heroesAvatar,
)

View file

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

View file

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

View file

@ -53,6 +53,7 @@ open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState>
aRoomDetailsState(knockRequestsCount = null, canShowKnockRequests = true),
aRoomDetailsState(knockRequestsCount = 4, canShowKnockRequests = true),
aRoomDetailsState(hasMemberVerificationViolations = true),
aRoomDetailsState(isTombstoned = true),
aDmRoomDetailsState(dmRoomMemberVerificationState = UserProfileVerificationState.VERIFIED),
aDmRoomDetailsState(dmRoomMemberVerificationState = UserProfileVerificationState.VERIFICATION_VIOLATION),
// Add other state here
@ -118,6 +119,7 @@ fun aRoomDetailsState(
canShowSecurityAndPrivacy: Boolean = true,
hasMemberVerificationViolations: Boolean = false,
canReportRoom: Boolean = true,
isTombstoned: Boolean = false,
eventSink: (RoomDetailsEvent) -> Unit = {},
) = RoomDetailsState(
roomId = roomId,
@ -148,6 +150,7 @@ fun aRoomDetailsState(
canShowSecurityAndPrivacy = canShowSecurityAndPrivacy,
hasMemberVerificationViolations = hasMemberVerificationViolations,
canReportRoom = canReportRoom,
isTombstoned = isTombstoned,
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.avatar.AvatarData
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.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
@ -138,6 +138,7 @@ fun RoomDetailsView(
roomName = state.roomName,
roomAlias = state.roomAlias,
heroes = state.heroes,
isTombstoned = state.isTombstoned,
openAvatarPreview = { avatarUrl ->
openAvatarPreview(state.roomName, avatarUrl)
},
@ -380,6 +381,7 @@ private fun RoomHeaderSection(
roomName: String,
roomAlias: RoomAlias?,
heroes: ImmutableList<MatrixUser>,
isTombstoned: Boolean,
openAvatarPreview: (url: String) -> Unit,
onSubtitleClick: (String) -> Unit,
) {
@ -389,11 +391,12 @@ private fun RoomHeaderSection(
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CompositeAvatar(
RoomAvatar(
avatarData = AvatarData(roomId.value, roomName, avatarUrl, AvatarSize.RoomHeader),
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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,9 +7,7 @@
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.size
import androidx.compose.foundation.shape.CircleShape
@ -21,21 +19,16 @@ 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.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
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 androidx.compose.ui.unit.sp
import coil3.compose.AsyncImagePainter
import coil3.compose.SubcomposeAsyncImage
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.preview.ElementThemedPreview
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.utils.CommonDrawables
import timber.log.Timber
@ -50,21 +43,18 @@ fun Avatar(
// If true, will show initials even if avatarData.url is not null
hideImage: Boolean = false,
) {
val commonModifier = modifier
.size(forcedAvatarSize ?: avatarData.size.dp)
.clip(CircleShape)
if (avatarData.url.isNullOrBlank() || hideImage) {
InitialsAvatar(
InitialLetterAvatar(
avatarData = avatarData,
forcedAvatarSize = forcedAvatarSize,
modifier = commonModifier,
modifier = modifier,
contentDescription = contentDescription,
)
} else {
ImageAvatar(
avatarData = avatarData,
forcedAvatarSize = forcedAvatarSize,
modifier = commonModifier,
modifier = modifier,
contentDescription = contentDescription,
)
}
@ -77,11 +67,14 @@ private fun ImageAvatar(
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) {
@ -90,13 +83,13 @@ private fun ImageAvatar(
SideEffect {
Timber.e(state.result.throwable, "Error loading avatar $state\n${state.result}")
}
InitialsAvatar(
InitialLetterAvatar(
avatarData = avatarData,
forcedAvatarSize = forcedAvatarSize,
contentDescription = contentDescription,
)
}
else -> InitialsAvatar(
else -> InitialLetterAvatar(
avatarData = avatarData,
forcedAvatarSize = forcedAvatarSize,
contentDescription = contentDescription,
@ -106,33 +99,20 @@ private fun ImageAvatar(
}
@Composable
private fun InitialsAvatar(
private fun InitialLetterAvatar(
avatarData: AvatarData,
forcedAvatarSize: Dp?,
contentDescription: String?,
modifier: Modifier = Modifier,
) {
val avatarColors = AvatarColorsProvider.provide(avatarData.id)
Box(
modifier.background(color = avatarColors.background)
) {
val fontSize = (forcedAvatarSize ?: avatarData.size.dp).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 = avatarData.initial,
style = originalFont.copy(fontSize = fontSize, lineHeight = lineHeight, letterSpacing = 0.sp),
color = avatarColors.foreground,
)
}
TextAvatar(
text = avatarData.initialLetter,
size = forcedAvatarSize ?: avatarData.size.dp,
colors = avatarColors,
contentDescription = contentDescription,
modifier = modifier
)
}
@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 size: AvatarSize,
) {
val initial by lazy {
val initialLetter by lazy {
// For roomIds, use "#" as initial
(name?.takeIf { it.isNotBlank() } ?: id.takeIf { !it.startsWith("!") } ?: "#")
.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
fun `initial with text should get the first char, uppercased`() {
val data = AvatarData("id", "test", null, AvatarSize.InviteSender)
assertThat(data.initial).isEqualTo("T")
assertThat(data.initialLetter).isEqualTo("T")
}
@Test
fun `initial with leading whitespace should get the first non-whitespace char, uppercased`() {
val data = AvatarData("id", " test", null, AvatarSize.InviteSender)
assertThat(data.initial).isEqualTo("T")
assertThat(data.initialLetter).isEqualTo("T")
}
@Test
fun `initial with long emoji should get the full emoji`() {
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
fun `initial with short emoji should get the emoji`() {
val data = AvatarData("id", "✂ Test", null, AvatarSize.InviteSender)
assertThat(data.initial).isEqualTo("")
assertThat(data.initialLetter).isEqualTo("")
}
@Test
fun `initial with a single letter should take that letter`() {
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,
avatarUrl: String? = null,
heroes: ImmutableList<MatrixUser> = persistentListOf(),
isTombstoned: Boolean = false,
) = SelectRoomInfo(
roomId = roomId,
name = name,
canonicalAlias = canonicalAlias,
avatarUrl = avatarUrl,
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.tokens.generated.CompoundIcons
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.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
@ -53,9 +53,10 @@ fun SelectedRoom(
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
CompositeAvatar(
RoomAvatar(
avatarData = roomInfo.getAvatarData(AvatarSize.SelectedRoom),
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

@ -21,6 +21,7 @@ data class SelectRoomInfo(
val canonicalAlias: RoomAlias?,
val avatarUrl: String?,
val heroes: ImmutableList<MatrixUser>,
val isTombstoned: Boolean,
) {
fun getAvatarData(size: AvatarSize) = AvatarData(
id = roomId.value,
@ -36,4 +37,5 @@ fun RoomSummary.toSelectRoomInfo() = SelectRoomInfo(
avatarUrl = info.avatarUrl,
heroes = info.heroes,
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 io.element.android.compound.theme.ElementTheme
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.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -214,11 +214,12 @@ private fun RoomSummaryView(
.heightIn(56.dp),
verticalAlignment = Alignment.CenterVertically
) {
CompositeAvatar(
RoomAvatar(
avatarData = roomInfo.getAvatarData(size = AvatarSize.RoomSelectRoomListItem),
heroes = roomInfo.heroes.map { user ->
user.getAvatarData(size = AvatarSize.RoomSelectRoomListItem)
}.toPersistentList()
}.toPersistentList(),
isTombstoned = roomInfo.isTombstoned,
)
Column(
modifier = Modifier

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:69d9aaf46305802861df8be0341abd73068376893bfd7aef43d7b8f3251350dd
size 38735
oid sha256:60571dc08867db9ad2f6528e95cc65df0eb57b9840bf8e96e6e1fbd802ad3a26
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
oid sha256:cabfc7c5bf6f9b612e933cad211b8261b236cd981488cfa081e0226940626339
size 39499
oid sha256:63c75fdca8960f015caaa11bbb970c6e7621e87afbe7761d23db875664c55cd1
size 42360

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cf96db08ac0382185c34863b38603fe55eb3aab691fa3c42b6d73e4167ce8f82
size 39383
oid sha256:cabfc7c5bf6f9b612e933cad211b8261b236cd981488cfa081e0226940626339
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