Message bubbles
This commit is contained in:
@@ -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
|
||||||
@@ -93,12 +99,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",
|
||||||
@@ -174,6 +185,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()
|
||||||
|
|
||||||
@@ -181,11 +196,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))
|
||||||
}
|
}
|
||||||
@@ -219,67 +234,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,
|
||||||
|
|||||||
Reference in New Issue
Block a user