From f9ad60c42365015b31fd2c1b26d5ebb8aa267a4c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 19:59:54 +0000 Subject: [PATCH] refactor: move edit-overlay resolvers off LocalCache onto Note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6 --- .../amethyst/model/LocalCache.kt | 32 +-------- .../amethyst/model/NoteEditOverlays.kt | 69 +++++++++++++++++++ .../reqCommand/event/EventObservers.kt | 4 +- .../amethyst/ui/note/NoteCompose.kt | 3 +- .../chats/feed/types/RenderBuzzNotes.kt | 10 +-- .../chats/feed/types/RenderConcordEdits.kt | 18 ++--- .../model/BuzzWorkspaceChannelTest.kt | 4 +- 7 files changed, 85 insertions(+), 55 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/NoteEditOverlays.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index c86b7d0c82..c1c5bf1952 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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 { - 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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/NoteEditOverlays.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/NoteEditOverlays.kt new file mode 100644 index 0000000000..aebd5bdb24 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/NoteEditOverlays.kt @@ -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 { + 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 } +} 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 8715fdc6eb..b52f7ee594 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 @@ -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 } 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 d1326aed5a..0c454edef5 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 @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt index 92441d91ab..9e9c7d3371 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt @@ -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(initialValue = null, note.idHex) { note.flow().edits.stateFlow.collect { - value = LocalCache.findLatestBuzzEditForNote(note) + value = note.latestBuzzEdit() } } return latest diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderConcordEdits.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderConcordEdits.kt index 8436f7554d..7337f084dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderConcordEdits.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderConcordEdits.kt @@ -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(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 diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt index 7ba6894f6e..03371a205a 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt @@ -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