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.webkit.WebViewClient
import android.widget.TextView import android.widget.TextView
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints 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.fillMaxHeight import androidx.compose.foundation.layout.Spacer
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.heightIn
import androidx.compose.foundation.layout.padding 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.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState 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.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -52,12 +56,14 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
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.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.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -94,12 +100,17 @@ import org.jsoup.Jsoup
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
import org.jsoup.safety.Safelist import org.jsoup.safety.Safelist
import ru.risdeveau.pixeldragon.client 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.abs
import kotlin.math.ceil import kotlin.math.ceil
import kotlin.math.max import kotlin.math.max
import kotlin.math.roundToInt 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 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",
@@ -175,6 +186,10 @@ fun Room(modifier: Modifier = Modifier, rid: String) {
} }
} }
val events by eventsFlow.collectAsState(initial = emptyList()) 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() val listState = rememberLazyListState()
@@ -182,11 +197,11 @@ fun Room(modifier: Modifier = Modifier, rid: String) {
var message by remember { mutableStateOf("") } var message by remember { mutableStateOf("") }
LazyColumn(Modifier.weight(1f), state = listState, reverseLayout = true) { LazyColumn(Modifier.weight(1f), state = listState, reverseLayout = true) {
items(events, key = { it.eventId.full }) { event -> items(timelineItems, key = { it.key }) { item ->
EventItem(event) TimelineItem(item)
} }
if (events.isEmpty()) { if (timelineItems.isEmpty()) {
item { item {
Text("Empty room", modifier = Modifier.padding(16.dp)) Text("Empty room", modifier = Modifier.padding(16.dp))
} }
@@ -220,67 +235,386 @@ fun Room(modifier: Modifier = Modifier, rid: String) {
} }
} }
@OptIn(ExperimentalMaterial3Api::class) private sealed interface TimelineUiItem {
@Composable val key: String
fun EventItem(event: TimelineEvent) { }
val content = event.content?.getOrNull()
Box( private data class DateDividerItem(
Modifier override val key: String,
.fillMaxWidth() val label: String,
.heightIn(min = 24.dp) ) : TimelineUiItem
) {
when { private data class MessageTimelineItem(
content == null -> Text( override val key: String,
"Not decrypted", val content: RoomMessageEventContent,
Modifier val senderId: String?,
.fillMaxHeight() val senderLabel: String,
.padding(4.dp) val timeLabel: String?,
.background(MaterialTheme.colorScheme.errorContainer) val timestampMs: Long?,
.padding(4.dp) 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()
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 -> { content is RoomMessageEventContent -> {
val formatted = content.formattedBody val senderId = event.extractSenderId()
if (formatted != null) { val isOwn = senderId != null && senderId == currentUserId
FormattedMessageContent( val senderLabel = event.extractSenderDisplayName().takeUnless { it.isNullOrBlank() }
htmlContent = formatted, ?: senderId.toSenderLabel()
plainText = content.body, 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 { } 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(
text = content.body, text = item.senderLabel,
modifier = Modifier.padding(4.dp) 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),
) )
} }
} }
event.event is ClientEvent.RoomEvent.StateEvent -> Text(
when (content) {
is AvatarEventContent -> "Avatar changed"
is PowerLevelsEventContent -> "Permissions changed"
is MemberEventContent -> "Membership changed"
is CanonicalAliasEventContent -> "Canonical alias changed"
is TopicEventContent -> "Topic changed"
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)
)
} }
} }
} }
@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(
htmlContent = formatted,
plainText = content.body,
)
} else {
Text(
text = content.body,
color = textColor,
style = MaterialTheme.typography.bodyMedium,
)
}
}
private fun stateEventLabel(content: Any): String {
return when (content) {
is AvatarEventContent -> "Avatar changed"
is PowerLevelsEventContent -> "Permissions changed"
is MemberEventContent -> "Membership changed"
is CanonicalAliasEventContent -> "Canonical alias changed"
is TopicEventContent -> "Topic changed"
is NameEventContent -> "Name changed"
is HistoryVisibilityEventContent -> "History visibility changed"
else -> content.toString()
}
}
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 @Composable
private fun FormattedMessageContent( private fun FormattedMessageContent(
htmlContent: String, htmlContent: String,