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.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,
)
@@ -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<String, ByteArray>()
@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<ByteArray?>(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<FileTransferProgress?>(null) }
val progressFlow = remember(mxcUrl) { MutableStateFlow<FileTransferProgress?>(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()
)
@@ -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<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
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),
)
+1 -1
View File
@@ -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"