refactor(timeago): consolidate toggle into one stable composable

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
This commit is contained in:
Claude
2026-05-13 19:31:15 +00:00
parent 38463e5f2c
commit 4d2886d162
4 changed files with 112 additions and 115 deletions
@@ -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,
)
}
@@ -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 },
)
}
@@ -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 },
)
}