Sticky avatar

This commit is contained in:
2026-04-18 23:25:19 +03:00
parent fc331f726e
commit 6a8e1780d7
3 changed files with 228 additions and 85 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
@@ -111,6 +117,8 @@ 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 MESSAGE_FIELD_TOP_PADDING = 8.dp
private val simpleHtmlTags = setOf(
"a", "b", "blockquote", "br", "code", "del", "div", "em", "h1", "h2",
@@ -190,19 +198,40 @@ 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) {
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() },
viewportBottomLimitPx = listContainerHeightPx,
)
}
}
LazyColumn(Modifier.fillMaxSize(), state = listState) {
for (timelineItem in timelineItems) {
when (timelineItem) {
is DateDividerItem -> stickyHeader(key = timelineItem.key) {
@@ -210,7 +239,10 @@ fun Room(modifier: Modifier = Modifier, rid: String) {
}
else -> item(key = timelineItem.key) {
TimelineItem(timelineItem)
TimelineItem(
item = timelineItem,
pinnedAvatarGroupKey = stickyAvatar?.groupKey,
)
}
}
}
@@ -222,10 +254,26 @@ fun Room(modifier: Modifier = Modifier, rid: String) {
}
}
stickyAvatar?.let { avatarState ->
Box(
modifier = Modifier
.offset {
IntOffset(
x = with(density) { MESSAGE_FIELD_TOP_PADDING.roundToPx() },
y = avatarState.yPx,
)
}
.zIndex(1f),
) {
AvatarPlaceholder(avatarState.senderLabel)
}
}
}
Row(Modifier.fillMaxWidth()) {
OutlinedTextField(
modifier = Modifier
.padding(4.dp)
.padding(MESSAGE_FIELD_TOP_PADDING)
.weight(1f),
value = message,
onValueChange = { message = it },
@@ -258,18 +306,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,
@@ -286,7 +345,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()
@@ -299,7 +358,7 @@ private fun buildTimelineItems(
label = formatDateDividerLabel(dayStartMs),
)
previousDayStartMs = dayStartMs
lastMessageIndex = null
lastMessageGroupIndex = null
}
when {
@@ -308,7 +367,7 @@ private fun buildTimelineItems(
key = "event:${event.eventId.full}",
text = "Not decrypted",
)
lastMessageIndex = null
lastMessageGroupIndex = null
}
content is RoomMessageEventContent -> {
@@ -317,22 +376,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) {
items[lastMessageIndex] = previousMessage.copy(showTimestamp = false)
}
items += MessageTimelineItem(
val message = MessageTimelineItem(
key = "event:${event.eventId.full}",
content = content,
senderId = senderId,
@@ -340,11 +384,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 -> {
@@ -352,7 +418,7 @@ private fun buildTimelineItems(
key = "event:${event.eventId.full}",
text = stateEventLabel(content),
)
lastMessageIndex = null
lastMessageGroupIndex = null
}
else -> {
@@ -360,7 +426,7 @@ private fun buildTimelineItems(
key = "event:${event.eventId.full}",
text = content.toString(),
)
lastMessageIndex = null
lastMessageGroupIndex = null
}
}
}
@@ -369,10 +435,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)
}
}
@@ -414,39 +486,61 @@ private fun SystemEventRow(item: SystemTimelineItem) {
}
@Composable
private fun MessageRow(item: MessageTimelineItem) {
val bubbleColor = if (item.senderId != client!!.userId.full)
MaterialTheme.colorScheme.surfaceContainer
else
MaterialTheme.colorScheme.primaryContainer
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),
horizontalAlignment = if (group.isOwn) Alignment.End else Alignment.Start,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
for ((index, message) in group.messages.withIndex()) {
MessageBubble(
item = message,
showSender = !group.isOwn && index == 0,
showTimestamp = index == group.messages.lastIndex,
)
}
}
}
}
}
@Composable
private fun MessageBubble(
item: MessageTimelineItem,
showSender: Boolean,
showTimestamp: Boolean,
) {
val bubbleColor = MaterialTheme.colorScheme.surfaceVariant
val bubbleTextColor = MaterialTheme.colorScheme.onSurfaceVariant
Column(
modifier = Modifier
.widthIn(max = 320.dp)
.background(bubbleColor, RoundedCornerShape(18.dp))
.padding(horizontal = 10.dp, vertical = 8.dp),
) {
if (item.showSender) {
if (showSender) {
Text(
text = item.senderLabel,
style = MaterialTheme.typography.labelSmall,
@@ -461,7 +555,7 @@ private fun MessageRow(item: MessageTimelineItem) {
textColor = bubbleTextColor,
)
if (item.showTimestamp && item.timeLabel != null) {
if (showTimestamp && item.timeLabel != null) {
Text(
text = item.timeLabel,
modifier = Modifier
@@ -473,8 +567,6 @@ private fun MessageRow(item: MessageTimelineItem) {
}
}
}
}
}
@Composable
private fun AvatarPlaceholder(senderLabel: String) {
@@ -517,6 +609,49 @@ 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,
viewportBottomLimitPx: Int,
): StickyAvatarState? {
val viewportBottom = if (viewportBottomLimitPx > 0) {
layoutInfo.viewportEndOffset.coerceAtMost(viewportBottomLimitPx)
} else {
layoutInfo.viewportEndOffset
}
val viewportTop = layoutInfo.viewportStartOffset
val pinnedAvatarTop = viewportBottom - avatarSizePx
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" }