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 =