diff --git a/app/src/main/java/ru/risdeveau/pixeldragon/ui/layout/Room.kt b/app/src/main/java/ru/risdeveau/pixeldragon/ui/layout/Room.kt index a14f83a..95b517d 100755 --- a/app/src/main/java/ru/risdeveau/pixeldragon/ui/layout/Room.kt +++ b/app/src/main/java/ru/risdeveau/pixeldragon/ui/layout/Room.kt @@ -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,67 +235,386 @@ fun Room(modifier: Modifier = Modifier, rid: String) { } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun EventItem(event: TimelineEvent) { - 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) +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, + currentUserId: String, +): List { + if (events.isEmpty()) return emptyList() + + val chronologicalEvents = events.asReversed() + val items = mutableListOf() + + 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 -> { - val formatted = content.formattedBody - if (formatted != null) { - FormattedMessageContent( - htmlContent = formatted, - plainText = content.body, - ) + 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 = content.body, - modifier = Modifier.padding(4.dp) + 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), ) } } - - 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 private fun FormattedMessageContent( htmlContent: String,