mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
@@ -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
|
||||||
|
|||||||
+15
-10
@@ -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()
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
|||||||
+6
@@ -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 {
|
||||||
|
|||||||
+72
-24
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+28
@@ -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(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+491
@@ -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
|
||||||
|
}
|
||||||
+171
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-1
@@ -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))
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+55
@@ -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
|
||||||
|
|||||||
+12
@@ -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(
|
||||||
|
|||||||
+6
@@ -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
|
||||||
|
|||||||
+57
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.nip29RelayGroups
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||||
|
|
||||||
|
/** A group and its (recursively assembled) subgroups. */
|
||||||
|
@Immutable
|
||||||
|
data class GroupTreeNode(
|
||||||
|
val metadata: GroupMetadataEvent,
|
||||||
|
val children: List<GroupTreeNode>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assembles the NIP-29 subgroup hierarchy from a flat set of `kind:39000`
|
||||||
|
* metadata events (all from the same relay — the tree is relay-scoped).
|
||||||
|
*
|
||||||
|
* Structure follows each group's own `parent` tag, which is the single source of
|
||||||
|
* truth for who a group's parent is (a group carries at most one `parent`, so a
|
||||||
|
* group can never end up under two parents). A parent's advertised `child` tag
|
||||||
|
* order is used only to order siblings; children a parent hasn't listed yet are
|
||||||
|
* appended after the listed ones in a stable order.
|
||||||
|
*
|
||||||
|
* Robustness rules, matching the spec's relay behaviour:
|
||||||
|
* - A group whose declared parent is not present in the set is treated as a root
|
||||||
|
* (the spec has relays reject such edits, but a client aggregating a partial
|
||||||
|
* view must still show the group rather than drop it).
|
||||||
|
* - Cycles cannot occur on a compliant relay (it rejects any `kind:9002` that
|
||||||
|
* would create one), but if malformed data produces one it is broken and the
|
||||||
|
* involved groups surface as roots rather than looping forever.
|
||||||
|
* - When multiple `kind:39000` events share a `d` id the newest wins.
|
||||||
|
*/
|
||||||
|
object SubgroupTree {
|
||||||
|
fun build(events: Collection<GroupMetadataEvent>): List<GroupTreeNode> {
|
||||||
|
val byId = LinkedHashMap<String, GroupMetadataEvent>()
|
||||||
|
events.forEach { event ->
|
||||||
|
val id = event.groupId().ifEmpty { return@forEach }
|
||||||
|
val existing = byId[id]
|
||||||
|
if (existing == null || event.createdAt >= existing.createdAt) byId[id] = event
|
||||||
|
}
|
||||||
|
|
||||||
|
val childrenOf = HashMap<String, MutableList<GroupMetadataEvent>>()
|
||||||
|
byId.values.forEach { event ->
|
||||||
|
val parent = event.parent()
|
||||||
|
if (parent != null && parent in byId) {
|
||||||
|
childrenOf.getOrPut(parent) { mutableListOf() }.add(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val placed = HashSet<String>()
|
||||||
|
|
||||||
|
fun node(event: GroupMetadataEvent): GroupTreeNode {
|
||||||
|
val id = event.groupId()
|
||||||
|
placed.add(id)
|
||||||
|
|
||||||
|
// Order siblings by the parent's advertised `child` tag order; anything
|
||||||
|
// the parent hasn't listed keeps its natural order after the listed ones.
|
||||||
|
val order = event.children().withIndex().associate { (index, childId) -> childId to index }
|
||||||
|
val orderedChildren =
|
||||||
|
childrenOf[id]
|
||||||
|
.orEmpty()
|
||||||
|
.filter { it.groupId() !in placed } // cycle guard
|
||||||
|
.sortedBy { order[it.groupId()] ?: Int.MAX_VALUE }
|
||||||
|
|
||||||
|
return GroupTreeNode(event, orderedChildren.map { node(it) })
|
||||||
|
}
|
||||||
|
|
||||||
|
val roots =
|
||||||
|
byId.values.filter {
|
||||||
|
val parent = it.parent()
|
||||||
|
parent == null || parent !in byId
|
||||||
|
}
|
||||||
|
|
||||||
|
val tree = roots.map { node(it) }.toMutableList()
|
||||||
|
|
||||||
|
// Safety net: any group left unplaced was part of a cycle — surface it as a root.
|
||||||
|
byId.values.forEach { event ->
|
||||||
|
if (event.groupId() !in placed) tree.add(node(event))
|
||||||
|
}
|
||||||
|
|
||||||
|
return tree
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
@@ -32,6 +32,8 @@ import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag
|
|||||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes
|
import com.vitorpamplona.quartz.nip01Core.tags.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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
+8
@@ -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(
|
||||||
|
|||||||
+8
@@ -25,14 +25,22 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
|||||||
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
|
import com.vitorpamplona.quartz.nip01Core.core.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 }
|
||||||
|
|||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.nip29RelayGroups.tags
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||||
|
import com.vitorpamplona.quartz.utils.ensure
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-29 subgroups `child` tag. A parent group's `kind:39000` metadata carries one
|
||||||
|
* `child` tag per direct subgroup, pointing at the child's `d` identifier. The
|
||||||
|
* order of the `child` tags in the array is the display order of the children; the
|
||||||
|
* relay appends new children as they are created and an admin can reorder them via
|
||||||
|
* a `kind:9002` carrying the full desired `child` list.
|
||||||
|
*/
|
||||||
|
class ChildTag {
|
||||||
|
companion object {
|
||||||
|
const val TAG_NAME = "child"
|
||||||
|
|
||||||
|
fun parse(tag: Array<String>): String? {
|
||||||
|
ensure(tag.has(1)) { return null }
|
||||||
|
ensure(tag[0] == TAG_NAME) { return null }
|
||||||
|
ensure(tag[1].isNotEmpty()) { return null }
|
||||||
|
return tag[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assemble(childGroupId: String) = arrayOf(TAG_NAME, childGroupId)
|
||||||
|
|
||||||
|
fun assemble(childGroupIds: List<String>) = childGroupIds.map { assemble(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.nip29RelayGroups.tags
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||||
|
import com.vitorpamplona.quartz.utils.ensure
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-29 subgroups `parent` tag. Points at the parent group's `d` identifier and
|
||||||
|
* appears on the subgroup's own `kind:39000` metadata (and on the `kind:9002`
|
||||||
|
* edit-metadata request that sets it). A group with no `parent` tag is a root.
|
||||||
|
*
|
||||||
|
* The spec allows at most one `parent` tag per group.
|
||||||
|
*/
|
||||||
|
class ParentTag {
|
||||||
|
companion object {
|
||||||
|
const val TAG_NAME = "parent"
|
||||||
|
|
||||||
|
fun parse(tag: Array<String>): String? {
|
||||||
|
ensure(tag.has(1)) { return null }
|
||||||
|
ensure(tag[0] == TAG_NAME) { return null }
|
||||||
|
ensure(tag[1].isNotEmpty()) { return null }
|
||||||
|
return tag[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assemble(parentGroupId: String) = arrayOf(TAG_NAME, parentGroupId)
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
@@ -147,6 +147,28 @@ class Nip11RelayInformationBuilderTest {
|
|||||||
assertEquals(3600, info.retention?.get(1)?.time)
|
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 =
|
||||||
|
|||||||
+68
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
+196
@@ -0,0 +1,196 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.nip29RelayGroups
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||||
|
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent
|
||||||
|
import com.vitorpamplona.quartz.utils.EventFactory
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-29 §Subgroups: the `parent`/`child` tags on `kind:39000` group metadata and
|
||||||
|
* `kind:9002` edit-metadata, plus [SubgroupTree] assembly of a relay's flat group
|
||||||
|
* set into the parent/child hierarchy (ordering, orphan-as-root, cycle safety).
|
||||||
|
*/
|
||||||
|
class SubgroupTest {
|
||||||
|
private val relaySelf = "aa".repeat(32)
|
||||||
|
private val sig = "bb".repeat(64)
|
||||||
|
private val id = "00".repeat(32)
|
||||||
|
|
||||||
|
private fun metadata(
|
||||||
|
groupId: String,
|
||||||
|
parent: String? = null,
|
||||||
|
children: List<String> = emptyList(),
|
||||||
|
createdAt: Long = 100,
|
||||||
|
): GroupMetadataEvent {
|
||||||
|
val template = GroupMetadataEvent.build(groupId, name = groupId, parent = parent, children = children, createdAt = createdAt)
|
||||||
|
return EventFactory.create(id, relaySelf, template.createdAt, GroupMetadataEvent.KIND, template.tags, "", sig) as GroupMetadataEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rootGroupHasNoParent() {
|
||||||
|
val root = metadata("tech", children = listOf("nostr"))
|
||||||
|
assertNull(root.parent())
|
||||||
|
assertTrue(root.isRoot())
|
||||||
|
assertEquals(listOf("nostr"), root.children())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun subgroupCarriesParentAndChildOrder() {
|
||||||
|
val sub = metadata("nostr", parent = "tech", children = listOf("nip29", "nips"))
|
||||||
|
assertEquals("tech", sub.parent())
|
||||||
|
assertEquals(false, sub.isRoot())
|
||||||
|
assertEquals(listOf("nip29", "nips"), sub.children())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun editMetadataRoundTripsParentAndChildren() {
|
||||||
|
val template = EditMetadataEvent.build("nostr", name = "Nostr", parent = "social", children = listOf("nip29"))
|
||||||
|
val event = EventFactory.create(id, relaySelf, template.createdAt, EditMetadataEvent.KIND, template.tags, "", sig) as EditMetadataEvent
|
||||||
|
|
||||||
|
assertEquals("nostr", event.groupId())
|
||||||
|
assertEquals("social", event.parent())
|
||||||
|
assertEquals(listOf("nip29"), event.children())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun editMetadataWithoutParentRoots() {
|
||||||
|
val template = EditMetadataEvent.build("nostr", name = "Nostr")
|
||||||
|
val event = EventFactory.create(id, relaySelf, template.createdAt, EditMetadataEvent.KIND, template.tags, "", sig) as EditMetadataEvent
|
||||||
|
assertNull(event.parent())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildsNestedTree() {
|
||||||
|
val tree =
|
||||||
|
SubgroupTree.build(
|
||||||
|
listOf(
|
||||||
|
metadata("tech", children = listOf("nostr")),
|
||||||
|
metadata("nostr", parent = "tech", children = listOf("nip29")),
|
||||||
|
metadata("nip29", parent = "nostr"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1, tree.size)
|
||||||
|
val tech = tree[0]
|
||||||
|
assertEquals("tech", tech.metadata.groupId())
|
||||||
|
assertEquals(1, tech.children.size)
|
||||||
|
val nostr = tech.children[0]
|
||||||
|
assertEquals("nostr", nostr.metadata.groupId())
|
||||||
|
assertEquals(
|
||||||
|
"nip29",
|
||||||
|
nostr.children
|
||||||
|
.single()
|
||||||
|
.metadata
|
||||||
|
.groupId(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ordersSiblingsByParentChildTags() {
|
||||||
|
// Parent lists children in b, a, c order — the tree must follow that, not insertion order.
|
||||||
|
val tree =
|
||||||
|
SubgroupTree.build(
|
||||||
|
listOf(
|
||||||
|
metadata("root", children = listOf("b", "a", "c")),
|
||||||
|
metadata("a", parent = "root"),
|
||||||
|
metadata("b", parent = "root"),
|
||||||
|
metadata("c", parent = "root"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf("b", "a", "c"), tree.single().children.map { it.metadata.groupId() })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun unlistedChildrenComeAfterListedOnes() {
|
||||||
|
val tree =
|
||||||
|
SubgroupTree.build(
|
||||||
|
listOf(
|
||||||
|
metadata("root", children = listOf("a")),
|
||||||
|
metadata("a", parent = "root"),
|
||||||
|
// "b" points at root but root hasn't listed it yet
|
||||||
|
metadata("b", parent = "root"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf("a", "b"), tree.single().children.map { it.metadata.groupId() })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun groupWithMissingParentBecomesRoot() {
|
||||||
|
// "nostr" declares a parent that isn't in the set — surface it as a root, not dropped.
|
||||||
|
val tree = SubgroupTree.build(listOf(metadata("nostr", parent = "ghost")))
|
||||||
|
assertEquals(listOf("nostr"), tree.map { it.metadata.groupId() })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun multipleRootsAreReturned() {
|
||||||
|
val tree =
|
||||||
|
SubgroupTree.build(
|
||||||
|
listOf(
|
||||||
|
metadata("tech"),
|
||||||
|
metadata("food"),
|
||||||
|
metadata("pizza", parent = "food"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(setOf("tech", "food"), tree.map { it.metadata.groupId() }.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cycleDoesNotLoopForever() {
|
||||||
|
// Malformed data: a <-> b point at each other. A compliant relay rejects this,
|
||||||
|
// but the assembler must terminate and still surface the groups.
|
||||||
|
val tree =
|
||||||
|
SubgroupTree.build(
|
||||||
|
listOf(
|
||||||
|
metadata("a", parent = "b"),
|
||||||
|
metadata("b", parent = "a"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val allIds = mutableSetOf<String>()
|
||||||
|
|
||||||
|
fun collect(node: GroupTreeNode) {
|
||||||
|
allIds.add(node.metadata.groupId())
|
||||||
|
node.children.forEach(::collect)
|
||||||
|
}
|
||||||
|
tree.forEach(::collect)
|
||||||
|
assertTrue(allIds.containsAll(setOf("a", "b")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun newestMetadataWinsForSameId() {
|
||||||
|
val tree =
|
||||||
|
SubgroupTree.build(
|
||||||
|
listOf(
|
||||||
|
metadata("g", children = listOf("old"), createdAt = 100),
|
||||||
|
metadata("g", children = listOf("new"), createdAt = 200),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf("new"), tree.single().metadata.children())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user