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
|
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,34 +198,74 @@ 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
|
||||||
for (timelineItem in timelineItems) {
|
BoxWithConstraints(
|
||||||
when (timelineItem) {
|
Modifier
|
||||||
is DateDividerItem -> stickyHeader(key = timelineItem.key) {
|
.weight(1f)
|
||||||
DateDivider(timelineItem)
|
.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) {
|
||||||
|
DateDivider(timelineItem)
|
||||||
|
}
|
||||||
|
|
||||||
else -> item(key = timelineItem.key) {
|
else -> item(key = timelineItem.key) {
|
||||||
TimelineItem(timelineItem)
|
TimelineItem(
|
||||||
|
item = timelineItem,
|
||||||
|
pinnedAvatarGroupKey = stickyAvatar?.groupKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timelineItems.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Text("Empty room", modifier = Modifier.padding(16.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (timelineItems.isEmpty()) {
|
stickyAvatar?.let { avatarState ->
|
||||||
item {
|
Box(
|
||||||
Text("Empty room", modifier = Modifier.padding(16.dp))
|
modifier = Modifier
|
||||||
|
.offset {
|
||||||
|
IntOffset(
|
||||||
|
x = with(density) { MESSAGE_FIELD_TOP_PADDING.roundToPx() },
|
||||||
|
y = avatarState.yPx,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.zIndex(1f),
|
||||||
|
) {
|
||||||
|
AvatarPlaceholder(avatarState.senderLabel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,7 +273,7 @@ fun Room(modifier: Modifier = Modifier, rid: String) {
|
|||||||
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,61 +486,39 @@ 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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier.widthIn(max = 320.dp),
|
||||||
.widthIn(max = 320.dp)
|
horizontalAlignment = if (group.isOwn) Alignment.End else Alignment.Start,
|
||||||
.background(bubbleColor, RoundedCornerShape(18.dp))
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
|
||||||
) {
|
) {
|
||||||
if (item.showSender) {
|
for ((index, message) in group.messages.withIndex()) {
|
||||||
Text(
|
MessageBubble(
|
||||||
text = item.senderLabel,
|
item = message,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
showSender = !group.isOwn && index == 0,
|
||||||
fontWeight = FontWeight.SemiBold,
|
showTimestamp = index == group.messages.lastIndex,
|
||||||
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),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -476,6 +526,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
|
@Composable
|
||||||
private fun AvatarPlaceholder(senderLabel: String) {
|
private fun AvatarPlaceholder(senderLabel: String) {
|
||||||
val initial = senderLabel.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
val initial = senderLabel.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user