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:
Claude
2026-07-08 02:27:18 +00:00
parent d85bac1f55
commit 633903b5c1
14 changed files with 235 additions and 52 deletions
@@ -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)
}
}
@@ -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))
}
},
@@ -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 ->
@@ -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) }
@@ -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
View File
@@ -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)
}
/**
@@ -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(
@@ -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
@@ -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
@@ -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"
@@ -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]