From e24eec9ca6c141c86b5645dc978f4fc853860730 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 01:51:40 +0000 Subject: [PATCH] feat(nip29): subgroup hierarchy (parent/child) for relay groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the NIP-29 Subgroups feature merged upstream: groups can now be organized into a parent/child tree, scoped per host relay. Quartz: - Add `parent`/`child` tag classes and TagArray (builder) helpers. - GroupMetadataEvent (39000): parent()/children()/isRoot() accessors and build params. - EditMetadataEvent (9002): parent()/children() accessors and build params (a 9002 re-carries the full child list, per spec, or the relay rejects it). - SubgroupTree: assembles a relay's flat 39000 set into the hierarchy — structure follows each group's parent tag, sibling order follows the parent's child-tag order, orphans surface as roots, and malformed cycles are broken rather than looping. - NIP-11: advertise/detect subgroup support via `nip29: { subgroups: true }`, with a `subgroups()` builder DSL helper. - Tests for tag round-trips, tree assembly, ordering, orphans and cycles, plus NIP-11 serialization. Amethyst: - RelayGroupChannel: parentGroupId()/childGroupIds()/isSubgroup() reading the latest metadata. - Account.editRelayGroupMetadata: preserve the group's current parent and full children list on a plain metadata edit so an admin renaming a subgroup no longer detaches it (or gets rejected for dropping children). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011Qst2JsmNYMvXitv2vxo4S --- .../vitorpamplona/amethyst/model/Account.kt | 15 +- .../nip29RelayGroups/RelayGroupChannel.kt | 9 + .../nip11RelayInfo/Nip11RelayInformation.kt | 12 ++ .../Nip11RelayInformationBuilder.kt | 6 + .../quartz/nip29RelayGroups/SubgroupTree.kt | 102 +++++++++ .../metadata/GroupMetadataEvent.kt | 22 ++ .../moderation/EditMetadataEvent.kt | 14 ++ .../moderation/TagArrayBuilderExt.kt | 8 + .../moderation/TagArrayExt.kt | 8 + .../quartz/nip29RelayGroups/tags/ChildTag.kt | 48 +++++ .../quartz/nip29RelayGroups/tags/ParentTag.kt | 46 ++++ .../Nip11RelayInformationBuilderTest.kt | 22 ++ .../quartz/nip29RelayGroups/SubgroupTest.kt | 196 ++++++++++++++++++ 13 files changed, 507 insertions(+), 1 deletion(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/SubgroupTree.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/ChildTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/ParentTag.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/SubgroupTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index ab22669284..88c531f1a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2811,7 +2811,16 @@ class Account( signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } } - /** Edit the group's relay-signed metadata with a kind 9002 event (admin only). */ + /** + * Edit the group's relay-signed metadata with a kind 9002 event (admin only). + * + * NIP-29 §Subgroups makes the metadata edit a full replacement of the hierarchy + * links: a 9002 with no `parent` tag re-roots the group, and one that drops any + * existing `child` is rejected by the relay. So unless the caller is explicitly + * re-parenting, we re-carry the group's current [parent] and full [children] list + * from its latest known metadata to keep the tree intact across a plain name/flag + * edit. Pass an explicit value to change them. + */ suspend fun editRelayGroupMetadata( channel: RelayGroupChannel, name: String?, @@ -2823,6 +2832,8 @@ class Account( isRestricted: Boolean, hashtags: List = emptyList(), geohashes: List = emptyList(), + parent: String? = channel.parentGroupId(), + children: List = channel.childGroupIds(), ) { val template = EditMetadataEvent.build( @@ -2833,6 +2844,8 @@ class Account( status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted), hashtags = hashtags, geohashes = geohashes, + parent = parent, + children = children, ) signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt index aa702e13c5..8c6efff29f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt @@ -137,6 +137,15 @@ class RelayGroupChannel( fun hasLivekit(): Boolean = event?.hasLivekit() ?: false + /** Subgroups: the id of this group's parent on the same host relay, or null when it's a root. */ + fun parentGroupId(): String? = event?.parent() + + /** Subgroups: the ordered ids of this group's direct children (empty when it has none). */ + fun childGroupIds(): List = event?.children() ?: emptyList() + + /** Whether this group sits under a parent group (i.e. it is a subgroup). */ + fun isSubgroup(): Boolean = event?.isRoot() == false + fun updateGroupInfo( event: GroupMetadataEvent, eventNote: Note? = null, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt index 552e5f6afc..e2b9465bb2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt @@ -53,6 +53,7 @@ data class Nip11RelayInformation( val fees: RelayInformationFees? = null, val nip50: List? = null, val supported_grasps: List? = null, + val nip29: Nip29Support? = null, ) { /** * Serializes this document to JSON for serving at the relay's root over the @@ -67,6 +68,17 @@ data class Nip11RelayInformation( fun fromJson(json: String): Nip11RelayInformation = JsonMapper.fromJson(json) } + /** + * NIP-29 relay capability advertisement. A relay that supports the subgroup + * hierarchy sets `nip29: { "subgroups": true }` in its NIP-11 document so + * clients know they can offer parent/child grouping for this relay's groups. + */ + @Stable + @Serializable + data class Nip29Support( + val subgroups: Boolean? = null, + ) + @Stable @Serializable data class RelayInformationFee( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilder.kt index 8a5fbcd460..54d77d8674 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilder.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilder.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip11RelayInfo import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RelayLimits +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.Nip29Support import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.RelayInformationFee import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.RelayInformationFees import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.RelayInformationLimitation @@ -87,6 +88,7 @@ class Nip11RelayInformationBuilder { private var limitation: RelayInformationLimitation? = null private var fees: RelayInformationFees? = null + private var nip29: Nip29Support? = null /** Advertise supported NIP numbers, e.g. `supports(1, 11, 42, 50)`. Repeatable. */ fun supports(vararg nips: Int) = apply { nips.forEach { supportedNips.add(it.toString()) } } @@ -112,6 +114,9 @@ class Nip11RelayInformationBuilder { /** GRASP git-server capabilities the relay implements (`supported_grasps`). Repeatable. */ fun grasps(vararg values: String) = apply { supportedGrasps.addAll(values) } + /** Advertise NIP-29 subgroup support (`nip29: { "subgroups": true }`). */ + fun subgroups(supported: Boolean = true) = apply { nip29 = Nip29Support(subgroups = supported) } + /** Declare the relay's `limitation` object via a nested DSL. */ fun limitation(initializer: LimitationBuilder.() -> Unit) = apply { @@ -165,6 +170,7 @@ class Nip11RelayInformationBuilder { fees = fees, nip50 = nip50Subfeatures.ifEmpty { null }?.toList(), supported_grasps = supportedGrasps.ifEmpty { null }?.toList(), + nip29 = nip29, ) @Nip11DslMarker diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/SubgroupTree.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/SubgroupTree.kt new file mode 100644 index 0000000000..af3e53593e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/SubgroupTree.kt @@ -0,0 +1,102 @@ +/* + * 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.nip29RelayGroups + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent + +/** A group and its (recursively assembled) subgroups. */ +@Immutable +data class GroupTreeNode( + val metadata: GroupMetadataEvent, + val children: List, +) + +/** + * Assembles the NIP-29 subgroup hierarchy from a flat set of `kind:39000` + * metadata events (all from the same relay — the tree is relay-scoped). + * + * Structure follows each group's own `parent` tag, which is the single source of + * truth for who a group's parent is (a group carries at most one `parent`, so a + * group can never end up under two parents). A parent's advertised `child` tag + * order is used only to order siblings; children a parent hasn't listed yet are + * appended after the listed ones in a stable order. + * + * Robustness rules, matching the spec's relay behaviour: + * - A group whose declared parent is not present in the set is treated as a root + * (the spec has relays reject such edits, but a client aggregating a partial + * view must still show the group rather than drop it). + * - Cycles cannot occur on a compliant relay (it rejects any `kind:9002` that + * would create one), but if malformed data produces one it is broken and the + * involved groups surface as roots rather than looping forever. + * - When multiple `kind:39000` events share a `d` id the newest wins. + */ +object SubgroupTree { + fun build(events: Collection): List { + val byId = LinkedHashMap() + events.forEach { event -> + val id = event.groupId().ifEmpty { return@forEach } + val existing = byId[id] + if (existing == null || event.createdAt >= existing.createdAt) byId[id] = event + } + + val childrenOf = HashMap>() + byId.values.forEach { event -> + val parent = event.parent() + if (parent != null && parent in byId) { + childrenOf.getOrPut(parent) { mutableListOf() }.add(event) + } + } + + val placed = HashSet() + + fun node(event: GroupMetadataEvent): GroupTreeNode { + val id = event.groupId() + placed.add(id) + + // Order siblings by the parent's advertised `child` tag order; anything + // the parent hasn't listed keeps its natural order after the listed ones. + val order = event.children().withIndex().associate { (index, childId) -> childId to index } + val orderedChildren = + childrenOf[id] + .orEmpty() + .filter { it.groupId() !in placed } // cycle guard + .sortedBy { order[it.groupId()] ?: Int.MAX_VALUE } + + return GroupTreeNode(event, orderedChildren.map { node(it) }) + } + + val roots = + byId.values.filter { + val parent = it.parent() + parent == null || parent !in byId + } + + val tree = roots.map { node(it) }.toMutableList() + + // Safety net: any group left unplaced was part of a cycle — surface it as a root. + byId.values.forEach { event -> + if (event.groupId() !in placed) tree.add(node(event)) + } + + return tree + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt index 618c41fab1..b708ca5b24 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt @@ -32,6 +32,8 @@ import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip29RelayGroups.tags.ChildTag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.ParentTag import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -83,6 +85,22 @@ class GroupMetadataEvent( /** Group supports LiveKit-powered live audio/video. Presence of the `livekit` flag. */ fun hasLivekit() = tags.hasTagName("livekit") + /** + * Subgroups: the id of this group's parent, or null when this is a root group. + * At most one `parent` tag is expected (NIP-29 §Subgroups). + */ + fun parent(): String? = tags.firstNotNullOfOrNull(ParentTag::parse) + + /** + * Subgroups: the ordered ids of this group's direct children. The position of + * each `child` tag in the array is the intended display order. Empty when the + * group has no children (or the relay doesn't advertise them). + */ + fun children(): List = tags.mapNotNull(ChildTag::parse) + + /** A group with no `parent` tag is a root group in the subgroup tree. */ + fun isRoot(): Boolean = parent() == null + /** * The kinds this group accepts, when constrained, e.g. `["supported_kinds", "9", "11"]`. * `null` (tag absent) means all kinds are accepted. @@ -127,6 +145,8 @@ class GroupMetadataEvent( supportedKinds: List? = null, hashtags: List = emptyList(), geohashes: List = emptyList(), + parent: String? = null, + children: List = emptyList(), createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { @@ -141,6 +161,8 @@ class GroupMetadataEvent( addAll(HashtagTag.assemble(hashtags)) // Mip-map each geohash into every prefix so a coarser followed geohash still matches. geohashes.forEach { addAll(GeoHashTag.assemble(it).toList()) } + parent?.let { add(ParentTag.assemble(it)) } + addAll(ChildTag.assemble(children)) initializer() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt index 00695e733e..df9fa03c92 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt @@ -54,6 +54,16 @@ class EditMetadataEvent( fun geohashes() = tags.geohashes() + /** Subgroups: the requested parent group id, or null to (re-)root this group. */ + fun parent() = tags.parentGroupId() + + /** + * Subgroups: the ordered child ids carried on this edit. Per NIP-29 a metadata + * edit of a parent group MUST re-list all of its children, so the relay rejects + * a `kind:9002` that drops any of them. + */ + fun children() = tags.childGroupIds() + fun previousEvents() = tags.previousEvents() override fun indexableContent() = listOfNotNull(name(), about()).joinToString("\n") @@ -69,6 +79,8 @@ class EditMetadataEvent( status: Set = emptySet(), hashtags: List = emptyList(), geohashes: List = emptyList(), + parent: String? = null, + children: List = emptyList(), previousEvents: List = emptyList(), createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, @@ -81,6 +93,8 @@ class EditMetadataEvent( addAll(HashtagTag.assemble(hashtags)) // Mip-map each geohash into every prefix so a coarser followed geohash still matches. geohashes.forEach { addAll(GeoHashTag.assemble(it).toList()) } + parent?.let { parentGroup(it) } + childGroups(children) previous(previousEvents) initializer() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayBuilderExt.kt index 00e5fe2f68..89e095edc2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayBuilderExt.kt @@ -23,14 +23,22 @@ package com.vitorpamplona.quartz.nip29RelayGroups.moderation 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.nip29RelayGroups.tags.ChildTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.CodeTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.ParentTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.PreviousTag fun TagArrayBuilder.groupId(groupId: String) = addUnique(GroupIdTag.assemble(groupId)) fun TagArrayBuilder.previous(eventIdPrefixes: List) = addAll(PreviousTag.assemble(eventIdPrefixes)) +/** Sets the subgroup `parent` tag (the parent group's id). At most one per event. */ +fun TagArrayBuilder.parentGroup(parentGroupId: String) = addUnique(ParentTag.assemble(parentGroupId)) + +/** Appends the ordered `child` subgroup tags. */ +fun TagArrayBuilder.childGroups(childGroupIds: List) = addAll(ChildTag.assemble(childGroupIds)) + fun TagArrayBuilder.userPubKey(pubKey: HexKey) = add(arrayOf("p", pubKey)) fun TagArrayBuilder.userPubKeyWithRoles( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt index 32115a9b7c..e3cc924fc2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt @@ -25,14 +25,22 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.firstTagValue import com.vitorpamplona.quartz.nip01Core.core.mapValueTagged import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.ChildTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.CodeTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.ParentTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.PreviousTag fun TagArray.groupId() = firstTagValue(GroupIdTag.TAG_NAME) fun TagArray.previousEvents() = mapNotNull(PreviousTag::parse) +/** The `parent` group id (subgroups), or null when this is a root group. At most one is expected. */ +fun TagArray.parentGroupId() = firstNotNullOfOrNull(ParentTag::parse) + +/** The ordered list of direct `child` subgroup ids advertised on a parent's metadata. */ +fun TagArray.childGroupIds(): List = mapNotNull(ChildTag::parse) + fun TagArray.userPubKeys(): List = mapNotNull(PTag::parseKey) fun TagArray.deletedEventIds(): List = mapValueTagged("e") { it } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/ChildTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/ChildTag.kt new file mode 100644 index 0000000000..0f934a41e3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/ChildTag.kt @@ -0,0 +1,48 @@ +/* + * 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.nip29RelayGroups.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * NIP-29 subgroups `child` tag. A parent group's `kind:39000` metadata carries one + * `child` tag per direct subgroup, pointing at the child's `d` identifier. The + * order of the `child` tags in the array is the display order of the children; the + * relay appends new children as they are created and an admin can reorder them via + * a `kind:9002` carrying the full desired `child` list. + */ +class ChildTag { + companion object { + const val TAG_NAME = "child" + + fun parse(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(childGroupId: String) = arrayOf(TAG_NAME, childGroupId) + + fun assemble(childGroupIds: List) = childGroupIds.map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/ParentTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/ParentTag.kt new file mode 100644 index 0000000000..dd724de792 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/ParentTag.kt @@ -0,0 +1,46 @@ +/* + * 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.nip29RelayGroups.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * NIP-29 subgroups `parent` tag. Points at the parent group's `d` identifier and + * appears on the subgroup's own `kind:39000` metadata (and on the `kind:9002` + * edit-metadata request that sets it). A group with no `parent` tag is a root. + * + * The spec allows at most one `parent` tag per group. + */ +class ParentTag { + companion object { + const val TAG_NAME = "parent" + + fun parse(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(parentGroupId: String) = arrayOf(TAG_NAME, parentGroupId) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilderTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilderTest.kt index 621b4aef9e..8a9c930807 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilderTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilderTest.kt @@ -147,6 +147,28 @@ class Nip11RelayInformationBuilderTest { assertEquals(3600, info.retention?.get(1)?.time) } + @Test + fun advertisesNip29SubgroupSupport() { + val info = + relayInformation { + name = "Groups" + supports(29) + subgroups() + } + + assertEquals(true, info.nip29?.subgroups) + val json = info.toJson() + assertTrue(json.contains("\"nip29\":{\"subgroups\":true}"), json) + assertEquals(info, Nip11RelayInformation.fromJson(json)) + } + + @Test + fun omitsNip29WhenNotDeclared() { + val info = relayInformation { name = "R" } + assertNull(info.nip29) + assertTrue(!info.toJson().contains("nip29"), info.toJson()) + } + @Test fun listHelpersAreRepeatableAndCollapseWhenEmpty() { val info = diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/SubgroupTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/SubgroupTest.kt new file mode 100644 index 0000000000..cc4360e828 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/SubgroupTest.kt @@ -0,0 +1,196 @@ +/* + * 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.nip29RelayGroups + +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent +import com.vitorpamplona.quartz.utils.EventFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * NIP-29 §Subgroups: the `parent`/`child` tags on `kind:39000` group metadata and + * `kind:9002` edit-metadata, plus [SubgroupTree] assembly of a relay's flat group + * set into the parent/child hierarchy (ordering, orphan-as-root, cycle safety). + */ +class SubgroupTest { + private val relaySelf = "aa".repeat(32) + private val sig = "bb".repeat(64) + private val id = "00".repeat(32) + + private fun metadata( + groupId: String, + parent: String? = null, + children: List = emptyList(), + createdAt: Long = 100, + ): GroupMetadataEvent { + val template = GroupMetadataEvent.build(groupId, name = groupId, parent = parent, children = children, createdAt = createdAt) + return EventFactory.create(id, relaySelf, template.createdAt, GroupMetadataEvent.KIND, template.tags, "", sig) as GroupMetadataEvent + } + + @Test + fun rootGroupHasNoParent() { + val root = metadata("tech", children = listOf("nostr")) + assertNull(root.parent()) + assertTrue(root.isRoot()) + assertEquals(listOf("nostr"), root.children()) + } + + @Test + fun subgroupCarriesParentAndChildOrder() { + val sub = metadata("nostr", parent = "tech", children = listOf("nip29", "nips")) + assertEquals("tech", sub.parent()) + assertEquals(false, sub.isRoot()) + assertEquals(listOf("nip29", "nips"), sub.children()) + } + + @Test + fun editMetadataRoundTripsParentAndChildren() { + val template = EditMetadataEvent.build("nostr", name = "Nostr", parent = "social", children = listOf("nip29")) + val event = EventFactory.create(id, relaySelf, template.createdAt, EditMetadataEvent.KIND, template.tags, "", sig) as EditMetadataEvent + + assertEquals("nostr", event.groupId()) + assertEquals("social", event.parent()) + assertEquals(listOf("nip29"), event.children()) + } + + @Test + fun editMetadataWithoutParentRoots() { + val template = EditMetadataEvent.build("nostr", name = "Nostr") + val event = EventFactory.create(id, relaySelf, template.createdAt, EditMetadataEvent.KIND, template.tags, "", sig) as EditMetadataEvent + assertNull(event.parent()) + } + + @Test + fun buildsNestedTree() { + val tree = + SubgroupTree.build( + listOf( + metadata("tech", children = listOf("nostr")), + metadata("nostr", parent = "tech", children = listOf("nip29")), + metadata("nip29", parent = "nostr"), + ), + ) + + assertEquals(1, tree.size) + val tech = tree[0] + assertEquals("tech", tech.metadata.groupId()) + assertEquals(1, tech.children.size) + val nostr = tech.children[0] + assertEquals("nostr", nostr.metadata.groupId()) + assertEquals( + "nip29", + nostr.children + .single() + .metadata + .groupId(), + ) + } + + @Test + fun ordersSiblingsByParentChildTags() { + // Parent lists children in b, a, c order — the tree must follow that, not insertion order. + val tree = + SubgroupTree.build( + listOf( + metadata("root", children = listOf("b", "a", "c")), + metadata("a", parent = "root"), + metadata("b", parent = "root"), + metadata("c", parent = "root"), + ), + ) + + assertEquals(listOf("b", "a", "c"), tree.single().children.map { it.metadata.groupId() }) + } + + @Test + fun unlistedChildrenComeAfterListedOnes() { + val tree = + SubgroupTree.build( + listOf( + metadata("root", children = listOf("a")), + metadata("a", parent = "root"), + // "b" points at root but root hasn't listed it yet + metadata("b", parent = "root"), + ), + ) + + assertEquals(listOf("a", "b"), tree.single().children.map { it.metadata.groupId() }) + } + + @Test + fun groupWithMissingParentBecomesRoot() { + // "nostr" declares a parent that isn't in the set — surface it as a root, not dropped. + val tree = SubgroupTree.build(listOf(metadata("nostr", parent = "ghost"))) + assertEquals(listOf("nostr"), tree.map { it.metadata.groupId() }) + } + + @Test + fun multipleRootsAreReturned() { + val tree = + SubgroupTree.build( + listOf( + metadata("tech"), + metadata("food"), + metadata("pizza", parent = "food"), + ), + ) + + assertEquals(setOf("tech", "food"), tree.map { it.metadata.groupId() }.toSet()) + } + + @Test + fun cycleDoesNotLoopForever() { + // Malformed data: a <-> b point at each other. A compliant relay rejects this, + // but the assembler must terminate and still surface the groups. + val tree = + SubgroupTree.build( + listOf( + metadata("a", parent = "b"), + metadata("b", parent = "a"), + ), + ) + + val allIds = mutableSetOf() + + fun collect(node: GroupTreeNode) { + allIds.add(node.metadata.groupId()) + node.children.forEach(::collect) + } + tree.forEach(::collect) + assertTrue(allIds.containsAll(setOf("a", "b"))) + } + + @Test + fun newestMetadataWinsForSameId() { + val tree = + SubgroupTree.build( + listOf( + metadata("g", children = listOf("old"), createdAt = 100), + metadata("g", children = listOf("new"), createdAt = 200), + ), + ) + + assertEquals(listOf("new"), tree.single().metadata.children()) + } +}