mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
feat(concord): true member count from the re-enabled Guestbook plane
The Guestbook membership fold was already written but dormant: I had decoupled its plane from the shared REQ + AUTH while chasing the empty-channels regression. The maintainer's `re-authenticate on an auth-required CLOSED` fix addresses that root cause, and the Guestbook + next-rekey stream keys derive from the entry alone (so they AUTH on the initial connection, unlike channel keys that appear only after the Control Plane folds). Re-enable them: - streamAuthSecretsFor now also signs the aux (Guestbook + next-rekey) stream keys; the assembler re-adds auxiliaryPlaneSubs to the plane subscription. - ConcordCommunitySession.allMembers()/memberCount(): Guestbook joins ∪ owner ∪ role-holders, minus banned — a best-effort floor (a silent key-holder who never posted a join and holds no role is invisible). - Surface it: the hub community header subtitle shows "N channels · M members", and the Members screen lists the Guestbook members alongside owner/admins/banned. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
This commit is contained in:
+14
-2
@@ -329,11 +329,23 @@ private fun CommunityHeader(
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (channelKeys.isNotEmpty()) {
|
||||
val memberCount =
|
||||
remember(revision) {
|
||||
accountViewModel.account.concordSessions
|
||||
.sessionFor(communityId)
|
||||
?.memberCount() ?: 0
|
||||
}
|
||||
val parts = mutableListOf<String>()
|
||||
if (channelKeys.isNotEmpty()) parts += pluralStringResource(R.plurals.concord_channel_count, channelKeys.size, channelKeys.size)
|
||||
if (memberCount > 0) parts += pluralStringResource(R.plurals.concord_member_count, memberCount, memberCount)
|
||||
val subtitle = parts.joinToString(" · ")
|
||||
if (subtitle.isNotEmpty()) {
|
||||
Text(
|
||||
pluralStringResource(R.plurals.concord_channel_count, channelKeys.size, channelKeys.size),
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+9
-2
@@ -94,12 +94,19 @@ fun ConcordMembersScreen(
|
||||
val session = remember(account, communityId, revision) { account.concordSessions.sessionFor(communityId) }
|
||||
val state by (session?.state ?: remember { MutableStateFlow(null) }).collectAsStateWithLifecycle()
|
||||
|
||||
// The Guestbook membership (self-signed joins), so plain members show alongside the owner,
|
||||
// admins and banned — not just the privileged roster the Control Plane traces.
|
||||
val guestbookMembers by (session?.members ?: remember { MutableStateFlow(emptySet<HexKey>()) }).collectAsStateWithLifecycle()
|
||||
|
||||
val myPubKey = account.signer.pubKey
|
||||
val roster =
|
||||
remember(state) {
|
||||
remember(state, guestbookMembers) {
|
||||
val s = state ?: return@remember emptyList<RosterEntry>()
|
||||
val authority = s.authority
|
||||
val pubkeys = (listOf(s.ownerPubKey) + authority.roleHolders() + authority.bannedMembers()).map { it.lowercase() }.distinct()
|
||||
val pubkeys =
|
||||
(listOf(s.ownerPubKey) + authority.roleHolders() + authority.bannedMembers() + guestbookMembers)
|
||||
.map { it.lowercase() }
|
||||
.distinct()
|
||||
pubkeys
|
||||
.map { RosterEntry(it, ConcordMembership.of(authority, it)) }
|
||||
.sortedWith(compareBy({ it.membership.sortRank() }, { it.pubkey }))
|
||||
|
||||
+5
-5
@@ -78,11 +78,11 @@ class ConcordChannelSubAssembler(
|
||||
// kind-1059 filters lives in the shared planner.
|
||||
val subs = ArrayList<ConcordPlaneSub>()
|
||||
subs += ConcordSubscriptionPlanner.controlPlaneSubs(entries)
|
||||
// NOTE: the CORD-06 Guestbook + next-rekey planes are deliberately NOT folded into this
|
||||
// shared control+channel REQ. Naming those extra stream keys here starved the whole
|
||||
// subscription on relays that gate (or close) a REQ on NIP-42 stream-key AUTH, so control
|
||||
// stopped folding and channels went empty. They'll return in their own isolated
|
||||
// subscription; keeping the core chat path byte-for-byte what it was before CORD-06.
|
||||
// The Guestbook (membership) + next-epoch base-rekey planes. Their stream keys derive from
|
||||
// the entry alone, so they AUTH on the initial connection; and since the relay now
|
||||
// re-authenticates on an `auth-required` CLOSED, naming them here no longer starves the
|
||||
// control/channel REQ the way it did before that fix.
|
||||
subs += ConcordSubscriptionPlanner.auxiliaryPlaneSubs(entries)
|
||||
for (entry in entries) {
|
||||
val state =
|
||||
account.concordSessions
|
||||
|
||||
@@ -320,6 +320,10 @@
|
||||
<item quantity="one">%1$d channel</item>
|
||||
<item quantity="other">%1$d channels</item>
|
||||
</plurals>
|
||||
<plurals name="concord_member_count">
|
||||
<item quantity="one">%1$d member</item>
|
||||
<item quantity="other">%1$d members</item>
|
||||
</plurals>
|
||||
<string name="concord_create_action">Create</string>
|
||||
<string name="concord_invite_action">Invite people</string>
|
||||
<string name="concord_invite_title">Invite link</string>
|
||||
|
||||
+16
@@ -104,6 +104,22 @@ class ConcordCommunitySession(
|
||||
/** The live Guestbook membership set (self-signed joins minus later leaves). */
|
||||
val members: StateFlow<Set<HexKey>> = _members
|
||||
|
||||
/**
|
||||
* The community's full membership (lowercase hex): everyone who announced on the Guestbook,
|
||||
* plus the owner and every role-holder (who are members whether or not they posted a join),
|
||||
* minus the banned. Best-effort — a member who joined without a Guestbook motion and holds no
|
||||
* role is invisible (key possession leaves no trace), so this is a floor, not a census.
|
||||
*/
|
||||
fun allMembers(): Set<HexKey> {
|
||||
val s = _state.value
|
||||
val roster = if (s != null) s.authority.roleHolders() + s.ownerPubKey.lowercase() else emptySet()
|
||||
val banned = s?.authority?.bannedMembers().orEmpty()
|
||||
return (_members.value + roster) - banned
|
||||
}
|
||||
|
||||
/** The size of [allMembers] — the community's true (best-effort) member count. */
|
||||
fun memberCount(): Int = allMembers().size
|
||||
|
||||
/** The current Chat Plane addresses to subscribe to, one per folded channel. */
|
||||
fun channelAddresses(): Set<HexKey> = lock.withLock { channelKeysByAddress.keys.toSet() }
|
||||
|
||||
|
||||
+13
-7
@@ -107,18 +107,24 @@ class ConcordSessionManager(
|
||||
|
||||
/**
|
||||
* The stream secret keys that must answer a NIP-42 AUTH challenge from [relay]:
|
||||
* every plane (control + folded channels) of every joined community whose relays
|
||||
* include [relay]. Concord relays serve a plane's kind-1059 wraps only to a
|
||||
* connection authenticated as that stream key, so the relay-auth layer signs a
|
||||
* kind-22242 with each of these (locally, never the user's signer) — without them
|
||||
* the connection is authed only as the user, the plane REQ is refused, and no
|
||||
* channel or message ever loads.
|
||||
* every plane of every joined community whose relays include [relay] — the Control
|
||||
* Plane + folded channels ([ConcordCommunitySession.streamKeys]) plus the Guestbook
|
||||
* and next-epoch base-rekey planes ([ConcordCommunitySession.auxStreamKeys]). Concord
|
||||
* relays serve a plane's kind-1059 wraps only to a connection authenticated as that
|
||||
* stream key, so the relay-auth layer signs a kind-22242 with each of these (locally,
|
||||
* never the user's signer) — without them the plane REQ is refused. Since the relay
|
||||
* re-authenticates on an `auth-required` CLOSED, a key revealed only after the Control
|
||||
* Plane folds (a channel) is picked up on the retry; the aux keys derive from the entry
|
||||
* alone, so they authenticate on the initial connection.
|
||||
*/
|
||||
fun streamAuthSecretsFor(relay: NormalizedRelayUrl): List<ByteArray> {
|
||||
val out = ArrayList<ByteArray>()
|
||||
for (session in registry.sessions()) {
|
||||
val relays = session.entry.relays.mapNotNullTo(HashSet()) { RelayUrlNormalizer.normalizeOrNull(it) }
|
||||
if (relay in relays) session.streamKeys().forEach { out.add(it.secretKey) }
|
||||
if (relay in relays) {
|
||||
session.streamKeys().forEach { out.add(it.secretKey) }
|
||||
session.auxStreamKeys().forEach { out.add(it.secretKey) }
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user