From 100c06d8a5b2969d248d38874e8887bf4577274c Mon Sep 17 00:00:00 2001 From: infi Date: Tue, 4 Aug 2026 11:46:58 +0200 Subject: [PATCH] feat: call duration in system messages surely calls will last multiple years --- .../stoat/composables/chat/SystemMessage.kt | 129 +++++++++++++++++- app/src/main/res/values/strings.xml | 22 +++ .../chat/stoat/core/model/schemas/Messages.kt | 3 +- 3 files changed, 149 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/chat/stoat/composables/chat/SystemMessage.kt b/app/src/main/java/chat/stoat/composables/chat/SystemMessage.kt index e7454bb0..c3ef4a01 100644 --- a/app/src/main/java/chat/stoat/composables/chat/SystemMessage.kt +++ b/app/src/main/java/chat/stoat/composables/chat/SystemMessage.kt @@ -24,14 +24,17 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import chat.stoat.R import chat.stoat.api.StoatAPI +import chat.stoat.api.internals.ULID import chat.stoat.composables.markdown.prose.ChatMarkdown import chat.stoat.core.model.schemas.Message +import kotlinx.datetime.Instant enum class SystemMessageType(val type: String) { CHANNEL_OWNERSHIP_CHANGED("channel_ownership_changed"), @@ -213,11 +216,23 @@ fun SystemMessage( } SystemMessageType.CALL_STARTED -> { + val callDuration = callDurationMillis( + messageId = message.id, + finishedAt = message.system!!.finishedAt + ) ChatMarkdown( - stringResource( - R.string.system_message_call_started, - message.system!!.by.mention() - ), + if (callDuration != null) { + stringResource( + R.string.system_message_call_started_with_finished_at, + message.system!!.by.mention(), + formatCallDuration(callDuration) + ) + } else { + stringResource( + R.string.system_message_call_started, + message.system!!.by.mention() + ) + }, serverId = serverId ) } @@ -230,6 +245,112 @@ fun SystemMessage( } } +internal fun callDurationMillis(messageId: String?, finishedAt: String?): Long? { + if (messageId == null || finishedAt == null) return null + + return runCatching { + val duration = Math.subtractExact( + Instant.parse(finishedAt).toEpochMilliseconds(), + ULID.asTimestamp(messageId) + ) + duration.takeIf { it >= 0 } + }.getOrNull() +} + +internal enum class CallDurationUnit { + FEW_SECONDS, + MINUTES, + HOURS, + DAYS, + MONTHS, + YEARS, +} + +internal data class CallDuration( + val unit: CallDurationUnit, + val quantity: Int = 0, +) + +internal fun humanizeCallDuration(durationMillis: Long): CallDuration { + val seconds = durationMillis.coerceAtLeast(0) / MILLIS_PER_SECOND + return when { + seconds < SECONDS_PER_MINUTE -> CallDuration(CallDurationUnit.FEW_SECONDS) + seconds < SECONDS_PER_HOUR -> CallDuration( + CallDurationUnit.MINUTES, + (seconds / SECONDS_PER_MINUTE).asQuantity() + ) + + seconds < SECONDS_PER_DAY -> CallDuration( + CallDurationUnit.HOURS, + (seconds / SECONDS_PER_HOUR).asQuantity() + ) + + seconds < SECONDS_PER_MONTH -> CallDuration( + CallDurationUnit.DAYS, + (seconds / SECONDS_PER_DAY).asQuantity() + ) + + seconds < SECONDS_PER_YEAR -> CallDuration( + CallDurationUnit.MONTHS, + (seconds / SECONDS_PER_MONTH).asQuantity() + ) + + else -> CallDuration( + CallDurationUnit.YEARS, + (seconds / SECONDS_PER_YEAR).asQuantity() + ) + } +} + +private const val MILLIS_PER_SECOND = 1_000L +private const val SECONDS_PER_MINUTE = 60L +private const val SECONDS_PER_HOUR = 60 * SECONDS_PER_MINUTE +private const val SECONDS_PER_DAY = 24 * SECONDS_PER_HOUR +private const val SECONDS_PER_MONTH = 30 * SECONDS_PER_DAY +private const val SECONDS_PER_YEAR = 365 * SECONDS_PER_DAY + +private fun Long.asQuantity(): Int = coerceAtMost(Int.MAX_VALUE.toLong()).toInt() + +@Composable +private fun formatCallDuration(durationMillis: Long): String { + val duration = humanizeCallDuration(durationMillis) + return when (duration.unit) { + CallDurationUnit.FEW_SECONDS -> stringResource( + R.string.system_message_call_duration_few_seconds + ) + + CallDurationUnit.MINUTES -> pluralStringResource( + R.plurals.system_message_call_duration_minutes, + duration.quantity, + duration.quantity + ) + + CallDurationUnit.HOURS -> pluralStringResource( + R.plurals.system_message_call_duration_hours, + duration.quantity, + duration.quantity + ) + + CallDurationUnit.DAYS -> pluralStringResource( + R.plurals.system_message_call_duration_days, + duration.quantity, + duration.quantity + ) + + CallDurationUnit.MONTHS -> pluralStringResource( + R.plurals.system_message_call_duration_months, + duration.quantity, + duration.quantity + ) + + CallDurationUnit.YEARS -> pluralStringResource( + R.plurals.system_message_call_duration_years, + duration.quantity, + duration.quantity + ) + } +} + @Composable fun SystemMessageIcon(type: SystemMessageType, modifier: Modifier = Modifier, size: Dp = 24.dp) { when (type) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3d24969b..a3dc543f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -316,6 +316,28 @@ %1$s pinned a message to this channel %1$s unpinned a message from this channel %1$s started a call + %1$s started a call which lasted %2$s + a few seconds + + %1$d minute + %1$d minutes + + + %1$d hour + %1$d hours + + + %1$d day + %1$d days + + + %1$d month + %1$d months + + + %1$d year + %1$d years + Today Yesterday diff --git a/core/model/src/main/java/chat/stoat/core/model/schemas/Messages.kt b/core/model/src/main/java/chat/stoat/core/model/schemas/Messages.kt index 22b5d73a..221c361c 100644 --- a/core/model/src/main/java/chat/stoat/core/model/schemas/Messages.kt +++ b/core/model/src/main/java/chat/stoat/core/model/schemas/Messages.kt @@ -111,7 +111,8 @@ data class SystemInfo( val by: String? = null, val from: String? = null, val to: String? = null, - val content: String? = null + val content: String? = null, + @SerialName("finished_at") val finishedAt: String? = null, ) @Serializable