From 38463e5f2c884d06ee10051f4697feb7460a40fa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 19:17:55 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20tap=20TimeAgo=20to=20toggle=20relat?= =?UTF-8?q?ive=20=E2=86=94=20absolute=20timestamp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every TimeAgo composable (Android NoteCompose timestamp, NormalTimeAgo, ChatTimeAgo, ChatroomHeaderCompose last-message time) and every Desktop timestamp Text (NoteCard, NotificationsScreen, ChatPane, ConversationListPane) is now clickable and toggles to a scale-adjusted absolute date/time: - same day → time only (e.g. "14:32"), locale-aware - same year → "MMM dd, HH:mm" - older → "MMM dd, yyyy" State is hoisted per-call site via rememberSaveable so the toggle survives scroll-induced disposal in lazy lists. Desktop call sites share a small ToggleableTimeAgoText wrapper; Android keeps its existing composable shapes and just gains a clickable modifier + state. https://claude.ai/code/session_01AuPon9VQeRfKV1BTVQuKGC --- .../amethyst/ui/note/TimeAgoFormatter.kt | 48 +++++++++++++ .../amethyst/ui/note/elements/TimeAgo.kt | 29 +++++++- .../screen/loggedIn/chats/feed/ChatTimeAgo.kt | 18 ++++- .../chats/rooms/ChatroomHeaderCompose.kt | 13 +++- .../amethyst/commons/util/TimeAgoFormatter.kt | 41 ++++++++++++ .../desktop/ui/NotificationsScreen.kt | 6 +- .../amethyst/desktop/ui/chats/ChatPane.kt | 6 +- .../desktop/ui/chats/ConversationListPane.kt | 6 +- .../ui/components/ToggleableTimeAgoText.kt | 67 +++++++++++++++++++ .../amethyst/desktop/ui/note/NoteCard.kt | 6 +- 10 files changed, 223 insertions(+), 17 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/components/ToggleableTimeAgoText.kt 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..59a296e44 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,17 +20,24 @@ */ package com.vitorpamplona.amethyst.ui.note.elements +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource 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.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow 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 @@ -48,11 +55,14 @@ fun TimeAgo(time: Long) { // 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 + var showAbsolute by rememberSaveable(time) { mutableStateOf(false) } + val interactionSource = remember { MutableInteractionSource() } + val timeStr by remember(time, context, nowState) { derivedStateOf { nowState.value - timeAgo(time, context = context) + if (showAbsolute) timeAbsolute(time, context) else timeAgo(time, context = context) } } @@ -60,6 +70,11 @@ fun TimeAgo(time: Long) { text = timeStr, color = MaterialTheme.colorScheme.placeholderText, maxLines = 1, + modifier = + Modifier.clickable( + interactionSource = interactionSource, + indication = null, + ) { showAbsolute = !showAbsolute }, ) } @@ -70,12 +85,16 @@ fun NormalTimeAgo( ) { val nowStr = stringRes(id = R.string.now) val nowState = LocalNowSeconds.current + val context = LocalContext.current + var showAbsolute by rememberSaveable(baseNote.idHex) { mutableStateOf(false) } + val interactionSource = remember { MutableInteractionSource() } val time by remember(baseNote, nowStr, nowState) { derivedStateOf { nowState.value - timeAgoShort(baseNote.createdAt() ?: 0L, nowStr) + val createdAt = baseNote.createdAt() ?: 0L + if (showAbsolute) timeAbsoluteNoDot(createdAt, context) else timeAgoShort(createdAt, nowStr) } } @@ -83,6 +102,10 @@ fun NormalTimeAgo( text = time, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = modifier, + modifier = + modifier.clickable( + interactionSource = interactionSource, + indication = null, + ) { showAbsolute = !showAbsolute }, ) } 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..cef3ef345 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 @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.size @@ -28,7 +30,10 @@ 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.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -38,6 +43,7 @@ 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.timeAbsoluteNoDot import com.vitorpamplona.amethyst.ui.note.timeAgoShort import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot import com.vitorpamplona.amethyst.ui.stringRes @@ -50,11 +56,16 @@ import com.vitorpamplona.quartz.nip40Expiration.expiration fun ChatTimeAgo(baseNote: Note) { val nowStr = stringRes(id = R.string.now) val nowState = LocalNowSeconds.current + val context = LocalContext.current + var showAbsolute by rememberSaveable(baseNote.idHex) { mutableStateOf(false) } + val interactionSource = remember { MutableInteractionSource() } + val time by remember(baseNote, nowStr, nowState) { derivedStateOf { nowState.value - timeAgoShort(baseNote.createdAt() ?: 0L, nowStr) + val createdAt = baseNote.createdAt() ?: 0L + if (showAbsolute) timeAbsoluteNoDot(createdAt, context) else timeAgoShort(createdAt, nowStr) } } @@ -63,6 +74,11 @@ fun ChatTimeAgo(baseNote: Note) { color = MaterialTheme.colorScheme.placeholderText, fontSize = Font12SP, maxLines = 1, + modifier = + Modifier.clickable( + interactionSource = interactionSource, + indication = null, + ) { showAbsolute = !showAbsolute }, ) } 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..495af1173 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 @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer import androidx.compose.material3.LocalTextStyle @@ -31,6 +33,7 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -62,6 +65,7 @@ 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.timeAbsolute import com.vitorpamplona.amethyst.ui.note.timeAgo import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay @@ -486,17 +490,24 @@ private fun TimeAgo(channelLastTime: Long?) { val context = LocalContext.current val nowState = LocalNowSeconds.current + var showAbsolute by rememberSaveable(channelLastTime) { mutableStateOf(false) } + val interactionSource = remember { MutableInteractionSource() } val timeAgo by remember(channelLastTime, context, nowState) { derivedStateOf { nowState.value - timeAgo(channelLastTime, context) + if (showAbsolute) timeAbsolute(channelLastTime, context) else timeAgo(channelLastTime, context) } } Text( text = timeAgo, color = MaterialTheme.colorScheme.grayText, maxLines = 1, + modifier = + Modifier.clickable( + interactionSource = interactionSource, + indication = null, + ) { showAbsolute = !showAbsolute }, ) } 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..5852bb27e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/components/ToggleableTimeAgoText.kt @@ -0,0 +1,67 @@ +/* + * 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.saveable.rememberSaveable +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. + * + * The toggle is local to the call site and persists across recomposition via + * [rememberSaveable], so it survives scroll-induced disposal in a LazyColumn. + */ +@Composable +fun ToggleableTimeAgoText( + timestamp: Long, + style: TextStyle, + color: Color, + modifier: Modifier = Modifier, +) { + var showAbsolute by rememberSaveable(timestamp) { mutableStateOf(false) } + val interactionSource = remember { MutableInteractionSource() } + val text = + if (showAbsolute) timeAbsolute(timestamp, withDot = false) else timestamp.toTimeAgo(withDot = false) + + Text( + text = text, + 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, ) From 4d2886d162f333d07280e678a32404c58a35fe8d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 19:31:15 +0000 Subject: [PATCH 2/2] refactor(timeago): consolidate toggle into one stable composable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit follow-up — the toggle behaviour now lives in a single `ToggleableTimeAgoText` core in `ui/note/elements/TimeAgo.kt`. `TimeAgo`, `NormalTimeAgo`, `ChatTimeAgo`, and `ChatroomHeaderCompose.TimeAgo` are thin wrappers that pick a `TimeAgoStyle` (Dotted / Short) and pass colour/font params; no per-site duplication of state + clickable + derivedStateOf. Performance fixes that matter for a feed with hundreds of timestamps: - `rememberSaveable` → `remember`. Persisting a transient peek-toggle to the SavedStateRegistry for every visible+scrolled-past note was pure memory bloat. Recycling now resets to relative, which is the expected behaviour for a transient inspect action. - The relative-mode `derivedStateOf` lambda reads `nowState.value` only when displaying a relative time. An item the user has frozen to its absolute date no longer re-evaluates every 30-second tick. - Core composable takes only stable primitive params (Long, Color, TextUnit, TextOverflow, enum) so Compose can skip it entirely when inputs don't change. - Desktop wrapper dropped the redundant `derivedStateOf`: its formatter reads no State, so derivedStateOf had nothing to observe. https://claude.ai/code/session_01AuPon9VQeRfKV1BTVQuKGC --- .../amethyst/ui/note/elements/TimeAgo.kt | 137 ++++++++++++------ .../screen/loggedIn/chats/feed/ChatTimeAgo.kt | 38 +---- .../chats/rooms/ChatroomHeaderCompose.kt | 33 +---- .../ui/components/ToggleableTimeAgoText.kt | 19 ++- 4 files changed, 112 insertions(+), 115 deletions(-) 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 59a296e44..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 @@ -22,6 +22,7 @@ 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 @@ -29,11 +30,12 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable 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 @@ -43,6 +45,85 @@ 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 @@ -51,30 +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 - var showAbsolute by rememberSaveable(time) { mutableStateOf(false) } - val interactionSource = remember { MutableInteractionSource() } - - val timeStr by - remember(time, context, nowState) { - derivedStateOf { - nowState.value - if (showAbsolute) timeAbsolute(time, context) else timeAgo(time, context = context) - } - } - - Text( - text = timeStr, + ToggleableTimeAgoText( + timestamp = time, + style = TimeAgoStyle.Dotted, color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - modifier = - Modifier.clickable( - interactionSource = interactionSource, - indication = null, - ) { showAbsolute = !showAbsolute }, ) } @@ -83,29 +144,11 @@ fun NormalTimeAgo( baseNote: Note, modifier: Modifier, ) { - val nowStr = stringRes(id = R.string.now) - val nowState = LocalNowSeconds.current - val context = LocalContext.current - var showAbsolute by rememberSaveable(baseNote.idHex) { mutableStateOf(false) } - val interactionSource = remember { MutableInteractionSource() } - - val time by - remember(baseNote, nowStr, nowState) { - derivedStateOf { - nowState.value - val createdAt = baseNote.createdAt() ?: 0L - if (showAbsolute) timeAbsoluteNoDot(createdAt, context) else timeAgoShort(createdAt, nowStr) - } - } - - Text( - text = time, - maxLines = 1, + val time = baseNote.createdAt() ?: 0L + ToggleableTimeAgoText( + timestamp = time, + style = TimeAgoStyle.Short, overflow = TextOverflow.Ellipsis, - modifier = - modifier.clickable( - interactionSource = interactionSource, - indication = null, - ) { showAbsolute = !showAbsolute }, + 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 cef3ef345..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 @@ -20,20 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer 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.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -42,9 +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.timeAbsoluteNoDot -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 @@ -54,31 +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 context = LocalContext.current - var showAbsolute by rememberSaveable(baseNote.idHex) { mutableStateOf(false) } - val interactionSource = remember { MutableInteractionSource() } - - val time by - remember(baseNote, nowStr, nowState) { - derivedStateOf { - nowState.value - val createdAt = baseNote.createdAt() ?: 0L - if (showAbsolute) timeAbsoluteNoDot(createdAt, context) else timeAgoShort(createdAt, nowStr) - } - } - - Text( - text = time, + ToggleableTimeAgoText( + timestamp = baseNote.createdAt() ?: 0L, + style = TimeAgoStyle.Short, color = MaterialTheme.colorScheme.placeholderText, fontSize = Font12SP, - maxLines = 1, - modifier = - Modifier.clickable( - interactionSource = interactionSource, - indication = null, - ) { showAbsolute = !showAbsolute }, ) } 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 495af1173..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 @@ -20,8 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer import androidx.compose.material3.LocalTextStyle @@ -29,14 +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.saveable.rememberSaveable 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 @@ -64,9 +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.timeAbsolute -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 @@ -487,27 +481,10 @@ fun ChannelName( @Composable private fun TimeAgo(channelLastTime: Long?) { if (channelLastTime == null) return - - val context = LocalContext.current - val nowState = LocalNowSeconds.current - var showAbsolute by rememberSaveable(channelLastTime) { mutableStateOf(false) } - val interactionSource = remember { MutableInteractionSource() } - val timeAgo by - remember(channelLastTime, context, nowState) { - derivedStateOf { - nowState.value - if (showAbsolute) timeAbsolute(channelLastTime, context) else timeAgo(channelLastTime, context) - } - } - Text( - text = timeAgo, + ToggleableTimeAgoText( + timestamp = channelLastTime, + style = TimeAgoStyle.Dotted, color = MaterialTheme.colorScheme.grayText, - maxLines = 1, - modifier = - Modifier.clickable( - interactionSource = interactionSource, - indication = null, - ) { showAbsolute = !showAbsolute }, ) } 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 index 5852bb27e..00f9d90f4 100644 --- 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 @@ -27,7 +27,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -39,8 +38,16 @@ 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. * - * The toggle is local to the call site and persists across recomposition via - * [rememberSaveable], so it survives scroll-induced disposal in a LazyColumn. + * 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( @@ -49,13 +56,11 @@ fun ToggleableTimeAgoText( color: Color, modifier: Modifier = Modifier, ) { - var showAbsolute by rememberSaveable(timestamp) { mutableStateOf(false) } val interactionSource = remember { MutableInteractionSource() } - val text = - if (showAbsolute) timeAbsolute(timestamp, withDot = false) else timestamp.toTimeAgo(withDot = false) + var showAbsolute by remember(timestamp) { mutableStateOf(false) } Text( - text = text, + text = if (showAbsolute) timeAbsolute(timestamp, withDot = false) else timestamp.toTimeAgo(withDot = false), style = style, color = color, modifier =