mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
Merge pull request #3161 from vitorpamplona/claude/friendly-mayer-g3lhj1
Count NIP-18 quote-reposts as boosts of quoted notes
This commit is contained in:
@@ -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<Note>,
|
||||
) {
|
||||
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)
|
||||
|
||||
Vendored
+30
@@ -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<Note>,
|
||||
) {
|
||||
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.
|
||||
|
||||
+133
@@ -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<TextNoteEvent>(
|
||||
createdAt = 1_700_000_000,
|
||||
kind = TextNoteEvent.KIND,
|
||||
tags = emptyArray(),
|
||||
content = "the original post",
|
||||
)
|
||||
cache.consume(original, relayUrl, wasVerified = true)
|
||||
|
||||
val quote =
|
||||
signer.sign<TextNoteEvent>(
|
||||
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<TextNoteEvent>(
|
||||
createdAt = 1_700_000_000,
|
||||
kind = TextNoteEvent.KIND,
|
||||
tags = emptyArray(),
|
||||
content = "target",
|
||||
)
|
||||
cache.consume(target, relayUrl, wasVerified = true)
|
||||
|
||||
val plain =
|
||||
signer.sign<TextNoteEvent>(
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user