mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-10-31 08:15:02 +00:00
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:
+1
@@ -36,6 +36,7 @@ class CurrentUserWithNeighborsBuilder {
|
||||
userId = UserId(it.userId),
|
||||
displayName = it.userDisplayName,
|
||||
avatarUrl = it.userAvatarUrl,
|
||||
avatarThumbnail = it.userAvatarData,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+26
@@ -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",
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -90,6 +90,7 @@ class PreferencesRootPresenter(
|
||||
userId = UserId(it.userId),
|
||||
displayName = it.userDisplayName,
|
||||
avatarUrl = it.userAvatarUrl,
|
||||
avatarThumbnail = it.userAvatarData,
|
||||
)
|
||||
}
|
||||
.toImmutableList()
|
||||
|
||||
+9
-1
@@ -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",
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -34,6 +34,7 @@ class AccountSelectPresenter(
|
||||
userId = UserId(it.userId),
|
||||
displayName = it.userDisplayName,
|
||||
avatarUrl = it.userAvatarUrl,
|
||||
avatarThumbnail = it.userAvatarData,
|
||||
)
|
||||
}
|
||||
.toImmutableList()
|
||||
|
||||
+2
@@ -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",
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+2
@@ -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,
|
||||
)
|
||||
|
||||
+51
-2
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+1
@@ -20,6 +20,7 @@ fun MatrixUser.getAvatarData(size: AvatarSize) = AvatarData(
|
||||
name = displayName,
|
||||
url = avatarUrl,
|
||||
size = size,
|
||||
thumbnail = avatarThumbnail,
|
||||
)
|
||||
|
||||
fun MatrixUser.getBestName(): String {
|
||||
|
||||
+2
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user