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) <noreply@anthropic.com>
This commit is contained in:
Benoit Marty
2026-09-28 11:38:45 +02:00
parent db23023eda
commit c2a2e16f46
12 changed files with 100 additions and 3 deletions
@@ -36,6 +36,7 @@ class CurrentUserWithNeighborsBuilder {
userId = UserId(it.userId),
displayName = it.userDisplayName,
avatarUrl = it.userAvatarUrl,
avatarThumbnail = it.userAvatarData,
)
}
}
@@ -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",
)
)
}
}
@@ -90,6 +90,7 @@ class PreferencesRootPresenter(
userId = UserId(it.userId),
displayName = it.userDisplayName,
avatarUrl = it.userAvatarUrl,
avatarThumbnail = it.userAvatarData,
)
}
.toImmutableList()
@@ -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",
)
)
}
}
@@ -34,6 +34,7 @@ class AccountSelectPresenter(
userId = UserId(it.userId),
displayName = it.userDisplayName,
avatarUrl = it.userAvatarUrl,
avatarThumbnail = it.userAvatarData,
)
}
.toImmutableList()
@@ -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",
)
)
}
@@ -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
@@ -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,
)
@@ -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,
)
}
}
}
}
@@ -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
@@ -20,6 +20,7 @@ fun MatrixUser.getAvatarData(size: AvatarSize) = AvatarData(
name = displayName,
url = avatarUrl,
size = size,
thumbnail = avatarThumbnail,
)
fun MatrixUser.getBestName(): String {
@@ -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)
}