From c2a2e16f46585dcad4bd7c0b8592b84831602931 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 28 Sep 2026 11:38:45 +0200 Subject: [PATCH] Use the stored avatar thumbnail as a fallback for other accounts Add an optional thumbnail to `MatrixUser` and `AvatarData`, filled from the session database for the other accounts in the room list header, the settings and the account selection screen. `ImageAvatar` renders this thumbnail instead of the initial letter while the avatar is loading or when it fails to load, for instance when the homeserver of the current account is not federated with the one of the other account. Fixes #5571 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../impl/CurrentUserWithNeighborsBuilder.kt | 1 + .../CurrentUserWithNeighborsBuilderTest.kt | 26 +++++++++ .../impl/root/PreferencesRootPresenter.kt | 1 + .../impl/root/PreferencesRootPresenterTest.kt | 10 +++- .../impl/AccountSelectPresenter.kt | 1 + .../impl/AccountSelectPresenterTest.kt | 2 + .../components/avatar/AvatarData.kt | 2 + .../components/avatar/AvatarDataProvider.kt | 2 + .../components/avatar/internal/ImageAvatar.kt | 53 ++++++++++++++++++- .../libraries/matrix/api/user/MatrixUser.kt | 2 + .../matrix/ui/model/MatrixUserExtensions.kt | 1 + .../ui/model/MatrixUserExtensionsTest.kt | 2 + 12 files changed, 100 insertions(+), 3 deletions(-) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilder.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilder.kt index b29dc788a7..f9c5fc3bc0 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilder.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilder.kt @@ -36,6 +36,7 @@ class CurrentUserWithNeighborsBuilder { userId = UserId(it.userId), displayName = it.userDisplayName, avatarUrl = it.userAvatarUrl, + avatarThumbnail = it.userAvatarData, ) } } diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilderTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilderTest.kt index 13c9585386..e8d041bca3 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilderTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilderTest.kt @@ -220,4 +220,30 @@ class CurrentUserWithNeighborsBuilderTest { ) ) } + + @Test + fun `other accounts use the data from db, including the avatar thumbnail`() { + val sut = CurrentUserWithNeighborsBuilder() + val matrixUser = aMatrixUser(id = A_USER_ID.value) + val list = listOf( + aSessionData( + sessionId = A_USER_ID.value, + ), + aSessionData( + sessionId = A_USER_ID_2.value, + userDisplayName = "Carol", + userAvatarUrl = "avatarUrl", + userAvatarData = "avatarData", + ), + ) + val result = sut.build(matrixUser, list) + assertThat(result[0]).isEqualTo( + MatrixUser( + userId = A_USER_ID_2, + displayName = "Carol", + avatarUrl = "avatarUrl", + avatarThumbnail = "avatarData", + ) + ) + } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt index 6bada34e93..4bbe2eeaf8 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt @@ -90,6 +90,7 @@ class PreferencesRootPresenter( userId = UserId(it.userId), displayName = it.userDisplayName, avatarUrl = it.userAvatarUrl, + avatarThumbnail = it.userAvatarData, ) } .toImmutableList() diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt index 3d438f7044..085b74e323 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt @@ -234,6 +234,7 @@ class PreferencesRootPresenterTest { sessionId = A_SESSION_ID_2.value, userDisplayName = "Bob", userAvatarUrl = "avatarUrl", + userAvatarData = "avatarData", ), ) ) @@ -241,7 +242,14 @@ class PreferencesRootPresenterTest { val state = awaitFirstItem() assertThat(state.isMultiAccountEnabled).isTrue() assertThat(state.otherSessions).hasSize(1) - assertThat(state.otherSessions[0]).isEqualTo(MatrixUser(userId = A_SESSION_ID_2, displayName = "Bob", avatarUrl = "avatarUrl")) + assertThat(state.otherSessions[0]).isEqualTo( + MatrixUser( + userId = A_SESSION_ID_2, + displayName = "Bob", + avatarUrl = "avatarUrl", + avatarThumbnail = "avatarData", + ) + ) } } diff --git a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.kt b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.kt index be6a8bea6f..e1d6b2800e 100644 --- a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.kt +++ b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.kt @@ -34,6 +34,7 @@ class AccountSelectPresenter( userId = UserId(it.userId), displayName = it.userDisplayName, avatarUrl = it.userAvatarUrl, + avatarThumbnail = it.userAvatarData, ) } .toImmutableList() diff --git a/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenterTest.kt b/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenterTest.kt index 8b0f581f6d..f9b9aeebcd 100644 --- a/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenterTest.kt +++ b/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenterTest.kt @@ -44,6 +44,7 @@ class AccountSelectPresenterTest { sessionId = A_SESSION_ID_2.value, userDisplayName = "Bob", userAvatarUrl = "avatarUrl", + userAvatarData = "avatarData", ), ) ) @@ -66,6 +67,7 @@ class AccountSelectPresenterTest { userId = A_SESSION_ID_2, displayName = "Bob", avatarUrl = "avatarUrl", + avatarThumbnail = "avatarData", ) ) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt index 99c45c67d9..88f8f3abb5 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt @@ -18,6 +18,8 @@ data class AvatarData( val name: String?, val url: String? = null, val size: AvatarSize, + /** Base64 encoded thumbnail of [url], stored locally, used as a fallback when the avatar cannot be loaded. */ + val thumbnail: String? = null, ) { val initialLetter by lazy { // For roomIds, use "#" as initial diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarDataProvider.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarDataProvider.kt index 53ef5593ed..ee069f71fd 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarDataProvider.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarDataProvider.kt @@ -16,9 +16,11 @@ fun anAvatarData( name: String? = USER_NAME_ALICE, url: String? = null, size: AvatarSize = AvatarSize.RoomListItem, + thumbnail: String? = null, ) = AvatarData( id = id, name = name, url = url, size = size, + thumbnail = thumbnail, ) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/ImageAvatar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/ImageAvatar.kt index da57fbcbe1..e8dcd94a77 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/ImageAvatar.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/ImageAvatar.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Shape @@ -21,8 +22,10 @@ import androidx.compose.ui.unit.Dp import coil3.compose.AsyncImagePainter import coil3.compose.SubcomposeAsyncImage import coil3.compose.SubcomposeAsyncImageContent +import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.designsystem.components.avatar.AvatarData import timber.log.Timber +import kotlin.io.encoding.Base64 @Composable internal fun ImageAvatar( @@ -33,6 +36,9 @@ internal fun ImageAvatar( contentDescription: String? = null, ) { val size = forcedAvatarSize ?: avatarData.size.dp + val thumbnailBytes = remember(avatarData.thumbnail) { + avatarData.thumbnail?.let { runCatchingExceptions { Base64.decode(it) }.getOrNull() } + } SubcomposeAsyncImage( model = avatarData, contentDescription = contentDescription, @@ -51,15 +57,17 @@ internal fun ImageAvatar( "Error loading avatar $state\n${state.result}" ) } - InitialLetterAvatar( + ThumbnailOrInitialLetterAvatar( avatarData = avatarData, + thumbnailBytes = thumbnailBytes, avatarShape = avatarShape, forcedAvatarSize = forcedAvatarSize, contentDescription = contentDescription, ) } - else -> InitialLetterAvatar( + else -> ThumbnailOrInitialLetterAvatar( avatarData = avatarData, + thumbnailBytes = thumbnailBytes, avatarShape = avatarShape, forcedAvatarSize = forcedAvatarSize, contentDescription = contentDescription, @@ -67,3 +75,44 @@ internal fun ImageAvatar( } } } + +/** + * Render the locally stored thumbnail of the avatar if any, else the initial letter. + */ +@Composable +private fun ThumbnailOrInitialLetterAvatar( + avatarData: AvatarData, + thumbnailBytes: ByteArray?, + avatarShape: Shape, + forcedAvatarSize: Dp?, + contentDescription: String?, +) { + if (thumbnailBytes == null) { + InitialLetterAvatar( + avatarData = avatarData, + avatarShape = avatarShape, + forcedAvatarSize = forcedAvatarSize, + contentDescription = contentDescription, + ) + } else { + SubcomposeAsyncImage( + model = thumbnailBytes, + contentDescription = contentDescription, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(forcedAvatarSize ?: avatarData.size.dp) + .clip(avatarShape) + ) { + val collectedState by painter.state.collectAsState() + when (collectedState) { + is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent() + else -> InitialLetterAvatar( + avatarData = avatarData, + avatarShape = avatarShape, + forcedAvatarSize = forcedAvatarSize, + contentDescription = contentDescription, + ) + } + } + } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixUser.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixUser.kt index 7a8ec739a5..f27557bda2 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixUser.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixUser.kt @@ -19,4 +19,6 @@ data class MatrixUser( val avatarUrl: String? = null, val displayedStatus: DisplayedStatus? = null, val rawStatus: UserStatus? = null, + /** Base64 encoded thumbnail of [avatarUrl], stored locally, used as a fallback when the avatar cannot be loaded. */ + val avatarThumbnail: String? = null, ) : Parcelable diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensions.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensions.kt index 83a707347d..ac3cdf0e3b 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensions.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensions.kt @@ -20,6 +20,7 @@ fun MatrixUser.getAvatarData(size: AvatarSize) = AvatarData( name = displayName, url = avatarUrl, size = size, + thumbnail = avatarThumbnail, ) fun MatrixUser.getBestName(): String { diff --git a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensionsTest.kt b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensionsTest.kt index 035548a4ec..ae8a04b7cd 100644 --- a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensionsTest.kt +++ b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensionsTest.kt @@ -33,12 +33,14 @@ class MatrixUserExtensionsTest : RobolectricTest() { userId = A_USER_ID, displayName = "displayName", avatarUrl = "avatarUrl", + avatarThumbnail = "avatarThumbnail", ) val expected = AvatarData( id = A_USER_ID.value, name = "displayName", url = "avatarUrl", size = AvatarSize.UserHeader, + thumbnail = "avatarThumbnail", ) assertThat(matrixUser.getAvatarData(AvatarSize.UserHeader)).isEqualTo(expected) }