feat(TimeAgo): single shared ticker so on-screen ages stay fresh

`TimeAgo`, `NormalTimeAgo`, `ChatTimeAgo`, and the chatroom header's
private `TimeAgo` previously formatted the timestamp once inside
`remember(time)` and never refreshed — a note shown when it was 59 s
old would keep saying "59s" forever, even when it had been minutes.

Introduce a single app-wide ticker:

* `LocalNowSeconds` is a `CompositionLocal<State<Long>>` whose value
  is refreshed every 30 s by a single `produceState` coroutine inside
  `NowProvider` (mounted once at the app root in `MainActivity`).
* Each `TimeAgo` composable reads the ticker inside `derivedStateOf`,
  so it only triggers a Text recomposition when the formatted string
  actually crosses a threshold (e.g. 1m → 2m). Ticks that don't change
  the displayed string are filtered by `derivedStateOf` equality.

One coroutine total, no per-item timers, and recompositions are
proportional to "strings that actually need to update" rather than
"items on screen × ticks/second".
This commit is contained in:
Claude
2026-05-12 01:53:14 +00:00
parent 4d9479fa1b
commit 1f578eaff8
5 changed files with 92 additions and 6 deletions
@@ -37,6 +37,7 @@ import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
import com.vitorpamplona.amethyst.ui.navigation.findParameterValue
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.elements.NowProvider
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
@@ -76,7 +77,9 @@ class MainActivity : AppCompatActivity() {
setContent {
StringResSetup()
AmethystTheme {
AccountScreen(Amethyst.instance.sessionManager)
NowProvider {
AccountScreen(Amethyst.instance.sessionManager)
}
}
}
}
@@ -0,0 +1,50 @@
/*
* 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.ui.note.elements
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.State
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.delay
private const val TICK_INTERVAL_MS = 30_000L
// Shared coarse-grained "now" ticker. One coroutine refreshes the value at TICK_INTERVAL_MS,
// every TimeAgo on screen reads from it. Because TimeAgo wraps the formatted string in
// `derivedStateOf`, the Text only recomposes when the displayed string actually changes
// (e.g. crossing 1m → 2m) — not on every tick.
val LocalNowSeconds = compositionLocalOf<State<Long>> { mutableStateOf(TimeUtils.now()) }
@Composable
fun NowProvider(content: @Composable () -> Unit) {
val now =
produceState(TimeUtils.now()) {
while (true) {
delay(TICK_INTERVAL_MS)
value = TimeUtils.now()
}
}
CompositionLocalProvider(LocalNowSeconds provides now, content = content)
}
@@ -25,7 +25,6 @@ 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.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -46,7 +45,16 @@ fun TimeAgo(note: Note) {
@Composable
fun TimeAgo(time: Long) {
val context = LocalContext.current
val timeStr by remember(time) { mutableStateOf(timeAgo(time, context = context)) }
// 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,
@@ -61,9 +69,15 @@ fun NormalTimeAgo(
modifier: Modifier,
) {
val nowStr = stringRes(id = R.string.now)
val nowState = LocalNowSeconds.current
val time by
remember(baseNote) { derivedStateOf { timeAgoShort(baseNote.createdAt() ?: 0L, nowStr) } }
remember(baseNote, nowStr, nowState) {
derivedStateOf {
nowState.value
timeAgoShort(baseNote.createdAt() ?: 0L, nowStr)
}
}
Text(
text = time,
@@ -26,6 +26,8 @@ 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
@@ -35,6 +37,7 @@ 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.timeAheadNoDot
import com.vitorpamplona.amethyst.ui.stringRes
@@ -46,7 +49,14 @@ import com.vitorpamplona.quartz.nip40Expiration.expiration
@Composable
fun ChatTimeAgo(baseNote: Note) {
val nowStr = stringRes(id = R.string.now)
val time = remember(baseNote) { timeAgoShort(baseNote.createdAt() ?: 0L, nowStr) }
val nowState = LocalNowSeconds.current
val time by
remember(baseNote, nowStr, nowState) {
derivedStateOf {
nowState.value
timeAgoShort(baseNote.createdAt() ?: 0L, nowStr)
}
}
Text(
text = time,
@@ -27,6 +27,7 @@ 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
@@ -60,6 +61,7 @@ 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.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay
@@ -483,7 +485,14 @@ private fun TimeAgo(channelLastTime: Long?) {
if (channelLastTime == null) return
val context = LocalContext.current
val timeAgo = remember(channelLastTime) { timeAgo(channelLastTime, context) }
val nowState = LocalNowSeconds.current
val timeAgo by
remember(channelLastTime, context, nowState) {
derivedStateOf {
nowState.value
timeAgo(channelLastTime, context)
}
}
Text(
text = timeAgo,
color = MaterialTheme.colorScheme.grayText,