Merge pull request #3596 from vitorpamplona/claude/nip-29-compliance-review-ngzzaw

NIP-29: subgroups, custom roles, timeline refs, invite links + relay-signed hardening
This commit is contained in:
Vitor Pamplona
2026-07-16 09:37:28 -04:00
committed by GitHub
27 changed files with 1556 additions and 53 deletions
@@ -257,6 +257,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.previous
import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent
import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent
import com.vitorpamplona.quartz.nip32Labeling.LabelEvent import com.vitorpamplona.quartz.nip32Labeling.LabelEvent
@@ -2189,6 +2190,7 @@ class Account(
signer.sign( signer.sign(
CommentEvent.replyBuilder(text, EventHintBundle(rootEvent, hostRelay)) { CommentEvent.replyBuilder(text, EventHintBundle(rootEvent, hostRelay)) {
hTag(group.groupId.id) hTag(group.groupId.id)
previous(group.previousEventRefs(pubKey))
}, },
) )
cache.justConsumeMyOwnEvent(signed) cache.justConsumeMyOwnEvent(signed)
@@ -2716,6 +2718,7 @@ class Account(
isRestricted: Boolean = false, isRestricted: Boolean = false,
hashtags: List<String> = emptyList(), hashtags: List<String> = emptyList(),
geohashes: List<String> = emptyList(), geohashes: List<String> = emptyList(),
parent: String? = null,
): GroupId { ): GroupId {
signAndSendPrivatelyOrBroadcast(CreateGroupEvent.build(groupId)) { listOf(relay) } signAndSendPrivatelyOrBroadcast(CreateGroupEvent.build(groupId)) { listOf(relay) }
@@ -2728,6 +2731,7 @@ class Account(
status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted), status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted),
hashtags = hashtags, hashtags = hashtags,
geohashes = geohashes, geohashes = geohashes,
parent = parent,
) )
signAndSendPrivatelyOrBroadcast(edit) { listOf(relay) } signAndSendPrivatelyOrBroadcast(edit) { listOf(relay) }
@@ -2760,7 +2764,11 @@ class Account(
title: String, title: String,
body: String, body: String,
) { ) {
val template = ThreadEvent.build(body, title) { hTag(channel.groupId.id) } val template =
ThreadEvent.build(body, title) {
hTag(channel.groupId.id)
previous(channel.previousEventRefs(pubKey))
}
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
} }
@@ -2826,7 +2834,16 @@ class Account(
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } 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( suspend fun editRelayGroupMetadata(
channel: RelayGroupChannel, channel: RelayGroupChannel,
name: String?, name: String?,
@@ -2838,6 +2855,8 @@ class Account(
isRestricted: Boolean, isRestricted: Boolean,
hashtags: List<String> = emptyList(), hashtags: List<String> = emptyList(),
geohashes: List<String> = emptyList(), geohashes: List<String> = emptyList(),
parent: String? = channel.parentGroupId(),
children: List<String> = channel.childGroupIds(),
) { ) {
val template = val template =
EditMetadataEvent.build( EditMetadataEvent.build(
@@ -2848,6 +2867,8 @@ class Account(
status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted), status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted),
hashtags = hashtags, hashtags = hashtags,
geohashes = geohashes, geohashes = geohashes,
parent = parent,
children = children,
) )
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
} }
@@ -1853,6 +1853,27 @@ object LocalCache : ILocalCache, ICacheProvider {
return new return new
} }
/**
* NIP-29 addressables (kinds 39000-39005) are authoritative for a group's metadata,
* roster, roles and pins ONLY when signed by the relay's own key — the NIP-11 `self`
* pubkey. This returns false only when we can positively tell an event is NOT relay-signed
* (the relay advertises a `self` and the event's author differs), so a stray or malicious
* user-published 39000/39001/… served by a lax relay can't overwrite a group's state (e.g.
* inject itself into the admin list). When `self` isn't known yet — the NIP-11 doc hasn't
* loaded, or the relay doesn't advertise one — we don't block, so legitimate groups still
* populate and this never regresses a relay whose key we simply haven't fetched.
*/
private fun isRelaySignedGroupEvent(
event: Event,
relay: NormalizedRelayUrl,
): Boolean {
val self =
Amethyst.instance.nip11Cache
.getFromCache(relay)
.self ?: return true
return event.pubKey == self
}
/** /**
* NIP-29 relay-signed group metadata (kind 39000). Stored as an addressable * NIP-29 relay-signed group metadata (kind 39000). Stored as an addressable
* note and used to populate the [RelayGroupChannel]'s name/picture/about/ * note and used to populate the [RelayGroupChannel]'s name/picture/about/
@@ -1868,7 +1889,7 @@ object LocalCache : ILocalCache, ICacheProvider {
): Boolean { ): Boolean {
val new = consumeBaseReplaceable(event, relay, wasVerified) val new = consumeBaseReplaceable(event, relay, wasVerified)
if (relay != null) { if (relay != null && isRelaySignedGroupEvent(event, relay)) {
val note = getOrCreateAddressableNote(event.address()) val note = getOrCreateAddressableNote(event.address())
val channel = getOrCreateRelayGroupChannel(GroupId(event.groupId(), relay)) val channel = getOrCreateRelayGroupChannel(GroupId(event.groupId(), relay))
(note.event as? GroupMetadataEvent)?.let { channel.updateGroupInfo(it, note) } (note.event as? GroupMetadataEvent)?.let { channel.updateGroupInfo(it, note) }
@@ -1884,7 +1905,7 @@ object LocalCache : ILocalCache, ICacheProvider {
wasVerified: Boolean, wasVerified: Boolean,
): Boolean { ): Boolean {
val new = consumeBaseReplaceable(event, relay, wasVerified) val new = consumeBaseReplaceable(event, relay, wasVerified)
if (relay != null) { if (relay != null && isRelaySignedGroupEvent(event, relay)) {
val latest = getOrCreateAddressableNote(event.address()).event as? GroupMembersEvent val latest = getOrCreateAddressableNote(event.address()).event as? GroupMembersEvent
latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updateMembers(it) } latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updateMembers(it) }
} }
@@ -1898,7 +1919,7 @@ object LocalCache : ILocalCache, ICacheProvider {
wasVerified: Boolean, wasVerified: Boolean,
): Boolean { ): Boolean {
val new = consumeBaseReplaceable(event, relay, wasVerified) val new = consumeBaseReplaceable(event, relay, wasVerified)
if (relay != null) { if (relay != null && isRelaySignedGroupEvent(event, relay)) {
val latest = getOrCreateAddressableNote(event.address()).event as? GroupAdminsEvent val latest = getOrCreateAddressableNote(event.address()).event as? GroupAdminsEvent
latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updateAdmins(it) } latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updateAdmins(it) }
} }
@@ -1912,13 +1933,27 @@ object LocalCache : ILocalCache, ICacheProvider {
wasVerified: Boolean, wasVerified: Boolean,
): Boolean { ): Boolean {
val new = consumeBaseReplaceable(event, relay, wasVerified) val new = consumeBaseReplaceable(event, relay, wasVerified)
if (relay != null) { if (relay != null && isRelaySignedGroupEvent(event, relay)) {
val latest = getOrCreateAddressableNote(event.address()).event as? GroupPinnedEvent val latest = getOrCreateAddressableNote(event.address()).event as? GroupPinnedEvent
latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updatePinned(it) } latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updatePinned(it) }
} }
return new return new
} }
/** NIP-29 relay-declared supported roles (kind 39003) → the group's role set. */
fun consume(
event: SupportedRolesEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean {
val new = consumeBaseReplaceable(event, relay, wasVerified)
if (relay != null && isRelaySignedGroupEvent(event, relay)) {
val latest = getOrCreateAddressableNote(event.address()).event as? SupportedRolesEvent
latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updateSupportedRoles(it) }
}
return new
}
/** /**
* Attach a group-scoped content event (a kind-9 chat, kind-1068 poll, … * Attach a group-scoped content event (a kind-9 chat, kind-1068 poll, …
* carrying an `h` tag) to its [RelayGroupChannel]. NIP-29 reuses the generic * carrying an `h` tag) to its [RelayGroupChannel]. NIP-29 reuses the generic
@@ -3876,16 +3911,18 @@ object LocalCache : ILocalCache, ICacheProvider {
consume(event, relay, wasVerified) consume(event, relay, wasVerified)
} }
// Remaining NIP-29 relay-group kinds. The two relay-signed addressables (39003 // 39003 (relay-declared roles) is durable group state like 39000/39001/39002:
// roles, 39004 AV participants) are durable group state alongside 39000/39001/39002, // route it onto the channel so a moderation UI can offer the relay's role set.
// so they're stored replaceably. The 9xxx moderation actions and join/leave requests
// are regular one-shot events the relay is authoritative for (it applies them and
// republishes the 39000/39001/39002); we store them so they're queryable and don't
// fall through to the "Not Supported" warning, but we don't act on them client-side.
is SupportedRolesEvent -> { is SupportedRolesEvent -> {
consumeBaseReplaceable(event, relay, wasVerified) consume(event, relay, wasVerified)
} }
// Remaining NIP-29 relay-group kinds. The relay-signed 39004 AV-participants
// addressable is durable group state, so it's stored replaceably. The 9xxx
// moderation actions and join/leave requests are regular one-shot events the
// relay is authoritative for (it applies them and republishes the
// 39000/39001/39002); we store them so they're queryable and don't fall through
// to the "Not Supported" warning, but we don't act on them client-side.
is GroupParticipantsEvent -> { is GroupParticipantsEvent -> {
consumeBaseReplaceable(event, relay, wasVerified) consumeBaseReplaceable(event, relay, wasVerified)
} }
@@ -51,6 +51,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip29RelayGroups.GroupInviteLink import com.vitorpamplona.quartz.nip29RelayGroups.GroupInviteLink
import com.vitorpamplona.quartz.nip29RelayGroups.GroupNAddrInvite
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
@@ -226,7 +227,8 @@ fun uriToRoute(
relayGroupInviteRoute(uri)?.let { return it } relayGroupInviteRoute(uri)?.let { return it }
concordInviteRoute(uri)?.let { return it } concordInviteRoute(uri)?.let { return it }
val nip19 = Nip19Parser.uriToRoute(uri)?.entity val parsedNip19 = Nip19Parser.uriToRoute(uri)
val nip19 = parsedNip19?.entity
if (nip19 != null) { if (nip19 != null) {
LocalCache.consume(nip19) LocalCache.consume(nip19)
@@ -252,7 +254,7 @@ fun uriToRoute(
} }
is NAddress -> { is NAddress -> {
relayGroupDirectRoute(nip19) relayGroupDirectRoute(nip19, parsedNip19.additionalChars)
?: routeFor( ?: routeFor(
note = LocalCache.getOrCreateAddressableNote(nip19.address()), note = LocalCache.getOrCreateAddressableNote(nip19.address()),
loggedIn = account, loggedIn = account,
@@ -341,10 +343,16 @@ private fun calendarDirectRoute(nip19: NAddress): Route? =
* id alone can't be resolved. With a hint we open the group chat straight away * id alone can't be resolved. With a hint we open the group chat straight away
* (cold start included); without one there's nowhere to look, so fall through. * (cold start included); without one there's nowhere to look, so fall through.
*/ */
private fun relayGroupDirectRoute(nip19: NAddress): Route? { private fun relayGroupDirectRoute(
nip19: NAddress,
additionalChars: String?,
): Route? {
if (nip19.kind != GroupMetadataEvent.KIND) return null if (nip19.kind != GroupMetadataEvent.KIND) return null
val relay = nip19.relay.firstOrNull() ?: return null val relay = nip19.relay.firstOrNull() ?: return null
return Route.RelayGroup(nip19.dTag, relay.url) // The spec allows an invite code appended as `naddr1…?invite=<code>`; it arrives
// in the trailing chars after the bech32 body. Carry it so the group auto-joins.
val inviteCode = GroupNAddrInvite.parse(additionalChars)
return Route.RelayGroup(nip19.dTag, relay.url, inviteCode = inviteCode)
} }
/** /**
@@ -81,6 +81,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
import com.vitorpamplona.quartz.nip19Bech32.toNIP19 import com.vitorpamplona.quartz.nip19Bech32.toNIP19
import com.vitorpamplona.quartz.nip29RelayGroups.GroupNAddrInvite
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@@ -212,10 +213,13 @@ private fun DisplayAddress(
// only per relay), so we need that hint to open it. // only per relay), so we need that hint to open it.
val groupRelay = if (nip19.kind == GroupMetadataEvent.KIND) nip19.relay.firstOrNull() else null val groupRelay = if (nip19.kind == GroupMetadataEvent.KIND) nip19.relay.firstOrNull() else null
if (groupRelay != null) { if (groupRelay != null) {
// An invite code may be appended to the group naddr as `?invite=<code>`; it lands
// in additionalChars. Pass it through so the group auto-joins with a kind-9021 code.
val inviteCode = GroupNAddrInvite.parse(additionalChars)
CreateClickableText( CreateClickableText(
clickablePart = "#${nip19.dTag}", clickablePart = "#${nip19.dTag}",
suffix = additionalChars, suffix = additionalChars,
route = Route.RelayGroup(nip19.dTag, groupRelay.url), route = Route.RelayGroup(nip19.dTag, groupRelay.url, inviteCode = inviteCode),
nav = nav, nav = nav,
) )
return return
@@ -67,11 +67,18 @@ fun InviteRelayGroupDialog(
.collectAsStateWithLifecycle() .collectAsStateWithLifecycle()
val liveChannel = channelState.channel as? RelayGroupChannel ?: channel val liveChannel = channelState.channel as? RelayGroupChannel ?: channel
// A shareable, cross-client coordinate for the group (opens the chat in any
// NIP-29 client). Null until the relay-signed metadata has loaded.
val nAddr = liveChannel.toNAddr()?.let { "nostr:$it" }
val isClosed = liveChannel.isClosed() val isClosed = liveChannel.isClosed()
// A shareable, cross-client coordinate for the group (opens the chat in any NIP-29
// client). Null until the relay-signed metadata has loaded. For a closed (invite-only)
// group we append the one-time code as the spec's `?invite=<code>` suffix so the whole
// link is a single tap that auto-joins — no separate code to paste.
val nAddr =
liveChannel.toNAddr()?.let { base ->
val uri = "nostr:$base"
if (isClosed) "$uri?invite=$code" else uri
}
// A join code is only meaningful for closed (invite-only) groups; open groups // A join code is only meaningful for closed (invite-only) groups; open groups
// join directly from the shared naddr. So mint the kind-9009 invite only when // join directly from the shared naddr. So mint the kind-9009 invite only when
// the group is actually closed, rather than on every dialog open. // the group is actually closed, rather than on every dialog open.
@@ -116,15 +123,13 @@ fun InviteRelayGroupDialog(
} }
}, },
confirmButton = { confirmButton = {
// Copy the group link, plus the code when the group is closed (so a // Copy the single shareable link. For a closed group the code is already
// recipient has both to join). Never fall back to copying a code the // embedded as `?invite=…`, so one tap joins. Nothing to copy until the
// dialog didn't show — for an open group with metadata not yet loaded // group's metadata (and thus the naddr) has loaded, so disable until then.
// there is simply nothing to copy, so disable the button.
val toCopy = listOfNotNull(nAddr, if (isClosed) code else null).joinToString("\n")
TextButton( TextButton(
enabled = toCopy.isNotBlank(), enabled = nAddr != null,
onClick = { onClick = {
scope.launch { clipboard.setText(toCopy) } nAddr?.let { link -> scope.launch { clipboard.setText(link) } }
onDismiss() onDismiss()
}, },
) { ) {
@@ -149,6 +149,12 @@ private fun ChannelView(
onJumpToNote = { jumpToNoteId.value = it.idHex }, onJumpToNote = { jumpToNoteId.value = it.idHex },
) )
RelayGroupSubgroupsBar(
channel = liveChannel,
accountViewModel = accountViewModel,
nav = nav,
)
Column( Column(
modifier = modifier =
remember { remember {
@@ -95,6 +95,10 @@ fun RelayGroupMembersScreen(
private class RosterEntry( private class RosterEntry(
val pubkey: HexKey, val pubkey: HexKey,
val membership: RelayGroupMembership, val membership: RelayGroupMembership,
// The relay-assigned role names for this member (from the kind-39001 admin list),
// e.g. ["admin"], ["ceo", "gardener"]. Empty for plain members. Used to show the
// real relay-defined role label instead of only the coarse admin/moderator bucket.
val roles: List<String>,
) )
@Composable @Composable
@@ -121,9 +125,10 @@ private fun RelayGroupMembers(
// is overkill here — rely on relay order but push elevated roles to the top. // is overkill here — rely on relay order but push elevated roles to the top.
val roster = val roster =
remember(channel.admins, channel.members) { remember(channel.admins, channel.members) {
val rolesByPubkey = channel.admins.associate { it.pubKey to it.roles }
val everyone = (channel.admins.map { it.pubKey } + channel.members).distinct() val everyone = (channel.admins.map { it.pubKey } + channel.members).distinct()
everyone everyone
.map { RosterEntry(it, channel.membershipOf(it)) } .map { RosterEntry(it, channel.membershipOf(it), rolesByPubkey[it] ?: emptyList()) }
.sortedBy { it.membership.rank() } .sortedBy { it.membership.rank() }
} }
@@ -226,7 +231,7 @@ private fun RelayGroupMemberRow(
} }
} }
MemberRoleBadge(entry.membership) MemberRoleBadge(entry)
// Moderators can act on others (not themselves); the relay is the final // Moderators can act on others (not themselves); the relay is the final
// authority, but hide obviously-useless menus (a moderator can't touch an admin). // authority, but hide obviously-useless menus (a moderator can't touch an admin).
@@ -244,23 +249,58 @@ private fun RelayGroupMemberRow(
) )
} }
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
if (viewerIsAdmin && entry.membership != RelayGroupMembership.ADMIN) { val declaredRoles = channel.supportedRoles
DropdownMenuItem( if (declaredRoles.isNotEmpty()) {
text = { Text(stringRes(R.string.relay_group_make_admin)) }, // The relay declares its own role set (kind 39003) — offer exactly those
onClick = { // instead of the built-in admin/moderator pair. Roles are privilege grants,
menuOpen = false // so only admins assign them; the relay is the final authority.
accountViewModel.putRelayGroupUser(channel, entry.pubkey, listOf(RelayGroupMembership.ROLE_ADMIN)) if (viewerIsAdmin) {
}, declaredRoles.forEach { role ->
) val alreadyHasRole = entry.roles.any { it.equals(role.name, true) }
} if (!alreadyHasRole) {
if (entry.membership != RelayGroupMembership.MODERATOR && entry.membership != RelayGroupMembership.ADMIN) { DropdownMenuItem(
DropdownMenuItem( text = {
text = { Text(stringRes(R.string.relay_group_make_moderator)) }, Column {
onClick = { Text(stringRes(R.string.relay_group_assign_role, role.name))
menuOpen = false role.description?.let {
accountViewModel.putRelayGroupUser(channel, entry.pubkey, listOf(RelayGroupMembership.ROLE_MODERATOR)) Text(
}, text = it,
) style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
onClick = {
menuOpen = false
// Additive: NIP-29 allows multiple roles per member and the menu only
// offers roles they lack, so keep the ones they already hold.
accountViewModel.putRelayGroupUser(channel, entry.pubkey, entry.roles + role.name)
},
)
}
}
}
} else {
// No 39003 role set advertised: fall back to the built-in admin/moderator shortcuts.
if (viewerIsAdmin && entry.membership != RelayGroupMembership.ADMIN) {
DropdownMenuItem(
text = { Text(stringRes(R.string.relay_group_make_admin)) },
onClick = {
menuOpen = false
accountViewModel.putRelayGroupUser(channel, entry.pubkey, listOf(RelayGroupMembership.ROLE_ADMIN))
},
)
}
if (entry.membership != RelayGroupMembership.MODERATOR && entry.membership != RelayGroupMembership.ADMIN) {
DropdownMenuItem(
text = { Text(stringRes(R.string.relay_group_make_moderator)) },
onClick = {
menuOpen = false
accountViewModel.putRelayGroupUser(channel, entry.pubkey, listOf(RelayGroupMembership.ROLE_MODERATOR))
},
)
}
} }
if (entry.membership == RelayGroupMembership.MODERATOR || entry.membership == RelayGroupMembership.ADMIN) { if (entry.membership == RelayGroupMembership.MODERATOR || entry.membership == RelayGroupMembership.ADMIN) {
DropdownMenuItem( DropdownMenuItem(
@@ -310,13 +350,21 @@ private fun RelayGroupMemberRow(
} }
} }
/** A small colored pill for an elevated role; plain members get nothing. */ /**
* A small colored pill for an elevated role; plain members get nothing.
*
* When the relay assigns explicit role labels (kind 39001), those are shown verbatim
* (e.g. `ceo`, `moderator`) so relay-defined roles are visible rather than collapsed
* into the coarse admin/moderator bucket. Falls back to the generic admin/moderator
* label when the member is in the admin list without any named role.
*/
@Composable @Composable
private fun MemberRoleBadge(membership: RelayGroupMembership) { private fun MemberRoleBadge(entry: RosterEntry) {
val label = val label =
when (membership) { when {
RelayGroupMembership.ADMIN -> stringRes(R.string.relay_group_role_admin) entry.roles.isNotEmpty() -> entry.roles.joinToString(", ")
RelayGroupMembership.MODERATOR -> stringRes(R.string.relay_group_role_moderator) entry.membership == RelayGroupMembership.ADMIN -> stringRes(R.string.relay_group_role_admin)
entry.membership == RelayGroupMembership.MODERATOR -> stringRes(R.string.relay_group_role_moderator)
else -> return else -> return
} }
@@ -226,6 +226,10 @@ private fun RelayGroupMetadataScaffold(
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
GroupMetadataFields(viewModel) GroupMetadataFields(viewModel)
Spacer(Modifier.height(16.dp))
ParentGroupSection(viewModel, accountViewModel)
} }
} }
} }
@@ -86,6 +86,20 @@ class RelayGroupMetadataViewModel : ViewModel() {
var isHidden by mutableStateOf(false) var isHidden by mutableStateOf(false)
var isRestricted by mutableStateOf(false) var isRestricted by mutableStateOf(false)
/**
* Subgroups: the parent group this group nests under, or null for a top-level group.
* Editable via the parent picker. Seeded from the current metadata in [prefillFrom].
*/
var parentGroupId by mutableStateOf<String?>(null)
private set
/**
* True once the user actually picks a parent in this session. Until then we let the save
* read the group's live parent rather than the (possibly not-yet-loaded) prefilled value, so
* a plain rename can never re-root a subgroup just because its metadata hadn't arrived yet.
*/
private var parentTouched by mutableStateOf(false)
var pickedMedia by mutableStateOf<SelectedMedia?>(null) var pickedMedia by mutableStateOf<SelectedMedia?>(null)
private set private set
@@ -135,6 +149,14 @@ class RelayGroupMetadataViewModel : ViewModel() {
topics.value = TextFieldValue(event?.hashtags()?.distinct()?.joinToString(" ") ?: "") topics.value = TextFieldValue(event?.hashtags()?.distinct()?.joinToString(" ") ?: "")
// Stored geohashes are mip-mapped into every prefix; the last (longest) is the real one. // Stored geohashes are mip-mapped into every prefix; the last (longest) is the real one.
geohash.value = TextFieldValue(event?.geohashes()?.maxByOrNull { it.length } ?: "") geohash.value = TextFieldValue(event?.geohashes()?.maxByOrNull { it.length } ?: "")
parentGroupId = channel.parentGroupId()
}
/** Set (or clear, with null) the group's parent from the picker; marks the form touched. */
fun setParent(groupId: String?) {
parentGroupId = groupId
parentTouched = true
markTouched()
} }
/** Split the topics field into distinct, non-blank, lowercased hashtags (leading `#` dropped). */ /** Split the topics field into distinct, non-blank, lowercased hashtags (leading `#` dropped). */
@@ -222,6 +244,7 @@ class RelayGroupMetadataViewModel : ViewModel() {
isRestricted = isRestricted, isRestricted = isRestricted,
hashtags = hashtags, hashtags = hashtags,
geohashes = geohashes, geohashes = geohashes,
parent = parentGroupId,
) )
} else { } else {
account.editRelayGroupMetadata( account.editRelayGroupMetadata(
@@ -235,6 +258,11 @@ class RelayGroupMetadataViewModel : ViewModel() {
isRestricted = isRestricted, isRestricted = isRestricted,
hashtags = hashtags, hashtags = hashtags,
geohashes = geohashes, geohashes = geohashes,
// Only override the parent when the user actually re-parented; otherwise let
// Account read the group's live parent so a rename can't accidentally re-root it.
// children likewise defaults to the live child list, so a concurrently-added
// subgroup isn't dropped by this metadata edit.
parent = if (parentTouched) parentGroupId else existing.parentGroupId(),
) )
} }
} }
@@ -0,0 +1,491 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.nip11RelayInfo.isRelaySignedRelayGroup
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupWarmupSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsOnRelaySubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
/**
* Subgroup authoring: the "Parent group" section of the create/edit form. Shows the
* currently-chosen parent (or top-level) as a tappable hero card and, on tap, opens a
* bottom-sheet picker of the other groups on the same host relay. Selecting one nests
* this group under it via the kind-9002 `parent` tag when the form is saved.
*/
@Composable
fun ParentGroupSection(
viewModel: RelayGroupMetadataViewModel,
accountViewModel: AccountViewModel,
) {
val relay = viewModel.relay ?: return
var pickerOpen by remember { mutableStateOf(false) }
Text(
text = stringRes(R.string.relay_group_section_structure),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = stringRes(R.string.relay_group_parent_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
Spacer(Modifier.height(10.dp))
ParentSelectorCard(
parentId = viewModel.parentGroupId,
relay = relay,
accountViewModel = accountViewModel,
onClick = { pickerOpen = true },
)
if (pickerOpen) {
ParentGroupPickerSheet(
selfGroupId = viewModel.groupId,
selectedParentId = viewModel.parentGroupId,
relay = relay,
accountViewModel = accountViewModel,
onSelect = {
viewModel.setParent(it)
pickerOpen = false
},
onDismiss = { pickerOpen = false },
)
}
}
/** The hero card in the form showing the current parent (or top-level) with a gradient badge. */
@Composable
private fun ParentSelectorCard(
parentId: String?,
relay: NormalizedRelayUrl,
accountViewModel: AccountViewModel,
onClick: () -> Unit,
) {
// Resolve the parent (get-or-create so it's never stuck null when the metadata isn't cached
// yet), warm its single 39000, and observe it so the name/picture fill in as they arrive.
val liveParent: RelayGroupChannel? =
parentId?.let { id ->
val channel = remember(id, relay) { accountViewModel.checkGetOrCreateRelayGroupChannel(GroupId(id, relay)) }
RelayGroupWarmupSubscription(channel, accountViewModel.dataSources().relayGroupWarmup, accountViewModel)
val state by channel
.flow()
.metadata.stateFlow
.collectAsStateWithLifecycle()
state.channel as? RelayGroupChannel ?: channel
}
Surface(
shape = RoundedCornerShape(18.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(18.dp))
.clickable(onClick = onClick),
) {
Row(
modifier = Modifier.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
GradientBadge {
if (liveParent != null) {
RobohashFallbackAsyncImage(
robot = liveParent.groupId.id,
model = liveParent.profilePicture(),
contentDescription = liveParent.toBestDisplayName(),
modifier = Modifier.size(46.dp).clip(CircleShape),
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
autoPlayGif = false,
)
} else {
Icon(
symbol = MaterialSymbols.Home,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(24.dp),
)
}
}
Column(Modifier.weight(1f)) {
Text(
text = stringRes(R.string.relay_group_parent_label),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = liveParent?.toBestDisplayName() ?: stringRes(R.string.relay_group_parent_none),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Icon(
symbol = MaterialSymbols.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp),
)
}
}
}
/** A circular badge with a primary→tertiary gradient fill, hosting an icon or avatar. */
@Composable
private fun GradientBadge(content: @Composable () -> Unit) {
Box(
modifier =
Modifier
.size(46.dp)
.clip(CircleShape)
.background(
Brush.linearGradient(
listOf(
MaterialTheme.colorScheme.primary,
MaterialTheme.colorScheme.tertiary,
),
),
),
contentAlignment = Alignment.Center,
content = { content() },
)
}
/** Bottom-sheet picker of the other groups on this relay, plus a "top-level" choice. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ParentGroupPickerSheet(
selfGroupId: String,
selectedParentId: String?,
relay: NormalizedRelayUrl,
accountViewModel: AccountViewModel,
onSelect: (String?) -> Unit,
onDismiss: () -> Unit,
) {
// Stream the relay's directory while the picker is open so candidates fill in live.
RelayGroupsOnRelaySubscription(relay, accountViewModel.dataSources().relayGroupsOnRelay, accountViewModel)
val relayInfo by loadRelayInfo(relay)
var query by remember { mutableStateOf("") }
// A group can't parent itself or any of its own descendants (would make a cycle the relay
// rejects), so exclude them from the candidate set.
val forbidden =
remember(selfGroupId, relay) {
descendantIdsOf(accountViewModel, selfGroupId, relay) + selfGroupId
}
// Re-read the relay's genuine, relay-signed groups whenever a kind-39000 lands. The initial
// value is empty (cheap) rather than an eager scan — a produceState initial arg is evaluated
// on every recomposition (e.g. each search keystroke), so the scan lives only in the producer.
val candidates by produceState(
initialValue = emptyList<RelayGroupChannel>(),
relay,
relayInfo,
forbidden,
) {
value = pickCandidates(accountViewModel, relay, relayInfo, forbidden)
LocalCache
.observeEvents<GroupMetadataEvent>(Filter(kinds = listOf(GroupMetadataEvent.KIND)))
.collect { value = pickCandidates(accountViewModel, relay, relayInfo, forbidden) }
}
val filtered =
remember(candidates, query) {
if (query.isBlank()) candidates else candidates.filter { it.anyNameStartsWith(query.trim()) }
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
Column(Modifier.padding(horizontal = 20.dp).padding(bottom = 24.dp)) {
Text(
text = stringRes(R.string.relay_group_parent_pick_title),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = query,
onValueChange = { query = it },
singleLine = true,
leadingIcon = {
Icon(
symbol = MaterialSymbols.Search,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
},
placeholder = { Text(stringRes(R.string.relay_group_parent_search)) },
shape = RoundedCornerShape(14.dp),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
LazyColumn(
modifier = Modifier.heightIn(max = 420.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
item {
TopLevelRow(selected = selectedParentId == null) { onSelect(null) }
}
items(filtered, key = { it.groupId.id }) { channel ->
GroupPickRow(
channel = channel,
selected = channel.groupId.id == selectedParentId,
accountViewModel = accountViewModel,
onClick = { onSelect(channel.groupId.id) },
)
}
if (filtered.isEmpty()) {
item {
Text(
text = stringRes(R.string.relay_group_parent_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
)
}
}
}
}
}
}
/** The "make this a top-level group" choice at the head of the picker. */
@Composable
private fun TopLevelRow(
selected: Boolean,
onClick: () -> Unit,
) {
PickRowScaffold(selected = selected, onClick = onClick) {
GradientBadge {
Icon(
symbol = MaterialSymbols.Home,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(22.dp),
)
}
Column(Modifier.weight(1f)) {
Text(
text = stringRes(R.string.relay_group_parent_top_level_option),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Text(
text = stringRes(R.string.relay_group_parent_none_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
SelectionMark(selected)
}
}
/** One candidate parent group row: avatar, name, member count and a selection mark. */
@Composable
private fun GroupPickRow(
channel: RelayGroupChannel,
selected: Boolean,
accountViewModel: AccountViewModel,
onClick: () -> Unit,
) {
val memberCount = channel.memberCount()
PickRowScaffold(selected = selected, onClick = onClick) {
RobohashFallbackAsyncImage(
robot = channel.groupId.id,
model = channel.profilePicture(),
contentDescription = channel.toBestDisplayName(),
modifier = Modifier.size(46.dp).clip(CircleShape),
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
autoPlayGif = false,
)
Column(Modifier.weight(1f)) {
Text(
text = channel.toBestDisplayName(),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val subtitle =
channel.summary()?.takeIf { it.isNotBlank() }
?: if (memberCount > 0) {
pluralStringResource(R.plurals.relay_group_member_count, memberCount, memberCount)
} else {
null
}
subtitle?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
SelectionMark(selected)
}
}
/** Shared row chrome: a tonal, rounded, clickable surface that highlights when selected. */
@Composable
private fun PickRowScaffold(
selected: Boolean,
onClick: () -> Unit,
content: @Composable RowScope.() -> Unit,
) {
val bg by animateColorAsState(
if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
label = "parentRowBg",
)
Surface(
shape = RoundedCornerShape(16.dp),
color = bg,
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.clickable(onClick = onClick),
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
content = content,
)
}
}
/** A filled check when selected, an empty ring otherwise. */
@Composable
private fun SelectionMark(selected: Boolean) {
Icon(
symbol = if (selected) MaterialSymbols.CheckCircle else MaterialSymbols.RadioButtonUnchecked,
contentDescription = null,
tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant,
modifier = Modifier.size(24.dp),
)
}
/** Genuine, relay-signed groups on [relay], minus the forbidden (self + descendant) ids, name-sorted. */
private fun pickCandidates(
accountViewModel: AccountViewModel,
relay: NormalizedRelayUrl,
relayInfo: Nip11RelayInformation,
forbidden: Set<String>,
): List<RelayGroupChannel> =
accountViewModel
.getRelayGroupChannelsOnRelay(relay)
.asSequence()
.filter { it.groupId.id !in forbidden }
.filter { it.event != null && isRelaySignedRelayGroup(it, relayInfo) }
.sortedBy { it.toBestDisplayName().lowercase() }
.toList()
/**
* The set of group ids reachable as descendants of [rootId] on [relay], following each group's
* advertised `child` links. Visited-guarded so a malformed cycle can't loop forever.
*/
private fun descendantIdsOf(
accountViewModel: AccountViewModel,
rootId: String,
relay: NormalizedRelayUrl,
): Set<String> {
val result = mutableSetOf<String>()
val queue = ArrayDeque<String>()
accountViewModel.getRelayGroupChannelIfExists(GroupId(rootId, relay))?.childGroupIds()?.let { queue.addAll(it) }
while (queue.isNotEmpty()) {
val id = queue.removeFirst()
if (!result.add(id)) continue
accountViewModel.getRelayGroupChannelIfExists(GroupId(id, relay))?.childGroupIds()?.let { queue.addAll(it) }
}
return result
}
@@ -0,0 +1,171 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsOnRelaySubscription
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
/** Cap on how many child chips get their own warm-up subscription, to bound relay load. */
private const val MAX_SUBGROUP_CHIPS = 20
/**
* Self-hiding NIP-29 subgroups bar shown under the group's top bar (below the pinned
* bar). Renders nothing for a flat group; when the group is part of a hierarchy it shows
* a breadcrumb chip up to the parent group and a horizontally-scrollable row of chips for
* the direct child subgroups, in the relay's advertised `child` order. Tapping a chip
* opens that group. Parent and children always live on the same host relay.
*/
@Composable
fun RelayGroupSubgroupsBar(
channel: RelayGroupChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
val parentId = channel.parentGroupId()
val childIds = channel.childGroupIds()
if (parentId == null && childIds.isEmpty()) return
val relay = channel.groupId.relayUrl
// Load the parent's + every child's kind-39000 (for their names) with ONE relay-directory
// subscription rather than a warm-up per chip — a single REQ that streams metadata for all
// of the relay's groups, versus up to N subscriptions that would each also pull content.
RelayGroupsOnRelaySubscription(relay, accountViewModel.dataSources().relayGroupsOnRelay, accountViewModel)
Surface(
color = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column {
Row(
modifier =
Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (parentId != null) {
SubgroupChip(
groupId = GroupId(parentId, relay),
leadingSymbol = MaterialSymbols.ArrowUpward,
accountViewModel = accountViewModel,
nav = nav,
)
}
childIds.take(MAX_SUBGROUP_CHIPS).forEach { childId ->
SubgroupChip(
groupId = GroupId(childId, relay),
leadingSymbol = MaterialSymbols.Group,
accountViewModel = accountViewModel,
nav = nav,
)
}
if (childIds.size > MAX_SUBGROUP_CHIPS) {
// Never claim to show all children when we don't: surface the remainder count.
Text(
text = "+${childIds.size - MAX_SUBGROUP_CHIPS}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
HorizontalDivider(thickness = DividerThickness, color = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp))
}
}
}
/**
* A single tappable group chip. Resolves the group by id on the shared host relay (its metadata
* is streamed by the bar's one directory subscription) and navigates to that group when tapped.
*/
@Composable
private fun SubgroupChip(
groupId: GroupId,
leadingSymbol: MaterialSymbol,
accountViewModel: AccountViewModel,
nav: INav,
) {
LoadRelayGroupChannel(groupId, accountViewModel) { child ->
val childState by child
.flow()
.metadata.stateFlow
.collectAsStateWithLifecycle()
val liveChild = childState.channel as? RelayGroupChannel ?: child
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.clickable { nav.nav(Route.RelayGroup(groupId.id, groupId.relayUrl.url)) },
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(
symbol = leadingSymbol,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(16.dp),
)
Text(
text = liveChild.toBestDisplayName(),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSecondaryContainer,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@@ -90,6 +90,7 @@ import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip28PublicChat.base.notify import com.vitorpamplona.quartz.nip28PublicChat.base.notify
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip29RelayGroups.hTag import com.vitorpamplona.quartz.nip29RelayGroups.hTag
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.previous
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason
@@ -459,7 +460,10 @@ open class ChannelNewMessageViewModel :
val minichatParent = replyTo.value?.takeIf { replyMode.value == ReplyMode.MINICHAT }?.event val minichatParent = replyTo.value?.takeIf { replyMode.value == ReplyMode.MINICHAT }?.event
if (minichatParent != null) { if (minichatParent != null) {
return CommentEvent.replyBuilder(tagger.message, EventHintBundle(minichatParent, channelRelays.firstOrNull())) { return CommentEvent.replyBuilder(tagger.message, EventHintBundle(minichatParent, channelRelays.firstOrNull())) {
if (channel is RelayGroupChannel) hTag(channel.groupId.id) if (channel is RelayGroupChannel) {
hTag(channel.groupId.id)
previous(channel.previousEventRefs(account.userProfile().pubkeyHex))
}
hashtags(findHashtags(tagger.message)) hashtags(findHashtags(tagger.message))
references(findURLs(tagger.message)) references(findURLs(tagger.message))
quotes(findNostrUris(tagger.message)) quotes(findNostrUris(tagger.message))
@@ -594,6 +598,7 @@ open class ChannelNewMessageViewModel :
// composer but is missing from the signed event. // composer but is missing from the signed event.
ChatEvent.reply(tagger.message, replyingToEvent) { ChatEvent.reply(tagger.message, replyingToEvent) {
hTag(channel.groupId.id) hTag(channel.groupId.id)
previous(channel.previousEventRefs(account.userProfile().pubkeyHex))
pTag(replyingToEvent.toPTag()) pTag(replyingToEvent.toPTag())
hashtags(findHashtags(tagger.message)) hashtags(findHashtags(tagger.message))
@@ -608,6 +613,7 @@ open class ChannelNewMessageViewModel :
} else { } else {
ChatEvent.build(tagger.message) { ChatEvent.build(tagger.message) {
hTag(channel.groupId.id) hTag(channel.groupId.id)
previous(channel.previousEventRefs(account.userProfile().pubkeyHex))
hashtags(findHashtags(tagger.message)) hashtags(findHashtags(tagger.message))
references(findURLs(tagger.message)) references(findURLs(tagger.message))
+10
View File
@@ -2157,6 +2157,7 @@
<string name="relay_group_members_title">Members</string> <string name="relay_group_members_title">Members</string>
<string name="relay_group_make_admin">Make admin</string> <string name="relay_group_make_admin">Make admin</string>
<string name="relay_group_make_moderator">Make moderator</string> <string name="relay_group_make_moderator">Make moderator</string>
<string name="relay_group_assign_role">Assign role: %1$s</string>
<string name="relay_group_demote_member">Remove role</string> <string name="relay_group_demote_member">Remove role</string>
<string name="relay_group_remove_user">Remove from group</string> <string name="relay_group_remove_user">Remove from group</string>
<string name="relay_group_remove_user_confirm">Remove %1$s from this group? They will lose access until re-added or re-invited.</string> <string name="relay_group_remove_user_confirm">Remove %1$s from this group? They will lose access until re-added or re-invited.</string>
@@ -2192,6 +2193,15 @@
<string name="relay_group_field_topics_hint">bitcoin, nostr, art</string> <string name="relay_group_field_topics_hint">bitcoin, nostr, art</string>
<string name="relay_group_field_geohash">Location (geohash)</string> <string name="relay_group_field_geohash">Location (geohash)</string>
<string name="relay_group_field_geohash_hint">u0nd</string> <string name="relay_group_field_geohash_hint">u0nd</string>
<string name="relay_group_section_structure">Structure</string>
<string name="relay_group_parent_desc">Nest this group under a parent to build a hierarchy.</string>
<string name="relay_group_parent_label">Parent group</string>
<string name="relay_group_parent_none">Top-level group</string>
<string name="relay_group_parent_pick_title">Choose a parent group</string>
<string name="relay_group_parent_search">Search groups</string>
<string name="relay_group_parent_top_level_option">No parent (top-level)</string>
<string name="relay_group_parent_none_desc">This group sits at the top level</string>
<string name="relay_group_parent_empty">No other groups on this relay yet</string>
<string name="relay_group_section_permissions">Permissions</string> <string name="relay_group_section_permissions">Permissions</string>
<string name="relay_group_flag_private">Private</string> <string name="relay_group_flag_private">Private</string>
<string name="relay_group_flag_private_desc">Only members can read messages.</string> <string name="relay_group_flag_private_desc">Only members can read messages.</string>
@@ -32,7 +32,9 @@ import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupAdminTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupAdminTag
import com.vitorpamplona.quartz.nip29RelayGroups.tags.RoleTag
import com.vitorpamplona.quartz.utils.cache.LargeCache import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -79,6 +81,16 @@ class RelayGroupChannel(
private set private set
private var pinnedUpdatedAt: Long = 0 private var pinnedUpdatedAt: Long = 0
/**
* Relay-declared roles this group supports (kind 39003), e.g. `admin`, `moderator`,
* `ceo`. Empty until (or unless) the relay publishes a 39003 for the group. These are
* the role names a moderation UI should offer when assigning a role, since the exact
* set is relay-defined (NIP-29 §Group management).
*/
var supportedRoles: List<RoleTag> = emptyList()
private set
private var supportedRolesUpdatedAt: Long = 0
/** /**
* Members ∪ admins, recomputed only when a roster event lands. [memberCount] and the discovery * Members ∪ admins, recomputed only when a roster event lands. [memberCount] and the discovery
* feed read this per note / per recomposition, so caching it avoids rebuilding the set each read. * feed read this per note / per recomposition, so caching it avoids rebuilding the set each read.
@@ -137,6 +149,15 @@ class RelayGroupChannel(
fun hasLivekit(): Boolean = event?.hasLivekit() ?: false 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( fun updateGroupInfo(
event: GroupMetadataEvent, event: GroupMetadataEvent,
eventNote: Note? = null, eventNote: Note? = null,
@@ -176,8 +197,42 @@ class RelayGroupChannel(
updateChannelInfo() updateChannelInfo()
} }
fun updateSupportedRoles(event: SupportedRolesEvent) {
// Only newer definitions supersede; equal-or-older is dropped (no redundant emit).
if (event.createdAt <= supportedRolesUpdatedAt) return
supportedRoles = event.roles()
supportedRolesUpdatedAt = event.createdAt
updateChannelInfo()
}
fun isPinned(eventId: HexKey): Boolean = eventId in pinnedEventIds fun isPinned(eventId: HexKey): Boolean = eventId in pinnedEventIds
/**
* NIP-29 timeline references (`previous` tag) for an event about to be sent to this
* group. Returns the first-8-hex-char id prefixes of the most recent events seen here
* from the host relay, excluding [selfPubkey]'s own posts.
*
* The spec uses these to stop a group's events from being replayed out of context on a
* forked relay: a relay rejects an event whose `previous` refs it doesn't recognise, so
* we only draw from events we actually received in this channel (guaranteeing the host
* relay has them) and cap at the spec's window of the last 50. It recommends including
* at least 3; [max] bounds how many we attach.
*/
fun previousEventRefs(
selfPubkey: HexKey,
max: Int = 8,
): List<String> =
notes
.mapNotNull { _, note ->
val createdAt = note.createdAt()
val author = note.author
// Require a resolved author so an unlinked note can't slip past the self-exclusion
// and make us reference our own event (the very thing `previous` guards against).
if (createdAt != null && author != null && author.pubkeyHex != selfPubkey) note to createdAt else null
}.sortedByDescending { it.second }
.take(max)
.map { it.first.idHex.take(8) }
/** /**
* The shareable NIP-19 `naddr` coordinate for this group's metadata (kind * The shareable NIP-19 `naddr` coordinate for this group's metadata (kind
* 39000, authored by the relay's own key, with the host relay as a hint), or * 39000, authored by the relay's own key, with the host relay as a hint), or
@@ -53,6 +53,7 @@ data class Nip11RelayInformation(
val fees: RelayInformationFees? = null, val fees: RelayInformationFees? = null,
val nip50: List<String>? = null, val nip50: List<String>? = null,
val supported_grasps: 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 * 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) 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 @Stable
@Serializable @Serializable
data class RelayInformationFee( data class RelayInformationFee(
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip11RelayInfo
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RelayLimits 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.RelayInformationFee
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.RelayInformationFees import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.RelayInformationFees
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.RelayInformationLimitation import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.RelayInformationLimitation
@@ -87,6 +88,7 @@ class Nip11RelayInformationBuilder {
private var limitation: RelayInformationLimitation? = null private var limitation: RelayInformationLimitation? = null
private var fees: RelayInformationFees? = null private var fees: RelayInformationFees? = null
private var nip29: Nip29Support? = null
/** Advertise supported NIP numbers, e.g. `supports(1, 11, 42, 50)`. Repeatable. */ /** Advertise supported NIP numbers, e.g. `supports(1, 11, 42, 50)`. Repeatable. */
fun supports(vararg nips: Int) = apply { nips.forEach { supportedNips.add(it.toString()) } } 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. */ /** GRASP git-server capabilities the relay implements (`supported_grasps`). Repeatable. */
fun grasps(vararg values: String) = apply { supportedGrasps.addAll(values) } 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. */ /** Declare the relay's `limitation` object via a nested DSL. */
fun limitation(initializer: LimitationBuilder.() -> Unit) = fun limitation(initializer: LimitationBuilder.() -> Unit) =
apply { apply {
@@ -165,6 +170,7 @@ class Nip11RelayInformationBuilder {
fees = fees, fees = fees,
nip50 = nip50Subfeatures.ifEmpty { null }?.toList(), nip50 = nip50Subfeatures.ifEmpty { null }?.toList(),
supported_grasps = supportedGrasps.ifEmpty { null }?.toList(), supported_grasps = supportedGrasps.ifEmpty { null }?.toList(),
nip29 = nip29,
) )
@Nip11DslMarker @Nip11DslMarker
@@ -0,0 +1,57 @@
/*
* 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
/**
* Reads the optional invite code appended to a NIP-29 group identifier.
*
* The spec shares a group as its `kind:39000` `naddr1…` and lets an invite code be
* appended as a query suffix:
* ```
* naddr1…?invite=<code>
* ```
* Because the bech32 charset has no `?`, everything before the `?` is still a valid
* naddr on its own — so a NIP-19 parser hands back the `?invite=<code>` remainder as
* the "additional characters" after the entity. This extracts the `invite` value from
* that remainder. Clients then send it in the `code` tag of the `kind:9021` join
* request. A client that doesn't understand the suffix can simply ignore it.
*/
object GroupNAddrInvite {
private const val PARAM = "invite"
/**
* Extracts the invite code from the [suffix] that trails a group `naddr` (e.g.
* `?invite=abc123` or `?foo=bar&invite=abc123`), or null when there is none.
*/
fun parse(suffix: String?): String? {
if (suffix.isNullOrEmpty()) return null
// Normally the code trails the naddr as `?invite=<code>`. Fall back to a bare
// `invite=<code>` remainder too, in case an upstream parser strips the `?`.
val query = if ('?' in suffix) suffix.substringAfter('?') else suffix
if (query.isEmpty()) return null
return query
.split('&')
.firstOrNull { it.startsWith("$PARAM=") }
?.removePrefix("$PARAM=")
?.takeIf { it.isNotEmpty() }
}
}
@@ -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
}
}
@@ -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.geohash.geohashes
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags 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.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
@@ -83,6 +85,22 @@ class GroupMetadataEvent(
/** Group supports LiveKit-powered live audio/video. Presence of the `livekit` flag. */ /** Group supports LiveKit-powered live audio/video. Presence of the `livekit` flag. */
fun hasLivekit() = tags.hasTagName("livekit") 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"]`. * The kinds this group accepts, when constrained, e.g. `["supported_kinds", "9", "11"]`.
* `null` (tag absent) means all kinds are accepted. * `null` (tag absent) means all kinds are accepted.
@@ -127,6 +145,8 @@ class GroupMetadataEvent(
supportedKinds: List<Int>? = null, supportedKinds: List<Int>? = null,
hashtags: List<String> = emptyList(), hashtags: List<String> = emptyList(),
geohashes: List<String> = emptyList(), geohashes: List<String> = emptyList(),
parent: String? = null,
children: List<String> = emptyList(),
createdAt: Long = TimeUtils.now(), createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<GroupMetadataEvent>.() -> Unit = {}, initializer: TagArrayBuilder<GroupMetadataEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) { ) = eventTemplate(KIND, "", createdAt) {
@@ -141,6 +161,8 @@ class GroupMetadataEvent(
addAll(HashtagTag.assemble(hashtags)) addAll(HashtagTag.assemble(hashtags))
// Mip-map each geohash into every prefix so a coarser followed geohash still matches. // Mip-map each geohash into every prefix so a coarser followed geohash still matches.
geohashes.forEach { addAll(GeoHashTag.assemble(it).toList()) } geohashes.forEach { addAll(GeoHashTag.assemble(it).toList()) }
parent?.let { add(ParentTag.assemble(it)) }
addAll(ChildTag.assemble(children))
initializer() initializer()
} }
} }
@@ -54,6 +54,16 @@ class EditMetadataEvent(
fun geohashes() = tags.geohashes() 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() fun previousEvents() = tags.previousEvents()
override fun indexableContent() = listOfNotNull(name(), about()).joinToString("\n") override fun indexableContent() = listOfNotNull(name(), about()).joinToString("\n")
@@ -69,6 +79,8 @@ class EditMetadataEvent(
status: Set<GroupMetadataEvent.GroupStatus> = emptySet(), status: Set<GroupMetadataEvent.GroupStatus> = emptySet(),
hashtags: List<String> = emptyList(), hashtags: List<String> = emptyList(),
geohashes: List<String> = emptyList(), geohashes: List<String> = emptyList(),
parent: String? = null,
children: List<String> = emptyList(),
previousEvents: List<String> = emptyList(), previousEvents: List<String> = emptyList(),
createdAt: Long = TimeUtils.now(), createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<EditMetadataEvent>.() -> Unit = {}, initializer: TagArrayBuilder<EditMetadataEvent>.() -> Unit = {},
@@ -81,6 +93,8 @@ class EditMetadataEvent(
addAll(HashtagTag.assemble(hashtags)) addAll(HashtagTag.assemble(hashtags))
// Mip-map each geohash into every prefix so a coarser followed geohash still matches. // Mip-map each geohash into every prefix so a coarser followed geohash still matches.
geohashes.forEach { addAll(GeoHashTag.assemble(it).toList()) } geohashes.forEach { addAll(GeoHashTag.assemble(it).toList()) }
parent?.let { parentGroup(it) }
childGroups(children)
previous(previousEvents) previous(previousEvents)
initializer() initializer()
} }
@@ -23,14 +23,22 @@ package com.vitorpamplona.quartz.nip29RelayGroups.moderation
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder 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.CodeTag
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
import com.vitorpamplona.quartz.nip29RelayGroups.tags.ParentTag
import com.vitorpamplona.quartz.nip29RelayGroups.tags.PreviousTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.PreviousTag
fun <T : Event> TagArrayBuilder<T>.groupId(groupId: String) = addUnique(GroupIdTag.assemble(groupId)) 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)) 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>.userPubKey(pubKey: HexKey) = add(arrayOf("p", pubKey))
fun <T : Event> TagArrayBuilder<T>.userPubKeyWithRoles( fun <T : Event> TagArrayBuilder<T>.userPubKeyWithRoles(
@@ -25,14 +25,22 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
import com.vitorpamplona.quartz.nip01Core.core.mapValueTagged import com.vitorpamplona.quartz.nip01Core.core.mapValueTagged
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag 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.CodeTag
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
import com.vitorpamplona.quartz.nip29RelayGroups.tags.ParentTag
import com.vitorpamplona.quartz.nip29RelayGroups.tags.PreviousTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.PreviousTag
fun TagArray.groupId() = firstTagValue(GroupIdTag.TAG_NAME) fun TagArray.groupId() = firstTagValue(GroupIdTag.TAG_NAME)
fun TagArray.previousEvents() = mapNotNull(PreviousTag::parse) 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.userPubKeys(): List<HexKey> = mapNotNull(PTag::parseKey)
fun TagArray.deletedEventIds(): List<HexKey> = mapValueTagged("e") { it } fun TagArray.deletedEventIds(): List<HexKey> = mapValueTagged("e") { it }
@@ -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) }
}
}
@@ -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)
}
}
@@ -147,6 +147,28 @@ class Nip11RelayInformationBuilderTest {
assertEquals(3600, info.retention?.get(1)?.time) 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 @Test
fun listHelpersAreRepeatableAndCollapseWhenEmpty() { fun listHelpersAreRepeatableAndCollapseWhenEmpty() {
val info = val info =
@@ -0,0 +1,68 @@
/*
* 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 kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* NIP-29 group identifier: the optional `?invite=<code>` suffix appended to a
* `kind:39000` `naddr` (extracted from the trailing chars a NIP-19 parser returns
* after the bech32 body).
*/
class GroupNAddrInviteTest {
@Test
fun parsesInviteParam() {
assertEquals("abc123", GroupNAddrInvite.parse("?invite=abc123"))
}
@Test
fun parsesInviteAmongOtherParams() {
assertEquals("abc123", GroupNAddrInvite.parse("?foo=bar&invite=abc123"))
assertEquals("abc123", GroupNAddrInvite.parse("?invite=abc123&foo=bar"))
}
@Test
fun nullWhenNoSuffix() {
assertNull(GroupNAddrInvite.parse(null))
assertNull(GroupNAddrInvite.parse(""))
assertNull(GroupNAddrInvite.parse("?"))
assertNull(GroupNAddrInvite.parse("?other=1"))
}
@Test
fun nullWhenInviteEmpty() {
assertNull(GroupNAddrInvite.parse("?invite="))
}
@Test
fun acceptsBareInviteWithoutQuestionMark() {
// Defensive: if an upstream parser drops the `?`, a bare `invite=<code>` still resolves.
assertEquals("abc123", GroupNAddrInvite.parse("invite=abc123"))
}
@Test
fun ignoresLeadingBech32RemainderWithoutQuery() {
// A plain trailing word (not a query) carries no invite.
assertNull(GroupNAddrInvite.parse("someword"))
}
}
@@ -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())
}
}