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:
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+5
-33
@@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+5
-28
@@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user