diff --git a/README.md b/README.md index dce91b635..7fda6ae95 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases) - [ ] Relay-based Groups (NIP-29) - [x] Custom Emoji (NIP-30) - [x] Event alt descriptors (NIP-31) -- [ ] Labeling (NIP-32) +- [x] Labeling (NIP-32) - [x] Git Stuff (NIP-34) - [x] Torrents (NIP-35) - [x] Sensitive Content (NIP-36) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip32Labeling/EventExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip32Labeling/EventExt.kt new file mode 100644 index 000000000..5a1adc81a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip32Labeling/EventExt.kt @@ -0,0 +1,33 @@ +/* + * 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.nip32Labeling + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip32Labeling.tags.LabelNamespaceTag +import com.vitorpamplona.quartz.nip32Labeling.tags.LabelTag + +/** + * NIP-32 self-reporting: `l` and `L` tags MAY be added to any event kind. + * For non-1985 events, labels refer to the event itself. + */ +fun Event.selfReportLabels() = tags.mapNotNull(LabelTag::parse) + +fun Event.selfReportNamespaces() = tags.mapNotNull(LabelNamespaceTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip32Labeling/LabelEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip32Labeling/LabelEvent.kt new file mode 100644 index 000000000..9615773a0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip32Labeling/LabelEvent.kt @@ -0,0 +1,153 @@ +/* + * 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.nip32Labeling + +import androidx.compose.runtime.Immutable +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.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.events.eTag +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip32Labeling.tags.LabelNamespaceTag +import com.vitorpamplona.quartz.nip32Labeling.tags.LabelTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * NIP-32: Label Event (kind 1985) + * + * Attaches labels to existing events, pubkeys, relays, or topics. + * Supports distributed moderation, collection management, license assignment, + * and content classification. + */ +@Immutable +class LabelEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + PubKeyHintProvider, + AddressHintProvider { + override fun pubKeyHints(): List = tags.mapNotNull(PTag::parseAsHint) + + override fun linkedPubKeys(): List = tags.mapNotNull(PTag::parseKey) + + override fun eventHints(): List = tags.mapNotNull(ETag::parseAsHint) + + override fun linkedEventIds(): List = tags.mapNotNull(ETag::parseId) + + override fun addressHints(): List = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds(): List = tags.mapNotNull(ATag::parseAddressId) + + /** All label namespace (`L`) tags on this event. */ + fun namespaces() = tags.mapNotNull(LabelNamespaceTag::parse) + + /** All label (`l`) tags on this event. */ + fun labels() = tags.mapNotNull(LabelTag::parse) + + /** Labels filtered by a specific namespace. */ + fun labelsByNamespace(namespace: String) = labels().filter { it.namespace == namespace } + + /** Referenced event IDs (label targets). */ + fun labeledEvents() = tags.mapNotNull(ETag::parseId) + + /** Referenced pubkeys (label targets). */ + fun labeledPubKeys() = tags.mapNotNull(PTag::parseKey) + + /** Referenced addresses (label targets). */ + fun labeledAddresses() = tags.mapNotNull(ATag::parseAddressId) + + /** Referenced hashtags/topics (label targets via `t` tag). */ + fun labeledHashtags() = tags.mapNotNull(HashtagTag::parse) + + /** Referenced relay URLs (label targets via `r` tag). */ + fun labeledRelayUrls(): List = + tags + .filter { it.size >= 2 && it[0] == "r" && it[1].isNotEmpty() } + .map { it[1] } + + companion object { + const val KIND = 1985 + const val ALT = "Label event" + + /** + * Build a label event for labeling events. + */ + fun buildEventLabel( + labeledEventId: HexKey, + labeledEventRelay: String? = null, + labeledEventAuthor: HexKey? = null, + labels: List, + content: String = "", + createdAt: Long = TimeUtils.now(), + ) = eventTemplate(KIND, content, createdAt) { + alt(ALT) + eTag(ETag(labeledEventId, labeledEventRelay?.let { RelayUrlNormalizer.normalizeOrNull(it) }, labeledEventAuthor)) + labels.map { it.namespace }.distinct().forEach { labelNamespace(it) } + labels.forEach { label(it) } + } + + /** + * Build a label event for labeling pubkeys. + */ + fun buildPubKeyLabel( + labeledPubKey: HexKey, + labeledPubKeyRelay: String? = null, + labels: List, + content: String = "", + createdAt: Long = TimeUtils.now(), + ) = eventTemplate(KIND, content, createdAt) { + alt(ALT) + pTag(labeledPubKey, labeledPubKeyRelay?.let { RelayUrlNormalizer.normalizeOrNull(it) }) + labels.map { it.namespace }.distinct().forEach { labelNamespace(it) } + labels.forEach { label(it) } + } + + /** + * Build a label event with custom tag targets and labels. + */ + fun build( + labels: List, + content: String = "", + createdAt: Long = TimeUtils.now(), + ) = eventTemplate(KIND, content, createdAt) { + alt(ALT) + labels.map { it.namespace }.distinct().forEach { labelNamespace(it) } + labels.forEach { label(it) } + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip32Labeling/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip32Labeling/TagArrayBuilderExt.kt new file mode 100644 index 000000000..f8830b89f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip32Labeling/TagArrayBuilderExt.kt @@ -0,0 +1,41 @@ +/* + * 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.nip32Labeling + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip32Labeling.tags.LabelNamespaceTag +import com.vitorpamplona.quartz.nip32Labeling.tags.LabelTag + +/** Adds a label (`l`) tag. */ +fun TagArrayBuilder.label(tag: LabelTag) = add(tag.toTagArray()) + +/** Adds a label (`l`) tag with explicit label and namespace values. */ +fun TagArrayBuilder.label( + label: String, + namespace: String, +) = add(LabelTag.assemble(label, namespace)) + +/** Adds a label namespace (`L`) tag. */ +fun TagArrayBuilder.labelNamespace(namespace: String) = add(LabelNamespaceTag.assemble(namespace)) + +/** Adds a label namespace (`L`) tag. */ +fun TagArrayBuilder.labelNamespace(tag: LabelNamespaceTag) = add(tag.toTagArray()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip32Labeling/tags/LabelNamespaceTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip32Labeling/tags/LabelNamespaceTag.kt new file mode 100644 index 000000000..fdeb864fc --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip32Labeling/tags/LabelNamespaceTag.kt @@ -0,0 +1,77 @@ +/* + * 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.nip32Labeling.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * NIP-32: `L` tag — label namespace. + * + * Format: `["L", ""]` + * + * Namespaces SHOULD be unambiguous (ISO standard or reverse domain name notation). + * The special `ugc` namespace MAY be used when label content is provided by an end user. + * `L` tags starting with `#` indicate that the label target should be associated + * with the label's value (attaching standard nostr tags to events, pubkeys, etc.). + */ +@Immutable +data class LabelNamespaceTag( + val namespace: String, +) { + fun toTagArray() = assemble(namespace) + + /** + * Returns true if this namespace is a tag-association namespace (starts with `#`). + * When `L` = `#t`, an `l` tag like `["l", "bitcoin", "#t"]` means the target + * should be associated with the hashtag `bitcoin`. + */ + fun isTagAssociation() = namespace.startsWith("#") + + companion object { + const val TAG_NAME = "L" + + fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun isTagged( + tag: Array, + namespace: String, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == namespace + + fun parse(tag: Array): LabelNamespaceTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + return LabelNamespaceTag(tag[1]) + } + + fun parseNamespace(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(namespace: String) = arrayOf(TAG_NAME, namespace) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip32Labeling/tags/LabelTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip32Labeling/tags/LabelTag.kt new file mode 100644 index 000000000..807480240 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip32Labeling/tags/LabelTag.kt @@ -0,0 +1,78 @@ +/* + * 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.nip32Labeling.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * NIP-32: `l` tag — label value with an optional namespace mark. + * + * Format: `["l", "