mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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:
@@ -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 }
|
||||
}
|
||||
+2
-2
@@ -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()
|
||||
|
||||
+5
-5
@@ -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
|
||||
|
||||
+4
-14
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user