mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
feat(nip29): subgroup hierarchy (parent/child) for relay groups
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Qst2JsmNYMvXitv2vxo4S
This commit is contained in:
@@ -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<String> = emptyList(),
|
||||
geohashes: List<String> = emptyList(),
|
||||
parent: String? = channel.parentGroupId(),
|
||||
children: List<String> = 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() }
|
||||
}
|
||||
|
||||
+9
@@ -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<String> = 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,
|
||||
|
||||
+12
@@ -53,6 +53,7 @@ data class Nip11RelayInformation(
|
||||
val fees: RelayInformationFees? = null,
|
||||
val nip50: List<String>? = null,
|
||||
val supported_grasps: List<String>? = 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<Nip11RelayInformation>(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(
|
||||
|
||||
+6
@@ -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
|
||||
|
||||
+102
@@ -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<GroupTreeNode>,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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<GroupMetadataEvent>): List<GroupTreeNode> {
|
||||
val byId = LinkedHashMap<String, GroupMetadataEvent>()
|
||||
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<String, MutableList<GroupMetadataEvent>>()
|
||||
byId.values.forEach { event ->
|
||||
val parent = event.parent()
|
||||
if (parent != null && parent in byId) {
|
||||
childrenOf.getOrPut(parent) { mutableListOf() }.add(event)
|
||||
}
|
||||
}
|
||||
|
||||
val placed = HashSet<String>()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
+22
@@ -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<String> = 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<Int>? = null,
|
||||
hashtags: List<String> = emptyList(),
|
||||
geohashes: List<String> = emptyList(),
|
||||
parent: String? = null,
|
||||
children: List<String> = emptyList(),
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<GroupMetadataEvent>.() -> 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()
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -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<GroupMetadataEvent.GroupStatus> = emptySet(),
|
||||
hashtags: List<String> = emptyList(),
|
||||
geohashes: List<String> = emptyList(),
|
||||
parent: String? = null,
|
||||
children: List<String> = emptyList(),
|
||||
previousEvents: List<String> = emptyList(),
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<EditMetadataEvent>.() -> 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()
|
||||
}
|
||||
|
||||
+8
@@ -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 <T : Event> TagArrayBuilder<T>.groupId(groupId: String) = addUnique(GroupIdTag.assemble(groupId))
|
||||
|
||||
fun <T : Event> TagArrayBuilder<T>.previous(eventIdPrefixes: List<String>) = addAll(PreviousTag.assemble(eventIdPrefixes))
|
||||
|
||||
/** Sets the subgroup `parent` tag (the parent group's id). At most one per event. */
|
||||
fun <T : Event> TagArrayBuilder<T>.parentGroup(parentGroupId: String) = addUnique(ParentTag.assemble(parentGroupId))
|
||||
|
||||
/** Appends the ordered `child` subgroup tags. */
|
||||
fun <T : Event> TagArrayBuilder<T>.childGroups(childGroupIds: List<String>) = addAll(ChildTag.assemble(childGroupIds))
|
||||
|
||||
fun <T : Event> TagArrayBuilder<T>.userPubKey(pubKey: HexKey) = add(arrayOf("p", pubKey))
|
||||
|
||||
fun <T : Event> TagArrayBuilder<T>.userPubKeyWithRoles(
|
||||
|
||||
+8
@@ -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<String> = mapNotNull(ChildTag::parse)
|
||||
|
||||
fun TagArray.userPubKeys(): List<HexKey> = mapNotNull(PTag::parseKey)
|
||||
|
||||
fun TagArray.deletedEventIds(): List<HexKey> = mapValueTagged("e") { it }
|
||||
|
||||
+48
@@ -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>): 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<String>) = childGroupIds.map { assemble(it) }
|
||||
}
|
||||
}
|
||||
+46
@@ -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>): 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)
|
||||
}
|
||||
}
|
||||
+22
@@ -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 =
|
||||
|
||||
+196
@@ -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<String> = 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<String>()
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user