diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt index deea89fdf..457d144f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt @@ -21,11 +21,14 @@ package com.vitorpamplona.amethyst.ui.note import android.content.Context +import android.text.format.DateFormat import android.text.format.DateUtils import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.utils.TimeUtils import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date import java.util.Locale import kotlin.math.round @@ -42,6 +45,51 @@ var monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) var yearNoDayFormatter = SimpleDateFormat(YEAR_NO_DAY_DATE_FORMAT, locale) var monthNoDayFormatter = SimpleDateFormat(MONTH_NO_DAY_DATE_FORMAT, locale) +/** + * Formats a Unix timestamp (seconds) as an absolute date/time string, picking the + * granularity from how far away the timestamp is: + * - same day → time only (e.g. "14:32" / "2:32 PM", locale-aware) + * - same year → "MMM dd, HH:mm" + * - older → "MMM dd, yyyy" + * + * Used by [com.vitorpamplona.amethyst.ui.note.elements.TimeAgo] when the user + * taps the relative timestamp to reveal the absolute one. + */ +fun timeAbsolute( + time: Long?, + context: Context, + prefix: String = " • ", +): String { + if (time == null) return " " + if (time == 0L) return prefix + stringRes(context, R.string.never) + + if (locale != Locale.getDefault()) { + locale = Locale.getDefault() + yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) + monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) + } + + val timeMs = time * 1000 + val now = Calendar.getInstance() + val then = Calendar.getInstance().apply { timeInMillis = timeMs } + + val sameYear = now.get(Calendar.YEAR) == then.get(Calendar.YEAR) + val sameDay = sameYear && now.get(Calendar.DAY_OF_YEAR) == then.get(Calendar.DAY_OF_YEAR) + + val timeOfDay = DateFormat.getTimeFormat(context).format(Date(timeMs)) + + return when { + sameDay -> prefix + timeOfDay + sameYear -> prefix + monthFormatter.format(timeMs) + ", " + timeOfDay + else -> prefix + yearFormatter.format(timeMs) + } +} + +fun timeAbsoluteNoDot( + time: Long?, + context: Context, +): String = timeAbsolute(time, context, prefix = "") + fun timeAgo( time: Long?, context: Context, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt index 3e6cb7f7e..c23fd2d41 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt @@ -20,22 +20,110 @@ */ package com.vitorpamplona.amethyst.ui.note.elements +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.TextUnit import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.note.timeAbsolute +import com.vitorpamplona.amethyst.ui.note.timeAbsoluteNoDot import com.vitorpamplona.amethyst.ui.note.timeAgo import com.vitorpamplona.amethyst.ui.note.timeAgoShort import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.placeholderText +/** + * Which relative-time format to use when the timestamp is *not* toggled to absolute. + * Absolute-mode uses [timeAbsolute] (dotted) or [timeAbsoluteNoDot] regardless. + */ +enum class TimeAgoStyle { + /** "• 5m", uses [timeAgo]. Used by note timestamps and chatroom row last-message time. */ + Dotted, + + /** "5m" (no dot, no leading space), uses [timeAgoShort]. Used by chat bubbles and channel headers. */ + Short, +} + +/** + * Core toggleable timestamp Text. All on-screen TimeAgo composables delegate to this. + * + * Behaviour: + * - Renders the timestamp relative to "now" (subscribing to [LocalNowSeconds]); on click, + * flips to an absolute date/time formatted by [timeAbsolute] / [timeAbsoluteNoDot]. + * - When displaying the absolute form we **stop subscribing to the tick** — the + * `derivedStateOf` lambda doesn't read `nowState.value` in that branch, so the Text + * does not recompose every 30s for items the user has "frozen" to a specific time. + * - Uses [remember] (not `rememberSaveable`): the toggle is a transient peek, not state + * worth saving to the SavedStateRegistry for every visible+scrolled-past note in the + * feed. Recycling resets to relative, which matches how peeking should work. + * - A single [MutableInteractionSource] per item, no ripple — tiny timestamp Text + * doesn't benefit from the indication. + * + * Stable params: all values, so the Compose compiler can skip this entirely when nothing changes. + */ +@Composable +fun ToggleableTimeAgoText( + timestamp: Long, + modifier: Modifier = Modifier, + style: TimeAgoStyle = TimeAgoStyle.Dotted, + color: Color = LocalContentColor.current, + fontSize: TextUnit = TextUnit.Unspecified, + maxLines: Int = 1, + overflow: TextOverflow = TextOverflow.Clip, +) { + val context = LocalContext.current + val nowState = LocalNowSeconds.current + val nowStr = stringRes(id = R.string.now) + val interactionSource = remember { MutableInteractionSource() } + var showAbsolute by remember(timestamp) { mutableStateOf(false) } + + val text by + remember(timestamp, context, style, nowState, nowStr) { + derivedStateOf { + if (showAbsolute) { + when (style) { + TimeAgoStyle.Dotted -> timeAbsolute(timestamp, context) + TimeAgoStyle.Short -> timeAbsoluteNoDot(timestamp, context) + } + } else { + // Read nowState only when displaying a relative time, so an item + // toggled to absolute doesn't recompose on every 30-second tick. + nowState.value + when (style) { + TimeAgoStyle.Dotted -> timeAgo(timestamp, context) + TimeAgoStyle.Short -> timeAgoShort(timestamp, nowStr) + } + } + } + } + + Text( + text = text, + color = color, + fontSize = fontSize, + maxLines = maxLines, + overflow = overflow, + modifier = + modifier.clickable( + interactionSource = interactionSource, + indication = null, + ) { showAbsolute = !showAbsolute }, + ) +} + @Composable fun TimeAgo(note: Note) { val time = note.createdAt() ?: return @@ -44,22 +132,10 @@ fun TimeAgo(note: Note) { @Composable fun TimeAgo(time: Long) { - val context = LocalContext.current - // Subscribe to the shared coarse ticker; `derivedStateOf` ensures the Text only - // recomposes when the formatted string actually flips (e.g. 1m → 2m), not on every tick. - val nowState = LocalNowSeconds.current - val timeStr by - remember(time, context, nowState) { - derivedStateOf { - nowState.value - timeAgo(time, context = context) - } - } - - Text( - text = timeStr, + ToggleableTimeAgoText( + timestamp = time, + style = TimeAgoStyle.Dotted, color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, ) } @@ -68,20 +144,10 @@ fun NormalTimeAgo( baseNote: Note, modifier: Modifier, ) { - val nowStr = stringRes(id = R.string.now) - val nowState = LocalNowSeconds.current - - val time by - remember(baseNote, nowStr, nowState) { - derivedStateOf { - nowState.value - timeAgoShort(baseNote.createdAt() ?: 0L, nowStr) - } - } - - Text( - text = time, - maxLines = 1, + val time = baseNote.createdAt() ?: 0L + ToggleableTimeAgoText( + timestamp = time, + style = TimeAgoStyle.Short, overflow = TextOverflow.Ellipsis, modifier = modifier, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatTimeAgo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatTimeAgo.kt index cce77ba4e..8fe683340 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatTimeAgo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatTimeAgo.kt @@ -26,8 +26,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -37,8 +35,8 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.note.elements.LocalNowSeconds -import com.vitorpamplona.amethyst.ui.note.timeAgoShort +import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle +import com.vitorpamplona.amethyst.ui.note.elements.ToggleableTimeAgoText import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Font12SP @@ -48,21 +46,11 @@ import com.vitorpamplona.quartz.nip40Expiration.expiration @Composable fun ChatTimeAgo(baseNote: Note) { - val nowStr = stringRes(id = R.string.now) - val nowState = LocalNowSeconds.current - val time by - remember(baseNote, nowStr, nowState) { - derivedStateOf { - nowState.value - timeAgoShort(baseNote.createdAt() ?: 0L, nowStr) - } - } - - Text( - text = time, + ToggleableTimeAgoText( + timestamp = baseNote.createdAt() ?: 0L, + style = TimeAgoStyle.Short, color = MaterialTheme.colorScheme.placeholderText, fontSize = Font12SP, - maxLines = 1, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 8f25e959e..dde17014f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -27,13 +27,11 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight @@ -61,8 +59,8 @@ import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContentOrNull import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures import com.vitorpamplona.amethyst.ui.note.ObserveDraftEvent -import com.vitorpamplona.amethyst.ui.note.elements.LocalNowSeconds -import com.vitorpamplona.amethyst.ui.note.timeAgo +import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle +import com.vitorpamplona.amethyst.ui.note.elements.ToggleableTimeAgoText import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.LoadEphemeralChatChannel @@ -483,20 +481,10 @@ fun ChannelName( @Composable private fun TimeAgo(channelLastTime: Long?) { if (channelLastTime == null) return - - val context = LocalContext.current - val nowState = LocalNowSeconds.current - val timeAgo by - remember(channelLastTime, context, nowState) { - derivedStateOf { - nowState.value - timeAgo(channelLastTime, context) - } - } - Text( - text = timeAgo, + ToggleableTimeAgoText( + timestamp = channelLastTime, + style = TimeAgoStyle.Dotted, color = MaterialTheme.colorScheme.grayText, - maxLines = 1, ) } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt index f03474017..3234edb75 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt @@ -21,7 +21,10 @@ package com.vitorpamplona.amethyst.commons.util import com.vitorpamplona.quartz.utils.TimeUtils +import java.text.DateFormat import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date import java.util.Locale private const val YEAR_DATE_FORMAT = "MMM dd, yyyy" @@ -30,12 +33,14 @@ private const val MONTH_DATE_FORMAT = "MMM dd" private var locale = Locale.getDefault() private var yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) private var monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) +private var timeOnlyFormatter = DateFormat.getTimeInstance(DateFormat.SHORT, locale) private fun updateFormattersIfNeeded() { if (locale != Locale.getDefault()) { locale = Locale.getDefault() yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) + timeOnlyFormatter = DateFormat.getTimeInstance(DateFormat.SHORT, locale) } } @@ -145,3 +150,39 @@ fun dateFormatter( * Extension function for Long timestamps. */ fun Long.toTimeAgo(withDot: Boolean = true): String = timeAgo(this, withDot) + +/** + * Formats a Unix timestamp (seconds) as an absolute date/time string. Granularity + * depends on how far in the past the timestamp is: + * - same day → time only (e.g. "14:32"), locale-aware + * - same year → "MMM dd, HH:mm" + * - older → "MMM dd, yyyy" + */ +fun timeAbsolute( + time: Long?, + withDot: Boolean = true, + never: String = "never", +): String { + if (time == null) return " " + val prefix = if (withDot) " • " else "" + if (time == 0L) return prefix + never + + updateFormattersIfNeeded() + + val timeMs = time * 1000 + val now = Calendar.getInstance() + val then = Calendar.getInstance().apply { timeInMillis = timeMs } + + val sameYear = now.get(Calendar.YEAR) == then.get(Calendar.YEAR) + val sameDay = sameYear && now.get(Calendar.DAY_OF_YEAR) == then.get(Calendar.DAY_OF_YEAR) + + val timeOfDay = timeOnlyFormatter.format(Date(timeMs)) + + return when { + sameDay -> prefix + timeOfDay + sameYear -> prefix + monthFormatter.format(timeMs) + ", " + timeOfDay + else -> prefix + yearFormatter.format(timeMs) + } +} + +fun Long.toTimeAbsolute(withDot: Boolean = true): String = timeAbsolute(this, withDot) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt index 319d10111..48cf62403 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt @@ -56,13 +56,13 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.rememberMaterialSymbolPa import com.vitorpamplona.amethyst.commons.state.EventCollectionState import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState -import com.vitorpamplona.amethyst.commons.util.toTimeAgo import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.createNotificationsSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription +import com.vitorpamplona.amethyst.desktop.ui.components.ToggleableTimeAgoText import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent @@ -342,8 +342,8 @@ fun NotificationCard(notification: NotificationItem) { ) } - Text( - text = notification.timestamp.toTimeAgo(withDot = false), + ToggleableTimeAgoText( + timestamp = notification.timestamp, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt index 8082ea476..da094091c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt @@ -84,10 +84,10 @@ import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState -import com.vitorpamplona.amethyst.commons.util.toTimeAgo import com.vitorpamplona.amethyst.commons.viewmodels.ChatNewMessageState import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel import com.vitorpamplona.amethyst.desktop.DesktopPreferences +import com.vitorpamplona.amethyst.desktop.ui.components.ToggleableTimeAgoText import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker import com.vitorpamplona.amethyst.desktop.ui.media.MediaAttachmentRow import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle @@ -528,8 +528,8 @@ private fun MessageWithReactions( // Timestamp note.createdAt()?.let { timestamp -> - Text( - text = timestamp.toTimeAgo(withDot = false), + ToggleableTimeAgoText( + timestamp = timestamp, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt index ed7ee475a..79c470c15 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt @@ -66,7 +66,7 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar -import com.vitorpamplona.amethyst.commons.util.toTimeAgo +import com.vitorpamplona.amethyst.desktop.ui.components.ToggleableTimeAgoText import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import kotlinx.coroutines.launch @@ -363,8 +363,8 @@ private fun ConversationCard( if (item.lastMessageTimestamp > 0) { Spacer(Modifier.width(8.dp)) - Text( - text = item.lastMessageTimestamp.toTimeAgo(withDot = false), + ToggleableTimeAgoText( + timestamp = item.lastMessageTimestamp, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/components/ToggleableTimeAgoText.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/components/ToggleableTimeAgoText.kt new file mode 100644 index 000000000..00f9d90f4 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/components/ToggleableTimeAgoText.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import com.vitorpamplona.amethyst.commons.util.timeAbsolute +import com.vitorpamplona.amethyst.commons.util.toTimeAgo + +/** + * Timestamp text that toggles between a relative ("5m") and an absolute, scale-adjusted + * ("14:32" / "Dec 12, 14:32" / "Dec 12, 2023") form when the user clicks it. + * + * Notes for the audit: + * - Plain [remember] (not `rememberSaveable`): the toggle is a transient peek. Persisting + * it for every visible+scrolled-past notification/chat/note across config changes is + * pure memory bloat — recycling resets to relative, which is the right default. + * - Both formatters here are pure functions of the timestamp, so for desktop we don't need + * any tick subscription at all (desktop has no shared "now" ticker). The string is + * stable for a given (timestamp, showAbsolute) pair, so a [derivedStateOf] also isn't + * needed — we just compute on flip. + * - All params are primitives/value types, so the Compose compiler can skip this composable + * when nothing changes. + */ +@Composable +fun ToggleableTimeAgoText( + timestamp: Long, + style: TextStyle, + color: Color, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + var showAbsolute by remember(timestamp) { mutableStateOf(false) } + + Text( + text = if (showAbsolute) timeAbsolute(timestamp, withDot = false) else timestamp.toTimeAgo(withDot = false), + style = style, + color = color, + modifier = + modifier.clickable( + interactionSource = interactionSource, + indication = null, + ) { showAbsolute = !showAbsolute }, + ) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index 879ae5ca2..1b0208f63 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -59,8 +59,8 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.commons.richtext.UrlParser import com.vitorpamplona.amethyst.commons.richtext.Urls import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar -import com.vitorpamplona.amethyst.commons.util.toTimeAgo import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.ui.components.ToggleableTimeAgoText import com.vitorpamplona.amethyst.desktop.ui.media.AnimatedGifImage import com.vitorpamplona.amethyst.desktop.ui.media.AudioPlayer import com.vitorpamplona.amethyst.desktop.ui.media.DesktopVideoPlayer @@ -202,8 +202,8 @@ fun NoteCard( } // Timestamp - Text( - text = note.createdAt.toTimeAgo(withDot = false), + ToggleableTimeAgoText( + timestamp = note.createdAt, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, )