feat(buzz): create channels/forums from section-label "+" instead of a FAB

On a Buzz community the FAB (which created a channel) is replaced by a "+" on the
Channels label — matching how Direct Messages already offers a "+" — and a Forums
label gains its own "+" that starts the create flow pre-set to a forum channel.
Both section headers now always render so the "+" is reachable even before any
channel loads; the collapse chevron is shown only when the section is non-empty.

The create route gains an isForum flag (threaded onto RelayGroupCreateScreen →
RelayGroupMetadataViewModel.isForum) so the Forums "+" opens the create screen on
a forum, the Channels "+" on a chat channel. The vanilla NIP-29 relay (a flat
directory with no sections) keeps its FAB. The stale "accept the invite in the
browser" empty text is dropped — an empty community is now a starting point.

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:10:11 +00:00
parent 9a5b36d0ba
commit a89aaed9fa
5 changed files with 73 additions and 27 deletions
@@ -790,6 +790,7 @@ fun BuildNavigation(
composableFromEndArgs<Route.RelayGroupCreate> {
RelayGroupCreateScreen(
relayUrl = it.relayUrl,
isForum = it.isForum,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -741,6 +741,9 @@ sealed class Route {
@Serializable data class RelayGroupCreate(
val relayUrl: String,
// Buzz only: start the create flow on a `forum` channel (threaded posts) instead of a
// `stream` (chat) one — set by the community screen's per-section "+" buttons.
val isForum: Boolean = false,
) : Route()
@Serializable data class RelayGroupEdit(
@@ -338,13 +338,17 @@ fun RelayGroupChannelListScreen(
}
},
floatingActionButton = {
FloatingActionButton(onClick = { nav.nav(Route.RelayGroupCreate(relay.url)) }, shape = CircleShape) {
Icon(
symbol = MaterialSymbols.Add,
contentDescription =
stringRes(if (isBuzz) R.string.buzz_channel_create_title else R.string.relay_group_create_title),
modifier = Modifier.size(24.dp),
)
// A Buzz community creates channels/forums from the per-section "+" in their labels (like
// Direct Messages), so no FAB there. A vanilla NIP-29 relay is a flat directory with no
// sections, so it keeps the FAB to create a group.
if (!isBuzz) {
FloatingActionButton(onClick = { nav.nav(Route.RelayGroupCreate(relay.url)) }, shape = CircleShape) {
Icon(
symbol = MaterialSymbols.Add,
contentDescription = stringRes(R.string.relay_group_create_title),
modifier = Modifier.size(24.dp),
)
}
}
},
) { padding ->
@@ -378,9 +382,11 @@ fun RelayGroupChannelListScreen(
// overlays content by design, so clearing it is the list's job. As contentPadding (not a
// modifier) so rows scroll *through* that strip and only come to rest clear of it; the
// modifier form would shrink the viewport and leave the FAB floating over dead space.
// Only the vanilla NIP-29 path has a FAB now; a Buzz community creates from its section
// headers, so it needs no bottom clearance.
LazyColumn(
modifier = Modifier.padding(padding),
contentPadding = PaddingValues(bottom = FAB_CLEARANCE),
contentPadding = PaddingValues(bottom = if (isBuzz) 0.dp else FAB_CLEARANCE),
) {
if (showTorHint) {
item(key = "tor-hint") {
@@ -394,16 +400,15 @@ fun RelayGroupChannelListScreen(
}
if (isBuzz) {
// While the membership fetch is still running and nothing has loaded, show a
// "Loading…" line. The old "you're not a member — accept the invite in the browser"
// empty text is gone: the section labels below now each carry a "+" to create a
// channel/forum, so an empty community is a starting point, not a dead end.
val noChannelsYet = buzzChatChannels.isEmpty() && buzzForumChannels.isEmpty()
if (noChannelsYet) {
item(key = "buzz-no-channels") {
if (noChannelsYet && buzzStatus is BuzzRelayImportViewModel.Status.Loading) {
item(key = "buzz-loading") {
Text(
text =
if (buzzStatus is BuzzRelayImportViewModel.Status.Loading) {
stringRes(R.string.buzz_import_loading)
} else {
stringRes(R.string.buzz_import_empty_body)
},
text = stringRes(R.string.buzz_import_loading),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth().padding(24.dp),
@@ -411,17 +416,24 @@ fun RelayGroupChannelListScreen(
}
}
// -- CHANNELS -- (Add-all now lives in the community's top-bar overflow menu)
if (buzzChatChannels.isNotEmpty()) {
// -- 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.
run {
val channelsCollapsed = "channels" in collapsedSections
item(key = "sec-channels") {
RelayGroupSectionHeader(
title = stringRes(R.string.relay_group_section_channels),
collapsed = channelsCollapsed,
onToggle = { toggleSection("channels") },
)
onToggle = if (buzzChatChannels.isNotEmpty()) ({ toggleSection("channels") }) else null,
) {
SectionAddButton(stringRes(R.string.buzz_channel_create_title)) {
nav.nav(Route.RelayGroupCreate(relay.url))
}
}
}
if (!channelsCollapsed) {
if (buzzChatChannels.isNotEmpty() && !channelsCollapsed) {
itemsIndexed(buzzChatChannels, key = { _, it -> "chat-${it.id}" }) { index, groupId ->
RowHairline(index)
BuzzImportRow(
@@ -438,17 +450,22 @@ fun RelayGroupChannelListScreen(
}
}
// -- FORUMS --
if (buzzForumChannels.isNotEmpty()) {
// -- FORUMS -- Same treatment: an always-visible label with a "+" that starts the
// create flow on a forum channel (threaded posts) instead of a chat one.
run {
val forumsCollapsed = "forums" in collapsedSections
item(key = "sec-forums") {
RelayGroupSectionHeader(
title = stringRes(R.string.relay_group_section_forums),
collapsed = forumsCollapsed,
onToggle = { toggleSection("forums") },
)
onToggle = if (buzzForumChannels.isNotEmpty()) ({ toggleSection("forums") }) else null,
) {
SectionAddButton(stringRes(R.string.buzz_forum_create_title)) {
nav.nav(Route.RelayGroupCreate(relay.url, isForum = true))
}
}
}
if (!forumsCollapsed) {
if (buzzForumChannels.isNotEmpty() && !forumsCollapsed) {
itemsIndexed(buzzForumChannels, key = { _, it -> "forum-${it.id}" }) { index, groupId ->
RowHairline(index)
BuzzImportRow(
@@ -652,6 +669,25 @@ private fun RelayGroupSectionHeader(
}
}
/**
* The trailing "+" for a section label (Channels / Forums), matching the Direct Messages header's
* New-message icon: a primary-tinted Add glyph that creates a new item of that section's type.
*/
@Composable
private fun SectionAddButton(
contentDescription: String,
onClick: () -> Unit,
) {
IconButton(onClick = onClick) {
Icon(
symbol = MaterialSymbols.Add,
contentDescription = contentDescription,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp),
)
}
}
/**
* One inline Direct-Message conversation row inside the community view: the counterpart's avatar +
* name (or a "+N" cluster label for a group DM), a preview of the last message, a compact
@@ -97,10 +97,15 @@ fun RelayGroupCreateScreen(
relayUrl: String,
accountViewModel: AccountViewModel,
nav: INav,
// Buzz only: pre-select the `forum` channel type (the Forums section's "+" routes here with true).
isForum: Boolean = false,
) {
val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } ?: return
val viewModel: RelayGroupMetadataViewModel = viewModel(key = "RelayGroupCreate:$relayUrl")
LaunchedEffect(relay) { viewModel.initCreate(accountViewModel, relay) }
LaunchedEffect(relay) {
viewModel.initCreate(accountViewModel, relay)
if (isForum) viewModel.isForum = true
}
// A group only works if the relay actually runs NIP-29 (otherwise it stores our 9007/9002 as
// ordinary events, never emits metadata/roster, and the "group" is a dead hex id). Gate creation
+1
View File
@@ -2570,6 +2570,7 @@
<string name="relay_group_create_title">Create a group</string>
<!-- Buzz calls its groups channels, and a Buzz relay is one workspace rather than a directory of groups. -->
<string name="buzz_channel_create_title">New channel</string>
<string name="buzz_forum_create_title">New forum</string>
<string name="buzz_channel_flag_private">Private channel</string>
<string name="buzz_channel_flag_private_desc">Hidden from the channel list and invite-only. Off means anyone on this relay can find and join it.</string>
<string name="buzz_channel_flag_forum">Forum channel</string>