Sticky avatar
This commit is contained in:
@@ -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,19 +198,41 @@ 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() },
|
||||
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) {
|
||||
@@ -209,7 +240,10 @@ fun Room(modifier: Modifier = Modifier, rid: String) {
|
||||
}
|
||||
|
||||
else -> item(key = timelineItem.key) {
|
||||
TimelineItem(timelineItem)
|
||||
TimelineItem(
|
||||
item = timelineItem,
|
||||
pinnedAvatarGroupKey = stickyAvatar?.groupKey,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,6 +255,22 @@ fun Room(modifier: Modifier = Modifier, rid: String) {
|
||||
}
|
||||
}
|
||||
|
||||
stickyAvatar?.let { avatarState ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset {
|
||||
IntOffset(
|
||||
x = with(density) { TIMELINE_AVATAR_START_PADDING.roundToPx() },
|
||||
y = avatarState.yPx,
|
||||
)
|
||||
}
|
||||
.zIndex(1f),
|
||||
) {
|
||||
AvatarPlaceholder(avatarState.senderLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
@@ -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,36 +487,61 @@ 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),
|
||||
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,
|
||||
@@ -457,7 +556,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
|
||||
@@ -468,8 +567,6 @@ private fun MessageRow(item: MessageTimelineItem) {
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user