Sticky avatar

This commit is contained in:
2026-04-18 23:25:19 +03:00
parent f90a8e3472
commit 0e107eca0b
3 changed files with 230 additions and 81 deletions
+6
View File
@@ -1,3 +1,8 @@
/*
* Created by sweetbread
* Copyright (c) 2026. All rights reserved.
*/
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
/*
@@ -58,6 +63,7 @@ dependencies {
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.compose.ui.unit)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
@@ -32,8 +32,10 @@ import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -51,6 +53,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -59,15 +62,18 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.zIndex
import androidx.core.text.HtmlCompat
import androidx.core.widget.TextViewCompat
import de.connect2x.trixnity.client.room
@@ -110,6 +116,9 @@ import kotlin.math.roundToInt
private const val WEBVIEW_HEIGHT_TITLE_PREFIX = "pd-height:"
private const val MESSAGE_GROUP_WINDOW_MS = 5 * 60 * 1000L
private val TIMELINE_AVATAR_SIZE = 32.dp
private val TIMELINE_AVATAR_START_PADDING = 8.dp
private val TIMELINE_AVATAR_BOTTOM_PADDING = 8.dp
private val simpleHtmlTags = setOf(
"a", "b", "blockquote", "br", "code", "del", "div", "em", "h1", "h2",
@@ -189,34 +198,75 @@ fun Room(modifier: Modifier = Modifier, rid: String) {
val timelineItems = remember(events, currentUserId) {
buildTimelineItems(events = events, currentUserId = currentUserId)
}
val latestTimelineKey = remember(timelineItems) {
timelineItems.lastOrNull()?.latestKey()
}
val listState = rememberLazyListState()
Column(modifier) {
var message by remember { mutableStateOf("") }
LaunchedEffect(timelineItems.size) {
LaunchedEffect(latestTimelineKey) {
if (timelineItems.isNotEmpty()) {
listState.scrollToItem(timelineItems.lastIndex)
}
}
LazyColumn(Modifier.weight(1f), state = listState) {
for (timelineItem in timelineItems) {
when (timelineItem) {
is DateDividerItem -> stickyHeader(key = timelineItem.key) {
DateDivider(timelineItem)
}
val density = LocalDensity.current
BoxWithConstraints(
Modifier
.weight(1f)
.fillMaxWidth()
.clipToBounds()
) {
val listContainerHeightPx = with(density) { maxHeight.roundToPx() }
val stickyAvatar by remember(timelineItems, listState, density, listContainerHeightPx) {
derivedStateOf {
calculateStickyAvatarState(
timelineItems = timelineItems,
layoutInfo = listState.layoutInfo,
avatarSizePx = with(density) { TIMELINE_AVATAR_SIZE.roundToPx() },
bottomPaddingPx = with(density) { TIMELINE_AVATAR_BOTTOM_PADDING.roundToPx() },
viewportBottomLimitPx = listContainerHeightPx,
)
}
}
LazyColumn(Modifier.fillMaxSize(), state = listState) {
for (timelineItem in timelineItems) {
when (timelineItem) {
is DateDividerItem -> stickyHeader(key = timelineItem.key) {
DateDivider(timelineItem)
}
else -> item(key = timelineItem.key) {
TimelineItem(timelineItem)
else -> item(key = timelineItem.key) {
TimelineItem(
item = timelineItem,
pinnedAvatarGroupKey = stickyAvatar?.groupKey,
)
}
}
}
if (timelineItems.isEmpty()) {
item {
Text("Empty room", modifier = Modifier.padding(16.dp))
}
}
}
if (timelineItems.isEmpty()) {
item {
Text("Empty room", modifier = Modifier.padding(16.dp))
stickyAvatar?.let { avatarState ->
Box(
modifier = Modifier
.offset {
IntOffset(
x = with(density) { TIMELINE_AVATAR_START_PADDING.roundToPx() },
y = avatarState.yPx,
)
}
.zIndex(1f),
) {
AvatarPlaceholder(avatarState.senderLabel)
}
}
}
@@ -257,18 +307,29 @@ private data class DateDividerItem(
val label: String,
) : TimelineUiItem
private data class MessageTimelineItem(
private data class MessageGroupItem(
override val key: String,
val senderId: String?,
val senderLabel: String,
val isOwn: Boolean,
val messages: List<MessageTimelineItem>,
) : TimelineUiItem
private data class MessageTimelineItem(
val key: String,
val content: RoomMessageEventContent,
val senderId: String?,
val senderLabel: String,
val timeLabel: String?,
val timestampMs: Long?,
val isOwn: Boolean,
val showSender: Boolean,
val showAvatar: Boolean,
val showTimestamp: Boolean,
) : TimelineUiItem
)
private data class StickyAvatarState(
val groupKey: String,
val senderLabel: String,
val yPx: Int,
)
private data class SystemTimelineItem(
override val key: String,
@@ -285,7 +346,7 @@ private fun buildTimelineItems(
val items = mutableListOf<TimelineUiItem>()
var previousDayStartMs: Long? = null
var lastMessageIndex: Int? = null
var lastMessageGroupIndex: Int? = null
for (event in chronologicalEvents) {
val content = event.content?.getOrNull()
@@ -298,7 +359,7 @@ private fun buildTimelineItems(
label = formatDateDividerLabel(dayStartMs),
)
previousDayStartMs = dayStartMs
lastMessageIndex = null
lastMessageGroupIndex = null
}
when {
@@ -307,7 +368,7 @@ private fun buildTimelineItems(
key = "event:${event.eventId.full}",
text = "Not decrypted",
)
lastMessageIndex = null
lastMessageGroupIndex = null
}
content is RoomMessageEventContent -> {
@@ -316,22 +377,7 @@ private fun buildTimelineItems(
val senderLabel = event.extractSenderDisplayName().takeUnless { it.isNullOrBlank() }
?: senderId.toSenderLabel()
val timeLabel = timestampMs?.let(::formatTimeLabel)
val previousMessage = lastMessageIndex
?.let { items.getOrNull(it) as? MessageTimelineItem }
val groupedWithPrevious = previousMessage != null &&
previousMessage.senderId != null &&
previousMessage.senderId == senderId &&
previousMessage.isOwn == isOwn &&
previousMessage.timestampMs != null &&
timestampMs != null &&
timestampMs - previousMessage.timestampMs <= MESSAGE_GROUP_WINDOW_MS
if (groupedWithPrevious && previousMessage != null && lastMessageIndex != null) {
items[lastMessageIndex] = previousMessage.copy(showTimestamp = false)
}
items += MessageTimelineItem(
val message = MessageTimelineItem(
key = "event:${event.eventId.full}",
content = content,
senderId = senderId,
@@ -339,11 +385,33 @@ private fun buildTimelineItems(
timeLabel = timeLabel,
timestampMs = timestampMs,
isOwn = isOwn,
showSender = !isOwn && !groupedWithPrevious,
showAvatar = !isOwn && !groupedWithPrevious,
showTimestamp = true,
)
lastMessageIndex = items.lastIndex
val previousGroup = lastMessageGroupIndex
?.let { items.getOrNull(it) as? MessageGroupItem }
val previousMessage = previousGroup?.messages?.lastOrNull()
val groupedWithPrevious = previousGroup != null &&
previousGroup.senderId != null &&
previousGroup.senderId == senderId &&
previousGroup.isOwn == isOwn &&
previousMessage?.timestampMs != null &&
timestampMs != null &&
timestampMs - previousMessage.timestampMs <= MESSAGE_GROUP_WINDOW_MS
if (groupedWithPrevious && previousGroup != null && lastMessageGroupIndex != null) {
items[lastMessageGroupIndex] = previousGroup.copy(
messages = previousGroup.messages + message,
)
} else {
items += MessageGroupItem(
key = "group:${event.eventId.full}",
senderId = senderId,
senderLabel = senderLabel,
isOwn = isOwn,
messages = listOf(message),
)
lastMessageGroupIndex = items.lastIndex
}
}
event.event is ClientEvent.RoomEvent.StateEvent -> {
@@ -351,7 +419,7 @@ private fun buildTimelineItems(
key = "event:${event.eventId.full}",
text = stateEventLabel(content),
)
lastMessageIndex = null
lastMessageGroupIndex = null
}
else -> {
@@ -359,7 +427,7 @@ private fun buildTimelineItems(
key = "event:${event.eventId.full}",
text = content.toString(),
)
lastMessageIndex = null
lastMessageGroupIndex = null
}
}
}
@@ -368,10 +436,16 @@ private fun buildTimelineItems(
}
@Composable
private fun TimelineItem(item: TimelineUiItem) {
private fun TimelineItem(
item: TimelineUiItem,
pinnedAvatarGroupKey: String?,
) {
when (item) {
is DateDividerItem -> DateDivider(item)
is MessageTimelineItem -> MessageRow(item)
is MessageGroupItem -> MessageGroupRow(
group = item,
hideInlineAvatar = pinnedAvatarGroupKey == item.key,
)
is SystemTimelineItem -> SystemEventRow(item)
}
}
@@ -413,58 +487,39 @@ private fun SystemEventRow(item: SystemTimelineItem) {
}
@Composable
private fun MessageRow(item: MessageTimelineItem) {
val bubbleColor = MaterialTheme.colorScheme.surfaceVariant
val bubbleTextColor = MaterialTheme.colorScheme.onSurfaceVariant
private fun MessageGroupRow(
group: MessageGroupItem,
hideInlineAvatar: Boolean,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp),
contentAlignment = if (item.isOwn) Alignment.CenterEnd else Alignment.CenterStart,
contentAlignment = if (group.isOwn) Alignment.CenterEnd else Alignment.CenterStart,
) {
Row(
horizontalArrangement = if (item.isOwn) Arrangement.End else Arrangement.Start,
horizontalArrangement = if (group.isOwn) Arrangement.End else Arrangement.Start,
verticalAlignment = Alignment.Bottom,
) {
if (!item.isOwn) {
if (item.showAvatar) {
AvatarPlaceholder(item.senderLabel)
if (!group.isOwn) {
if (hideInlineAvatar) {
Spacer(Modifier.size(TIMELINE_AVATAR_SIZE))
} else {
Spacer(Modifier.width(32.dp))
AvatarPlaceholder(group.senderLabel)
}
Spacer(Modifier.width(8.dp))
}
Column(
modifier = Modifier
.widthIn(max = 320.dp)
.background(bubbleColor, RoundedCornerShape(18.dp))
.padding(horizontal = 10.dp, vertical = 8.dp),
modifier = Modifier.widthIn(max = 320.dp),
horizontalAlignment = if (group.isOwn) Alignment.End else Alignment.Start,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
if (item.showSender) {
Text(
text = item.senderLabel,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = bubbleTextColor,
modifier = Modifier.padding(bottom = 4.dp),
)
}
MessageBody(
content = item.content,
textColor = bubbleTextColor,
)
if (item.showTimestamp && item.timeLabel != null) {
Text(
text = item.timeLabel,
modifier = Modifier
.align(Alignment.End)
.padding(top = 4.dp),
style = MaterialTheme.typography.labelSmall,
color = bubbleTextColor.copy(alpha = 0.75f),
for ((index, message) in group.messages.withIndex()) {
MessageBubble(
item = message,
showSender = !group.isOwn && index == 0,
showTimestamp = index == group.messages.lastIndex,
)
}
}
@@ -472,6 +527,48 @@ private fun MessageRow(item: MessageTimelineItem) {
}
}
@Composable
private fun MessageBubble(
item: MessageTimelineItem,
showSender: Boolean,
showTimestamp: Boolean,
) {
val bubbleColor = MaterialTheme.colorScheme.surfaceVariant
val bubbleTextColor = MaterialTheme.colorScheme.onSurfaceVariant
Column(
modifier = Modifier
.background(bubbleColor, RoundedCornerShape(18.dp))
.padding(horizontal = 10.dp, vertical = 8.dp),
) {
if (showSender) {
Text(
text = item.senderLabel,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = bubbleTextColor,
modifier = Modifier.padding(bottom = 4.dp),
)
}
MessageBody(
content = item.content,
textColor = bubbleTextColor,
)
if (showTimestamp && item.timeLabel != null) {
Text(
text = item.timeLabel,
modifier = Modifier
.align(Alignment.End)
.padding(top = 4.dp),
style = MaterialTheme.typography.labelSmall,
color = bubbleTextColor.copy(alpha = 0.75f),
)
}
}
}
@Composable
private fun AvatarPlaceholder(senderLabel: String) {
val initial = senderLabel.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "?"
@@ -513,6 +610,50 @@ private fun MessageBody(
}
}
private fun TimelineUiItem.latestKey(): String {
return when (this) {
is MessageGroupItem -> messages.lastOrNull()?.key ?: key
else -> key
}
}
private fun calculateStickyAvatarState(
timelineItems: List<TimelineUiItem>,
layoutInfo: androidx.compose.foundation.lazy.LazyListLayoutInfo,
avatarSizePx: Int,
bottomPaddingPx: Int,
viewportBottomLimitPx: Int,
): StickyAvatarState? {
val viewportBottom = if (viewportBottomLimitPx > 0) {
layoutInfo.viewportEndOffset.coerceAtMost(viewportBottomLimitPx)
} else {
layoutInfo.viewportEndOffset
}
val viewportTop = layoutInfo.viewportStartOffset
val pinnedAvatarTop = viewportBottom - avatarSizePx - bottomPaddingPx
return layoutInfo.visibleItemsInfo
.mapNotNull { visibleItem ->
val group = timelineItems.getOrNull(visibleItem.index) as? MessageGroupItem
?: return@mapNotNull null
if (group.isOwn) return@mapNotNull null
val groupTop = visibleItem.offset
val groupBottom = visibleItem.offset + visibleItem.size
val normalAvatarTop = groupBottom - avatarSizePx
val isVisible = groupBottom > viewportTop && groupTop < viewportBottom
val shouldPin = isVisible && normalAvatarTop > pinnedAvatarTop
if (!shouldPin) return@mapNotNull null
StickyAvatarState(
groupKey = group.key,
senderLabel = group.senderLabel,
yPx = pinnedAvatarTop.coerceAtLeast(groupTop),
)
}
.lastOrNull()
}
private fun stateEventLabel(content: Any): String {
return when (content) {
is AvatarEventContent -> "Avatar changed"
+2
View File
@@ -17,6 +17,7 @@ navigationCompose = "2.9.7"
room = "2.6.1"
splittiesFunPackAndroidBase = "3.0.0"
trixnityClient = "5.2.0"
uiUnit = "1.10.6"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -53,6 +54,7 @@ trixnity-client = { module = "de.connect2x.trixnity:trixnity-client", version.re
trixnity-client-media-okio = { module = "de.connect2x.trixnity:trixnity-client-media-okio", version.ref = "trixnityClient" }
trixnity-client-repository-room = { module = "de.connect2x.trixnity:trixnity-client-repository-room", version.ref = "trixnityClient" }
trixnity-client-cryptodriver-vodozemac = { module = "de.connect2x.trixnity:trixnity-client-cryptodriver-vodozemac", version.ref = "trixnityClient" }
androidx-compose-ui-unit = { group = "androidx.compose.ui", name = "ui-unit", version.ref = "uiUnit" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }