Message bubbles

This commit is contained in:
2026-04-14 01:01:15 +03:00
parent 0001de3128
commit 414eeae4f6
@@ -25,19 +25,23 @@ import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.TextView
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -52,12 +56,14 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
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.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.dp
import androidx.compose.ui.unit.sp
@@ -94,12 +100,17 @@ import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import org.jsoup.safety.Safelist
import ru.risdeveau.pixeldragon.client
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import kotlin.math.abs
import kotlin.math.ceil
import kotlin.math.max
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 simpleHtmlTags = setOf(
"a", "b", "blockquote", "br", "code", "del", "div", "em", "h1", "h2",
@@ -175,6 +186,10 @@ fun Room(modifier: Modifier = Modifier, rid: String) {
}
}
val events by eventsFlow.collectAsState(initial = emptyList())
val currentUserId = remember { client!!.userId.toString() }
val timelineItems = remember(events, currentUserId) {
buildTimelineItems(events = events, currentUserId = currentUserId)
}
val listState = rememberLazyListState()
@@ -182,11 +197,11 @@ fun Room(modifier: Modifier = Modifier, rid: String) {
var message by remember { mutableStateOf("") }
LazyColumn(Modifier.weight(1f), state = listState, reverseLayout = true) {
items(events, key = { it.eventId.full }) { event ->
EventItem(event)
items(timelineItems, key = { it.key }) { item ->
TimelineItem(item)
}
if (events.isEmpty()) {
if (timelineItems.isEmpty()) {
item {
Text("Empty room", modifier = Modifier.padding(16.dp))
}
@@ -220,26 +235,246 @@ fun Room(modifier: Modifier = Modifier, rid: String) {
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EventItem(event: TimelineEvent) {
private sealed interface TimelineUiItem {
val key: String
}
private data class DateDividerItem(
override val key: String,
val label: String,
) : TimelineUiItem
private data class MessageTimelineItem(
override 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 SystemTimelineItem(
override val key: String,
val text: String,
) : TimelineUiItem
private fun buildTimelineItems(
events: List<TimelineEvent>,
currentUserId: String,
): List<TimelineUiItem> {
if (events.isEmpty()) return emptyList()
val chronologicalEvents = events.asReversed()
val items = mutableListOf<TimelineUiItem>()
var previousDayStartMs: Long? = null
var lastMessageIndex: Int? = null
for (event in chronologicalEvents) {
val content = event.content?.getOrNull()
Box(
Modifier
.fillMaxWidth()
.heightIn(min = 24.dp)
) {
when {
content == null -> Text(
"Not decrypted",
Modifier
.fillMaxHeight()
.padding(4.dp)
.background(MaterialTheme.colorScheme.errorContainer)
.padding(4.dp)
val timestampMs = event.extractTimestampMillis()
val dayStartMs = timestampMs?.let(::startOfDayMillis)
if (dayStartMs != null && dayStartMs != previousDayStartMs) {
items += DateDividerItem(
key = "date:$dayStartMs",
label = formatDateDividerLabel(dayStartMs),
)
previousDayStartMs = dayStartMs
lastMessageIndex = null
}
when {
content == null -> {
items += SystemTimelineItem(
key = "event:${event.eventId.full}",
text = "Not decrypted",
)
lastMessageIndex = null
}
content is RoomMessageEventContent -> {
val senderId = event.extractSenderId()
val isOwn = senderId != null && senderId == currentUserId
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(
key = "event:${event.eventId.full}",
content = content,
senderId = senderId,
senderLabel = senderLabel,
timeLabel = timeLabel,
timestampMs = timestampMs,
isOwn = isOwn,
showSender = !isOwn && !groupedWithPrevious,
showAvatar = !isOwn && !groupedWithPrevious,
showTimestamp = true,
)
lastMessageIndex = items.lastIndex
}
event.event is ClientEvent.RoomEvent.StateEvent -> {
items += SystemTimelineItem(
key = "event:${event.eventId.full}",
text = stateEventLabel(content),
)
lastMessageIndex = null
}
else -> {
items += SystemTimelineItem(
key = "event:${event.eventId.full}",
text = content.toString(),
)
lastMessageIndex = null
}
}
}
return items.asReversed()
}
@Composable
private fun TimelineItem(item: TimelineUiItem) {
when (item) {
is DateDividerItem -> DateDivider(item)
is MessageTimelineItem -> MessageRow(item)
is SystemTimelineItem -> SystemEventRow(item)
}
}
@Composable
private fun DateDivider(item: DateDividerItem) {
Text(
text = item.label,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@Composable
private fun SystemEventRow(item: SystemTimelineItem) {
Text(
text = item.text,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@Composable
private fun MessageRow(item: MessageTimelineItem) {
val bubbleColor = MaterialTheme.colorScheme.surfaceVariant
val bubbleTextColor = MaterialTheme.colorScheme.onSurfaceVariant
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp),
contentAlignment = if (item.isOwn) Alignment.CenterEnd else Alignment.CenterStart,
) {
Row(
horizontalArrangement = if (item.isOwn) Arrangement.End else Arrangement.Start,
verticalAlignment = Alignment.Bottom,
) {
if (!item.isOwn) {
if (item.showAvatar) {
AvatarPlaceholder(item.senderLabel)
} else {
Spacer(Modifier.width(32.dp))
}
Spacer(Modifier.width(8.dp))
}
Column(
modifier = Modifier
.widthIn(max = 320.dp)
.background(bubbleColor, RoundedCornerShape(18.dp))
.padding(horizontal = 10.dp, vertical = 8.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),
)
}
}
}
}
}
@Composable
private fun AvatarPlaceholder(senderLabel: String) {
val initial = senderLabel.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "?"
Box(
modifier = Modifier
.size(32.dp)
.background(
color = MaterialTheme.colorScheme.secondaryContainer,
shape = CircleShape,
),
contentAlignment = Alignment.Center,
) {
Text(
text = initial,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
@Composable
private fun MessageBody(
content: RoomMessageEventContent,
textColor: Color,
) {
val formatted = content.formattedBody
if (formatted != null) {
FormattedMessageContent(
@@ -249,13 +484,14 @@ fun EventItem(event: TimelineEvent) {
} else {
Text(
text = content.body,
modifier = Modifier.padding(4.dp)
color = textColor,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
event.event is ClientEvent.RoomEvent.StateEvent -> Text(
when (content) {
private fun stateEventLabel(content: Any): String {
return when (content) {
is AvatarEventContent -> "Avatar changed"
is PowerLevelsEventContent -> "Permissions changed"
is MemberEventContent -> "Membership changed"
@@ -264,21 +500,119 @@ fun EventItem(event: TimelineEvent) {
is NameEventContent -> "Name changed"
is HistoryVisibilityEventContent -> "History visibility changed"
else -> content.toString()
},
Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
}
}
else -> Text(
content.toString(),
Modifier
.fillMaxHeight()
.padding(4.dp)
.background(MaterialTheme.colorScheme.errorContainer)
.padding(4.dp)
private fun TimelineEvent.extractSenderId(): String? {
return readPropertyValue(this, "sender")?.toString()?.takeIf { it.isNotBlank() }
?: readPropertyValue(event, "sender")?.toString()?.takeIf { it.isNotBlank() }
}
private fun TimelineEvent.extractSenderDisplayName(): String? {
return listOf(this, event).firstNotNullOfOrNull { holder ->
readPropertyValue(holder, "senderDisplayName")?.toString()?.takeIf { it.isNotBlank() }
?: readPropertyValue(holder, "displayName")?.toString()?.takeIf { it.isNotBlank() }
?: readPropertyValue(holder, "senderName")?.toString()?.takeIf { it.isNotBlank() }
}
}
private fun TimelineEvent.extractTimestampMillis(): Long? {
val rawValue = listOf(this, event).firstNotNullOfOrNull { holder ->
readPropertyValue(holder, "originTimestamp")
?: readPropertyValue(holder, "originServerTs")
?: readPropertyValue(holder, "timestamp")
}
return rawValue.toEpochMillisOrNull()
}
private fun readPropertyValue(target: Any?, propertyName: String): Any? {
if (target == null) return null
val getterName = buildString {
append("get")
append(propertyName.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() })
}
val methodsToTry = listOf(propertyName, getterName)
for (methodName in methodsToTry) {
val value = runCatching {
target.javaClass.methods.firstOrNull {
it.name == methodName && it.parameterCount == 0
}?.invoke(target)
}.getOrNull()
if (value != null) return value
}
return runCatching {
target.javaClass.declaredFields.firstOrNull { it.name == propertyName }?.let { field ->
field.isAccessible = true
field.get(target)
}
}.getOrNull()
}
private fun Any?.toEpochMillisOrNull(): Long? {
return when (this) {
null -> null
is Number -> toLong()
is Date -> time
else -> {
val directString = toString().toLongOrNull()
if (directString != null) {
directString
} else {
val methodNames = listOf(
"toEpochMilliseconds",
"toEpochMilli",
"getEpochMilliseconds",
"getTime",
)
methodNames.firstNotNullOfOrNull { methodName ->
runCatching {
javaClass.methods.firstOrNull {
it.name == methodName && it.parameterCount == 0
}?.invoke(this)
}.getOrNull().let { value ->
when (value) {
is Number -> value.toLong()
else -> value?.toString()?.toLongOrNull()
}
}
}
}
}
}
}
private fun String?.toSenderLabel(): String {
if (this.isNullOrBlank()) return "Unknown"
val withoutSigil = removePrefix("@")
return withoutSigil.substringBefore(':').ifBlank { this }
}
private fun startOfDayMillis(timestampMs: Long): Long {
val calendar = Calendar.getInstance().apply {
timeInMillis = timestampMs
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}
return calendar.timeInMillis
}
private fun formatDateDividerLabel(dayStartMs: Long): String {
val todayStart = startOfDayMillis(System.currentTimeMillis())
val yesterdayStart = todayStart - 24L * 60L * 60L * 1000L
return when (dayStartMs) {
todayStart -> "Today"
yesterdayStart -> "Yesterday"
else -> SimpleDateFormat("d MMM", Locale.getDefault()).format(Date(dayStartMs))
}
}
private fun formatTimeLabel(timestampMs: Long): String {
return SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(timestampMs))
}
@Composable