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:
Claude
2026-07-13 22:23:41 +00:00
parent 1a1389e9ef
commit b7df458071
6 changed files with 61 additions and 16 deletions
@@ -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,
)
}
}
@@ -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 }))
@@ -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
+4
View File
@@ -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>
@@ -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() }
@@ -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
}