refactor: move edit-overlay resolvers off LocalCache onto Note

findLatestModificationForNote (and the Buzz resolver) were pure Note.edits
filters with no LocalCache state — they only lived there for historical
reasons (back when resolving edits meant scanning the whole cache). Now that
every edit is a hard-referenced child of its message, resolution is a cheap
in-memory fold that belongs on the note.

New NoteEditOverlays.kt collects all three as Note extensions, so every edit
kind resolves the same way and none touches LocalCache:
- Note.textNoteModifications() (1010, author-only + NIP-40, version list)
- Note.latestBuzzEdit() (40003, author-only, newest by created_at)
- Note.latestConcordEdit() (3302, author-only, newest by CORD-02 send time)

Callers updated: observeEdits, observeNoteModifications, observeBuzzEdit,
observeConcordEdit, and the Buzz test. observeConcordEdit also drops its
early-return-before-produceState guards (a conditional-hook hazard) since the
resolver returns null for a non-Concord note anyway. LocalCache no longer
carries any edit-filtering logic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6
This commit is contained in:
Claude
2026-07-24 19:59:54 +00:00
parent 9fa1d769f9
commit f9ad60c423
7 changed files with 85 additions and 55 deletions
@@ -2775,7 +2775,7 @@ object LocalCache : ILocalCache, ICacheProvider {
note.loadEvent(event, author, emptyList())
// Anchor the modification to the note it edits, like every other edit kind — the read side
// ([findLatestModificationForNote]) then folds `edited.edits` instead of scanning the cache,
// (Note.textNoteModifications) then folds `edited.edits` instead of scanning the cache,
// and addEdit invalidates the note's edits flow so the UI re-derives.
event.editedNote()?.let {
checkGetOrCreateNote(it.eventId)?.addEdit(note)
@@ -3251,36 +3251,6 @@ object LocalCache : ILocalCache, ICacheProvider {
return minTime
}
/**
* The NIP-1010 edits of [note] to apply, oldest first — folded from the note's own
* [Note.edits] (where [consume] anchors each modification) rather than scanned from the
* whole cache. Only the original author's edits count, and expired (NIP-40) ones are
* dropped. Cheap (bounded by this note's edits), so it's safe to call from any thread.
*/
fun findLatestModificationForNote(note: Note): List<Note> {
val noteAuthor = note.author ?: return emptyList()
val time = TimeUtils.now()
return note.edits
.filter { item ->
val noteEvent = item.event
noteEvent is TextNoteModificationEvent && noteAuthor == item.author && !noteEvent.isExpirationBefore(time)
}.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
}
/**
* The kind-40003 Buzz edit currently overlaying [note], or null when unedited. Like every other
* edit kind, only the ORIGINAL message author's edits count — the send side already gates Edit to
* your own messages, and the relay is not trusted to reject a cross-author edit, so a 40003 signed
* by anyone else can never rewrite your message. The newest by created_at wins (Buzz's rule).
*/
fun findLatestBuzzEditForNote(note: Note): Note? {
val authorHex = note.author?.pubkeyHex ?: return null
return note.edits
.filter { it.event is StreamMessageEditEvent && it.author?.pubkeyHex == authorHex }
.maxByOrNull { it.createdAt() ?: 0L }
}
fun cleanMemory() {
Log.d("LargeCache") { "Notes cleanup started. Current size: ${notes.size()}" }
notes.cleanUp()
@@ -0,0 +1,69 @@
/*
* 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.model
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore
import com.vitorpamplona.quartz.utils.TimeUtils
/*
* Per-kind resolution of a message's edit overlay from its own `Note.edits`. Every edit that
* targets a note is held there as a hard-referenced child (like a reaction), so these are cheap
* in-memory folds no cache scan, no LocalCache state involved, which is why they live on the
* note rather than the cache.
*
* All three kinds apply ONLY edits authored by the edited note's own author: the send side gates
* editing to your own messages, and neither the relay (Buzz) nor an encrypted-plane peer (Concord)
* is trusted to enforce that, so a foreign-authored edit never rewrites your message.
*/
/**
* Every kind-1010 post modification of this note, oldest first (author-only, dropping NIP-40
* expired ones). A list because the post's EditState cycles through the original + each version.
*/
fun Note.textNoteModifications(): List<Note> {
val noteAuthor = author ?: return emptyList()
val now = TimeUtils.now()
return edits
.filter { item ->
val e = item.event
e is TextNoteModificationEvent && noteAuthor == item.author && !e.isExpirationBefore(now)
}.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
}
/** The kind-40003 Buzz edit overlaying this message, or null — author-only, newest by created_at. */
fun Note.latestBuzzEdit(): Note? {
val authorHex = author?.pubkeyHex ?: return null
return edits
.filter { it.event is StreamMessageEditEvent && it.author?.pubkeyHex == authorHex }
.maxByOrNull { it.createdAt() ?: 0L }
}
/** The kind-3302 Concord edit overlaying this message, or null — author-only, newest by CORD-02 §4 send time. */
fun Note.latestConcordEdit(): Note? {
val authorHex = author?.pubkeyHex ?: return null
return edits
.filter { it.author?.pubkeyHex == authorHex && it.event is ConcordChatEditEvent }
.maxWithOrNull(compareBy({ (it.event as ConcordChatEditEvent).orderingMs() }, { it.idHex }))
?.takeIf { it.event != null }
}
@@ -25,10 +25,10 @@ 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
import com.vitorpamplona.amethyst.model.textNoteModifications
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.isMinichatReply
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -418,7 +418,7 @@ fun observeNoteModifications(
.edits
.stateFlow
.sample(500)
.mapLatest { LocalCache.findLatestModificationForNote(note) }
.mapLatest { note.textNoteModifications() }
.distinctUntilChanged()
.flowOn(Dispatchers.IO)
.collect { value = it }
@@ -71,6 +71,7 @@ import com.vitorpamplona.amethyst.commons.ui.state.produceCachedStateAsync
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.textNoteModifications
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.observeNoteEvent
@@ -2062,7 +2063,7 @@ fun observeEdits(
remember(baseNote.idHex) {
// Edits are anchored on the note (Note.edits), so the current set is readable synchronously
// (no cache scan) — start Empty or Loaded, never Loading.
val cached = LocalCache.findLatestModificationForNote(baseNote)
val cached = baseNote.textNoteModifications()
mutableStateOf(
if (cached.isEmpty()) {
GenericLoadable.Empty()
@@ -42,8 +42,8 @@ import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.latestBuzzEdit
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -68,17 +68,17 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
* Observes the newest kind-40003 edit overlaying [note], recomposing when new edits
* arrive. Returns null when the message is unedited.
*
* Each edit is anchored on the message it edits ([Note.edits], where [LocalCache] consumes it),
* Each edit is anchored on the message it edits ([Note.edits], where LocalCache consumes it),
* so it is held for as long as its message and read straight off the note no channel-keyed side
* store. [LocalCache.findLatestBuzzEditForNote] applies only the original author's newest edit;
* [addEdit] invalidates the note's edits flow, so collecting it re-runs the fold.
* store. [Note.latestBuzzEdit] applies only the original author's newest edit; [addEdit]
* invalidates the note's edits flow, so collecting it re-runs the fold.
*/
@Composable
fun observeBuzzEdit(note: Note): Note? {
val latest by
produceState<Note?>(initialValue = null, note.idHex) {
note.flow().edits.stateFlow.collect {
value = LocalCache.findLatestBuzzEditForNote(note)
value = note.latestBuzzEdit()
}
}
return latest
@@ -33,15 +33,14 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.latestConcordEdit
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
/**
* Observes the newest kind-3302 Concord edit overlaying a Concord chat message [note],
@@ -59,21 +58,12 @@ import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
*/
@Composable
fun observeConcordEdit(note: Note): Note? {
// Key on note.event, not note: LocalCache mutates a Note in place, so keying on the Note instance
// would cache a null gatherer taken before the event populated and never recompute for that row.
val isConcord = remember(note.event) { note.inGatherers?.any { it is ConcordChannel } == true }
if (!isConcord) return null
val authorHex = note.author?.pubkeyHex ?: return null
// `addEdit` invalidates this flow, so collecting it re-runs the fold below on each new edit.
// `addEdit` invalidates this flow, so collecting it re-runs the fold on each new edit. A non-Concord
// message simply has no kind-3302 edits, so [Note.latestConcordEdit] returns null for it.
val latest by
produceState<Note?>(initialValue = null, note.idHex) {
note.flow().edits.stateFlow.collect {
value =
note.edits
.filter { it.author?.pubkeyHex == authorHex && it.event is ConcordChatEditEvent }
.maxWithOrNull(compareBy({ (it.event as ConcordChatEditEvent).orderingMs() }, { it.idHex }))
?.takeIf { it.event != null }
value = note.latestConcordEdit()
}
}
return latest
@@ -199,13 +199,13 @@ class BuzzWorkspaceChannelTest {
// …but the overlay only applies the ORIGINAL author's edits, so it is ignored.
assertNull(
"an edit by a different author must never override the message",
LocalCache.findLatestBuzzEditForNote(target),
target.latestBuzzEdit(),
)
// The real author's own later edit does apply.
val real = signer.sign(StreamMessageEditEvent.build(channelId, original.id, "the fix", createdAt = original.createdAt + 200))
LocalCache.checkDeletionAndConsume(real, buzzRelay, false)
assertEquals("the fix", LocalCache.findLatestBuzzEditForNote(target)?.event?.content)
assertEquals("the fix", target.latestBuzzEdit()?.event?.content)
}
@Test