From 10f369c43c4c7459f28dddd2c5286f50b675e280 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 20:15:19 +0000 Subject: [PATCH] feat(model): count NIP-18 quote-reposts in the repost counter A kind:1 note carrying a `q` tag is a quote-repost of the quoted note, but Amethyst's reaction-row repost counter reads `Note.boosts`, which only collected kind:6/kind:16 reposts. Quote-reposts were treated purely as inline citations (stripped by `tagsWithoutCitations()`), so they never appeared in the quoted note's repost count. LocalCache now adds a `q`-tagged note as a boost of each quoted note when consuming text notes/comments, and detaches it on deletion. The quoted note is deliberately kept out of `replyTo` so the quote still renders as a root post in the home feed (`Note.isNewThread`). The same wiring is added to DesktopLocalCache for parity, with tests pinning the behavior using the exact event reported. --- .../amethyst/model/LocalCache.kt | 33 +++++ .../desktop/cache/DesktopLocalCache.kt | 30 ++++ .../cache/DesktopLocalCacheQuoteBoostTest.kt | 133 ++++++++++++++++++ 3 files changed, 196 insertions(+) create mode 100644 desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCacheQuoteBoostTest.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 a2bf434c66..6c8b5dc779 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -121,6 +121,7 @@ import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip18Reposts.BaseRepostEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.decodeEventIdAsHexOrNull import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull @@ -784,6 +785,13 @@ object LocalCache : ILocalCache, ICacheProvider { // Counts the replies replyTo.forEach { it.addReply(note) } + // NIP-18 quote reposts: a note carrying a `q` tag is a quote-repost of the + // quoted note. Count it as a boost so it shows in the quoted note's repost + // counter alongside kind:6/kind:16 reposts. The quoted note is deliberately + // kept out of `replyTo` so the quote still renders as a root post in the home + // feed (see Note.isNewThread); deletion cleanup lives in unlinkAndRemove. + addQuoteBoosts(event, note, replyTo) + refreshNewNoteObservers(note) true @@ -792,6 +800,25 @@ object LocalCache : ILocalCache, ICacheProvider { } } + /** + * Adds [note] as a boost of every event/address referenced by a NIP-18 `q` tag + * (a quote-repost). Targets already in [replyTo] are skipped so a note that both + * replies to and quotes the same note isn't counted twice, and self-quotes are + * ignored. + */ + private fun addQuoteBoosts( + event: Event, + note: Note, + replyTo: List, + ) { + event.taggedQuoteIds().forEach { quotedId -> + val quoted = checkGetOrCreateNote(quotedId) + if (quoted != null && quoted != note && quoted !in replyTo) { + quoted.addBoost(note) + } + } + } + fun consume( event: NipTextEvent, relay: NormalizedRelayUrl?, @@ -2834,6 +2861,12 @@ object LocalCache : ILocalCache, ICacheProvider { val noteEvent = note.event + // Quote-repost boosts are tracked outside `replyTo` (see addQuoteBoosts), so + // detach this note from every quoted note's boosts here. + noteEvent?.taggedQuoteIds()?.forEach { quotedId -> + getNoteIfExists(quotedId)?.removeBoost(note) + } + if (noteEvent is ReportEvent) { noteEvent.reportedAuthor().forEach { getUserIfExists(it.pubkey)?.reportsOrNull()?.removeReport(note) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index be1a200191..a8d6ac087b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -42,6 +42,9 @@ import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip18Reposts.quotes.QAddressableTag +import com.vitorpamplona.quartz.nip18Reposts.quotes.QEventTag +import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuotes import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent @@ -294,6 +297,7 @@ class DesktopLocalCache : ICacheProvider { trackNoteAuthor(note, event.pubKey) relay?.let { note.addRelay(it) } repliesTo.forEach { it.addReply(note) } + addQuoteBoosts(event, note, repliesTo) return true } @@ -313,9 +317,35 @@ class DesktopLocalCache : ICacheProvider { trackNoteAuthor(note, event.pubKey) relay?.let { note.addRelay(it) } repliesTo.forEach { it.addReply(note) } + addQuoteBoosts(event, note, repliesTo) return true } + /** + * NIP-18 quote reposts: a note carrying a `q` tag is a quote-repost of the quoted + * note, so it counts as a boost in the quoted note's repost counter alongside + * kind:6/kind:16 reposts. The quoted note is kept out of `replyTo` so the quote + * still renders as a root post; targets already replied to are skipped to avoid + * double-counting, and self-quotes are ignored. + */ + private fun addQuoteBoosts( + event: Event, + note: Note, + repliesTo: List, + ) { + event.taggedQuotes().forEach { qTag -> + val quoted = + when (qTag) { + is QEventTag -> getOrCreateNote(qTag.eventId) + is QAddressableTag -> getOrCreateAddressableNote(qTag.address) + else -> null + } + if (quoted != null && quoted != note && quoted !in repliesTo) { + quoted.addBoost(note) + } + } + } + /** * Consumes a kind 7 reaction event. * Links reaction to target notes via e-tags and a-tags. diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCacheQuoteBoostTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCacheQuoteBoostTest.kt new file mode 100644 index 0000000000..2641907f78 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCacheQuoteBoostTest.kt @@ -0,0 +1,133 @@ +/* + * 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.desktop.cache + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip18Reposts.quotes.QEventTag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * A NIP-18 quote-repost is a kind:1 note that carries a `q` tag pointing at the + * quoted note. Amethyst's repost counter reads `Note.boosts`, which historically + * only collected kind:6/kind:16 reposts — so quote-reposts never showed in the + * quoted note's reaction row. These tests pin the fix: consuming a `q`-tagged note + * adds it as a boost of the quoted note. + */ +class DesktopLocalCacheQuoteBoostTest { + private val relayUrl = NormalizedRelayUrl("wss://relay.test/") + + // The exact event reported in the task: a kind:1 quote-repost of 9430…ab10. + private val quoteJson = + """ + { + "id": "e45b2897ea5550f68421e0dc3387c2ab2fdcb5f303f91f88199cf45abb18a6b5", + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "created_at": 1781031744, + "kind": 1, + "tags": [ + ["alt", "A short note: Few"], + ["p", "deab79dafa1c2be4b4a6d3aca1357b6caa0b744bf46ad529a5ae464288579e68", "wss://nostr.wine/"], + ["q", "943082a78d462df27a9cf2267366a2b9533817a6d32234e15a7e4f0f7df0ab10", "wss://spatia-arcana.com/", "deab79dafa1c2be4b4a6d3aca1357b6caa0b744bf46ad529a5ae464288579e68"], + ["client", "Amethyst"] + ], + "content": "Few", + "sig": "3d35780c14d295167b62a760f2409a50e444eb13381ddc0b851becb476c67564f486882a4ea1a2f0247acbfd7c58e37085101b5db6712d972d42e5baafc63520" + } + """.trimIndent() + + private val quotedId = "943082a78d462df27a9cf2267366a2b9533817a6d32234e15a7e4f0f7df0ab10" + + @Test + fun `a quote-repost counts as a boost of the quoted note`() { + val cache = DesktopLocalCache() + val quote = Event.fromJson(quoteJson) + + // wasVerified = true: this test pins the boost wiring, not signature checks. + val consumed = cache.consume(quote, relayUrl, wasVerified = true) + assertTrue(consumed, "The quote-repost should be consumed") + + val quotedNote = cache.getNoteIfExists(quotedId) + assertTrue(quotedNote != null, "The quoted note placeholder should exist") + assertEquals(1, quotedNote.boosts.size, "The quote should count as one boost") + assertEquals(quote.id, quotedNote.boosts.first().idHex) + } + + @Test + fun `a quote-repost authored locally is counted via a fresh signed event`() { + val cache = DesktopLocalCache() + val signer = NostrSignerSync(KeyPair()) + + val original = + signer.sign( + createdAt = 1_700_000_000, + kind = TextNoteEvent.KIND, + tags = emptyArray(), + content = "the original post", + ) + cache.consume(original, relayUrl, wasVerified = true) + + val quote = + signer.sign( + createdAt = 1_700_000_100, + kind = TextNoteEvent.KIND, + tags = arrayOf(QEventTag(original.id).toTagArray()), + content = "quoting the original", + ) + cache.consume(quote, relayUrl, wasVerified = true) + + val originalNote = cache.getNoteIfExists(original.id) + assertTrue(originalNote != null) + assertEquals(1, originalNote.boosts.size, "The quote should boost the original") + assertEquals(quote.id, originalNote.boosts.first().idHex) + } + + @Test + fun `a plain note with no quote tag does not boost anything`() { + val cache = DesktopLocalCache() + val signer = NostrSignerSync(KeyPair()) + + val target = + signer.sign( + createdAt = 1_700_000_000, + kind = TextNoteEvent.KIND, + tags = emptyArray(), + content = "target", + ) + cache.consume(target, relayUrl, wasVerified = true) + + val plain = + signer.sign( + createdAt = 1_700_000_100, + kind = TextNoteEvent.KIND, + tags = emptyArray(), + content = "no quotes here", + ) + cache.consume(plain, relayUrl, wasVerified = true) + + assertEquals(0, cache.getNoteIfExists(target.id)?.boosts?.size) + } +}