mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
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:
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
|
||||
+17
-15
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+19
-15
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
+5
-40
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user