From 2092de3e9a1adc6f453ed2abb40a5cd545ffc88a Mon Sep 17 00:00:00 2001 From: Sweetbread Date: Fri, 8 May 2026 20:37:45 +0300 Subject: [PATCH] fix: round avatar --- .../risdeveau/pixeldragon/ui/item/Avatar.kt | 8 +- .../ru/risdeveau/pixeldragon/ui/item/Image.kt | 26 ++++- .../risdeveau/pixeldragon/ui/layout/Room.kt | 95 ++++++++++++++----- gradle/libs.versions.toml | 2 +- 4 files changed, 100 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/ru/risdeveau/pixeldragon/ui/item/Avatar.kt b/app/src/main/java/ru/risdeveau/pixeldragon/ui/item/Avatar.kt index 93fb0bf..1581243 100755 --- a/app/src/main/java/ru/risdeveau/pixeldragon/ui/item/Avatar.kt +++ b/app/src/main/java/ru/risdeveau/pixeldragon/ui/item/Avatar.kt @@ -7,11 +7,13 @@ package ru.risdeveau.pixeldragon.ui.item import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text 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.graphics.Color import androidx.compose.ui.layout.ContentScale @@ -22,17 +24,19 @@ fun Avatar( fallbackName: String, fallbackColor: Color = MaterialTheme.colorScheme.secondaryContainer, ) { + val avatarModifier = modifier.clip(CircleShape) + if (url != null) { MXCImage( mxcUrl = url, - modifier = modifier, + modifier = avatarModifier, contentScale = ContentScale.Crop, contentDescription = fallbackName, showProgress = false, ) } else { AvatarPlaceholder( - modifier, + avatarModifier, fallbackName, fallbackColor, ) diff --git a/app/src/main/java/ru/risdeveau/pixeldragon/ui/item/Image.kt b/app/src/main/java/ru/risdeveau/pixeldragon/ui/item/Image.kt index 2665f17..bf9eba7 100755 --- a/app/src/main/java/ru/risdeveau/pixeldragon/ui/item/Image.kt +++ b/app/src/main/java/ru/risdeveau/pixeldragon/ui/item/Image.kt @@ -8,7 +8,6 @@ package ru.risdeveau.pixeldragon.ui.item import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import io.github.rabehx.iconsax.Iconsax import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.runtime.Composable @@ -24,16 +23,20 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import coil3.compose.rememberAsyncImagePainter import coil3.request.ImageRequest -import kotlinx.coroutines.flow.MutableStateFlow import de.connect2x.trixnity.client.media import de.connect2x.trixnity.clientserverapi.model.media.FileTransferProgress +import io.github.rabehx.iconsax.Iconsax import io.github.rabehx.iconsax.outline.Warning2 +import kotlinx.coroutines.flow.MutableStateFlow import ru.risdeveau.pixeldragon.client +import java.util.concurrent.ConcurrentHashMap enum class ImageLoadState { Loading, Success, Error } +private val mxcImageByteCache = ConcurrentHashMap() + @Composable fun MXCImage( mxcUrl: String, @@ -43,16 +46,26 @@ fun MXCImage( showProgress: Boolean = true ) { val context = LocalContext.current - var imageLoadState by remember { mutableStateOf(ImageLoadState.Loading) } - var imageBytes by remember { mutableStateOf(null) } + val cachedBytes = remember(mxcUrl) { mxcImageByteCache[mxcUrl] } + var imageLoadState by remember(mxcUrl) { + mutableStateOf(if (cachedBytes != null) ImageLoadState.Success else ImageLoadState.Loading) + } + var imageBytes by remember(mxcUrl) { mutableStateOf(cachedBytes) } - val progressFlow = remember { MutableStateFlow(null) } + val progressFlow = remember(mxcUrl) { MutableStateFlow(null) } LaunchedEffect(mxcUrl) { if (mxcUrl.isBlank()) { imageLoadState = ImageLoadState.Error return@LaunchedEffect } + + mxcImageByteCache[mxcUrl]?.let { bytes -> + imageBytes = bytes + imageLoadState = ImageLoadState.Success + return@LaunchedEffect + } + imageLoadState = ImageLoadState.Loading progressFlow.value = null @@ -66,6 +79,7 @@ fun MXCImage( onSuccess = { media -> val bytes = media.toByteArray() if (bytes != null) { + mxcImageByteCache[mxcUrl] = bytes imageBytes = bytes ImageLoadState.Success } else { @@ -87,6 +101,8 @@ fun MXCImage( val painter = rememberAsyncImagePainter( model = ImageRequest.Builder(context) .data(imageBytes) + .memoryCacheKey(mxcUrl) + .diskCacheKey(mxcUrl) .build() ) diff --git a/app/src/main/java/ru/risdeveau/pixeldragon/ui/layout/Room.kt b/app/src/main/java/ru/risdeveau/pixeldragon/ui/layout/Room.kt index afdb6ef..a637a24 100755 --- a/app/src/main/java/ru/risdeveau/pixeldragon/ui/layout/Room.kt +++ b/app/src/main/java/ru/risdeveau/pixeldragon/ui/layout/Room.kt @@ -88,7 +88,10 @@ import de.connect2x.trixnity.client.room.message.text import de.connect2x.trixnity.client.room.toFlowList import de.connect2x.trixnity.client.store.TimelineEvent import de.connect2x.trixnity.client.store.eventId +import de.connect2x.trixnity.clientserverapi.model.user.avatarUrl +import de.connect2x.trixnity.clientserverapi.model.user.displayName import de.connect2x.trixnity.core.model.RoomId +import de.connect2x.trixnity.core.model.UserId import de.connect2x.trixnity.core.model.events.ClientEvent import de.connect2x.trixnity.core.model.events.m.FullyReadEventContent import de.connect2x.trixnity.core.model.events.m.room.AvatarEventContent @@ -114,10 +117,12 @@ import org.jsoup.Jsoup import org.jsoup.nodes.Element import org.jsoup.safety.Safelist import ru.risdeveau.pixeldragon.client +import ru.risdeveau.pixeldragon.ui.item.Avatar import java.text.SimpleDateFormat import java.util.Calendar import java.util.Date import java.util.Locale +import java.util.concurrent.ConcurrentHashMap import kotlin.math.abs import kotlin.math.ceil import kotlin.math.max @@ -365,7 +370,11 @@ fun Room(modifier: Modifier = Modifier, rid: String) { } .zIndex(1f), ) { - AvatarPlaceholder(avatarState.senderLabel) + TimelineAvatar( + modifier = Modifier.size(TIMELINE_AVATAR_SIZE), + senderId = avatarState.senderId, + senderLabel = avatarState.senderLabel, + ) } } } @@ -449,6 +458,7 @@ private data class MessageTimelineItem( private data class StickyAvatarState( val groupKey: String, + val senderId: String?, val senderLabel: String, val yPx: Int, ) @@ -677,7 +687,11 @@ private fun MessageGroupRow( if (hideInlineAvatar) { Spacer(Modifier.size(TIMELINE_AVATAR_SIZE)) } else { - AvatarPlaceholder(group.senderLabel) + TimelineAvatar( + modifier = Modifier.size(TIMELINE_AVATAR_SIZE), + senderId = group.senderId, + senderLabel = group.senderLabel, + ) } Spacer(Modifier.width(8.dp)) } @@ -699,6 +713,61 @@ private fun MessageGroupRow( } } +private val timelineAvatarProfileCache = ConcurrentHashMap() + +private data class TimelineAvatarProfile( + val displayName: String, + val avatarUrl: String?, +) + +@Composable +private fun TimelineAvatar( + modifier: Modifier = Modifier, + senderId: String?, + senderLabel: String, +) { + val fallbackProfile = remember(senderId, senderLabel) { + TimelineAvatarProfile(senderLabel.takeUnless { it.isBlank() } ?: "?", null) + } + var profile by remember(senderId, senderLabel) { + mutableStateOf(senderId?.let(timelineAvatarProfileCache::get) ?: fallbackProfile) + } + + LaunchedEffect(senderId, senderLabel) { + val id = senderId ?: run { + profile = fallbackProfile + return@LaunchedEffect + } + + timelineAvatarProfileCache[id]?.let { cached -> + profile = cached + return@LaunchedEffect + } + + val fetchedProfile = client?.api?.user + ?.getProfile(UserId(id)) + ?.getOrNull() + + val resolvedProfile = TimelineAvatarProfile( + displayName = fetchedProfile + ?.displayName + ?.takeUnless { it.isBlank() } + ?: senderLabel.takeUnless { it.isBlank() } + ?: "?", + avatarUrl = fetchedProfile?.avatarUrl, + ) + + timelineAvatarProfileCache[id] = resolvedProfile + profile = resolvedProfile + } + + Avatar( + modifier = modifier, + url = profile.avatarUrl, + fallbackName = profile.displayName, + ) +} + @Composable private fun MessageBubble( item: MessageTimelineItem, @@ -744,27 +813,6 @@ private fun MessageBubble( } } -@Composable -private fun AvatarPlaceholder(senderLabel: String) { - val initial = senderLabel.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "?" - - Box( - modifier = Modifier - .size(32.dp) - .background( - color = MaterialTheme.colorScheme.secondaryContainer, - shape = CircleShape, - ), - contentAlignment = Alignment.Center, - ) { - Text( - text = initial, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSecondaryContainer, - ) - } -} - @Composable private fun MessageBody( content: RoomMessageEventContent, @@ -821,6 +869,7 @@ private fun calculateStickyAvatarState( StickyAvatarState( groupKey = group.key, + senderId = group.senderId, senderLabel = group.senderLabel, yPx = pinnedAvatarTop.coerceAtLeast(groupTop), ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 221c4ea..8199c8b 100755 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,7 +16,7 @@ composeBom = "2026.03.00" navigationCompose = "2.9.7" room = "2.6.1" splittiesFunPackAndroidBase = "3.0.0" -trixnityClient = "5.2.0" +trixnityClient = "5.5.2" unifiedPushConnector = "3.3.2" uiUnit = "1.10.6"