fix: round avatar
This commit is contained in:
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user