diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/CachePruner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/CachePruner.kt index c906079d84..1f3f356995 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/CachePruner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/CachePruner.kt @@ -395,7 +395,7 @@ class CachePruner( if (noteEvent is ReportEvent) { noteEvent.reportedAuthor().forEach { - cache.getUserIfExists(it.pubkey)?.reportsOrNull()?.let { reports -> + cache.getUserIfExists(it.pubKey)?.reportsOrNull()?.let { reports -> reports.removeReport(note) reports.removeReportNamingUser(note) } 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 4029469535..31afef4830 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -1864,7 +1864,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { val new = consumeRegularEvent(event, relay, wasVerified) if (new) { - val authorsReported = event.reportedAuthor().mapNotNull { checkGetOrCreateUser(it.pubkey) } + val authorsReported = event.reportedAuthor().mapNotNull { checkGetOrCreateUser(it.pubKey) } val eventsReported = event.reportedPost().mapNotNull { checkGetOrCreateNote(it.eventId) } + event.reportedAddresses().map { getOrCreateAddressableNote(it.address) } @@ -1885,7 +1885,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { // report can `p`-tag an incidentally-mentioned third party with no type of its own, // and there is no threshold here to absorb that noise the way // `receivedReportsByAuthor`'s hide path does. - val explicitlyTyped = event.reportedAuthorsWithOwnType().mapTo(mutableSetOf()) { it.pubkey } + val explicitlyTyped = event.reportedAuthorsWithOwnType().mapTo(mutableSetOf()) { it.pubKey } authorsReported.forEach { author -> if (author.pubkeyHex in explicitlyTyped) author.reports().addReportNamingUser(note) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/graperank/GrapeRankScore.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/graperank/GrapeRankScore.kt index 4a147dc115..34efcf4f86 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/graperank/GrapeRankScore.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/graperank/GrapeRankScore.kt @@ -303,7 +303,7 @@ object GrapeRankScore { dropped++ continue } - builder.addReports(r.pubKey, r.reportedAuthor().map { it.pubkey }) + builder.addReports(r.pubKey, r.reportedAuthor().map { it.pubKey }) } if (dropped > 0) System.err.println("[graperank] dropped $dropped retracted reports (NIP-09 deletions)") return dropped diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip56Reports/UserReportWarningState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip56Reports/UserReportWarningState.kt index 18f3657b29..0b7fe44d04 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip56Reports/UserReportWarningState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip56Reports/UserReportWarningState.kt @@ -85,7 +85,7 @@ fun dmReportWarningFor( (report.event as? ReportEvent)?.reportedAuthor()?.forEach { // A single report can name several people; only this counterpart's reason belongs here. - if (it.pubkey == counterpart.pubkeyHex) it.type?.let(types::add) + if (it.pubKey == counterpart.pubkeyHex) it.type?.let(types::add) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/messages/ChatMessageEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/messages/ChatMessageEvent.kt index 1dbbc919a2..8cc2be58a2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/messages/ChatMessageEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/messages/ChatMessageEvent.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag @@ -41,10 +42,15 @@ class ChatMessageEvent( content: String, sig: HexKey, ) : BaseDMGroupEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, SearchableEvent { // content is the decrypted (plaintext) direct-message body. override fun indexableContent() = content + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + fun replyTo() = tags.mapNotNull(ETag::parseId) companion object { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelCreateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelCreateEvent.kt index ed61fa5d9e..88a0db285f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelCreateEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelCreateEvent.kt @@ -25,10 +25,12 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelData import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelDataNorm import com.vitorpamplona.quartz.nip50Search.SearchableEvent @@ -46,9 +48,14 @@ class ChannelCreateEvent( sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig), EventHintProvider, + AddressHintProvider, SearchableEvent { override fun indexableContent() = channelInfo().let { listOfNotNull(it.name, it.about, it.picture).joinToString(" ") } + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + @kotlinx.serialization.Transient @kotlin.jvm.Transient var cache: ChannelDataNorm? = null diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/ReportEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/ReportEvent.kt index 7a64e5d275..6a94d60a1f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/ReportEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/ReportEvent.kt @@ -26,6 +26,9 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip56Reports.tags.DefaultReportTag import com.vitorpamplona.quartz.nip56Reports.tags.ReportedAddressTag @@ -42,7 +45,22 @@ class ReportEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + PubKeyHintProvider, + EventHintProvider, + AddressHintProvider { + override fun pubKeyHints() = tags.mapNotNull(ReportedAuthorTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(ReportedAuthorTag::parseKey) + + override fun eventHints() = tags.mapNotNull(ReportedEventTag::parseAsHint) + + override fun linkedEventIds() = tags.mapNotNull(ReportedEventTag::parseId) + + override fun addressHints() = tags.mapNotNull(ReportedAddressTag::parseAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(ReportedAddressTag::parseAddressId) + @kotlinx.serialization.Transient @kotlin.jvm.Transient private var defaultType: ReportType? = null diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/TagArrayBuilderExt.kt index 5912ae66a5..fa05fff641 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/TagArrayBuilderExt.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nip56Reports import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip56Reports.tags.HashSha256Tag import com.vitorpamplona.quartz.nip56Reports.tags.ReportedAddressTag import com.vitorpamplona.quartz.nip56Reports.tags.ReportedAuthorTag @@ -32,17 +33,20 @@ import com.vitorpamplona.quartz.nip56Reports.tags.ServerTag fun TagArrayBuilder.event( eventId: HexKey, reportType: ReportType, -) = addUnique(ReportedEventTag.assemble(eventId, reportType)) + relay: NormalizedRelayUrl? = null, +) = addUnique(ReportedEventTag.assemble(eventId, relay, reportType)) fun TagArrayBuilder.address( address: Address, reportType: ReportType, -) = addUnique(ReportedAddressTag.assemble(address, reportType)) + relay: NormalizedRelayUrl? = null, +) = addUnique(ReportedAddressTag.assemble(address, relay, reportType)) fun TagArrayBuilder.user( pubkey: HexKey, reportType: ReportType, -) = addUnique(ReportedAuthorTag.assemble(pubkey, reportType)) + relay: NormalizedRelayUrl? = null, +) = addUnique(ReportedAuthorTag.assemble(pubkey, relay, reportType)) fun TagArrayBuilder.hash( x: String, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/tags/ReportTagLayout.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/tags/ReportTagLayout.kt new file mode 100644 index 0000000000..80cfbd1d1e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/tags/ReportTagLayout.kt @@ -0,0 +1,75 @@ +/* + * 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.quartz.nip56Reports.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip56Reports.ReportType + +/** + * Positional layout shared by the NIP-56 pointer tags (`p`, `e`, `a`). + * + * NIP-56 predates the convention that slot 2 of a pointer tag is a relay + * hint: it put the report type there instead. Both layouts are in the wild, + * so every reader has to disambiguate: + * + * ``` + * ["p", "", "nudity"] // legacy: type at 2 + * ["p", "", "wss://relay/", "nudity"] // modern: hint at 2, type at 3 + * ["p", "", "wss://relay/"] // modern, no per-tag type + * ["p", "", "", "nudity"] // empty hint slot + * ``` + * + * Disambiguation is by *shape*, not by tag length: a slot that parses as a + * relay URL is a hint, never a type. No report-type code is a relay URL, so + * the two spaces cannot collide. + */ +internal object ReportTagLayout { + /** The relay hint at slot 2, or null under the legacy layout. */ + fun relayHint(tag: Array): NormalizedRelayUrl? { + if (tag.has(2) && tag[2].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[2])) { + return RelayUrlNormalizer.normalizeOrNull(tag[2]) + } + return null + } + + /** + * The report type this tag carries, falling back to [default] (the + * event-level type) when the tag does not name one of its own. + * + * Only a slot that is neither blank nor a relay URL is offered to + * [ReportType.parseOrNull] — which despite its name never returns null + * and maps anything unrecognized to [ReportType.OTHER]. Feeding it a + * relay URL would turn every hint-carrying tag into an `OTHER` report + * and mask the event-level default. + */ + fun reportType( + tag: Array, + default: ReportType?, + ): ReportType? { + val slot = if (tag.has(2) && (tag[2].isBlank() || relayHint(tag) != null)) 3 else 2 + + if (!tag.has(slot) || tag[slot].isBlank()) return default + + return ReportType.parseOrNull(tag[slot], tag) ?: default + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/tags/ReportedAddressTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/tags/ReportedAddressTag.kt index ef40dcf78d..46d7241bcd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/tags/ReportedAddressTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/tags/ReportedAddressTag.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.nip56Reports.tags import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @@ -30,9 +32,10 @@ import com.vitorpamplona.quartz.utils.ensure @Immutable class ReportedAddressTag( val address: Address, + val relay: NormalizedRelayUrl? = null, override val type: ReportType? = null, ) : BaseReportTag { - fun toTagArray() = assemble(address, type) + fun toTagArray() = assemble(address, relay, type) companion object { const val TAG_NAME = "a" @@ -49,21 +52,48 @@ class ReportedAddressTag( ensure(address != null) { return null } - val type = - if (tag.size == 2) { - defaultReportType - } else if (tag.size == 3) { - ReportType.parseOrNull(tag[2], tag) ?: defaultReportType - } else { - ReportType.parseOrNull(tag[3], tag) ?: defaultReportType - } + return ReportedAddressTag( + address, + ReportTagLayout.relayHint(tag), + ReportTagLayout.reportType(tag, defaultReportType), + ) + } - return ReportedAddressTag(address, type) + fun parseAddressId(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return Address.parse(tag[1])?.toValue() + } + + fun parseAsHint(tag: Array): AddressHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + val address = Address.parse(tag[1]) + ensure(address != null) { return null } + + val hint = ReportTagLayout.relayHint(tag) + ensure(hint != null) { return null } + + return AddressHint(address.toValue(), hint) + } + + /** See [ReportedAuthorTag.assemble] for why the layout is conditional. */ + fun assemble( + address: Address, + relay: NormalizedRelayUrl?, + type: ReportType?, + ) = if (relay != null) { + arrayOfNotNull(TAG_NAME, address.toValue(), relay.url, type?.code) + } else { + arrayOfNotNull(TAG_NAME, address.toValue(), type?.code) } fun assemble( address: Address, type: ReportType? = null, - ) = arrayOfNotNull(TAG_NAME, address.toValue(), type?.code) + ) = assemble(address, null, type) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/tags/ReportedAuthorTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/tags/ReportedAuthorTag.kt index 8fc7e76791..8885c1cafd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/tags/ReportedAuthorTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/tags/ReportedAuthorTag.kt @@ -23,16 +23,21 @@ package com.vitorpamplona.quartz.nip56Reports.tags import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @Immutable class ReportedAuthorTag( - val pubkey: HexKey, + override val pubKey: HexKey, + override val relayHint: NormalizedRelayUrl? = null, override val type: ReportType? = null, -) : BaseReportTag { - fun toTagArray() = assemble(pubkey, type) +) : BaseReportTag, + PubKeyReferenceTag { + fun toTagArray() = assemble(pubKey, relayHint, type) companion object { const val TAG_NAME = "p" @@ -45,21 +50,51 @@ class ReportedAuthorTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - val type = - if (tag.size == 2) { - defaultReportType - } else if (tag.size == 3) { - ReportType.parseOrNull(tag[2], tag) ?: defaultReportType - } else { - ReportType.parseOrNull(tag[3], tag) ?: defaultReportType - } + return ReportedAuthorTag( + tag[1], + ReportTagLayout.relayHint(tag), + ReportTagLayout.reportType(tag, defaultReportType), + ) + } - return ReportedAuthorTag(tag[1], type) + fun parseKey(tag: Array): HexKey? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + return tag[1] + } + + fun parseAsHint(tag: Array): PubKeyHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + + val hint = ReportTagLayout.relayHint(tag) + + ensure(hint != null) { return null } + + return PubKeyHint(tag[1], hint) + } + + /** + * Emits the legacy `[name, id, type]` layout unless a relay hint is + * supplied. Padding slot 2 with `""` to always reach the modern + * layout would be well-formed here but unreadable to the many + * clients that still read the report type out of slot 2. + */ + fun assemble( + pubkey: HexKey, + relayHint: NormalizedRelayUrl?, + type: ReportType?, + ) = if (relayHint != null) { + arrayOfNotNull(TAG_NAME, pubkey, relayHint.url, type?.code) + } else { + arrayOfNotNull(TAG_NAME, pubkey, type?.code) } fun assemble( pubkey: HexKey, type: ReportType? = null, - ) = arrayOfNotNull(TAG_NAME, pubkey, type?.code) + ) = assemble(pubkey, null, type) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/tags/ReportedEventTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/tags/ReportedEventTag.kt index f749c49d64..fcc92b9b3a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/tags/ReportedEventTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/tags/ReportedEventTag.kt @@ -23,16 +23,24 @@ package com.vitorpamplona.quartz.nip56Reports.tags import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.events.GenericETag import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @Immutable class ReportedEventTag( - val eventId: HexKey, + override val eventId: HexKey, + override val relay: NormalizedRelayUrl? = null, override val type: ReportType? = null, -) : BaseReportTag { - fun toTagArray() = assemble(eventId, type) +) : BaseReportTag, + GenericETag { + /** NIP-56 `e` tags carry no author slot — slot 3 is the report type. */ + override val author: HexKey? = null + + override fun toTagArray() = assemble(eventId, relay, type) companion object { const val TAG_NAME = "e" @@ -45,21 +53,46 @@ class ReportedEventTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - val type = - if (tag.size == 2) { - defaultReportType - } else if (tag.size == 3) { - ReportType.parseOrNull(tag[2], tag) ?: defaultReportType - } else { - ReportType.parseOrNull(tag[3], tag) ?: defaultReportType - } + return ReportedEventTag( + tag[1], + ReportTagLayout.relayHint(tag), + ReportTagLayout.reportType(tag, defaultReportType), + ) + } - return ReportedEventTag(tag[1], type) + fun parseId(tag: Array): HexKey? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + return tag[1] + } + + fun parseAsHint(tag: Array): EventIdHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + + val hint = ReportTagLayout.relayHint(tag) + + ensure(hint != null) { return null } + + return EventIdHint(tag[1], hint) + } + + /** See [ReportedAuthorTag.assemble] for why the layout is conditional. */ + fun assemble( + eventId: HexKey, + relay: NormalizedRelayUrl?, + type: ReportType?, + ) = if (relay != null) { + arrayOfNotNull(TAG_NAME, eventId, relay.url, type?.code) + } else { + arrayOfNotNull(TAG_NAME, eventId, type?.code) } fun assemble( eventId: HexKey, type: ReportType? = null, - ) = arrayOfNotNull(TAG_NAME, eventId, type?.code) + ) = assemble(eventId, null, type) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip99Classifieds/ClassifiedsEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip99Classifieds/ClassifiedsEvent.kt index 1f890a4d07..dba1ba9905 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip99Classifieds/ClassifiedsEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip99Classifieds/ClassifiedsEvent.kt @@ -25,9 +25,15 @@ import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.containsAllTagNamesWithValues +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag @@ -53,9 +59,24 @@ class ClassifiedsEvent( sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), PublishedAtProvider, + PubKeyHintProvider, + EventHintProvider, + AddressHintProvider, SearchableEvent { override fun indexableContent() = listOfNotNull(title(), summary(), content).joinToString("\n") + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip56Reports/ReportHintProviderTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip56Reports/ReportHintProviderTest.kt new file mode 100644 index 0000000000..6842c27753 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip56Reports/ReportHintProviderTest.kt @@ -0,0 +1,187 @@ +/* + * 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.quartz.nip56Reports + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip56Reports.tags.ReportedAddressTag +import com.vitorpamplona.quartz.nip56Reports.tags.ReportedAuthorTag +import com.vitorpamplona.quartz.nip56Reports.tags.ReportedEventTag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ReportHintProviderTest { + private val pubkey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + private val other = "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + private val eventId = "43575072239da152afe3d7b5c70ed2beb48db2b10e60c60da45229c09c877d2a" + private val addressId = "30402:$other:7e5fec50-9add-48c6-985c-eb593e6c14cd" + private val relay = "wss://relay.damus.io/" + + private fun report(vararg tags: Array) = + ReportEvent( + id = "00".repeat(32), + pubKey = pubkey, + createdAt = 1700000000, + tags = arrayOf(*tags), + content = "", + sig = "00".repeat(64), + ) + + // --------------------------------------------------------------- + // Layout disambiguation: legacy [name, id, type] vs modern + // [name, id, relay, type]. See ReportTagLayout. + // --------------------------------------------------------------- + + @Test + fun legacyLayoutReadsTypeFromSlotTwo() { + val tag = ReportedAuthorTag.parse(arrayOf("p", other, "nudity"))!! + assertEquals(other, tag.pubKey) + assertEquals(ReportType.NUDITY, tag.type) + assertNull(tag.relayHint) + } + + @Test + fun modernLayoutReadsHintFromTwoAndTypeFromThree() { + val tag = ReportedAuthorTag.parse(arrayOf("p", other, relay, "impersonation"))!! + assertEquals(other, tag.pubKey) + assertEquals(ReportType.IMPERSONATION, tag.type) + assertEquals(relay, tag.relayHint?.url) + } + + /** + * Regression: a relay URL used to be fed to ReportType.parseOrNull, + * which maps anything unrecognized to OTHER — so a hint-carrying tag + * with no per-tag type silently became an `OTHER` report and masked + * the event-level default. + */ + @Test + fun relayHintWithoutTypeFallsBackToDefaultNotOther() { + val tag = ReportedAuthorTag.parse(arrayOf("p", other, relay), ReportType.SPAM)!! + assertEquals(relay, tag.relayHint?.url) + assertEquals(ReportType.SPAM, tag.type) + } + + @Test + fun emptyHintSlotStillReadsTypeFromSlotThree() { + val tag = ReportedAuthorTag.parse(arrayOf("p", other, "", "nudity"))!! + assertEquals(ReportType.NUDITY, tag.type) + assertNull(tag.relayHint) + } + + @Test + fun blankTypeSlotFallsBackToDefault() { + val tag = ReportedAuthorTag.parse(arrayOf("p", other, ""), ReportType.MALWARE)!! + assertEquals(ReportType.MALWARE, tag.type) + } + + @Test + fun eventAndAddressTagsShareTheSameLayoutRules() { + val e = ReportedEventTag.parse(arrayOf("e", eventId, relay, "illegal"))!! + assertEquals(eventId, e.eventId) + assertEquals(relay, e.relay?.url) + assertEquals(ReportType.ILLEGAL, e.type) + assertNull(e.author) + + val a = ReportedAddressTag.parse(arrayOf("a", addressId, relay, "spam"))!! + assertEquals(addressId, a.address.toValue()) + assertEquals(relay, a.relay?.url) + assertEquals(ReportType.SPAM, a.type) + } + + // --------------------------------------------------------------- + // Round trip + // --------------------------------------------------------------- + + @Test + fun assembleRoundTripsBothLayouts() { + val legacy = ReportedAuthorTag.assemble(other, ReportType.SPAM) + assertEquals(listOf("p", other, "spam"), legacy.toList()) + assertEquals(ReportType.SPAM, ReportedAuthorTag.parse(legacy)!!.type) + + val modern = ReportedAuthorTag(other, RelayUrlNormalizer.normalizeOrNull(relay), ReportType.SPAM).toTagArray() + assertEquals(listOf("p", other, relay, "spam"), modern.toList()) + + val reparsed = ReportedAuthorTag.parse(modern)!! + assertEquals(ReportType.SPAM, reparsed.type) + assertEquals(relay, reparsed.relayHint?.url) + } + + // --------------------------------------------------------------- + // Hint providers + // --------------------------------------------------------------- + + @Test + fun reportEventExposesEveryPointerItCarries() { + val event = + report( + arrayOf("p", other, relay, "impersonation"), + arrayOf("e", eventId, "nudity"), + arrayOf("a", addressId, relay, "spam"), + ) + + assertEquals(listOf(other), event.linkedPubKeys()) + assertEquals(listOf(eventId), event.linkedEventIds()) + assertEquals(listOf(addressId), event.linkedAddressIds()) + + // hints only where a relay is actually present + assertEquals(listOf(other), event.pubKeyHints().map { it.pubkey }) + assertEquals(listOf(relay), event.pubKeyHints().map { it.relay.url }) + assertTrue(event.eventHints().isEmpty()) + assertEquals(listOf(addressId), event.addressHints().map { it.addressId }) + } + + @Test + fun linkedPubKeysMatchesTypedAccessorsSoTheGraphCannotDrift() { + val event = + report( + arrayOf("p", other, "nudity"), + arrayOf("p", pubkey, relay, "spam"), + arrayOf("e", eventId, "nudity"), + ) + + // The completeness invariant: every pubkey the typed accessor + // reports is also reported by the generic hint provider. + assertEquals( + event.reportedAuthor().map { it.pubKey }.toSet(), + event.linkedPubKeys().toSet(), + ) + assertEquals( + event.reportedPost().map { it.eventId }.toSet(), + event.linkedEventIds().toSet(), + ) + } + + @Test + fun malformedPointersAreDropped() { + val event = + report( + arrayOf("p", "tooshort", "nudity"), + arrayOf("p"), + arrayOf("e", eventId), + arrayOf("a", "not-an-address"), + ) + + assertTrue(event.linkedPubKeys().isEmpty()) + assertEquals(listOf(eventId), event.linkedEventIds()) + assertTrue(event.linkedAddressIds().isEmpty()) + } +}