From 691adb361cc0bd9c2aaa545eaff91ee2ec64acf0 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 13 Jul 2026 14:03:55 -0400 Subject: [PATCH] fix(concord): drop deleted-message ghost rows in channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Concord chat row rendered a permanent "Event is loading or can't be found in your relay list" placeholder when a message's kind-5 delete was processed before the message itself — easy to hit because a reproject re-emits the whole wrap buffer and wrap ordering isn't guaranteed. `consumeConcordRumor` attaches the row before `justConsume`, but `justConsume` bails without loading the event once the rumor has been deleted, leaving an event-null note pinned in the channel forever. Skip attaching a row for a rumor already known deleted (also avoids add/remove churn on every reproject), and after consuming, drop the row if its event never loaded. The reverse order (delete after the message) is still handled by the normal deletion cascade unlinking the note. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../amethyst/model/LocalCache.kt | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) 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 517a3ab972..f3fff5ce5b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -750,9 +750,20 @@ object LocalCache : ILocalCache, ICacheProvider { // Attach to the channel BEFORE justConsume sets the event and notifies feeds, // so the note already carries its ConcordChannel gatherer when it flows through // the Messages-list incremental filter (which routes rows by that gatherer). - if (rumor is ChatEvent || rumor is CommentEvent) { - getOrCreateConcordChannel(ConcordChannelId(communityId, channelIdHex)).addNote(getOrCreateNote(rumor.id)) - } + val messageRow = + if (rumor is ChatEvent || rumor is CommentEvent) { + val ch = getOrCreateConcordChannel(ConcordChannelId(communityId, channelIdHex)) + val note = getOrCreateNote(rumor.id) + // Skip attaching a row for a message we already know is deleted (its kind-5 delete + // was processed first). Otherwise every reproject — which re-emits the whole wrap + // buffer — would re-add then re-remove it, churning the feed. justConsume still + // records the (already-known) deletion below; a delete arriving LATER is handled by + // the normal deletion cascade unlinking the note from its gatherers. + if (!deletionIndex.hasBeenDeleted(rumor)) ch.addNote(note) + ch to note + } else { + null + } // wasVerified = true: a Concord rumor is unsigned (its `sig` is empty), so a signature // check would fail and the event would never load onto its Note — leaving the chat row // stuck on the "loading / not found" placeholder. Its authenticity is already established @@ -760,6 +771,16 @@ object LocalCache : ILocalCache, ICacheProvider { // binds rumor.pubKey == seal.pubKey, and checks rumor.verifyId()), exactly like a NIP-59 // gift-wrapped DM rumor, so we consume it as pre-verified. justConsume(rumor, null, true) + + // justConsume bails without loading the event when the rumor has already been deleted + // (a kind-5 delete referencing it was processed first — easy to hit in Concord because a + // reproject re-emits the whole wrap buffer and ordering isn't guaranteed) or fails to + // verify. We attached the row up front, so an unpopulated note would otherwise linger as a + // permanent "Event is loading…" ghost. Drop it; the reverse order (delete after the message) + // is already handled by the normal deletion cascade unlinking the note from its gatherers. + messageRow?.let { (ch, note) -> + if (note.event == null) ch.removeNote(note) + } } fun checkGetOrCreatePublicChatChannel(key: String): PublicChatChannel? {