From 0e107eca0b5d2ea90eea5f137dffd4dbfff194bb Mon Sep 17 00:00:00 2001 From: Sweetbread Date: Sat, 18 Apr 2026 23:25:19 +0300 Subject: [PATCH] Sticky avatar --- app/build.gradle.kts | 6 + .../risdeveau/pixeldragon/ui/layout/Room.kt | 303 +++++++++++++----- gradle/libs.versions.toml | 2 + 3 files changed, 230 insertions(+), 81 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 323033f..44b676a 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/ru/risdeveau/pixeldragon/ui/layout/Room.kt b/app/src/main/java/ru/risdeveau/pixeldragon/ui/layout/Room.kt index 32d69c6..e522872 100755 --- a/app/src/main/java/ru/risdeveau/pixeldragon/ui/layout/Room.kt +++ b/app/src/main/java/ru/risdeveau/pixeldragon/ui/layout/Room.kt @@ -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, +) : 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() 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, + 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" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fa6ae02..6134d67 100755 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }