diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index f29bc549d2..7f9496458a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -257,6 +257,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent 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.LeaveRequestEvent import com.vitorpamplona.quartz.nip32Labeling.LabelEvent @@ -2189,6 +2190,7 @@ class Account( signer.sign( CommentEvent.replyBuilder(text, EventHintBundle(rootEvent, hostRelay)) { hTag(group.groupId.id) + previous(group.previousEventRefs(pubKey)) }, ) cache.justConsumeMyOwnEvent(signed) @@ -2716,6 +2718,7 @@ class Account( isRestricted: Boolean = false, hashtags: List = emptyList(), geohashes: List = emptyList(), + parent: String? = null, ): GroupId { signAndSendPrivatelyOrBroadcast(CreateGroupEvent.build(groupId)) { listOf(relay) } @@ -2728,6 +2731,7 @@ class Account( status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted), hashtags = hashtags, geohashes = geohashes, + parent = parent, ) signAndSendPrivatelyOrBroadcast(edit) { listOf(relay) } @@ -2760,7 +2764,11 @@ class Account( title: 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() } } @@ -2826,7 +2834,16 @@ class Account( signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } } - /** Edit the group's relay-signed metadata with a kind 9002 event (admin only). */ + /** + * Edit the group's relay-signed metadata with a kind 9002 event (admin only). + * + * NIP-29 §Subgroups makes the metadata edit a full replacement of the hierarchy + * links: a 9002 with no `parent` tag re-roots the group, and one that drops any + * existing `child` is rejected by the relay. So unless the caller is explicitly + * re-parenting, we re-carry the group's current [parent] and full [children] list + * from its latest known metadata to keep the tree intact across a plain name/flag + * edit. Pass an explicit value to change them. + */ suspend fun editRelayGroupMetadata( channel: RelayGroupChannel, name: String?, @@ -2838,6 +2855,8 @@ class Account( isRestricted: Boolean, hashtags: List = emptyList(), geohashes: List = emptyList(), + parent: String? = channel.parentGroupId(), + children: List = channel.childGroupIds(), ) { val template = EditMetadataEvent.build( @@ -2848,6 +2867,8 @@ class Account( status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted), hashtags = hashtags, geohashes = geohashes, + parent = parent, + children = children, ) signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 90a6adbe3a..1a5c583ad6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -1853,6 +1853,27 @@ object LocalCache : ILocalCache, ICacheProvider { 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 * note and used to populate the [RelayGroupChannel]'s name/picture/about/ @@ -1868,7 +1889,7 @@ object LocalCache : ILocalCache, ICacheProvider { ): Boolean { val new = consumeBaseReplaceable(event, relay, wasVerified) - if (relay != null) { + if (relay != null && isRelaySignedGroupEvent(event, relay)) { val note = getOrCreateAddressableNote(event.address()) val channel = getOrCreateRelayGroupChannel(GroupId(event.groupId(), relay)) (note.event as? GroupMetadataEvent)?.let { channel.updateGroupInfo(it, note) } @@ -1884,7 +1905,7 @@ object LocalCache : ILocalCache, ICacheProvider { wasVerified: Boolean, ): Boolean { val new = consumeBaseReplaceable(event, relay, wasVerified) - if (relay != null) { + if (relay != null && isRelaySignedGroupEvent(event, relay)) { val latest = getOrCreateAddressableNote(event.address()).event as? GroupMembersEvent latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updateMembers(it) } } @@ -1898,7 +1919,7 @@ object LocalCache : ILocalCache, ICacheProvider { wasVerified: Boolean, ): Boolean { val new = consumeBaseReplaceable(event, relay, wasVerified) - if (relay != null) { + if (relay != null && isRelaySignedGroupEvent(event, relay)) { val latest = getOrCreateAddressableNote(event.address()).event as? GroupAdminsEvent latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updateAdmins(it) } } @@ -1912,13 +1933,27 @@ object LocalCache : ILocalCache, ICacheProvider { wasVerified: Boolean, ): Boolean { val new = consumeBaseReplaceable(event, relay, wasVerified) - if (relay != null) { + if (relay != null && isRelaySignedGroupEvent(event, relay)) { val latest = getOrCreateAddressableNote(event.address()).event as? GroupPinnedEvent latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updatePinned(it) } } 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, … * carrying an `h` tag) to its [RelayGroupChannel]. NIP-29 reuses the generic @@ -3876,16 +3911,18 @@ object LocalCache : ILocalCache, ICacheProvider { consume(event, relay, wasVerified) } - // Remaining NIP-29 relay-group kinds. The two relay-signed addressables (39003 - // roles, 39004 AV participants) are durable group state alongside 39000/39001/39002, - // 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. + // 39003 (relay-declared roles) is durable group state like 39000/39001/39002: + // route it onto the channel so a moderation UI can offer the relay's role set. 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 -> { consumeBaseReplaceable(event, relay, wasVerified) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index d87ea9db23..30fa7e80c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -51,6 +51,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NNote import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip29RelayGroups.GroupInviteLink +import com.vitorpamplona.quartz.nip29RelayGroups.GroupNAddrInvite import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent @@ -226,7 +227,8 @@ fun uriToRoute( relayGroupInviteRoute(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) { LocalCache.consume(nip19) @@ -252,7 +254,7 @@ fun uriToRoute( } is NAddress -> { - relayGroupDirectRoute(nip19) + relayGroupDirectRoute(nip19, parsedNip19.additionalChars) ?: routeFor( note = LocalCache.getOrCreateAddressableNote(nip19.address()), 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 * (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 val relay = nip19.relay.firstOrNull() ?: return null - return Route.RelayGroup(nip19.dTag, relay.url) + // The spec allows an invite code appended as `naddr1…?invite=`; 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) } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt index 32780c6599..a0e80679bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt @@ -81,6 +81,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay import com.vitorpamplona.quartz.nip19Bech32.entities.NSec import com.vitorpamplona.quartz.nip19Bech32.toNIP19 +import com.vitorpamplona.quartz.nip29RelayGroups.GroupNAddrInvite import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import kotlinx.collections.immutable.ImmutableList @@ -212,10 +213,13 @@ private fun DisplayAddress( // only per relay), so we need that hint to open it. val groupRelay = if (nip19.kind == GroupMetadataEvent.KIND) nip19.relay.firstOrNull() else null if (groupRelay != null) { + // An invite code may be appended to the group naddr as `?invite=`; it lands + // in additionalChars. Pass it through so the group auto-joins with a kind-9021 code. + val inviteCode = GroupNAddrInvite.parse(additionalChars) CreateClickableText( clickablePart = "#${nip19.dTag}", suffix = additionalChars, - route = Route.RelayGroup(nip19.dTag, groupRelay.url), + route = Route.RelayGroup(nip19.dTag, groupRelay.url, inviteCode = inviteCode), nav = nav, ) return diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/InviteRelayGroupDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/InviteRelayGroupDialog.kt index e89cd689e1..16c210e033 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/InviteRelayGroupDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/InviteRelayGroupDialog.kt @@ -67,11 +67,18 @@ fun InviteRelayGroupDialog( .collectAsStateWithLifecycle() 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() + // 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=` 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 // 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. @@ -116,15 +123,13 @@ fun InviteRelayGroupDialog( } }, confirmButton = { - // Copy the group link, plus the code when the group is closed (so a - // recipient has both to join). Never fall back to copying a code the - // dialog didn't show — for an open group with metadata not yet loaded - // there is simply nothing to copy, so disable the button. - val toCopy = listOfNotNull(nAddr, if (isClosed) code else null).joinToString("\n") + // Copy the single shareable link. For a closed group the code is already + // embedded as `?invite=…`, so one tap joins. Nothing to copy until the + // group's metadata (and thus the naddr) has loaded, so disable until then. TextButton( - enabled = toCopy.isNotBlank(), + enabled = nAddr != null, onClick = { - scope.launch { clipboard.setText(toCopy) } + nAddr?.let { link -> scope.launch { clipboard.setText(link) } } onDismiss() }, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt index befde7fc84..fb93f065a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt @@ -149,6 +149,12 @@ private fun ChannelView( onJumpToNote = { jumpToNoteId.value = it.idHex }, ) + RelayGroupSubgroupsBar( + channel = liveChannel, + accountViewModel = accountViewModel, + nav = nav, + ) + Column( modifier = remember { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt index 70b810d053..37a24dea10 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt @@ -95,6 +95,10 @@ fun RelayGroupMembersScreen( private class RosterEntry( val pubkey: HexKey, 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, ) @Composable @@ -121,9 +125,10 @@ private fun RelayGroupMembers( // is overkill here — rely on relay order but push elevated roles to the top. val roster = 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() everyone - .map { RosterEntry(it, channel.membershipOf(it)) } + .map { RosterEntry(it, channel.membershipOf(it), rolesByPubkey[it] ?: emptyList()) } .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 // 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 }) { - 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)) - }, - ) + val declaredRoles = channel.supportedRoles + if (declaredRoles.isNotEmpty()) { + // The relay declares its own role set (kind 39003) — offer exactly those + // instead of the built-in admin/moderator pair. Roles are privilege grants, + // so only admins assign them; the relay is the final authority. + if (viewerIsAdmin) { + declaredRoles.forEach { role -> + val alreadyHasRole = entry.roles.any { it.equals(role.name, true) } + if (!alreadyHasRole) { + DropdownMenuItem( + text = { + Column { + Text(stringRes(R.string.relay_group_assign_role, role.name)) + role.description?.let { + 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) { 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 -private fun MemberRoleBadge(membership: RelayGroupMembership) { +private fun MemberRoleBadge(entry: RosterEntry) { val label = - when (membership) { - RelayGroupMembership.ADMIN -> stringRes(R.string.relay_group_role_admin) - RelayGroupMembership.MODERATOR -> stringRes(R.string.relay_group_role_moderator) + when { + entry.roles.isNotEmpty() -> entry.roles.joinToString(", ") + entry.membership == RelayGroupMembership.ADMIN -> stringRes(R.string.relay_group_role_admin) + entry.membership == RelayGroupMembership.MODERATOR -> stringRes(R.string.relay_group_role_moderator) else -> return } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt index 66dc72dd20..5f2bbe0c45 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt @@ -226,6 +226,10 @@ private fun RelayGroupMetadataScaffold( Spacer(Modifier.height(16.dp)) GroupMetadataFields(viewModel) + + Spacer(Modifier.height(16.dp)) + + ParentGroupSection(viewModel, accountViewModel) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt index 5dc7735f39..db8e535d49 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt @@ -86,6 +86,20 @@ class RelayGroupMetadataViewModel : ViewModel() { var isHidden 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(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(null) private set @@ -135,6 +149,14 @@ class RelayGroupMetadataViewModel : ViewModel() { topics.value = TextFieldValue(event?.hashtags()?.distinct()?.joinToString(" ") ?: "") // Stored geohashes are mip-mapped into every prefix; the last (longest) is the real one. 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). */ @@ -222,6 +244,7 @@ class RelayGroupMetadataViewModel : ViewModel() { isRestricted = isRestricted, hashtags = hashtags, geohashes = geohashes, + parent = parentGroupId, ) } else { account.editRelayGroupMetadata( @@ -235,6 +258,11 @@ class RelayGroupMetadataViewModel : ViewModel() { isRestricted = isRestricted, hashtags = hashtags, 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(), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt new file mode 100644 index 0000000000..f3760e4216 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt @@ -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(), + relay, + relayInfo, + forbidden, + ) { + value = pickCandidates(accountViewModel, relay, relayInfo, forbidden) + LocalCache + .observeEvents(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, +): List = + 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 { + val result = mutableSetOf() + val queue = ArrayDeque() + 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 +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupSubgroupsBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupSubgroupsBar.kt new file mode 100644 index 0000000000..00b32fda10 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupSubgroupsBar.kt @@ -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, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index 995a5367d6..78e7ad613e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -90,6 +90,7 @@ import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip28PublicChat.base.notify import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip29RelayGroups.hTag +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.previous import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason @@ -459,7 +460,10 @@ open class ChannelNewMessageViewModel : val minichatParent = replyTo.value?.takeIf { replyMode.value == ReplyMode.MINICHAT }?.event if (minichatParent != null) { 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)) references(findURLs(tagger.message)) quotes(findNostrUris(tagger.message)) @@ -594,6 +598,7 @@ open class ChannelNewMessageViewModel : // composer but is missing from the signed event. ChatEvent.reply(tagger.message, replyingToEvent) { hTag(channel.groupId.id) + previous(channel.previousEventRefs(account.userProfile().pubkeyHex)) pTag(replyingToEvent.toPTag()) hashtags(findHashtags(tagger.message)) @@ -608,6 +613,7 @@ open class ChannelNewMessageViewModel : } else { ChatEvent.build(tagger.message) { hTag(channel.groupId.id) + previous(channel.previousEventRefs(account.userProfile().pubkeyHex)) hashtags(findHashtags(tagger.message)) references(findURLs(tagger.message)) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index eae15c4d7c..97004239da 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2157,6 +2157,7 @@ Members Make admin Make moderator + Assign role: %1$s Remove role Remove from group Remove %1$s from this group? They will lose access until re-added or re-invited. @@ -2192,6 +2193,15 @@ bitcoin, nostr, art Location (geohash) u0nd + Structure + Nest this group under a parent to build a hierarchy. + Parent group + Top-level group + Choose a parent group + Search groups + No parent (top-level) + This group sits at the top level + No other groups on this relay yet Permissions Private Only members can read messages. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt index aa702e13c5..2605b0c13c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt @@ -32,7 +32,9 @@ import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent 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.RoleTag import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -79,6 +81,16 @@ class RelayGroupChannel( private set 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 = emptyList() + private set + private var supportedRolesUpdatedAt: Long = 0 + /** * 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. @@ -137,6 +149,15 @@ class RelayGroupChannel( fun hasLivekit(): Boolean = event?.hasLivekit() ?: false + /** Subgroups: the id of this group's parent on the same host relay, or null when it's a root. */ + fun parentGroupId(): String? = event?.parent() + + /** Subgroups: the ordered ids of this group's direct children (empty when it has none). */ + fun childGroupIds(): List = event?.children() ?: emptyList() + + /** Whether this group sits under a parent group (i.e. it is a subgroup). */ + fun isSubgroup(): Boolean = event?.isRoot() == false + fun updateGroupInfo( event: GroupMetadataEvent, eventNote: Note? = null, @@ -176,8 +197,42 @@ class RelayGroupChannel( 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 + /** + * 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 = + 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 * 39000, authored by the relay's own key, with the host relay as a hint), or diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt index 552e5f6afc..e2b9465bb2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt @@ -53,6 +53,7 @@ data class Nip11RelayInformation( val fees: RelayInformationFees? = null, val nip50: List? = null, val supported_grasps: List? = null, + val nip29: Nip29Support? = null, ) { /** * Serializes this document to JSON for serving at the relay's root over the @@ -67,6 +68,17 @@ data class Nip11RelayInformation( fun fromJson(json: String): Nip11RelayInformation = JsonMapper.fromJson(json) } + /** + * NIP-29 relay capability advertisement. A relay that supports the subgroup + * hierarchy sets `nip29: { "subgroups": true }` in its NIP-11 document so + * clients know they can offer parent/child grouping for this relay's groups. + */ + @Stable + @Serializable + data class Nip29Support( + val subgroups: Boolean? = null, + ) + @Stable @Serializable data class RelayInformationFee( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilder.kt index 8a5fbcd460..54d77d8674 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilder.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilder.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip11RelayInfo import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RelayLimits +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.Nip29Support import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.RelayInformationFee import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.RelayInformationFees import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.RelayInformationLimitation @@ -87,6 +88,7 @@ class Nip11RelayInformationBuilder { private var limitation: RelayInformationLimitation? = null private var fees: RelayInformationFees? = null + private var nip29: Nip29Support? = null /** Advertise supported NIP numbers, e.g. `supports(1, 11, 42, 50)`. Repeatable. */ fun supports(vararg nips: Int) = apply { nips.forEach { supportedNips.add(it.toString()) } } @@ -112,6 +114,9 @@ class Nip11RelayInformationBuilder { /** GRASP git-server capabilities the relay implements (`supported_grasps`). Repeatable. */ fun grasps(vararg values: String) = apply { supportedGrasps.addAll(values) } + /** Advertise NIP-29 subgroup support (`nip29: { "subgroups": true }`). */ + fun subgroups(supported: Boolean = true) = apply { nip29 = Nip29Support(subgroups = supported) } + /** Declare the relay's `limitation` object via a nested DSL. */ fun limitation(initializer: LimitationBuilder.() -> Unit) = apply { @@ -165,6 +170,7 @@ class Nip11RelayInformationBuilder { fees = fees, nip50 = nip50Subfeatures.ifEmpty { null }?.toList(), supported_grasps = supportedGrasps.ifEmpty { null }?.toList(), + nip29 = nip29, ) @Nip11DslMarker diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupNAddrInvite.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupNAddrInvite.kt new file mode 100644 index 0000000000..ac59490d4a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupNAddrInvite.kt @@ -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= + * ``` + * 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=` 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=`. Fall back to a bare + // `invite=` 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() } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/SubgroupTree.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/SubgroupTree.kt new file mode 100644 index 0000000000..af3e53593e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/SubgroupTree.kt @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip29RelayGroups + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent + +/** A group and its (recursively assembled) subgroups. */ +@Immutable +data class GroupTreeNode( + val metadata: GroupMetadataEvent, + val children: List, +) + +/** + * Assembles the NIP-29 subgroup hierarchy from a flat set of `kind:39000` + * metadata events (all from the same relay — the tree is relay-scoped). + * + * Structure follows each group's own `parent` tag, which is the single source of + * truth for who a group's parent is (a group carries at most one `parent`, so a + * group can never end up under two parents). A parent's advertised `child` tag + * order is used only to order siblings; children a parent hasn't listed yet are + * appended after the listed ones in a stable order. + * + * Robustness rules, matching the spec's relay behaviour: + * - A group whose declared parent is not present in the set is treated as a root + * (the spec has relays reject such edits, but a client aggregating a partial + * view must still show the group rather than drop it). + * - Cycles cannot occur on a compliant relay (it rejects any `kind:9002` that + * would create one), but if malformed data produces one it is broken and the + * involved groups surface as roots rather than looping forever. + * - When multiple `kind:39000` events share a `d` id the newest wins. + */ +object SubgroupTree { + fun build(events: Collection): List { + val byId = LinkedHashMap() + events.forEach { event -> + val id = event.groupId().ifEmpty { return@forEach } + val existing = byId[id] + if (existing == null || event.createdAt >= existing.createdAt) byId[id] = event + } + + val childrenOf = HashMap>() + byId.values.forEach { event -> + val parent = event.parent() + if (parent != null && parent in byId) { + childrenOf.getOrPut(parent) { mutableListOf() }.add(event) + } + } + + val placed = HashSet() + + fun node(event: GroupMetadataEvent): GroupTreeNode { + val id = event.groupId() + placed.add(id) + + // Order siblings by the parent's advertised `child` tag order; anything + // the parent hasn't listed keeps its natural order after the listed ones. + val order = event.children().withIndex().associate { (index, childId) -> childId to index } + val orderedChildren = + childrenOf[id] + .orEmpty() + .filter { it.groupId() !in placed } // cycle guard + .sortedBy { order[it.groupId()] ?: Int.MAX_VALUE } + + return GroupTreeNode(event, orderedChildren.map { node(it) }) + } + + val roots = + byId.values.filter { + val parent = it.parent() + parent == null || parent !in byId + } + + val tree = roots.map { node(it) }.toMutableList() + + // Safety net: any group left unplaced was part of a cycle — surface it as a root. + byId.values.forEach { event -> + if (event.groupId() !in placed) tree.add(node(event)) + } + + return tree + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt index 618c41fab1..b708ca5b24 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt @@ -32,6 +32,8 @@ import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip29RelayGroups.tags.ChildTag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.ParentTag import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -83,6 +85,22 @@ class GroupMetadataEvent( /** Group supports LiveKit-powered live audio/video. Presence of the `livekit` flag. */ fun hasLivekit() = tags.hasTagName("livekit") + /** + * Subgroups: the id of this group's parent, or null when this is a root group. + * At most one `parent` tag is expected (NIP-29 §Subgroups). + */ + fun parent(): String? = tags.firstNotNullOfOrNull(ParentTag::parse) + + /** + * Subgroups: the ordered ids of this group's direct children. The position of + * each `child` tag in the array is the intended display order. Empty when the + * group has no children (or the relay doesn't advertise them). + */ + fun children(): List = tags.mapNotNull(ChildTag::parse) + + /** A group with no `parent` tag is a root group in the subgroup tree. */ + fun isRoot(): Boolean = parent() == null + /** * The kinds this group accepts, when constrained, e.g. `["supported_kinds", "9", "11"]`. * `null` (tag absent) means all kinds are accepted. @@ -127,6 +145,8 @@ class GroupMetadataEvent( supportedKinds: List? = null, hashtags: List = emptyList(), geohashes: List = emptyList(), + parent: String? = null, + children: List = emptyList(), createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { @@ -141,6 +161,8 @@ class GroupMetadataEvent( addAll(HashtagTag.assemble(hashtags)) // Mip-map each geohash into every prefix so a coarser followed geohash still matches. geohashes.forEach { addAll(GeoHashTag.assemble(it).toList()) } + parent?.let { add(ParentTag.assemble(it)) } + addAll(ChildTag.assemble(children)) initializer() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt index 00695e733e..df9fa03c92 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt @@ -54,6 +54,16 @@ class EditMetadataEvent( fun geohashes() = tags.geohashes() + /** Subgroups: the requested parent group id, or null to (re-)root this group. */ + fun parent() = tags.parentGroupId() + + /** + * Subgroups: the ordered child ids carried on this edit. Per NIP-29 a metadata + * edit of a parent group MUST re-list all of its children, so the relay rejects + * a `kind:9002` that drops any of them. + */ + fun children() = tags.childGroupIds() + fun previousEvents() = tags.previousEvents() override fun indexableContent() = listOfNotNull(name(), about()).joinToString("\n") @@ -69,6 +79,8 @@ class EditMetadataEvent( status: Set = emptySet(), hashtags: List = emptyList(), geohashes: List = emptyList(), + parent: String? = null, + children: List = emptyList(), previousEvents: List = emptyList(), createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, @@ -81,6 +93,8 @@ class EditMetadataEvent( addAll(HashtagTag.assemble(hashtags)) // Mip-map each geohash into every prefix so a coarser followed geohash still matches. geohashes.forEach { addAll(GeoHashTag.assemble(it).toList()) } + parent?.let { parentGroup(it) } + childGroups(children) previous(previousEvents) initializer() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayBuilderExt.kt index 00e5fe2f68..89e095edc2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayBuilderExt.kt @@ -23,14 +23,22 @@ package com.vitorpamplona.quartz.nip29RelayGroups.moderation import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip29RelayGroups.tags.ChildTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.CodeTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.ParentTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.PreviousTag fun TagArrayBuilder.groupId(groupId: String) = addUnique(GroupIdTag.assemble(groupId)) fun TagArrayBuilder.previous(eventIdPrefixes: List) = addAll(PreviousTag.assemble(eventIdPrefixes)) +/** Sets the subgroup `parent` tag (the parent group's id). At most one per event. */ +fun TagArrayBuilder.parentGroup(parentGroupId: String) = addUnique(ParentTag.assemble(parentGroupId)) + +/** Appends the ordered `child` subgroup tags. */ +fun TagArrayBuilder.childGroups(childGroupIds: List) = addAll(ChildTag.assemble(childGroupIds)) + fun TagArrayBuilder.userPubKey(pubKey: HexKey) = add(arrayOf("p", pubKey)) fun TagArrayBuilder.userPubKeyWithRoles( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt index 32115a9b7c..e3cc924fc2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt @@ -25,14 +25,22 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.firstTagValue import com.vitorpamplona.quartz.nip01Core.core.mapValueTagged import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.ChildTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.CodeTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.ParentTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.PreviousTag fun TagArray.groupId() = firstTagValue(GroupIdTag.TAG_NAME) fun TagArray.previousEvents() = mapNotNull(PreviousTag::parse) +/** The `parent` group id (subgroups), or null when this is a root group. At most one is expected. */ +fun TagArray.parentGroupId() = firstNotNullOfOrNull(ParentTag::parse) + +/** The ordered list of direct `child` subgroup ids advertised on a parent's metadata. */ +fun TagArray.childGroupIds(): List = mapNotNull(ChildTag::parse) + fun TagArray.userPubKeys(): List = mapNotNull(PTag::parseKey) fun TagArray.deletedEventIds(): List = mapValueTagged("e") { it } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/ChildTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/ChildTag.kt new file mode 100644 index 0000000000..0f934a41e3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/ChildTag.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip29RelayGroups.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * NIP-29 subgroups `child` tag. A parent group's `kind:39000` metadata carries one + * `child` tag per direct subgroup, pointing at the child's `d` identifier. The + * order of the `child` tags in the array is the display order of the children; the + * relay appends new children as they are created and an admin can reorder them via + * a `kind:9002` carrying the full desired `child` list. + */ +class ChildTag { + companion object { + const val TAG_NAME = "child" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(childGroupId: String) = arrayOf(TAG_NAME, childGroupId) + + fun assemble(childGroupIds: List) = childGroupIds.map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/ParentTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/ParentTag.kt new file mode 100644 index 0000000000..dd724de792 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/ParentTag.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip29RelayGroups.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * NIP-29 subgroups `parent` tag. Points at the parent group's `d` identifier and + * appears on the subgroup's own `kind:39000` metadata (and on the `kind:9002` + * edit-metadata request that sets it). A group with no `parent` tag is a root. + * + * The spec allows at most one `parent` tag per group. + */ +class ParentTag { + companion object { + const val TAG_NAME = "parent" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(parentGroupId: String) = arrayOf(TAG_NAME, parentGroupId) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilderTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilderTest.kt index 621b4aef9e..8a9c930807 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilderTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilderTest.kt @@ -147,6 +147,28 @@ class Nip11RelayInformationBuilderTest { assertEquals(3600, info.retention?.get(1)?.time) } + @Test + fun advertisesNip29SubgroupSupport() { + val info = + relayInformation { + name = "Groups" + supports(29) + subgroups() + } + + assertEquals(true, info.nip29?.subgroups) + val json = info.toJson() + assertTrue(json.contains("\"nip29\":{\"subgroups\":true}"), json) + assertEquals(info, Nip11RelayInformation.fromJson(json)) + } + + @Test + fun omitsNip29WhenNotDeclared() { + val info = relayInformation { name = "R" } + assertNull(info.nip29) + assertTrue(!info.toJson().contains("nip29"), info.toJson()) + } + @Test fun listHelpersAreRepeatableAndCollapseWhenEmpty() { val info = diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupNAddrInviteTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupNAddrInviteTest.kt new file mode 100644 index 0000000000..c18b584495 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupNAddrInviteTest.kt @@ -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=` 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=` 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")) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/SubgroupTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/SubgroupTest.kt new file mode 100644 index 0000000000..cc4360e828 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/SubgroupTest.kt @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip29RelayGroups + +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent +import com.vitorpamplona.quartz.utils.EventFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * NIP-29 §Subgroups: the `parent`/`child` tags on `kind:39000` group metadata and + * `kind:9002` edit-metadata, plus [SubgroupTree] assembly of a relay's flat group + * set into the parent/child hierarchy (ordering, orphan-as-root, cycle safety). + */ +class SubgroupTest { + private val relaySelf = "aa".repeat(32) + private val sig = "bb".repeat(64) + private val id = "00".repeat(32) + + private fun metadata( + groupId: String, + parent: String? = null, + children: List = emptyList(), + createdAt: Long = 100, + ): GroupMetadataEvent { + val template = GroupMetadataEvent.build(groupId, name = groupId, parent = parent, children = children, createdAt = createdAt) + return EventFactory.create(id, relaySelf, template.createdAt, GroupMetadataEvent.KIND, template.tags, "", sig) as GroupMetadataEvent + } + + @Test + fun rootGroupHasNoParent() { + val root = metadata("tech", children = listOf("nostr")) + assertNull(root.parent()) + assertTrue(root.isRoot()) + assertEquals(listOf("nostr"), root.children()) + } + + @Test + fun subgroupCarriesParentAndChildOrder() { + val sub = metadata("nostr", parent = "tech", children = listOf("nip29", "nips")) + assertEquals("tech", sub.parent()) + assertEquals(false, sub.isRoot()) + assertEquals(listOf("nip29", "nips"), sub.children()) + } + + @Test + fun editMetadataRoundTripsParentAndChildren() { + val template = EditMetadataEvent.build("nostr", name = "Nostr", parent = "social", children = listOf("nip29")) + val event = EventFactory.create(id, relaySelf, template.createdAt, EditMetadataEvent.KIND, template.tags, "", sig) as EditMetadataEvent + + assertEquals("nostr", event.groupId()) + assertEquals("social", event.parent()) + assertEquals(listOf("nip29"), event.children()) + } + + @Test + fun editMetadataWithoutParentRoots() { + val template = EditMetadataEvent.build("nostr", name = "Nostr") + val event = EventFactory.create(id, relaySelf, template.createdAt, EditMetadataEvent.KIND, template.tags, "", sig) as EditMetadataEvent + assertNull(event.parent()) + } + + @Test + fun buildsNestedTree() { + val tree = + SubgroupTree.build( + listOf( + metadata("tech", children = listOf("nostr")), + metadata("nostr", parent = "tech", children = listOf("nip29")), + metadata("nip29", parent = "nostr"), + ), + ) + + assertEquals(1, tree.size) + val tech = tree[0] + assertEquals("tech", tech.metadata.groupId()) + assertEquals(1, tech.children.size) + val nostr = tech.children[0] + assertEquals("nostr", nostr.metadata.groupId()) + assertEquals( + "nip29", + nostr.children + .single() + .metadata + .groupId(), + ) + } + + @Test + fun ordersSiblingsByParentChildTags() { + // Parent lists children in b, a, c order — the tree must follow that, not insertion order. + val tree = + SubgroupTree.build( + listOf( + metadata("root", children = listOf("b", "a", "c")), + metadata("a", parent = "root"), + metadata("b", parent = "root"), + metadata("c", parent = "root"), + ), + ) + + assertEquals(listOf("b", "a", "c"), tree.single().children.map { it.metadata.groupId() }) + } + + @Test + fun unlistedChildrenComeAfterListedOnes() { + val tree = + SubgroupTree.build( + listOf( + metadata("root", children = listOf("a")), + metadata("a", parent = "root"), + // "b" points at root but root hasn't listed it yet + metadata("b", parent = "root"), + ), + ) + + assertEquals(listOf("a", "b"), tree.single().children.map { it.metadata.groupId() }) + } + + @Test + fun groupWithMissingParentBecomesRoot() { + // "nostr" declares a parent that isn't in the set — surface it as a root, not dropped. + val tree = SubgroupTree.build(listOf(metadata("nostr", parent = "ghost"))) + assertEquals(listOf("nostr"), tree.map { it.metadata.groupId() }) + } + + @Test + fun multipleRootsAreReturned() { + val tree = + SubgroupTree.build( + listOf( + metadata("tech"), + metadata("food"), + metadata("pizza", parent = "food"), + ), + ) + + assertEquals(setOf("tech", "food"), tree.map { it.metadata.groupId() }.toSet()) + } + + @Test + fun cycleDoesNotLoopForever() { + // Malformed data: a <-> b point at each other. A compliant relay rejects this, + // but the assembler must terminate and still surface the groups. + val tree = + SubgroupTree.build( + listOf( + metadata("a", parent = "b"), + metadata("b", parent = "a"), + ), + ) + + val allIds = mutableSetOf() + + fun collect(node: GroupTreeNode) { + allIds.add(node.metadata.groupId()) + node.children.forEach(::collect) + } + tree.forEach(::collect) + assertTrue(allIds.containsAll(setOf("a", "b"))) + } + + @Test + fun newestMetadataWinsForSameId() { + val tree = + SubgroupTree.build( + listOf( + metadata("g", children = listOf("old"), createdAt = 100), + metadata("g", children = listOf("new"), createdAt = 200), + ), + ) + + assertEquals(listOf("new"), tree.single().metadata.children()) + } +}