feat(buzz): gate the channel/forum "+" to community members

Only a Buzz community member may create a channel (the relay makes the creator
its owner; a non-member's kind-9007 is rejected), so the section-label "+" now
shows only when the viewer is in the community's NIP-43 roster. Reactive: the
relay-signed roster snapshot (kind 13534, republished on every membership change)
can arrive after first composition, so BuzzCommunityMembership is re-read whenever
one lands. The section labels themselves still render for everyone; only the "+"
is gated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
This commit is contained in:
Claude
2026-07-29 14:26:23 +00:00
parent a89aaed9fa
commit 34fa103dbd
@@ -109,6 +109,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -208,6 +209,24 @@ fun RelayGroupChannelListScreen(
// (kind-44100) so browsing the relay lists the channels you already belong to — each addable to
// your kind-10009 list (so it then shows in Messages / Relay Groups). Same screen, one Browse.
val isBuzz = BuzzRelayDialect.isBuzz(relay) || relayInfo.software?.contains("buzz", ignoreCase = true) == true
// Who may create a channel/forum here — the gate for the section "+" buttons. A Buzz relay lets
// any community member create one (the creator becomes its owner); a non-member's 9007 is
// rejected, so we only offer the "+" to members of this community's NIP-43 roster. Reactive: the
// relay-signed roster snapshot (kind 13534, republished after every membership change) may land
// after first composition, so re-read [BuzzCommunityMembership] whenever one does.
val myPubkey = accountViewModel.account.signer.pubKey
val canCreateChannels by produceState(
initialValue = BuzzCommunityMembership.isMember(relay, myPubkey),
relay,
myPubkey,
) {
value = BuzzCommunityMembership.isMember(relay, myPubkey)
LocalCache
.observeEvents<RelayMembershipListEvent>(Filter(kinds = listOf(RelayMembershipListEvent.KIND)))
.collect { value = BuzzCommunityMembership.isMember(relay, myPubkey) }
}
val buzzVm: BuzzRelayImportViewModel = viewModel(key = "BuzzImport-${relay.url}")
LaunchedEffect(relay, isBuzz) { if (isBuzz) buzzVm.bind(accountViewModel.account, relay.url) }
val buzzChannels by buzzVm.channels.collectAsStateWithLifecycle()
@@ -352,7 +371,6 @@ fun RelayGroupChannelListScreen(
}
},
) { padding ->
val myPubkey = accountViewModel.userProfile().pubkeyHex
// A Buzz relay is a community, so always render its sectioned list (Channels, Forums, Direct
// Messages, Agent Console) even before anything loads, rather than the generic "empty" text.
if (channels.isEmpty() && !isBuzz) {
@@ -419,7 +437,8 @@ fun RelayGroupChannelListScreen(
// -- CHANNELS -- The label carries a "+" to create a channel (the community's FAB
// moved here, like Direct Messages). Add-all lives in the top-bar overflow menu.
// The header always shows so the "+" is available even before any channel loads;
// the collapse toggle is offered only when there's something to collapse.
// the collapse toggle is offered only when there's something to collapse. The "+"
// is offered only to community members, who are the ones the relay lets create.
run {
val channelsCollapsed = "channels" in collapsedSections
item(key = "sec-channels") {
@@ -427,11 +446,17 @@ fun RelayGroupChannelListScreen(
title = stringRes(R.string.relay_group_section_channels),
collapsed = channelsCollapsed,
onToggle = if (buzzChatChannels.isNotEmpty()) ({ toggleSection("channels") }) else null,
) {
SectionAddButton(stringRes(R.string.buzz_channel_create_title)) {
nav.nav(Route.RelayGroupCreate(relay.url))
}
}
trailing =
if (canCreateChannels) {
{
SectionAddButton(stringRes(R.string.buzz_channel_create_title)) {
nav.nav(Route.RelayGroupCreate(relay.url))
}
}
} else {
null
},
)
}
if (buzzChatChannels.isNotEmpty() && !channelsCollapsed) {
itemsIndexed(buzzChatChannels, key = { _, it -> "chat-${it.id}" }) { index, groupId ->
@@ -459,11 +484,17 @@ fun RelayGroupChannelListScreen(
title = stringRes(R.string.relay_group_section_forums),
collapsed = forumsCollapsed,
onToggle = if (buzzForumChannels.isNotEmpty()) ({ toggleSection("forums") }) else null,
) {
SectionAddButton(stringRes(R.string.buzz_forum_create_title)) {
nav.nav(Route.RelayGroupCreate(relay.url, isForum = true))
}
}
trailing =
if (canCreateChannels) {
{
SectionAddButton(stringRes(R.string.buzz_forum_create_title)) {
nav.nav(Route.RelayGroupCreate(relay.url, isForum = true))
}
}
} else {
null
},
)
}
if (buzzForumChannels.isNotEmpty() && !forumsCollapsed) {
itemsIndexed(buzzForumChannels, key = { _, it -> "forum-${it.id}" }) { index, groupId ->