fix: round avatar

This commit is contained in:
2026-05-08 20:37:45 +03:00
parent 098c9fe2aa
commit 2092de3e9a
4 changed files with 100 additions and 31 deletions
@@ -7,11 +7,13 @@ package ru.risdeveau.pixeldragon.ui.item
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
@@ -22,17 +24,19 @@ fun Avatar(
fallbackName: String, fallbackName: String,
fallbackColor: Color = MaterialTheme.colorScheme.secondaryContainer, fallbackColor: Color = MaterialTheme.colorScheme.secondaryContainer,
) { ) {
val avatarModifier = modifier.clip(CircleShape)
if (url != null) { if (url != null) {
MXCImage( MXCImage(
mxcUrl = url, mxcUrl = url,
modifier = modifier, modifier = avatarModifier,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
contentDescription = fallbackName, contentDescription = fallbackName,
showProgress = false, showProgress = false,
) )
} else { } else {
AvatarPlaceholder( AvatarPlaceholder(
modifier, avatarModifier,
fallbackName, fallbackName,
fallbackColor, fallbackColor,
) )
@@ -8,7 +8,6 @@ package ru.risdeveau.pixeldragon.ui.item
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import io.github.rabehx.iconsax.Iconsax
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -24,16 +23,20 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import coil3.compose.rememberAsyncImagePainter import coil3.compose.rememberAsyncImagePainter
import coil3.request.ImageRequest import coil3.request.ImageRequest
import kotlinx.coroutines.flow.MutableStateFlow
import de.connect2x.trixnity.client.media import de.connect2x.trixnity.client.media
import de.connect2x.trixnity.clientserverapi.model.media.FileTransferProgress import de.connect2x.trixnity.clientserverapi.model.media.FileTransferProgress
import io.github.rabehx.iconsax.Iconsax
import io.github.rabehx.iconsax.outline.Warning2 import io.github.rabehx.iconsax.outline.Warning2
import kotlinx.coroutines.flow.MutableStateFlow
import ru.risdeveau.pixeldragon.client import ru.risdeveau.pixeldragon.client
import java.util.concurrent.ConcurrentHashMap
enum class ImageLoadState { enum class ImageLoadState {
Loading, Success, Error Loading, Success, Error
} }
private val mxcImageByteCache = ConcurrentHashMap<String, ByteArray>()
@Composable @Composable
fun MXCImage( fun MXCImage(
mxcUrl: String, mxcUrl: String,
@@ -43,16 +46,26 @@ fun MXCImage(
showProgress: Boolean = true showProgress: Boolean = true
) { ) {
val context = LocalContext.current val context = LocalContext.current
var imageLoadState by remember { mutableStateOf(ImageLoadState.Loading) } val cachedBytes = remember(mxcUrl) { mxcImageByteCache[mxcUrl] }
var imageBytes by remember { mutableStateOf<ByteArray?>(null) } 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<FileTransferProgress?>(null) } val progressFlow = remember(mxcUrl) { MutableStateFlow<FileTransferProgress?>(null) }
LaunchedEffect(mxcUrl) { LaunchedEffect(mxcUrl) {
if (mxcUrl.isBlank()) { if (mxcUrl.isBlank()) {
imageLoadState = ImageLoadState.Error imageLoadState = ImageLoadState.Error
return@LaunchedEffect return@LaunchedEffect
} }
mxcImageByteCache[mxcUrl]?.let { bytes ->
imageBytes = bytes
imageLoadState = ImageLoadState.Success
return@LaunchedEffect
}
imageLoadState = ImageLoadState.Loading imageLoadState = ImageLoadState.Loading
progressFlow.value = null progressFlow.value = null
@@ -66,6 +79,7 @@ fun MXCImage(
onSuccess = { media -> onSuccess = { media ->
val bytes = media.toByteArray() val bytes = media.toByteArray()
if (bytes != null) { if (bytes != null) {
mxcImageByteCache[mxcUrl] = bytes
imageBytes = bytes imageBytes = bytes
ImageLoadState.Success ImageLoadState.Success
} else { } else {
@@ -87,6 +101,8 @@ fun MXCImage(
val painter = rememberAsyncImagePainter( val painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(context) model = ImageRequest.Builder(context)
.data(imageBytes) .data(imageBytes)
.memoryCacheKey(mxcUrl)
.diskCacheKey(mxcUrl)
.build() .build()
) )
@@ -88,7 +88,10 @@ import de.connect2x.trixnity.client.room.message.text
import de.connect2x.trixnity.client.room.toFlowList import de.connect2x.trixnity.client.room.toFlowList
import de.connect2x.trixnity.client.store.TimelineEvent import de.connect2x.trixnity.client.store.TimelineEvent
import de.connect2x.trixnity.client.store.eventId 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.RoomId
import de.connect2x.trixnity.core.model.UserId
import de.connect2x.trixnity.core.model.events.ClientEvent 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.FullyReadEventContent
import de.connect2x.trixnity.core.model.events.m.room.AvatarEventContent 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.nodes.Element
import org.jsoup.safety.Safelist import org.jsoup.safety.Safelist
import ru.risdeveau.pixeldragon.client import ru.risdeveau.pixeldragon.client
import ru.risdeveau.pixeldragon.ui.item.Avatar
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Calendar import java.util.Calendar
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.ceil import kotlin.math.ceil
import kotlin.math.max import kotlin.math.max
@@ -365,7 +370,11 @@ fun Room(modifier: Modifier = Modifier, rid: String) {
} }
.zIndex(1f), .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( private data class StickyAvatarState(
val groupKey: String, val groupKey: String,
val senderId: String?,
val senderLabel: String, val senderLabel: String,
val yPx: Int, val yPx: Int,
) )
@@ -677,7 +687,11 @@ private fun MessageGroupRow(
if (hideInlineAvatar) { if (hideInlineAvatar) {
Spacer(Modifier.size(TIMELINE_AVATAR_SIZE)) Spacer(Modifier.size(TIMELINE_AVATAR_SIZE))
} else { } else {
AvatarPlaceholder(group.senderLabel) TimelineAvatar(
modifier = Modifier.size(TIMELINE_AVATAR_SIZE),
senderId = group.senderId,
senderLabel = group.senderLabel,
)
} }
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
} }
@@ -699,6 +713,61 @@ private fun MessageGroupRow(
} }
} }
private val timelineAvatarProfileCache = ConcurrentHashMap<String, TimelineAvatarProfile>()
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 @Composable
private fun MessageBubble( private fun MessageBubble(
item: MessageTimelineItem, 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 @Composable
private fun MessageBody( private fun MessageBody(
content: RoomMessageEventContent, content: RoomMessageEventContent,
@@ -821,6 +869,7 @@ private fun calculateStickyAvatarState(
StickyAvatarState( StickyAvatarState(
groupKey = group.key, groupKey = group.key,
senderId = group.senderId,
senderLabel = group.senderLabel, senderLabel = group.senderLabel,
yPx = pinnedAvatarTop.coerceAtLeast(groupTop), yPx = pinnedAvatarTop.coerceAtLeast(groupTop),
) )
+1 -1
View File
@@ -16,7 +16,7 @@ composeBom = "2026.03.00"
navigationCompose = "2.9.7" navigationCompose = "2.9.7"
room = "2.6.1" room = "2.6.1"
splittiesFunPackAndroidBase = "3.0.0" splittiesFunPackAndroidBase = "3.0.0"
trixnityClient = "5.2.0" trixnityClient = "5.5.2"
unifiedPushConnector = "3.3.2" unifiedPushConnector = "3.3.2"
uiUnit = "1.10.6" uiUnit = "1.10.6"