mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 16:33:27 +00:00
fix: relay-group audit — timeline, roster, membership, list-safety
Fixes found in a full audit of the NIP-29 relay-groups feature across quartz/commons/amethyst/cli. Correctness (app): - Own group messages never appeared in the timeline until an app restart: the optimistic send is consumed with a null relay, so attachToRelayGroup bailed on the relay==null guard, and the host relay's echo (new==false) was skipped by the "only attach when newly consumed" gate. Attach now runs on every arrival, gated on the note being loaded, and the null-relay case attaches to the already-open channel(s) for that group id. Also avoids the wrong-relay phantom by only fabricating a channel from real provenance. - Roster subscription was frozen after an in-place join/leave (state keyed on the stable account, never re-derived); it now invalidates on every liveRelayGroupList change, so a fresh join's 39002 admission is fetched. - membershipOf demoted a 39001 admin with an empty/unknown role to MEMBER, hiding moderation; presence in the admins list now means at least MODERATOR. - Members roster showed permanent truncated-hex names (one-shot getUserIfExists cached null); uses checkGetOrCreateUser so UsernameDisplay fills in when kind:0 arrives. Protocol / data: - GroupTag had no value equality → joined-group Sets never deduped and the StateFlow re-emitted on every identical re-arrival. Equality is now the (id, relay) pair, excluding the cosmetic name. - create/edit emitted non-canonical ["public"]/["open"] status tags; NIP-29 flags are presence-only, so only private/closed are emitted when set. - Metadata/member/admin supersede guards use <= so an equal-createdAt duplicate isn't reprocessed (first-arrival wins); updatedMetadataAt is now private-set. Relay-group channels are now included in the prune loops. CLI: - join/leave/create updated the kind:10009 list from a network-only drain; a slow/empty fetch could publish a fresh list containing ONLY the new group, wiping the rest. Now reads the local store (source of truth) too. - edit re-asserted both visibility axes from flag presence, so --closed on a private group leaked it public. It now reads current 39000 and merges, with --public/--open counter-flags; only the specified axis changes. - create now tracks the new group in kind:10009 (parity with join/Android). UI polish: - Invite dialog no longer mints a 9009 for open groups and won't copy a code it never displayed. Browse "popular" list normalizes URLs before filtering. Tests: GroupTag identity, unknown-role-admin-moderates, equal-createdAt no-resupersede added; all quartz+commons NIP-29 suites and the amy relaygroup harness pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
This commit is contained in:
@@ -1503,8 +1503,10 @@ class Account(
|
||||
|
||||
val status =
|
||||
buildSet {
|
||||
add(if (isPrivate) GroupMetadataEvent.GroupStatus.PRIVATE else GroupMetadataEvent.GroupStatus.PUBLIC)
|
||||
add(if (isClosed) GroupMetadataEvent.GroupStatus.CLOSED else GroupMetadataEvent.GroupStatus.OPEN)
|
||||
// NIP-29 flags are presence-only: public/open is the absence of the
|
||||
// private/closed tags, so emit only the restrictive flags that are on.
|
||||
if (isPrivate) add(GroupMetadataEvent.GroupStatus.PRIVATE)
|
||||
if (isClosed) add(GroupMetadataEvent.GroupStatus.CLOSED)
|
||||
}
|
||||
val edit = EditMetadataEvent.build(groupId, name = name, about = about, status = status)
|
||||
signAndSendPrivatelyOrBroadcast(edit) { listOf(relay) }
|
||||
@@ -1555,8 +1557,10 @@ class Account(
|
||||
) {
|
||||
val status =
|
||||
buildSet {
|
||||
add(if (isPrivate) GroupMetadataEvent.GroupStatus.PRIVATE else GroupMetadataEvent.GroupStatus.PUBLIC)
|
||||
add(if (isClosed) GroupMetadataEvent.GroupStatus.CLOSED else GroupMetadataEvent.GroupStatus.OPEN)
|
||||
// NIP-29 flags are presence-only: public/open is the absence of the
|
||||
// private/closed tags, so emit only the restrictive flags that are on.
|
||||
if (isPrivate) add(GroupMetadataEvent.GroupStatus.PRIVATE)
|
||||
if (isClosed) add(GroupMetadataEvent.GroupStatus.CLOSED)
|
||||
}
|
||||
val template = EditMetadataEvent.build(channel.groupId.id, name = name, about = about, status = status)
|
||||
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
|
||||
@@ -1800,9 +1800,25 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
relay: NormalizedRelayUrl?,
|
||||
) {
|
||||
val groupId = event.groupId() ?: return
|
||||
if (relay == null) return
|
||||
val channel = getOrCreateRelayGroupChannel(GroupId(groupId, relay))
|
||||
channel.addNote(getOrCreateNote(event.id), relay)
|
||||
val note = getOrCreateNote(event.id)
|
||||
// Only attach a note we've actually loaded — never a placeholder for an
|
||||
// unverified/not-yet-seen event. This is checked here (not via the "was
|
||||
// newly consumed" flag) so the host relay's echo of an event we already
|
||||
// stored from our own send still lands in the channel.
|
||||
if (note.event == null) return
|
||||
|
||||
if (relay != null) {
|
||||
// Normal arrival: the group only exists on its host relay and the
|
||||
// filters are host-pinned, so the serving relay is the group's key.
|
||||
getOrCreateRelayGroupChannel(GroupId(groupId, relay)).addNote(note, relay)
|
||||
} else {
|
||||
// Our own optimistic send has no provenance relay, so we can't build
|
||||
// the (groupId, relay) key. Attach to every already-open channel with
|
||||
// this group id — normally the exact room being composed in — so the
|
||||
// message appears immediately. Don't fabricate a channel from a guessed
|
||||
// relay; the host relay's later echo attaches it to the canonical key.
|
||||
relayGroupChannels.filter { key, _ -> key.id == groupId }.forEach { it.addNote(note, null) }
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(
|
||||
@@ -2739,6 +2755,10 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
publicChatChannels.forEach { _, channel ->
|
||||
pruneHiddenMessagesChannel(channel, account)
|
||||
}
|
||||
|
||||
relayGroupChannels.forEach { _, channel ->
|
||||
pruneHiddenMessagesChannel(channel, account)
|
||||
}
|
||||
}
|
||||
|
||||
// 2× the 10-min `PRESENCE_FRESHNESS_WINDOW_SECONDS` used by
|
||||
@@ -2789,6 +2809,10 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
pruneOldMessagesChannel(channel)
|
||||
}
|
||||
|
||||
relayGroupChannels.forEach { _, channel ->
|
||||
pruneOldMessagesChannel(channel)
|
||||
}
|
||||
|
||||
chatroomList.forEach { userHex, room ->
|
||||
// History floors are pinned per scope on first advance; null means that window never paged
|
||||
// history, so its cursors hold no position to misalign and nothing needs rewinding. Only the
|
||||
@@ -3993,13 +4017,17 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
|
||||
is ChatEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified).also {
|
||||
if (it) attachToRelayGroupIfScoped(event, relay)
|
||||
// Attach on every arrival, not just the newly-consumed one:
|
||||
// our own send is consumed first with a null relay, so the
|
||||
// host relay's later echo (new == false) is what carries the
|
||||
// provenance needed to key the channel. attach is idempotent.
|
||||
attachToRelayGroupIfScoped(event, relay)
|
||||
}
|
||||
}
|
||||
|
||||
is PollEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified).also {
|
||||
if (it) attachToRelayGroupIfScoped(event, relay)
|
||||
attachToRelayGroupIfScoped(event, relay)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+19
-12
@@ -54,16 +54,18 @@ fun InviteRelayGroupDialog(
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val code by remember { mutableStateOf(RandomInstance.bytes(6).toHexKey()) }
|
||||
|
||||
// Publishing the invite is a one-shot side effect when the dialog opens.
|
||||
LaunchedEffect(code) {
|
||||
accountViewModel.createRelayGroupInvite(channel, code)
|
||||
}
|
||||
|
||||
// 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 = channel.toNAddr()?.let { "nostr:$it" }
|
||||
val isClosed = channel.isClosed()
|
||||
|
||||
// A join code is only meaningful for closed (invite-only) groups; open groups
|
||||
// join directly from the shared naddr. So mint the kind-9009 invite only when
|
||||
// the group is actually closed, rather than on every dialog open.
|
||||
LaunchedEffect(code, isClosed) {
|
||||
if (isClosed) accountViewModel.createRelayGroupInvite(channel, code)
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringRes(R.string.relay_group_invite_title)) },
|
||||
@@ -94,13 +96,18 @@ fun InviteRelayGroupDialog(
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
// Copy the most useful thing: the group link, plus the code when
|
||||
// the group is closed (so a recipient has both to join).
|
||||
val toCopy = listOfNotNull(nAddr, if (isClosed) code else null).joinToString("\n")
|
||||
clipboard.setText(AnnotatedString(toCopy.ifBlank { code }))
|
||||
onDismiss()
|
||||
}) {
|
||||
// Copy the group link, plus the code when the group is closed (so a
|
||||
// recipient has both to join). Never fall back to copying a code the
|
||||
// dialog didn't show — for an open group with metadata not yet loaded
|
||||
// there is simply nothing to copy, so disable the button.
|
||||
val toCopy = listOfNotNull(nAddr, if (isClosed) code else null).joinToString("\n")
|
||||
TextButton(
|
||||
enabled = toCopy.isNotBlank(),
|
||||
onClick = {
|
||||
clipboard.setText(AnnotatedString(toCopy))
|
||||
onDismiss()
|
||||
},
|
||||
) {
|
||||
Text(stringRes(R.string.copy))
|
||||
}
|
||||
},
|
||||
|
||||
+4
-1
@@ -145,7 +145,10 @@ fun RelayGroupBrowseScreen(
|
||||
}
|
||||
}
|
||||
|
||||
val suggestions = POPULAR_RELAYS.filter { it !in joined }
|
||||
// `joined` holds normalized URLs (trailing-slash form); normalize the
|
||||
// literals before comparing so an already-joined popular relay is hidden.
|
||||
val joinedNormalized = joined.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it)?.url }
|
||||
val suggestions = POPULAR_RELAYS.filter { RelayUrlNormalizer.normalizeOrNull(it)?.url !in joinedNormalized }
|
||||
if (suggestions.isNotEmpty()) {
|
||||
SectionHeader(stringRes(R.string.relay_group_browse_popular))
|
||||
suggestions.forEach { server ->
|
||||
|
||||
+4
-1
@@ -167,7 +167,10 @@ private fun RelayGroupMemberRow(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val user = remember(entry.pubkey) { accountViewModel.getUserIfExists(entry.pubkey) }
|
||||
// Create-or-get (never a one-shot null): UsernameDisplay observes the user's
|
||||
// metadata flow, so the name fills in when the kind:0 arrives instead of being
|
||||
// stuck on truncated hex forever.
|
||||
val user = remember(entry.pubkey) { accountViewModel.checkGetOrCreateUser(entry.pubkey) }
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
var confirmRemove by remember { mutableStateOf(false) }
|
||||
|
||||
|
||||
+14
-3
@@ -21,15 +21,22 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
/**
|
||||
* Mount on any screen that lists the user's joined groups (the Messages tab's
|
||||
* inline/grouped views, the Relay Groups home) to keep their rosters live. The
|
||||
* assembler re-derives its per-relay filters from the joined-group list, so it
|
||||
* follows joins/leaves automatically.
|
||||
* inline/grouped views, the Relay Groups home) to keep their rosters live.
|
||||
*
|
||||
* The query state is keyed on the account (stable), so the assembler wouldn't
|
||||
* re-run its filter derivation on its own when the joined-group set changes.
|
||||
* We watch [liveRelayGroupList] and invalidate the assembler on every change, so
|
||||
* a join/leave while this screen stays foregrounded immediately re-subscribes to
|
||||
* the new group's roster (critical for confirming admission to a closed group).
|
||||
*/
|
||||
@Composable
|
||||
fun RelayGroupRosterSubscription(
|
||||
@@ -41,5 +48,9 @@ fun RelayGroupRosterSubscription(
|
||||
RelayGroupRosterQueryState(accountViewModel.account)
|
||||
}
|
||||
|
||||
val joined by accountViewModel.account.relayGroupList.liveRelayGroupList
|
||||
.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(joined) { dataSource.invalidateFilters() }
|
||||
|
||||
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
|
||||
}
|
||||
|
||||
+1
-1
@@ -428,7 +428,7 @@ screen speaks.
|
||||
| `amy relaygroup join RELAY GID [--code CODE]` | Request to join (9021) and add it to your kind:10009 list. |
|
||||
| `amy relaygroup leave RELAY GID` | Leave (9022) and drop it from your kind:10009 list. |
|
||||
| `amy relaygroup message RELAY GID TEXT` | Post a kind:9 chat message into the group. |
|
||||
| `amy relaygroup edit RELAY GID [--name X] [--about A] [--private] [--closed]` | Edit metadata (9002, admin only). |
|
||||
| `amy relaygroup edit RELAY GID [--name X] [--about A] [--private\|--public] [--closed\|--open]` | Edit metadata (9002, admin only). Reads current visibility and changes only the axis you pass, so re-asserting one flag never resets the other. |
|
||||
| `amy relaygroup invite RELAY GID --code CODE` | Mint an invite code (9009, moderator). |
|
||||
| `amy relaygroup put-user RELAY GID PUBKEY [--role admin\|moderator]` | Add or promote a user (9000, moderator). |
|
||||
| `amy relaygroup remove-user RELAY GID PUBKEY` | Kick a user (9001, moderator). |
|
||||
|
||||
@@ -578,8 +578,9 @@ private fun printUsage() {
|
||||
| relaygroup join RELAY GID [--code CODE] request to join (kind 9021)
|
||||
| relaygroup leave RELAY GID leave (kind 9022)
|
||||
| relaygroup message RELAY GID TEXT post a kind-9 chat to the group
|
||||
| relaygroup edit RELAY GID [--name N] edit metadata (kind 9002, admin)
|
||||
| [--about A] [--private] [--closed]
|
||||
| relaygroup edit RELAY GID [--name N] edit metadata (kind 9002, admin);
|
||||
| [--about A] [--private|--public] reads current visibility and only
|
||||
| [--closed|--open] changes the axis you specify
|
||||
| relaygroup invite RELAY GID --code CODE mint an invite code (kind 9009)
|
||||
| relaygroup put-user RELAY GID PUBKEY add/promote a user (kind 9000)
|
||||
| [--role admin|moderator]
|
||||
|
||||
@@ -94,6 +94,9 @@ object RelayGroupCommands {
|
||||
val status = groupStatus(isPrivate, isClosed)
|
||||
val edit = EditMetadataEvent.build(groupId, name = name, about = about, status = status)
|
||||
val editAck = ctx.publish(ctx.signer.sign(edit), target)
|
||||
// Track it in our own kind:10009 so `relaygroup list` shows it, matching
|
||||
// the Android create flow (Account.createRelayGroup → follow).
|
||||
val listed = updateGroupList(ctx, relay, groupId, add = true)
|
||||
|
||||
Output.emit(
|
||||
mapOf(
|
||||
@@ -103,6 +106,7 @@ object RelayGroupCommands {
|
||||
"private" to isPrivate,
|
||||
"closed" to isClosed,
|
||||
"published" to (createAck.values.any { it } && editAck.values.any { it }),
|
||||
"listed" to listed,
|
||||
),
|
||||
)
|
||||
return 0
|
||||
@@ -192,13 +196,20 @@ private suspend fun updateGroupList(
|
||||
val outbox = ctx.outboxRelays()
|
||||
if (outbox.isEmpty()) return false
|
||||
|
||||
// Load the current list from BOTH the local store (amy's source of truth —
|
||||
// every list we've published/synced is here) and a fresh relay drain, then
|
||||
// take the newest. Relying on the drain alone is unsafe: a slow or empty
|
||||
// fetch would look like "no list", and the `create` branch below would then
|
||||
// replace the user's entire kind:10009 with just this one group.
|
||||
val filter = Filter(kinds = listOf(SimpleGroupListEvent.KIND), authors = listOf(ctx.identity.pubKeyHex), limit = 1)
|
||||
val current =
|
||||
val stored = ctx.latestReplaceable(ctx.identity.pubKeyHex, SimpleGroupListEvent.KIND) as? SimpleGroupListEvent
|
||||
val drained =
|
||||
ctx
|
||||
.drain(outbox.associateWith { listOf(filter) }, 5_000)
|
||||
.map { it.second }
|
||||
.filterIsInstance<SimpleGroupListEvent>()
|
||||
.maxByOrNull { it.createdAt }
|
||||
val current = listOfNotNull(stored, drained).maxByOrNull { it.createdAt }
|
||||
|
||||
val tag = GroupTag(groupId, relay.url, null)
|
||||
val updated =
|
||||
@@ -212,14 +223,19 @@ private suspend fun updateGroupList(
|
||||
return ctx.publish(updated, outbox).values.any { it }
|
||||
}
|
||||
|
||||
/** The NIP-29 status flag set for the given visibility toggles. */
|
||||
/**
|
||||
* The NIP-29 status flag set for the given visibility. NIP-29 flags are
|
||||
* presence-only: a group is public/open by the ABSENCE of the private/closed
|
||||
* tags, so we emit only the restrictive flags that are actually on — never a
|
||||
* `["public"]`/`["open"]` tag (which are non-canonical and can confuse relays).
|
||||
*/
|
||||
internal fun groupStatus(
|
||||
isPrivate: Boolean,
|
||||
isClosed: Boolean,
|
||||
): Set<GroupMetadataEvent.GroupStatus> =
|
||||
buildSet {
|
||||
add(if (isPrivate) GroupMetadataEvent.GroupStatus.PRIVATE else GroupMetadataEvent.GroupStatus.PUBLIC)
|
||||
add(if (isClosed) GroupMetadataEvent.GroupStatus.CLOSED else GroupMetadataEvent.GroupStatus.OPEN)
|
||||
if (isPrivate) add(GroupMetadataEvent.GroupStatus.PRIVATE)
|
||||
if (isClosed) add(GroupMetadataEvent.GroupStatus.CLOSED)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+73
-10
@@ -24,6 +24,8 @@ import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent
|
||||
@@ -35,22 +37,83 @@ import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent
|
||||
* host relay via [publishScoped] or a direct publish.
|
||||
*/
|
||||
object RelayGroupModerationCommands {
|
||||
/** `relaygroup edit RELAY GROUP_ID [--name N] [--about A] [--private] [--closed]` → 9002. */
|
||||
/**
|
||||
* `relaygroup edit RELAY GROUP_ID [--name N] [--about A] [--private|--public] [--closed|--open]` → 9002.
|
||||
*
|
||||
* A kind-9002 edit re-asserts the group's status flags, so sending only one
|
||||
* axis would silently reset the other (e.g. `--closed` on a private group
|
||||
* would drop `private` and leak it public). To avoid that we read the group's
|
||||
* current 39000 metadata and merge: each axis keeps its current value unless
|
||||
* the caller explicitly changes it with the flag or its counter-flag.
|
||||
*/
|
||||
suspend fun edit(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int =
|
||||
publishScoped(dataDir, rest, "relaygroup edit RELAY GROUP_ID [--name N] [--about A] [--private] [--closed]") { _, groupId, args ->
|
||||
// Only touch visibility when the user actually passed a flag — otherwise
|
||||
// leave name/about edits without asserting an (unknown) status.
|
||||
val status =
|
||||
if (args.bool("private") || args.bool("closed")) {
|
||||
groupStatus(args.bool("private"), args.bool("closed"))
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val usage = "relaygroup edit RELAY GROUP_ID [--name N] [--about A] [--private|--public] [--closed|--open]"
|
||||
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
|
||||
val groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage)
|
||||
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
|
||||
val filter =
|
||||
Filter(kinds = listOf(GroupMetadataEvent.KIND), tags = mapOf("d" to listOf(groupId)), limit = 1)
|
||||
val meta =
|
||||
ctx
|
||||
.drain(mapOf(relay to listOf(filter)), 6_000)
|
||||
.map { it.second }
|
||||
.filterIsInstance<GroupMetadataEvent>()
|
||||
.maxByOrNull { it.createdAt }
|
||||
if (meta == null) {
|
||||
System.err.println(
|
||||
"warning: could not read current metadata for $groupId on ${relay.url}; " +
|
||||
"visibility will be set from the flags given only",
|
||||
)
|
||||
}
|
||||
|
||||
val isPrivate =
|
||||
if (args.bool("private")) {
|
||||
true
|
||||
} else if (args.bool("public")) {
|
||||
false
|
||||
} else {
|
||||
emptySet()
|
||||
(meta?.isPrivate() ?: false)
|
||||
}
|
||||
EditMetadataEvent.build(groupId, name = args.flag("name"), about = args.flag("about"), status = status)
|
||||
val isClosed =
|
||||
if (args.bool("closed")) {
|
||||
true
|
||||
} else if (args.bool("open")) {
|
||||
false
|
||||
} else {
|
||||
(meta?.isClosed() ?: false)
|
||||
}
|
||||
|
||||
val signed =
|
||||
ctx.signer.sign(
|
||||
EditMetadataEvent.build(
|
||||
groupId,
|
||||
name = args.flag("name"),
|
||||
about = args.flag("about"),
|
||||
status = groupStatus(isPrivate, isClosed),
|
||||
),
|
||||
)
|
||||
val ack = ctx.publish(signed, setOf(relay))
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"event_id" to signed.id,
|
||||
"group_id" to groupId,
|
||||
"relay" to relay.url,
|
||||
"private" to isPrivate,
|
||||
"closed" to isClosed,
|
||||
"published" to ack.values.any { it },
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** `relaygroup invite RELAY GROUP_ID --code CODE` → 9009. */
|
||||
suspend fun invite(
|
||||
|
||||
+13
-6
@@ -53,6 +53,7 @@ class RelayGroupChannel(
|
||||
var metadataNote: Note? = null
|
||||
|
||||
var updatedMetadataAt: Long = 0
|
||||
private set
|
||||
|
||||
/** Relay-signed member pubkeys (kind 39002). */
|
||||
var members: Set<HexKey> = emptySet()
|
||||
@@ -87,8 +88,10 @@ class RelayGroupChannel(
|
||||
event: GroupMetadataEvent,
|
||||
eventNote: Note? = null,
|
||||
) {
|
||||
// Only newer metadata supersedes.
|
||||
if (event.createdAt < updatedMetadataAt) return
|
||||
// Only newer metadata supersedes; equal-or-older is dropped, so a duplicate
|
||||
// arrival isn't reprocessed (no redundant emit) and first-arrival wins on a
|
||||
// createdAt tie. First load passes since real events have createdAt > 0.
|
||||
if (event.createdAt <= updatedMetadataAt) return
|
||||
this.event = event
|
||||
this.metadataNote = eventNote
|
||||
this.updatedMetadataAt = event.createdAt
|
||||
@@ -96,14 +99,14 @@ class RelayGroupChannel(
|
||||
}
|
||||
|
||||
fun updateMembers(event: GroupMembersEvent) {
|
||||
if (event.createdAt < membersUpdatedAt) return
|
||||
if (event.createdAt <= membersUpdatedAt) return
|
||||
members = event.members().toSet()
|
||||
membersUpdatedAt = event.createdAt
|
||||
updateChannelInfo()
|
||||
}
|
||||
|
||||
fun updateAdmins(event: GroupAdminsEvent) {
|
||||
if (event.createdAt < adminsUpdatedAt) return
|
||||
if (event.createdAt <= adminsUpdatedAt) return
|
||||
admins = event.admins()
|
||||
adminsUpdatedAt = event.createdAt
|
||||
updateChannelInfo()
|
||||
@@ -132,8 +135,12 @@ class RelayGroupChannel(
|
||||
if (admin != null) {
|
||||
return when {
|
||||
admin.roles.any { it.equals(RelayGroupMembership.ROLE_ADMIN, true) } -> RelayGroupMembership.ADMIN
|
||||
admin.roles.any { it.equals(RelayGroupMembership.ROLE_MODERATOR, true) } -> RelayGroupMembership.MODERATOR
|
||||
else -> RelayGroupMembership.MEMBER
|
||||
// Presence in the kind-39001 admins list IS the moderation signal;
|
||||
// the role labels (moderator, ceo, owner, …) are relay-defined. So
|
||||
// anyone in that list who isn't the top-level admin is at least a
|
||||
// moderator — never demote them to a plain member just because the
|
||||
// role string is unrecognized or absent.
|
||||
else -> RelayGroupMembership.MODERATOR
|
||||
}
|
||||
}
|
||||
return if (pubkey in members) RelayGroupMembership.MEMBER else RelayGroupMembership.NONE
|
||||
|
||||
+17
-3
@@ -105,10 +105,24 @@ class RelayGroupChannelTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun adminWithoutKnownRoleIsPlainMember() {
|
||||
fun adminWithUnknownRoleStillModerates() {
|
||||
// Presence in the 39001 admins list is the moderation signal; an
|
||||
// unrecognized (or empty) role label must not demote to plain MEMBER.
|
||||
val c = channel()
|
||||
c.updateAdmins(admins(100, alice to listOf("ceo")))
|
||||
assertEquals(RelayGroupMembership.MEMBER, c.membershipOf(alice))
|
||||
c.updateAdmins(admins(100, alice to listOf("ceo"), bob to emptyList()))
|
||||
assertEquals(RelayGroupMembership.MODERATOR, c.membershipOf(alice))
|
||||
assertEquals(RelayGroupMembership.MODERATOR, c.membershipOf(bob))
|
||||
assertTrue(c.membershipOf(alice).canModerate())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun equalCreatedAtDoesNotResupersede() {
|
||||
val c = channel()
|
||||
c.updateMembers(members(100, alice, bob))
|
||||
// A second 39002 with the SAME createdAt but fewer members must not win.
|
||||
c.updateMembers(members(100, alice))
|
||||
assertEquals(RelayGroupMembership.MEMBER, c.membershipOf(bob))
|
||||
assertEquals(2, c.memberCount())
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+11
@@ -33,6 +33,17 @@ class GroupTag(
|
||||
|
||||
fun toTagIdOnly() = arrayOf(TAG_NAME, groupId, relayUrl)
|
||||
|
||||
// Identity is the (group id, host relay) pair — the group's real key. The
|
||||
// optional `name` is cosmetic and deliberately excluded, so the same group
|
||||
// stored twice (e.g. once as a public tag, once decrypted from a private
|
||||
// item, or with/without a cached name) collapses in a Set and a StateFlow of
|
||||
// GroupTags stops re-emitting on every identical re-arrival of the list.
|
||||
override fun equals(other: Any?): Boolean =
|
||||
this === other ||
|
||||
(other is GroupTag && groupId == other.groupId && relayUrl == other.relayUrl)
|
||||
|
||||
override fun hashCode(): Int = 31 * groupId.hashCode() + relayUrl.hashCode()
|
||||
|
||||
companion object {
|
||||
const val TAG_NAME = "group"
|
||||
|
||||
|
||||
+15
@@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
|
||||
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
@@ -259,6 +260,20 @@ class Nip29ArmadaInteropTest {
|
||||
|
||||
// ── kind 10009 user group list (NIP-51 simple groups) ────────────────────
|
||||
|
||||
@Test
|
||||
fun groupTagIdentityIsIdAndRelayNotName() {
|
||||
// Same (id, relay) is the same group regardless of the cached name, so a
|
||||
// Set dedups it — otherwise the same group stored as a public tag and a
|
||||
// private item would show twice and the joined-list flow would churn.
|
||||
val a = GroupTag(gid, "wss://r", "Alpha")
|
||||
val b = GroupTag(gid, "wss://r", null)
|
||||
val c = GroupTag(gid, "wss://other", "Alpha")
|
||||
assertEquals(a, b)
|
||||
assertEquals(a.hashCode(), b.hashCode())
|
||||
assertEquals(1, setOf(a, b).size)
|
||||
assertTrue(a != c)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesUserGroupListPublicGroups() {
|
||||
// Armada: ["group", id, relay]
|
||||
|
||||
Reference in New Issue
Block a user