From 18c1ab2ecebd0107972442b221214708655281e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 10 May 2026 20:47:03 +0000 Subject: [PATCH 1/8] perf(NoteCompose): cut per-item allocations during feed scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three targeted fixes for the jitter that shows up as soon as a feed contains nested NoteCompose (reposts, quotes, BechLink previews): * `calculateBackgroundColor`: only schedule the 5s "new item" fade LaunchedEffect when the item is actually new and tracks read state. Inner notes pass `routeForLastRead = null` and were parking a coroutine for 5s per item, every scroll. Also drop a per-call `Color.copy(alpha = 0f)` allocation in favor of `Color.Transparent`. * `EventObservers` + `WatchBlockAndReport`: wrap the `StateFlow` lookups in `remember(note)` so each recomposition of the same note doesn't re-resolve `note.flow().…stateFlow` (and, in `WatchBlockAndReport`, hit the synchronized LRU lookup) on every pass. Affects observeNote / Replies / Reactions / Zaps / Reposts / Ots / Edits and the per-item hidden-flow check. * `produceCachedState` / `produceCachedStateAsync`: short-circuit on cache hits. The previous `produceState` body always launched a coroutine even when the LRU already had the value; this is the common path for BechLink previews and draft notes during scroll. Now we read the cache synchronously inside `remember`, and only launch a `LaunchedEffect` for the actual miss. No behavior change. --- .../reqCommand/event/EventObservers.kt | 44 ++++++------------- .../amethyst/ui/note/BlockReportChecker.kt | 3 +- .../amethyst/ui/note/NoteCompose.kt | 33 +++++++------- .../commons/compose/AsyncCachedState.kt | 38 ++++++++++------ .../amethyst/commons/compose/CachedState.kt | 39 ++++++++++------ 5 files changed, 84 insertions(+), 73 deletions(-) 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 c9d414105..f46e817bb 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 @@ -52,10 +52,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 +185,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 +221,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 +259,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 +272,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,11 +355,8 @@ 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() } @Composable @@ -381,11 +368,8 @@ fun observeNoteEdits( EventFinderFilterAssemblerSubscription(note, accountViewModel) // Subscribe in the LocalCache for changes that arrive in the device - return note - .flow() - .edits - .stateFlow - .collectAsStateWithLifecycle() + val flow = remember(note) { note.flow().edits.stateFlow } + return flow.collectAsStateWithLifecycle() } @Composable 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 847ec9007..f6e3fbe3a 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 7b2c010d7..70249d1dc 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 @@ -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 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 0aaae33ac..752f899f5 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 e00a83e84..b03110275 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? From c66acfa131ecfd01d570a2b260a083d68bfe653b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 10 May 2026 21:45:54 +0000 Subject: [PATCH 2/8] perf(RelayBadges): one sampled flow per note instead of three MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `RelayBadges` runs once per note in complete-UI mode. The closed view was opening three separate `relays.stateFlow` subscriptions (one per icon slot, each with its own `mapNotNull`), and both the closed and expanded views collected the relay list raw — every relay arrival on a fanned-out note triggered an immediate recomposition. Collapse to a single subscription that emits `relays.take(3)`, throttled with `sample(500)` and `distinctUntilChanged`. Slot widgets now pull from the resulting list instead of each owning a flow. Same treatment for the expanded `RenderAllRelayList` (now sampled) and `ShouldShowExpandButton` (wraps the cached `createMustShowExpandButtonFlows` lookup in `remember(note)` so the LRU is hit once per note instead of per recomposition). --- .../amethyst/ui/note/RelayListBox.kt | 64 +++++++++++-------- .../amethyst/ui/note/RelayListRow.kt | 3 +- 2 files changed, 41 insertions(+), 26 deletions(-) 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 9f6e76aae..8a3d5551a 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 068b925f4..1d1672067 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() From 680e5bc0be5ff67fd5cd69554efd23cf56bbf200 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 10 May 2026 22:34:48 +0000 Subject: [PATCH 3/8] perf(RichTextViewer): share isMarkdown decision via CachedRichTextParser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The markdown check is purely a function of `content`, but it was a top-level function in RichTextViewer.kt — every distinct instance of the same content (e.g. a quoted note rendered inside its quoter, or the same viral note shown multiple places in a feed) re-scanned the string. Move the decision into `CachedRichTextParser` with its own small LRU keyed on content.hashCode(). Composables still wrap the call in `remember(content)` so recompositions skip even the LRU lookup. --- .../amethyst/service/CachedRichTextParser.kt | 20 +++++++++++++++++++ .../amethyst/ui/components/RichTextViewer.kt | 11 +--------- 2 files changed, 21 insertions(+), 10 deletions(-) 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 ef442e3c5..6ca953b4e 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/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index ed5559c0f..f54f53494 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) From 7e2e3304e1ffb546aab21b2e68ee931c3108d2e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 11 May 2026 00:25:36 +0000 Subject: [PATCH 4/8] refactor(RichTextViewer): move isMarkdown onto RichTextViewerState MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleaner conceptual model: the parsed state object is the place where all derived facts about the content live, so `isMarkdown` becomes a field on `RichTextViewerState` (populated by `RichTextParser.parseText` using the same cheap heuristic). `RichTextViewer` now calls `CachedRichTextParser.parseText` once at the top and dispatches on `state.isMarkdown` instead of running a separate scan. `CachedRichTextParser.isMarkdown(content)` and its dedicated `isMarkdownCache` go away — the single `richTextCache` carries the decision alongside the parsed segments. Inner callers (`DisplaySecretEmoji`, `MultiSetCompose` reaction preview, `DisplayUncitedHashtags`) are unaffected: they continue to receive a fully-parsed state with all segments populated. --- .../amethyst/service/CachedRichTextParser.kt | 20 ------------------- .../amethyst/ui/components/RichTextViewer.kt | 6 +++++- .../commons/richtext/RichTextParser.kt | 13 ++++++++++++ .../richtext/RichTextParserSegments.kt | 1 + 4 files changed, 19 insertions(+), 21 deletions(-) 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 6ca953b4e..ef442e3c5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt @@ -28,7 +28,6 @@ import com.vitorpamplona.amethyst.commons.richtext.UrlParser object CachedRichTextParser { private val richTextCache = LruCache(50) - private val isMarkdownCache = LruCache(200) private fun hashCodeCache( content: String, @@ -70,25 +69,6 @@ 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/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index f54f53494..62471f950 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 @@ -136,7 +136,11 @@ fun RichTextViewer( nav: INav, ) { Column(modifier = modifier) { - if (remember(content) { CachedRichTextParser.isMarkdown(content) }) { + val state = + remember(content, tags) { + CachedRichTextParser.parseText(content, tags, callbackUri, authorPubKey) + } + if (state.isMarkdown) { RenderContentAsMarkdown(content, tags, canPreview, quotesLeft, backgroundColor, callbackUri, accountViewModel, nav) } else { RenderRegular(content, tags, canPreview, quotesLeft, backgroundColor, callbackUri, authorPubKey, accountViewModel, nav) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index e3cb26313..f3ed69134 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -224,6 +224,7 @@ class RichTextParser { customEmoji = emojiMap.toImmutableMap(), paragraphs = segments, tags = tags, + isMarkdown = isMarkdown(content), ) } @@ -411,6 +412,18 @@ class RichTextParser { } companion object { + // Cheap heuristic: stored on the parsed state so callers (e.g. RichTextViewer's + // markdown vs regular dispatch) can read the decision off the cached result instead + // of running a separate scan + maintaining a separate cache. + fun isMarkdown(content: String): Boolean = + content.startsWith("> ") || + content.startsWith("# ") || + content.contains("##") || + content.contains("__") || + content.contains("**") || + content.contains("```") || + content.contains("](") + val longDatePattern: Regex = Regex("^\\d{4}-\\d{2}-\\d{2}$") val shortDatePattern: Regex = Regex("^\\d{2}-\\d{2}-\\d{2}$") diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt index c0aa426c0..39a79378d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt @@ -33,6 +33,7 @@ class RichTextViewerState( val customEmoji: ImmutableMap, val paragraphs: ImmutableList, val tags: ImmutableListOfLists, + val isMarkdown: Boolean = false, ) @Immutable From 4d9479fa1b22e6bd296cb829230fb92ddf06a9c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 12 May 2026 01:09:05 +0000 Subject: [PATCH 5/8] Revert "refactor(RichTextViewer): move isMarkdown onto RichTextViewerState" This reverts commit 7e2e3304e1ffb546aab21b2e68ee931c3108d2e1. --- .../amethyst/service/CachedRichTextParser.kt | 20 +++++++++++++++++++ .../amethyst/ui/components/RichTextViewer.kt | 6 +----- .../commons/richtext/RichTextParser.kt | 13 ------------ .../richtext/RichTextParserSegments.kt | 1 - 4 files changed, 21 insertions(+), 19 deletions(-) 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 ef442e3c5..6ca953b4e 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/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 62471f950..f54f53494 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 @@ -136,11 +136,7 @@ fun RichTextViewer( nav: INav, ) { Column(modifier = modifier) { - val state = - remember(content, tags) { - CachedRichTextParser.parseText(content, tags, callbackUri, authorPubKey) - } - if (state.isMarkdown) { + 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/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index f3ed69134..e3cb26313 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -224,7 +224,6 @@ class RichTextParser { customEmoji = emojiMap.toImmutableMap(), paragraphs = segments, tags = tags, - isMarkdown = isMarkdown(content), ) } @@ -412,18 +411,6 @@ class RichTextParser { } companion object { - // Cheap heuristic: stored on the parsed state so callers (e.g. RichTextViewer's - // markdown vs regular dispatch) can read the decision off the cached result instead - // of running a separate scan + maintaining a separate cache. - fun isMarkdown(content: String): Boolean = - content.startsWith("> ") || - content.startsWith("# ") || - content.contains("##") || - content.contains("__") || - content.contains("**") || - content.contains("```") || - content.contains("](") - val longDatePattern: Regex = Regex("^\\d{4}-\\d{2}-\\d{2}$") val shortDatePattern: Regex = Regex("^\\d{2}-\\d{2}-\\d{2}$") diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt index 39a79378d..c0aa426c0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt @@ -33,7 +33,6 @@ class RichTextViewerState( val customEmoji: ImmutableMap, val paragraphs: ImmutableList, val tags: ImmutableListOfLists, - val isMarkdown: Boolean = false, ) @Immutable From 1f578eaff8619b5ce4d7b9523fd6b791b73c617e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 12 May 2026 01:53:14 +0000 Subject: [PATCH 6/8] feat(TimeAgo): single shared ticker so on-screen ages stay fresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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>` 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". --- .../vitorpamplona/amethyst/ui/MainActivity.kt | 5 +- .../amethyst/ui/note/elements/NowProvider.kt | 50 +++++++++++++++++++ .../amethyst/ui/note/elements/TimeAgo.kt | 20 ++++++-- .../screen/loggedIn/chats/feed/ChatTimeAgo.kt | 12 ++++- .../chats/rooms/ChatroomHeaderCompose.kt | 11 +++- 5 files changed, 92 insertions(+), 6 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NowProvider.kt 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, From c92a9df6ea453d9237cf1a89af7676295735e91c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 12 May 2026 02:11:53 +0000 Subject: [PATCH 7/8] perf(observeEdits): filter modification updates at the flow level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `observeEdits` in NoteCompose previously listened to the raw `note.flow().edits.stateFlow` and re-ran `findModificationEventsForNote` (an IO scan) inside a `LaunchedEffect` on every emission — even when the emission didn't change the actual list of modifications. That mutated `editState` repeatedly with the same value during scroll on any active TextNote. Add `observeNoteModifications` in EventObservers.kt: it does the IO resolution via `mapLatest { LocalCache.findLatestModificationForNote(note) }` on Dispatchers.IO, then `distinctUntilChanged()` — so the State only updates (and the consumer's LaunchedEffect only re-keys) when the modification list truly changes. The State is `null` until the first IO resolution completes; consumers treat that as "still loading" and don't flip the UI to "no edits" prematurely. Drop the now-unused `observeNoteEdits` and the `AccountViewModel.findModificationEventsForNote` wrapper. --- .../reqCommand/event/EventObservers.kt | 24 ++++++++++--- .../amethyst/ui/note/NoteCompose.kt | 34 ++++++++++--------- .../ui/screen/loggedIn/AccountViewModel.kt | 5 --- 3 files changed, 37 insertions(+), 26 deletions(-) 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 f46e817bb..1f7a8e31e 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 @@ -359,17 +361,29 @@ fun observeNoteOts( 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. +// 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) @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 - val flow = remember(note) { note.flow().edits.stateFlow } - return flow.collectAsStateWithLifecycle() + return produceState?>(initialValue = null, note) { + note + .flow() + .edits + .stateFlow + .mapLatest { LocalCache.findLatestModificationForNote(note) } + .distinctUntilChanged() + .flowOn(Dispatchers.IO) + .collect { value = it } + } } @Composable 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 70249d1dc..e33451c4c 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 @@ -1783,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/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index a45ab3186..b5983ccc6 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 @@ -1310,11 +1310,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) From e56e4f79a99ef3b5dca56f47a7e8e31b2ba53d63 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 12 May 2026 13:21:01 +0000 Subject: [PATCH 8/8] perf(observeNoteModifications): sample(500) to absorb edit bursts A heavily-edited note can fire `edits.stateFlow` hundreds of times during initial relay sync; without throttling, each emission still hits the IO scan even though `distinctUntilChanged` would collapse most of them downstream. `sample(500)` keeps only the most recent state per ~half second, so the IO `findLatestModificationForNote` runs at most ~twice a second per note instead of once per arrival. --- .../service/relayClient/reqCommand/event/EventObservers.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 1f7a8e31e..96fa4a6f1 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 @@ -363,9 +363,11 @@ fun observeNoteOts( // 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) +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @Composable fun observeNoteModifications( note: Note, @@ -379,6 +381,7 @@ fun observeNoteModifications( .flow() .edits .stateFlow + .sample(500) .mapLatest { LocalCache.findLatestModificationForNote(note) } .distinctUntilChanged() .flowOn(Dispatchers.IO)