Message bubbles
This commit is contained in:
@@ -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
|
||||
@@ -93,12 +99,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",
|
||||
@@ -174,6 +185,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()
|
||||
|
||||
@@ -181,11 +196,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))
|
||||
}
|
||||
@@ -219,26 +234,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(
|
||||
@@ -248,13 +483,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"
|
||||
@@ -263,22 +499,120 @@ 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
|
||||
private fun FormattedMessageContent(
|
||||
|
||||
Reference in New Issue
Block a user