diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 2ba61c755d..eb796e9eeb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index bb26b588e7..54f30ade19 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/InviteRelayGroupDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/InviteRelayGroupDialog.kt index 1d607806b3..ffd6cab72c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/InviteRelayGroupDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/InviteRelayGroupDialog.kt @@ -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)) } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupBrowseScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupBrowseScreen.kt index 48100715db..0c4d8707ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupBrowseScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupBrowseScreen.kt @@ -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 -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt index aaa56543db..cc58adb8bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupRosterSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupRosterSubscription.kt index 8b747bd0c2..c497a5151a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupRosterSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupRosterSubscription.kt @@ -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) } diff --git a/cli/README.md b/cli/README.md index f3d07048a6..ae3ad7ed48 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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). | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index a67f2a5fe1..49157a3c10 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -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] diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupCommands.kt index 52da4563e4..849392ddc7 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupCommands.kt @@ -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() .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 = 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) } /** diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt index 4808a79c40..dd9bbcef09 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt @@ -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, - ): 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() + .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( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt index e9c4136b9b..442ccaf47c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt @@ -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 = 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 diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannelTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannelTest.kt index ba992c399f..4f84c6db22 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannelTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannelTest.kt @@ -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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/GroupTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/GroupTag.kt index b3e860bdd0..969cfbb9ca 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/GroupTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/GroupTag.kt @@ -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" diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29ArmadaInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29ArmadaInteropTest.kt index 071ddaeb32..fcb3374944 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29ArmadaInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29ArmadaInteropTest.kt @@ -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]