refactor: unify all message edits onto Note.edits

All three edit kinds now anchor on the message they edit via the same
Note.edits collection, instead of each maintaining its own store:

- Feed edits (kind 1010): consume(TextNoteModificationEvent) calls
  editedNote.addEdit(note); findLatestModificationForNote folds note.edits
  (author-only, NIP-40 expiry) instead of scanning the whole cache. Drops
  the O(all-notes) scan and the 20-entry modificationCache LRU;
  cachedModificationEventsForNote is now synchronous (no Loading state).
- Buzz edits (kind 40003): consume(StreamMessageEditEvent) calls
  target.addEdit(note); observeBuzzEdit reads note.edits (newest by
  created_at, no author gate — Buzz's own rule). Removes the channel-keyed
  BuzzWorkspaceState edit store, its editUpdates/editFor/effectiveContentFor/
  addEdit and the pruneEdits reaping (edits now prune with their message).
- Concord edits (kind 3302): already on note.edits.

Each reader keeps its own semantics by filtering note.edits on its event
type; the shared field only unifies storage + lifecycle, so an edit lives
exactly as long as the message it edits. Buzz edit tests rewritten against
note.edits (6/6 green).

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 17:39:03 +00:00
parent 2213031f9f
commit 98d8f88c0a
6 changed files with 70 additions and 120 deletions
@@ -22,7 +22,6 @@
package com.vitorpamplona.amethyst.model
import android.util.LruCache
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.cashu.MintDirectoryIndex
@@ -2241,15 +2240,14 @@ object LocalCache : ILocalCache, ICacheProvider {
wasVerified: Boolean,
): Boolean =
// Buzz's own timeline set excludes 40003: an edit is an OVERLAY replacing an
// earlier message's content, never a row of its own. Store it and record the
// overlay (keyed by the channel's UUID, so own sends with no provenance relay
// land too), but do NOT attach it to the timeline.
// earlier message's content, never a row of its own. Store it and anchor it to the
// message it edits (Note.edits) — like every other edit kind — so the overlay is held
// for as long as its message and never leaks into the timeline as a bubble.
consumeBuzzRegularEvent(event, relay, wasVerified).also {
val target = event.editedMessage() ?: return@also
val channelId = event.channel() ?: return@also
val editNote = getOrCreateNote(event.id)
if (editNote.event != null) {
BuzzWorkspaceStates.getOrCreate(channelId).addEdit(target, editNote)
getOrCreateNote(target).addEdit(editNote)
}
}
@@ -2776,12 +2774,11 @@ object LocalCache : ILocalCache, ICacheProvider {
if (wasVerified || justVerify(event)) {
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,
// and addEdit invalidates the note's edits flow so the UI re-derives.
event.editedNote()?.let {
checkGetOrCreateNote(it.eventId)?.let { editedNote ->
modificationCache.remove(editedNote.idHex)
// must update list of Notes to quickly update the user.
editedNote.flowSet?.edits?.invalidateData()
}
checkGetOrCreateNote(it.eventId)?.addEdit(note)
}
refreshNewNoteObservers(note)
@@ -3254,34 +3251,25 @@ object LocalCache : ILocalCache, ICacheProvider {
return minTime
}
val modificationCache = LruCache<HexKey, List<Note>>(20)
fun cachedModificationEventsForNote(note: Note): List<Note>? = modificationCache[note.idHex]
/**
* 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> {
checkNotInMainThread()
val noteAuthor = note.author ?: return emptyList()
modificationCache[note.idHex]?.let {
return it
}
val time = TimeUtils.now()
val newNotes =
notes
.filter { _, item ->
val noteEvent = item.event
noteEvent is TextNoteModificationEvent && noteAuthor == item.author && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time)
}.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
modificationCache.put(note.idHex, newNotes)
return newNotes
return note.edits
.filter { item ->
val noteEvent = item.event
noteEvent is TextNoteModificationEvent && noteAuthor == item.author && !noteEvent.isExpirationBefore(time)
}.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
}
fun cachedModificationEventsForNote(note: Note): List<Note> = findLatestModificationForNote(note)
fun cleanMemory() {
Log.d("LargeCache") { "Notes cleanup started. Current size: ${notes.size()}" }
notes.cleanUp()
@@ -3372,13 +3360,6 @@ object LocalCache : ILocalCache, ICacheProvider {
channel.pruneStalePresence(TimeUtils.now() - PRESENCE_PRUNE_AGE_SECONDS)
}
// A Buzz workspace's edit/canvas overlay is keyed off the channel id, outside
// `notes`, so the top-N reap never touches it. Drop overlay entries whose target
// message was just pruned, else they pin the edit note + author forever.
if (channel is RelayGroupChannel) {
BuzzWorkspaceStates.getIfExists(channel.groupId.id)?.pruneEdits(channel.notes.keys())
}
if (toBeRemoved.size > 100 || channel.notes.size() > 100) {
println(
"PRUNE: ${toBeRemoved.size} old messages removed from ${channel.toBestDisplayName()}. ${channel.notes.size()} kept",
@@ -2060,18 +2060,16 @@ fun observeEdits(
val editState =
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 = accountViewModel.cachedModificationEventsForNote(baseNote)
mutableStateOf(
if (cached != null) {
if (cached.isEmpty()) {
GenericLoadable.Empty()
} else {
val state = EditState()
state.updateModifications(cached)
GenericLoadable.Loaded(state)
}
if (cached.isEmpty()) {
GenericLoadable.Empty()
} else {
GenericLoadable.Loading()
val state = EditState()
state.updateModifications(cached)
GenericLoadable.Loaded(state)
},
)
}
@@ -2037,7 +2037,7 @@ class AccountViewModel(
fun getAddressableNoteIfExists(key: Address): AddressableNote? = LocalCache.getAddressableNoteIfExists(key)
fun cachedModificationEventsForNote(note: Note) = LocalCache.cachedModificationEventsForNote(note)
fun cachedModificationEventsForNote(note: Note): List<Note> = LocalCache.cachedModificationEventsForNote(note)
fun checkGetOrCreatePublicChatChannel(key: HexKey): PublicChatChannel = LocalCache.getOrCreatePublicChatChannel(key)
@@ -30,8 +30,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -41,7 +41,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
@@ -61,28 +60,31 @@ import com.vitorpamplona.quartz.buzz.jobs.JobProgressEvent
import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent
import com.vitorpamplona.quartz.buzz.jobs.JobResultEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip29RelayGroups.groupId
/**
* Observes the newest kind-40003 edit overlaying [note], recomposing when new edits
* arrive. Returns null when the message is unedited or has no channel scope.
* arrive. Returns null when the message is unedited.
*
* Resolution goes through [BuzzWorkspaceStates] keyed by the note's `h` channel id
* (a Buzz UUID) rather than any channel object: the state exists independently of
* when or whether the channel materialized, so a row composed before the first
* edit arrived still starts rendering overlays the moment one lands.
* 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. Buzz keeps the newest by `created_at` regardless of author (its own last-write-wins
* rule); [addEdit] invalidates the note's edits flow, so collecting it re-runs the fold.
*/
@Composable
fun observeBuzzEdit(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 groupId taken before the event populated and never recompute for that row.
val channelId = remember(note.event) { note.event?.groupId() } ?: return null
val state = remember(channelId) { BuzzWorkspaceStates.getOrCreate(channelId) }
// Subscribing to the version counter is what re-runs editFor on new arrivals.
val version by state.editUpdates.collectAsState()
return remember(note, version) { state.editFor(note.idHex) }
val latest by
produceState<Note?>(initialValue = null, note.idHex) {
note.flow().edits.stateFlow.collect {
value =
note.edits
.filter { it.event is StreamMessageEditEvent }
.maxByOrNull { it.createdAt() ?: 0L }
}
}
return latest
}
/**
@@ -39,7 +39,6 @@ import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@@ -48,7 +47,7 @@ import java.util.UUID
/**
* The Buzz dialect of NIP-29 in `LocalCache`: dialect detection off VERIFIED events,
* timeline attachment into the group's (stable, never-swapped) `RelayGroupChannel`, and
* the kind-40003 edit overlay held in `BuzzWorkspaceStates` keyed by the channel id.
* the kind-40003 edit overlay anchored on the message it edits (`Note.edits`).
*/
class BuzzWorkspaceChannelTest {
private val buzzRelay = RelayUrlNormalizer.normalizeOrNull("wss://buzz.example.team/")!!
@@ -147,9 +146,11 @@ class BuzzWorkspaceChannelTest {
LocalCache.checkDeletionAndConsume(edit2, buzzRelay, false)
LocalCache.checkDeletionAndConsume(edit1, buzzRelay, false)
val state = BuzzWorkspaceStates.getIfExists(channelId)!!
assertEquals("newest edit wins regardless of arrival order", "the fix", state.effectiveContentFor(original.id))
assertEquals(edit2.id, state.editFor(original.id)?.idHex)
// The edits are anchored on the message they edit (Note.edits); newest by created_at wins.
val target = LocalCache.getNoteIfExists(original.id)!!
val newest = target.edits.filter { it.event is StreamMessageEditEvent }.maxByOrNull { it.createdAt() ?: 0L }
assertEquals("newest edit wins regardless of arrival order", "the fix", newest?.event?.content)
assertEquals(edit2.id, newest?.idHex)
val channel = LocalCache.getRelayGroupChannelIfExists(GroupId(channelId, buzzRelay))!!
assertFalse("edits are overlays, never timeline rows", channel.notes.containsKey(edit1.id))
@@ -157,10 +158,10 @@ class BuzzWorkspaceChannelTest {
}
@Test
fun overlayIsKeyedByChannelIdSoOwnSendsWithNoRelayLand() =
fun ownSendsWithNoRelayStillOverlayTheirMessage() =
runBlocking {
// An edit consumed with a null provenance relay (own optimistic send) must
// still record its overlay — the registry is keyed by channel id, not relay.
// An edit consumed with a null provenance relay (own optimistic send) must still
// overlay its message — it is anchored on the message note, independent of any relay.
val channelId = newChannelId()
val original = streamMessage(channelId, "original")
LocalCache.checkDeletionAndConsume(original, null, true)
@@ -171,11 +172,13 @@ class BuzzWorkspaceChannelTest {
)
LocalCache.checkDeletionAndConsume(edit, null, true)
assertEquals("edited offline", BuzzWorkspaceStates.getIfExists(channelId)?.effectiveContentFor(original.id))
val target = LocalCache.getNoteIfExists(original.id)!!
val newest = target.edits.filter { it.event is StreamMessageEditEvent }.maxByOrNull { it.createdAt() ?: 0L }
assertEquals("edited offline", newest?.event?.content)
}
@Test
fun pruneDropsOverlaysForMessagesNoLongerInTheChannel() =
fun pruningAMessageReleasesItsEdits() =
runBlocking {
val channelId = newChannelId()
val original = streamMessage(channelId, "will be pruned")
@@ -183,11 +186,12 @@ class BuzzWorkspaceChannelTest {
val edit = signer.sign(StreamMessageEditEvent.build(channelId, original.id, "edit", createdAt = original.createdAt + 5))
LocalCache.checkDeletionAndConsume(edit, buzzRelay, false)
val state = BuzzWorkspaceStates.getIfExists(channelId)!!
assertNotNull(state.editFor(original.id))
val target = LocalCache.getNoteIfExists(original.id)!!
assertTrue("the edit is anchored on its message", target.edits.any { it.idHex == edit.id })
// Simulate the message having been reaped from the channel.
state.pruneEdits(emptySet())
assertNull("overlay for a pruned message must be dropped", state.editFor(original.id))
// An edit lives in Note.edits, so reaping the message releases the overlay with it —
// no separate side store to prune.
target.clearChildLinks()
assertTrue("overlay for a pruned message must be dropped", target.edits.isEmpty())
}
}
@@ -23,16 +23,15 @@ package com.vitorpamplona.amethyst.commons.model.buzz
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlin.concurrent.Volatile
/**
* Buzz-only overlay state for one workspace channel: the kind-40003 edit overlay
* (newest edit per message rendering the original without it shows stale text as
* current) and the newest kind-40100 canvas.
* Buzz-only overlay state for one workspace channel: the newest kind-40100 canvas.
* (Kind-40003 message edits are no longer tracked here like every other edit kind
* they are anchored on the message they edit via `Note.edits`.)
*
* This lives OUTSIDE the channel object on purpose. Screens, feed filters, and
* composers capture their `RelayGroupChannel` instance once and hold it for the whole
@@ -41,17 +40,11 @@ import kotlin.concurrent.Volatile
* orphaned instance. Keeping the overlay in a registry keyed by the channel id makes
* dialect discovery a non-event for object identity.
*
* All mutations are guarded by a per-state lock: consume runs on multiple relay
* dispatcher threads, and unsynchronized check-then-act would let an older edit
* overwrite a newer one.
* The mutation is guarded by a per-state lock: consume runs on multiple relay dispatcher
* threads, and unsynchronized check-then-act would let an older canvas overwrite a newer one.
*/
class BuzzWorkspaceState {
private val lock = KmpLock()
private val editsByTarget = LargeCache<HexKey, Note>()
private val editVersion = MutableStateFlow(0)
/** Bumps when any overlay entry changes, so rows re-read [editFor]. */
val editUpdates: StateFlow<Int> = editVersion
/** The newest canvas (kind 40100) note for this channel, or null when none seen. */
@Volatile
@@ -63,24 +56,6 @@ class BuzzWorkspaceState {
/** Bumps when [canvasNote] is replaced by a newer revision, so a canvas view re-reads it. */
val canvasUpdates: StateFlow<Int> = canvasVersion
/** Records a 40003 edit; keeps only the newest per target (last-write-wins by created_at). */
fun addEdit(
targetId: HexKey,
editNote: Note,
) = lock.withLock {
val current = editsByTarget.get(targetId)
if (current == null || (editNote.createdAt() ?: 0L) > (current.createdAt() ?: 0L)) {
editsByTarget.put(targetId, editNote)
editVersion.value = editVersion.value + 1
}
}
/** The newest edit note overlaying [targetId], or null when the message is unedited. */
fun editFor(targetId: HexKey): Note? = editsByTarget.get(targetId)
/** The effective display content for a message: its newest edit's text, or null when unedited. */
fun effectiveContentFor(targetId: HexKey): String? = editsByTarget.get(targetId)?.event?.content
fun updateCanvas(note: Note) =
lock.withLock {
if ((note.createdAt() ?: 0L) > (canvasNote?.createdAt() ?: 0L)) {
@@ -88,16 +63,6 @@ class BuzzWorkspaceState {
canvasVersion.value = canvasVersion.value + 1
}
}
/** Drops overlay entries whose target message id is not in [aliveTargetIds] (memory pruning). */
fun pruneEdits(aliveTargetIds: Set<HexKey>) =
lock.withLock {
val dead = editsByTarget.keys().filter { it !in aliveTargetIds }
if (dead.isNotEmpty()) {
dead.forEach { editsByTarget.remove(it) }
editVersion.value = editVersion.value + 1
}
}
}
/**