From 7c0f24ef573706b373341be7fa10585a4f74218c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 22:31:57 +0000 Subject: [PATCH] feat(nip29): accurate membership state from the relay roster Membership is now derived from the relay's own signed lists (kind 39001 admins / 39002 members) instead of the client's kind-10009 intent: - RelayGroupChannel gains members/admins (from 39002/39001), a RelayGroupMembership derivation (ADMIN/MODERATOR/MEMBER/NONE, + a client-side PENDING), and a member count. LocalCache consumes 39001/39002 into the channel. - RelayGroupTopBar shows the real state: member count and your role in the subtitle; a Join button when you're not a member, an optimistic "Requested" after you tap Join (until the relay's roster confirms), and Invite (mods only) + Leave once you're in. Invite is gated on moderate rights. Caveat: private groups may hide 39002 from non-members, so state resolves after the roster is visible; a targeted 9000/9001 subscription could tighten that later. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../amethyst/model/LocalCache.kt | 38 ++++++ .../relayGroup/RelayGroupTopBar.kt | 109 ++++++++++++------ amethyst/src/main/res/values/strings.xml | 8 ++ .../nip29RelayGroups/RelayGroupChannel.kt | 48 ++++++++ .../nip29RelayGroups/RelayGroupMembership.kt | 60 ++++++++++ 5 files changed, 230 insertions(+), 33 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMembership.kt 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 286796875a..bb26b588e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -155,6 +155,8 @@ import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.nip29RelayGroups.groupId +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent @@ -1758,6 +1760,34 @@ object LocalCache : ILocalCache, ICacheProvider { return new } + /** NIP-29 relay-signed member list (kind 39002) → the group's roster. */ + fun consume( + event: GroupMembersEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { + val new = consumeBaseReplaceable(event, relay, wasVerified) + if (relay != null) { + val latest = getOrCreateAddressableNote(event.address()).event as? GroupMembersEvent + latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updateMembers(it) } + } + return new + } + + /** NIP-29 relay-signed admin list (kind 39001) → the group's roster. */ + fun consume( + event: GroupAdminsEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { + val new = consumeBaseReplaceable(event, relay, wasVerified) + if (relay != null) { + val latest = getOrCreateAddressableNote(event.address()).event as? GroupAdminsEvent + latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updateAdmins(it) } + } + return new + } + /** * Attach a group-scoped content event (a kind-9 chat, kind-1068 poll, … * carrying an `h` tag) to its [RelayGroupChannel]. NIP-29 reuses the generic @@ -3600,6 +3630,14 @@ object LocalCache : ILocalCache, ICacheProvider { consume(event, relay, wasVerified) } + is GroupMembersEvent -> { + consume(event, relay, wasVerified) + } + + is GroupAdminsEvent -> { + consume(event, relay, wasVerified) + } + is ExternalIdentitiesEvent -> { consumeBaseReplaceable(event, relay, wasVerified) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt index 10eb845c57..a9a92f1909 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt @@ -28,21 +28,23 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl @Composable fun RelayGroupTopBar( @@ -50,17 +52,21 @@ fun RelayGroupTopBar( accountViewModel: AccountViewModel, nav: INav, ) { - // Recompose the title when the relay-signed metadata (name) arrives/changes. + // Recompose when the relay-signed metadata / roster changes. val channelState by observeChannel(baseChannel, accountViewModel) val channel = channelState?.channel as? RelayGroupChannel ?: baseChannel - val joinedGroups by accountViewModel.account.relayGroupList.liveRelayGroupList - .collectAsStateWithLifecycle() - val isJoined = - joinedGroups.any { - it.groupId == channel.groupId.id && - RelayUrlNormalizer.normalizeOrNull(it.relayUrl) == channel.groupId.relayUrl - } + val myPubkey = accountViewModel.userProfile().pubkeyHex + val membership = channel.membershipOf(myPubkey) + val memberCount = channel.memberCount() + + // Optimistic "requested" state: set on Join, cleared once the relay's roster + // shows us as a member. Never persisted — a fresh visit reads the relay truth. + var requested by remember(channel.groupId) { mutableStateOf(false) } + LaunchedEffect(membership) { + if (membership.isMember()) requested = false + } + val displayMembership = if (!membership.isMember() && requested) RelayGroupMembership.PENDING else membership var menuOpen by remember { mutableStateOf(false) } var showInvite by remember { mutableStateOf(false) } @@ -75,7 +81,7 @@ fun RelayGroupTopBar( overflow = TextOverflow.Ellipsis, ) Text( - text = channel.groupId.relayUrl.url, + text = subtitle(channel.groupId.relayUrl.displayUrl(), memberCount, displayMembership), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, @@ -84,30 +90,47 @@ fun RelayGroupTopBar( } }, actions = { - if (!isJoined) { - FilledTonalButton(onClick = { accountViewModel.joinRelayGroup(channel) }) { - Text(stringRes(R.string.join)) - } - } else { - IconButton(onClick = { menuOpen = true }) { - Text("⋮", style = MaterialTheme.typography.titleLarge) - } - DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { - DropdownMenuItem( - text = { Text(stringRes(R.string.relay_group_invite_title)) }, - onClick = { - menuOpen = false - showInvite = true - }, - ) - DropdownMenuItem( - text = { Text(stringRes(R.string.leave)) }, - onClick = { - menuOpen = false - accountViewModel.leaveRelayGroup(channel) - }, + when { + displayMembership == RelayGroupMembership.PENDING -> { + Text( + text = stringRes(R.string.relay_group_pending), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + + !displayMembership.isMember() -> { + FilledTonalButton(onClick = { + requested = true + accountViewModel.joinRelayGroup(channel) + }) { + Text(stringRes(R.string.join)) + } + } + + else -> { + IconButton(onClick = { menuOpen = true }) { + Text("⋮", style = MaterialTheme.typography.titleLarge) + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + if (displayMembership.canModerate()) { + DropdownMenuItem( + text = { Text(stringRes(R.string.relay_group_invite_title)) }, + onClick = { + menuOpen = false + showInvite = true + }, + ) + } + DropdownMenuItem( + text = { Text(stringRes(R.string.leave)) }, + onClick = { + menuOpen = false + accountViewModel.leaveRelayGroup(channel) + }, + ) + } + } } }, popBack = nav::popBack, @@ -117,3 +140,23 @@ fun RelayGroupTopBar( InviteRelayGroupDialog(channel, accountViewModel) { showInvite = false } } } + +@Composable +private fun subtitle( + relayHost: String, + memberCount: Int, + membership: RelayGroupMembership, +): String { + val parts = mutableListOf(relayHost) + if (memberCount > 0) parts.add(pluralStringResource(R.plurals.relay_group_member_count, memberCount, memberCount)) + val role = + when (membership) { + RelayGroupMembership.ADMIN -> stringRes(R.string.relay_group_role_admin) + RelayGroupMembership.MODERATOR -> stringRes(R.string.relay_group_role_moderator) + RelayGroupMembership.MEMBER -> stringRes(R.string.relay_group_role_member) + RelayGroupMembership.PENDING -> stringRes(R.string.relay_group_pending) + RelayGroupMembership.NONE -> null + } + role?.let { parts.add(it) } + return parts.joinToString(" · ") +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 1bccd67f01..10eba0acd7 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1941,6 +1941,14 @@ Share this code so people can join this channel. Creating an invite… Invite code copied + Requested + Admin + Moderator + Member + + %1$d member + %1$d members + Private To Subject 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 3c61880574..5cacbad479 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 @@ -23,8 +23,12 @@ package com.vitorpamplona.amethyst.commons.model.nip29RelayGroups import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupAdminTag /** * A NIP-29 relay-based group ("channel" in Discord terms). Unlike a NIP-C7 @@ -49,6 +53,16 @@ class RelayGroupChannel( var updatedMetadataAt: Long = 0 + /** Relay-signed member pubkeys (kind 39002). */ + var members: Set = emptySet() + private set + private var membersUpdatedAt: Long = 0 + + /** Relay-signed admins with their roles (kind 39001). */ + var admins: List = emptyList() + private set + private var adminsUpdatedAt: Long = 0 + /** A relay group lives on exactly one relay: its host. */ override fun relays() = setOf(groupId.relayUrl) @@ -80,6 +94,40 @@ class RelayGroupChannel( updateChannelInfo() } + fun updateMembers(event: GroupMembersEvent) { + if (event.createdAt < membersUpdatedAt) return + members = event.members().toSet() + membersUpdatedAt = event.createdAt + updateChannelInfo() + } + + fun updateAdmins(event: GroupAdminsEvent) { + if (event.createdAt < adminsUpdatedAt) return + admins = event.admins() + adminsUpdatedAt = event.createdAt + updateChannelInfo() + } + + /** Number of known members (admins are members too). */ + fun memberCount(): Int = (members + admins.map { it.pubKey }).size + + /** + * The relay's view of [pubkey]'s membership, from the signed admin/member + * lists. Returns [RelayGroupMembership.NONE] when not in either list (or when + * the lists haven't loaded yet). + */ + fun membershipOf(pubkey: HexKey): RelayGroupMembership { + val admin = admins.firstOrNull { it.pubKey == pubkey } + 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 + } + } + return if (pubkey in members) RelayGroupMembership.MEMBER else RelayGroupMembership.NONE + } + fun anyNameStartsWith(prefix: String): Boolean = groupId.id.contains(prefix, true) || event?.name()?.contains(prefix, true) == true || diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMembership.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMembership.kt new file mode 100644 index 0000000000..8a4e55ca33 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMembership.kt @@ -0,0 +1,60 @@ +/* + * 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.commons.model.nip29RelayGroups + +/** + * A user's membership in a NIP-29 group, derived from the relay's own signed + * lists (kind 39001 admins / 39002 members). This is the relay's truth, not the + * client's kind-10009 intent. + * + * [PENDING] is a client-side, optimistic state: the user sent a join request + * (kind 9021) but the relay hasn't added them to the roster yet (a closed group + * awaiting approval, or an open group we haven't re-fetched). It is never read + * from a relay event. + */ +enum class RelayGroupMembership { + /** Holds the `admin` role — full control. */ + ADMIN, + + /** Holds the `moderator` role — can moderate. */ + MODERATOR, + + /** In the members list, no elevated role. */ + MEMBER, + + /** Join request sent, not yet admitted (optimistic, client-side only). */ + PENDING, + + /** Not in the roster. */ + NONE, + ; + + /** True when the relay currently considers the user part of the group. */ + fun isMember(): Boolean = this == ADMIN || this == MODERATOR || this == MEMBER + + /** True when the user may moderate (remove users, delete messages, invite). */ + fun canModerate(): Boolean = this == ADMIN || this == MODERATOR + + companion object { + const val ROLE_ADMIN = "admin" + const val ROLE_MODERATOR = "moderator" + } +}