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 import org.jetbrains.kotlin.gradle.dsl.JvmTarget
/* /*
@@ -58,6 +63,7 @@ dependencies {
implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3) implementation(libs.androidx.material3)
implementation(libs.androidx.compose.ui.unit)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) 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.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@@ -51,6 +53,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -59,15 +62,18 @@ import androidx.compose.runtime.setValue
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.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.zIndex
import androidx.core.text.HtmlCompat import androidx.core.text.HtmlCompat
import androidx.core.widget.TextViewCompat import androidx.core.widget.TextViewCompat
import de.connect2x.trixnity.client.room 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 WEBVIEW_HEIGHT_TITLE_PREFIX = "pd-height:"
private const val MESSAGE_GROUP_WINDOW_MS = 5 * 60 * 1000L 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( private val simpleHtmlTags = setOf(
"a", "b", "blockquote", "br", "code", "del", "div", "em", "h1", "h2", "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) { val timelineItems = remember(events, currentUserId) {
buildTimelineItems(events = events, currentUserId = currentUserId) buildTimelineItems(events = events, currentUserId = currentUserId)
} }
val latestTimelineKey = remember(timelineItems) {
timelineItems.lastOrNull()?.latestKey()
}
val listState = rememberLazyListState() val listState = rememberLazyListState()
Column(modifier) { Column(modifier) {
var message by remember { mutableStateOf("") } var message by remember { mutableStateOf("") }
LaunchedEffect(timelineItems.size) { LaunchedEffect(latestTimelineKey) {
if (timelineItems.isNotEmpty()) { if (timelineItems.isNotEmpty()) {
listState.scrollToItem(timelineItems.lastIndex) 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) { for (timelineItem in timelineItems) {
when (timelineItem) { when (timelineItem) {
is DateDividerItem -> stickyHeader(key = timelineItem.key) { is DateDividerItem -> stickyHeader(key = timelineItem.key) {
@@ -210,7 +239,10 @@ fun Room(modifier: Modifier = Modifier, rid: String) {
} }
else -> item(key = timelineItem.key) { 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()) { Row(Modifier.fillMaxWidth()) {
OutlinedTextField( OutlinedTextField(
modifier = Modifier modifier = Modifier
.padding(4.dp) .padding(MESSAGE_FIELD_TOP_PADDING)
.weight(1f), .weight(1f),
value = message, value = message,
onValueChange = { message = it }, onValueChange = { message = it },
@@ -258,18 +306,29 @@ private data class DateDividerItem(
val label: String, val label: String,
) : TimelineUiItem ) : TimelineUiItem
private data class MessageTimelineItem( private data class MessageGroupItem(
override val key: String, 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 content: RoomMessageEventContent,
val senderId: String?, val senderId: String?,
val senderLabel: String, val senderLabel: String,
val timeLabel: String?, val timeLabel: String?,
val timestampMs: Long?, val timestampMs: Long?,
val isOwn: Boolean, val isOwn: Boolean,
val showSender: Boolean, )
val showAvatar: Boolean,
val showTimestamp: Boolean, private data class StickyAvatarState(
) : TimelineUiItem val groupKey: String,
val senderLabel: String,
val yPx: Int,
)
private data class SystemTimelineItem( private data class SystemTimelineItem(
override val key: String, override val key: String,
@@ -286,7 +345,7 @@ private fun buildTimelineItems(
val items = mutableListOf<TimelineUiItem>() val items = mutableListOf<TimelineUiItem>()
var previousDayStartMs: Long? = null var previousDayStartMs: Long? = null
var lastMessageIndex: Int? = null var lastMessageGroupIndex: Int? = null
for (event in chronologicalEvents) { for (event in chronologicalEvents) {
val content = event.content?.getOrNull() val content = event.content?.getOrNull()
@@ -299,7 +358,7 @@ private fun buildTimelineItems(
label = formatDateDividerLabel(dayStartMs), label = formatDateDividerLabel(dayStartMs),
) )
previousDayStartMs = dayStartMs previousDayStartMs = dayStartMs
lastMessageIndex = null lastMessageGroupIndex = null
} }
when { when {
@@ -308,7 +367,7 @@ private fun buildTimelineItems(
key = "event:${event.eventId.full}", key = "event:${event.eventId.full}",
text = "Not decrypted", text = "Not decrypted",
) )
lastMessageIndex = null lastMessageGroupIndex = null
} }
content is RoomMessageEventContent -> { content is RoomMessageEventContent -> {
@@ -317,22 +376,7 @@ private fun buildTimelineItems(
val senderLabel = event.extractSenderDisplayName().takeUnless { it.isNullOrBlank() } val senderLabel = event.extractSenderDisplayName().takeUnless { it.isNullOrBlank() }
?: senderId.toSenderLabel() ?: senderId.toSenderLabel()
val timeLabel = timestampMs?.let(::formatTimeLabel) val timeLabel = timestampMs?.let(::formatTimeLabel)
val message = MessageTimelineItem(
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(
key = "event:${event.eventId.full}", key = "event:${event.eventId.full}",
content = content, content = content,
senderId = senderId, senderId = senderId,
@@ -340,11 +384,33 @@ private fun buildTimelineItems(
timeLabel = timeLabel, timeLabel = timeLabel,
timestampMs = timestampMs, timestampMs = timestampMs,
isOwn = isOwn, 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 -> { event.event is ClientEvent.RoomEvent.StateEvent -> {
@@ -352,7 +418,7 @@ private fun buildTimelineItems(
key = "event:${event.eventId.full}", key = "event:${event.eventId.full}",
text = stateEventLabel(content), text = stateEventLabel(content),
) )
lastMessageIndex = null lastMessageGroupIndex = null
} }
else -> { else -> {
@@ -360,7 +426,7 @@ private fun buildTimelineItems(
key = "event:${event.eventId.full}", key = "event:${event.eventId.full}",
text = content.toString(), text = content.toString(),
) )
lastMessageIndex = null lastMessageGroupIndex = null
} }
} }
} }
@@ -369,10 +435,16 @@ private fun buildTimelineItems(
} }
@Composable @Composable
private fun TimelineItem(item: TimelineUiItem) { private fun TimelineItem(
item: TimelineUiItem,
pinnedAvatarGroupKey: String?,
) {
when (item) { when (item) {
is DateDividerItem -> DateDivider(item) is DateDividerItem -> DateDivider(item)
is MessageTimelineItem -> MessageRow(item) is MessageGroupItem -> MessageGroupRow(
group = item,
hideInlineAvatar = pinnedAvatarGroupKey == item.key,
)
is SystemTimelineItem -> SystemEventRow(item) is SystemTimelineItem -> SystemEventRow(item)
} }
} }
@@ -414,39 +486,61 @@ private fun SystemEventRow(item: SystemTimelineItem) {
} }
@Composable @Composable
private fun MessageRow(item: MessageTimelineItem) { private fun MessageGroupRow(
val bubbleColor = if (item.senderId != client!!.userId.full) group: MessageGroupItem,
MaterialTheme.colorScheme.surfaceContainer hideInlineAvatar: Boolean,
else ) {
MaterialTheme.colorScheme.primaryContainer
val bubbleTextColor = MaterialTheme.colorScheme.onSurfaceVariant
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp), .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( Row(
horizontalArrangement = if (item.isOwn) Arrangement.End else Arrangement.Start, horizontalArrangement = if (group.isOwn) Arrangement.End else Arrangement.Start,
verticalAlignment = Alignment.Bottom, verticalAlignment = Alignment.Bottom,
) { ) {
if (!item.isOwn) { if (!group.isOwn) {
if (item.showAvatar) { if (hideInlineAvatar) {
AvatarPlaceholder(item.senderLabel) Spacer(Modifier.size(TIMELINE_AVATAR_SIZE))
} else { } else {
Spacer(Modifier.width(32.dp)) AvatarPlaceholder(group.senderLabel)
} }
Spacer(Modifier.width(8.dp)) 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( Column(
modifier = Modifier modifier = Modifier
.widthIn(max = 320.dp)
.background(bubbleColor, RoundedCornerShape(18.dp)) .background(bubbleColor, RoundedCornerShape(18.dp))
.padding(horizontal = 10.dp, vertical = 8.dp), .padding(horizontal = 10.dp, vertical = 8.dp),
) { ) {
if (item.showSender) { if (showSender) {
Text( Text(
text = item.senderLabel, text = item.senderLabel,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
@@ -461,7 +555,7 @@ private fun MessageRow(item: MessageTimelineItem) {
textColor = bubbleTextColor, textColor = bubbleTextColor,
) )
if (item.showTimestamp && item.timeLabel != null) { if (showTimestamp && item.timeLabel != null) {
Text( Text(
text = item.timeLabel, text = item.timeLabel,
modifier = Modifier modifier = Modifier
@@ -472,8 +566,6 @@ private fun MessageRow(item: MessageTimelineItem) {
) )
} }
} }
}
}
} }
@Composable @Composable
@@ -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 { private fun stateEventLabel(content: Any): String {
return when (content) { return when (content) {
is AvatarEventContent -> "Avatar changed" is AvatarEventContent -> "Avatar changed"
+2
View File
@@ -17,6 +17,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.2.0"
uiUnit = "1.10.6"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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-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-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" } 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }