perf(observeEdits): filter modification updates at the flow level

`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.
This commit is contained in:
Claude
2026-05-12 02:11:53 +00:00
parent 1f578eaff8
commit c92a9df6ea
3 changed files with 37 additions and 26 deletions
@@ -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<NoteState?> {
): State<List<Note>?> {
// 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<List<Note>?>(initialValue = null, note) {
note
.flow()
.edits
.stateFlow
.mapLatest { LocalCache.findLatestModificationForNote(note) }
.distinctUntilChanged()
.flowOn(Dispatchers.IO)
.collect { value = it }
}
}
@Composable
@@ -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<EditState>)?.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)
}
}
}
@@ -1310,11 +1310,6 @@ class AccountViewModel(
fun cachedModificationEventsForNote(note: Note) = LocalCache.cachedModificationEventsForNote(note)
suspend fun findModificationEventsForNote(note: Note): List<Note> =
withContext(Dispatchers.IO) {
LocalCache.findLatestModificationForNote(note)
}
fun checkGetOrCreatePublicChatChannel(key: HexKey): PublicChatChannel = LocalCache.getOrCreatePublicChatChannel(key)
fun checkGetOrCreateLiveActivityChannel(key: Address): LiveActivitiesChannel = LocalCache.getOrCreateLiveChannel(key)