diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index 40a464cec..fa62e44fe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -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) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NowProvider.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NowProvider.kt new file mode 100644 index 000000000..9944d64cd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NowProvider.kt @@ -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> { 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) +} 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 02b699ef9..3e6cb7f7e 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 @@ -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, 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 bddff08f9..cce77ba4e 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 @@ -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, 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 edc6bf1d3..8f25e959e 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 @@ -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,