diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt index ef442e3c5f..6ca953b4e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.richtext.UrlParser object CachedRichTextParser { private val richTextCache = LruCache(50) + private val isMarkdownCache = LruCache(200) private fun hashCodeCache( content: String, @@ -69,6 +70,25 @@ object CachedRichTextParser { newUrls } } + + // Shared across every RichTextViewer instance so that the same content quoted in multiple + // notes only pays for the scan once. The decision is purely a function of `content`. + fun isMarkdown(content: String): Boolean { + val key = content.hashCode() + isMarkdownCache[key]?.let { return it } + val result = computeIsMarkdown(content) + isMarkdownCache.put(key, result) + return result + } + + private fun computeIsMarkdown(content: String): Boolean = + content.startsWith("> ") || + content.startsWith("# ") || + content.contains("##") || + content.contains("__") || + content.contains("**") || + content.contains("```") || + content.contains("](") } object CachedUrlParser { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt index c9d414105f..96fa4a6f1a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt @@ -22,8 +22,10 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event import androidx.compose.runtime.Composable import androidx.compose.runtime.State +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.NoteState import com.vitorpamplona.amethyst.model.User @@ -52,10 +54,8 @@ fun observeNote( EventFinderFilterAssemblerSubscription(note, accountViewModel) // Subscribe in the LocalCache for changes that arrive in the device - return note - .flow() - .metadata.stateFlow - .collectAsStateWithLifecycle() + val flow = remember(note) { note.flow().metadata.stateFlow } + return flow.collectAsStateWithLifecycle() } @Suppress("UNCHECKED_CAST") @@ -187,10 +187,8 @@ fun observeNoteReplies( EventFinderFilterAssemblerSubscription(note, accountViewModel) // Subscribe in the LocalCache for changes that arrive in the device - return note - .flow() - .replies.stateFlow - .collectAsStateWithLifecycle() + val flow = remember(note) { note.flow().replies.stateFlow } + return flow.collectAsStateWithLifecycle() } @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @@ -225,10 +223,8 @@ fun observeNoteReactions( EventFinderFilterAssemblerSubscription(note, accountViewModel) // Subscribe in the LocalCache for changes that arrive in the device - return note - .flow() - .reactions.stateFlow - .collectAsStateWithLifecycle() + val flow = remember(note) { note.flow().reactions.stateFlow } + return flow.collectAsStateWithLifecycle() } @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @@ -265,10 +261,8 @@ fun observeNoteZaps( EventFinderFilterAssemblerSubscription(note, accountViewModel) // Subscribe in the LocalCache for changes that arrive in the device - return note - .flow() - .zaps.stateFlow - .collectAsStateWithLifecycle() + val flow = remember(note) { note.flow().zaps.stateFlow } + return flow.collectAsStateWithLifecycle() } @Composable @@ -280,10 +274,8 @@ fun observeNoteReposts( EventFinderFilterAssemblerSubscription(note, accountViewModel) // Subscribe in the LocalCache for changes that arrive in the device - return note - .flow() - .boosts.stateFlow - .collectAsStateWithLifecycle() + val flow = remember(note) { note.flow().boosts.stateFlow } + return flow.collectAsStateWithLifecycle() } @OptIn(ExperimentalCoroutinesApi::class) @@ -365,27 +357,36 @@ fun observeNoteOts( EventFinderFilterAssemblerSubscription(note, accountViewModel) // Subscribe in the LocalCache for changes that arrive in the device - return note - .flow() - .ots - .stateFlow - .collectAsStateWithLifecycle() + val flow = remember(note) { note.flow().ots.stateFlow } + return flow.collectAsStateWithLifecycle() } +// Resolves the actual modification list off the main thread and filters identical results, +// so the caller's LaunchedEffect only fires when the list of edits truly changes. +// `sample(500)` collapses bursts — a heavily-edited note can emit hundreds of times during +// initial relay sync, and we only need the last state per ~half second. +// Returns `null` until the first IO resolution completes — callers should treat that as +// "still loading" and not flip their UI to "no edits". +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @Composable -fun observeNoteEdits( +fun observeNoteModifications( note: Note, accountViewModel: AccountViewModel, -): State { +): State?> { // Subscribe in the relay for changes in this note. EventFinderFilterAssemblerSubscription(note, accountViewModel) - // Subscribe in the LocalCache for changes that arrive in the device - return note - .flow() - .edits - .stateFlow - .collectAsStateWithLifecycle() + return produceState?>(initialValue = null, note) { + note + .flow() + .edits + .stateFlow + .sample(500) + .mapLatest { LocalCache.findLatestModificationForNote(note) } + .distinctUntilChanged() + .flowOn(Dispatchers.IO) + .collect { value = it } + } } @Composable 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 40a464cec0..fa62e44fee 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/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index ed5559c0f9..f54f53494c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -122,15 +122,6 @@ import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -fun isMarkdown(content: String): Boolean = - content.startsWith("> ") || - content.startsWith("# ") || - content.contains("##") || - content.contains("__") || - content.contains("**") || - content.contains("```") || - content.contains("](") - @Composable fun RichTextViewer( content: String, @@ -145,7 +136,7 @@ fun RichTextViewer( nav: INav, ) { Column(modifier = modifier) { - if (remember(content) { isMarkdown(content) }) { + if (remember(content) { CachedRichTextParser.isMarkdown(content) }) { RenderContentAsMarkdown(content, tags, canPreview, quotesLeft, backgroundColor, callbackUri, accountViewModel, nav) } else { RenderRegular(content, tags, canPreview, quotesLeft, backgroundColor, callbackUri, authorPubKey, accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlockReportChecker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlockReportChecker.kt index 847ec90077..f6e3fbe3a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlockReportChecker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlockReportChecker.kt @@ -59,7 +59,8 @@ fun WatchBlockAndReport( nav: INav, normalNote: @Composable (canPreview: Boolean) -> Unit, ) { - val isHidden by accountViewModel.createIsHiddenFlow(note).collectAsStateWithLifecycle() + val isHidden by remember(note) { accountViewModel.createIsHiddenFlow(note) } + .collectAsStateWithLifecycle() val showAnyway = remember { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 7b2c010d71..e33451c4c4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -66,8 +66,8 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannelPicture import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeCommunityApprovalNeedStatus -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEdits import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteModifications import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage @@ -489,30 +489,31 @@ fun calculateBackgroundColor( ): MutableState { val defaultBackgroundColor = MaterialTheme.colorScheme.background val newItemColor = MaterialTheme.colorScheme.newItemBackgroundColor + + // Only fade in/out the "new item" highlight for items that track read state. + // Inner notes (reposts/quotes) pass routeForLastRead = null and reuse the parent color directly, + // so the LaunchedEffect would just park a coroutine for 5s per item during scroll. + val isNew = + remember(createdAt, routeForLastRead) { + routeForLastRead != null && accountViewModel.loadAndMarkAsRead(routeForLastRead, createdAt) + } + val bgColor = remember(createdAt) { mutableStateOf( - if (routeForLastRead != null) { - val isNew = accountViewModel.loadAndMarkAsRead(routeForLastRead, createdAt) - - if (isNew) { - if (parentBackgroundColor != null) { - newItemColor.compositeOver(parentBackgroundColor.value) - } else { - newItemColor.compositeOver(defaultBackgroundColor) - } - } else { - parentBackgroundColor?.value ?: defaultBackgroundColor.copy(alpha = 0f) - } + if (isNew) { + newItemColor.compositeOver(parentBackgroundColor?.value ?: defaultBackgroundColor) } else { - parentBackgroundColor?.value ?: defaultBackgroundColor.copy(alpha = 0f) + parentBackgroundColor?.value ?: Color.Transparent }, ) } - LaunchedEffect(createdAt) { - delay(5000) - bgColor.value = parentBackgroundColor?.value ?: defaultBackgroundColor.copy(alpha = 0f) + if (isNew) { + LaunchedEffect(createdAt) { + delay(5000) + bgColor.value = parentBackgroundColor?.value ?: Color.Transparent + } } return bgColor @@ -1782,23 +1783,25 @@ fun observeEdits( ) } - val updatedNote by observeNoteEdits(baseNote, accountViewModel) + // Upstream resolves on IO and `distinctUntilChanged`s, so this LaunchedEffect only + // fires when the actual modification list changes — no more recomputing + reassigning + // editState on every unrelated emission of the edits flow. + val modifications by observeNoteModifications(baseNote, accountViewModel) - LaunchedEffect(key1 = updatedNote) { - updatedNote?.note?.let { - val newModifications = accountViewModel.findModificationEventsForNote(it) - if (newModifications.isEmpty()) { - if (editState.value !is GenericLoadable.Empty) { - editState.value = GenericLoadable.Empty() - } + LaunchedEffect(modifications) { + val mods = modifications ?: return@LaunchedEffect + if (mods.isEmpty()) { + if (editState.value !is GenericLoadable.Empty) { + editState.value = GenericLoadable.Empty() + } + } else { + val current = editState.value + if (current is GenericLoadable.Loaded) { + current.loaded.updateModifications(mods) } else { - if (editState.value is GenericLoadable.Loaded) { - (editState.value as? GenericLoadable.Loaded)?.loaded?.updateModifications(newModifications) - } else { - val state = EditState() - state.updateModifications(newModifications) - editState.value = GenericLoadable.Loaded(state) - } + val state = EditState() + state.updateModifications(mods) + editState.value = GenericLoadable.Loaded(state) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt index 9f6e76aae6..8a3d5551ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt @@ -59,7 +59,11 @@ import com.vitorpamplona.amethyst.ui.theme.Size17Modifier import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.noteComposeRelayBox import com.vitorpamplona.amethyst.ui.theme.placeholderText -import kotlinx.coroutines.flow.mapNotNull +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.sample @Composable fun RelayBadges( @@ -81,7 +85,7 @@ fun RelayBadges( } } -@OptIn(ExperimentalLayoutApi::class) +@OptIn(ExperimentalLayoutApi::class, FlowPreview::class) @Composable fun RenderAllRelayList( baseNote: Note, @@ -90,16 +94,26 @@ fun RenderAllRelayList( accountViewModel: AccountViewModel, nav: INav, ) { - val noteRelays by baseNote - .flow() - .relays.stateFlow - .collectAsStateWithLifecycle() + val flow = + remember(baseNote) { + baseNote + .flow() + .relays.stateFlow + .sample(500) + .map { it.note.relays } + .distinctUntilChanged() + } + + val relays by flow.collectAsStateWithLifecycle(baseNote.relays) FlowRow(modifier, verticalArrangement = verticalArrangement) { - noteRelays.note.relays.forEach { RenderRelay(it, accountViewModel, nav) } + relays.forEach { RenderRelay(it, accountViewModel, nav) } } } +// Single sampled subscription instead of one per slot: emits the first 3 relays from the note. +// Throttled to 500ms because relay arrivals can churn a list of an actively-fanned-out note. +@OptIn(FlowPreview::class) @Composable fun RenderClosedRelayList( baseNote: Note, @@ -108,32 +122,32 @@ fun RenderClosedRelayList( accountViewModel: AccountViewModel, nav: INav, ) { + val flow = + remember(baseNote) { + baseNote + .flow() + .relays.stateFlow + .sample(500) + .map { it.note.relays.take(3) } + .distinctUntilChanged() + } + + val initial = remember(baseNote) { baseNote.relays.take(3) } + val relays by flow.collectAsStateWithLifecycle(initial) + Row(modifier, verticalAlignment = verticalAlignment) { - WatchAndRenderRelay(baseNote, 0, accountViewModel, nav) - WatchAndRenderRelay(baseNote, 1, accountViewModel, nav) - WatchAndRenderRelay(baseNote, 2, accountViewModel, nav) + RenderRelaySlot(relays.getOrNull(0), accountViewModel, nav) + RenderRelaySlot(relays.getOrNull(1), accountViewModel, nav) + RenderRelaySlot(relays.getOrNull(2), accountViewModel, nav) } } @Composable -fun WatchAndRenderRelay( - baseNote: Note, - relayIndex: Int, +private fun RenderRelaySlot( + relay: NormalizedRelayUrl?, accountViewModel: AccountViewModel, nav: INav, ) { - val flow = - remember(baseNote, relayIndex) { - baseNote - .flow() - .relays.stateFlow - .mapNotNull { - it.note.relays.getOrNull(relayIndex) - } - } - - val relay by flow.collectAsStateWithLifecycle(baseNote.relays.getOrNull(relayIndex)) - CrossfadeIfEnabled(targetState = relay, label = "RenderRelay", modifier = Size17Modifier, accountViewModel = accountViewModel) { if (it != null) { RenderRelay(it, accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt index 068b925f44..1d16720674 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt @@ -99,7 +99,8 @@ fun ShouldShowExpandButton( accountViewModel: AccountViewModel, content: @Composable () -> Unit, ) { - val showExpandButton by accountViewModel.createMustShowExpandButtonFlows(baseNote).collectAsStateWithLifecycle() + val flow = remember(baseNote) { accountViewModel.createMustShowExpandButtonFlows(baseNote) } + val showExpandButton by flow.collectAsStateWithLifecycle() if (showExpandButton) { content() 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 0000000000..9944d64cdb --- /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 02b699ef9a..3e6cb7f7e3 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/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index bc3a4a331a..774e625776 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1328,11 +1328,6 @@ class AccountViewModel( fun cachedModificationEventsForNote(note: Note) = LocalCache.cachedModificationEventsForNote(note) - suspend fun findModificationEventsForNote(note: Note): List = - withContext(Dispatchers.IO) { - LocalCache.findLatestModificationForNote(note) - } - fun checkGetOrCreatePublicChatChannel(key: HexKey): PublicChatChannel = LocalCache.getOrCreatePublicChatChannel(key) fun checkGetOrCreateLiveActivityChannel(key: Address): LiveActivitiesChannel = LocalCache.getOrCreateLiveChannel(key) 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 bddff08f98..cce77ba4e3 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 edc6bf1d32..8f25e959e5 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, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/AsyncCachedState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/AsyncCachedState.kt index 0aaae33acf..752f899f59 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/AsyncCachedState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/AsyncCachedState.kt @@ -22,35 +22,47 @@ package com.vitorpamplona.amethyst.commons.compose import androidx.collection.LruCache import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State -import androidx.compose.runtime.produceState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +// On a cache hit, short-circuit with a remembered State and skip the produceState coroutine. +// Only on a miss do we launch the suspending update. @Composable fun produceCachedStateAsync( cache: AsyncCachedState, key: K, -): State = - @Suppress("ProduceStateDoesNotAssignValue") - produceState(initialValue = cache.cached(key), key1 = key) { - val newValue = cache.update(key) - if (newValue != value) { - value = newValue +): State { + val state = remember(key) { mutableStateOf(cache.cached(key)) } + if (state.value == null) { + LaunchedEffect(key) { + val newValue = cache.update(key) + if (state.value != newValue) { + state.value = newValue + } } } + return state +} @Composable fun produceCachedStateAsync( cache: AsyncCachedState, key: String, updateValue: K, -): State = - @Suppress("ProduceStateDoesNotAssignValue") - produceState(initialValue = cache.cached(updateValue), key1 = key) { - val newValue = cache.update(updateValue) - if (newValue != value) { - value = newValue +): State { + val state = remember(key) { mutableStateOf(cache.cached(updateValue)) } + if (state.value == null) { + LaunchedEffect(key) { + val newValue = cache.update(updateValue) + if (state.value != newValue) { + state.value = newValue + } } } + return state +} interface AsyncCachedState { fun cached(k: K): V? diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/CachedState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/CachedState.kt index e00a83e84b..b031102758 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/CachedState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/CachedState.kt @@ -22,35 +22,48 @@ package com.vitorpamplona.amethyst.commons.compose import androidx.collection.LruCache import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State -import androidx.compose.runtime.produceState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +// On a cache hit (the common case during scroll for things like Bech32 link previews), +// short-circuit with a remembered State and skip the produceState coroutine entirely. +// Only on a miss do we launch the suspending update. @Composable fun produceCachedState( cache: CachedState, key: K, -): State = - @Suppress("ProduceStateDoesNotAssignValue") - produceState(initialValue = cache.cached(key), key1 = key) { - val newValue = cache.update(key) - if (value != newValue) { - value = newValue +): State { + val state = remember(key) { mutableStateOf(cache.cached(key)) } + if (state.value == null) { + LaunchedEffect(key) { + val newValue = cache.update(key) + if (state.value != newValue) { + state.value = newValue + } } } + return state +} @Composable fun produceCachedState( cache: CachedState, key: String, updateValue: K, -): State = - @Suppress("ProduceStateDoesNotAssignValue") - produceState(initialValue = cache.cached(updateValue), key1 = key) { - val newValue = cache.update(updateValue) - if (value != newValue) { - value = newValue +): State { + val state = remember(key) { mutableStateOf(cache.cached(updateValue)) } + if (state.value == null) { + LaunchedEffect(key) { + val newValue = cache.update(updateValue) + if (state.value != newValue) { + state.value = newValue + } } } + return state +} interface CachedState { fun cached(k: K): V?