feat(buzz): 'Workspaces' bottom-nav tab + hub screen

Gives Buzz a first-class navigation identity instead of hiding inside the generic
Relay Groups list. NavBarItem.BUZZ (Route.BuzzWorkspaces) is a pinnable bottom-nav
destination (added to NavBarCatalog + BottomBarCategories + the preloader when).

BuzzWorkspacesScreen is a modern hub: an Agent-Console hero card up top, then the
user's joined groups filtered to Buzz-dialect relays as cards with a colored
monogram avatar, live name/host/member-count and an unread dot, plus an inviting
empty state that routes to Browse groups. It reuses the always-on relay-group state
subscription (no per-screen fetch) and the same RelayGroupChannel the chat opens.

Also fixes the open-chat-tail test for the added 20002 typing kind
(RELAY_GROUP_OPEN_TAIL_KINDS), and documents typing + the hub in the buzz README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
This commit is contained in:
Claude
2026-07-22 04:27:59 +00:00
parent a0d50c69f6
commit 54364731a6
8 changed files with 392 additions and 10 deletions

View File

@@ -103,6 +103,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.AgentAttestationScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.AgentConsoleScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.AgentPersonaEditScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzCanvasScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzWorkspacesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarCollectionsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarReminderSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarsScreen
@@ -457,6 +458,7 @@ fun BuildNavigation(
composableFromEnd<Route.Shorts> { ShortsScreen(accountViewModel, nav) }
composableFromEnd<Route.PublicChats> { PublicChatsScreen(accountViewModel, nav) }
composableFromEnd<Route.RelayGroups> { RelayGroupDiscoveryScreen(accountViewModel, nav) }
composableFromEnd<Route.BuzzWorkspaces> { BuzzWorkspacesScreen(accountViewModel, nav) }
composableFromEnd<Route.FollowPacks> { FollowPacksScreen(accountViewModel, nav) }
composableFromEnd<Route.LiveStreams> { LiveStreamsScreen(accountViewModel, nav) }
composableFromEnd<Route.Nests> { NestsScreen(accountViewModel, nav) }

View File

@@ -69,6 +69,7 @@ enum class NavBarItem {
PODCASTS,
PUBLIC_CHATS,
RELAY_GROUPS,
BUZZ,
CONCORD,
GEOHASH_CHATS,
FOLLOW_PACKS,
@@ -344,6 +345,13 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
icon = MaterialSymbols.Forum,
resolveRoute = { Route.RelayGroups },
),
NavBarItem.BUZZ to
NavBarItemDef(
id = NavBarItem.BUZZ,
labelRes = R.string.buzz_workspaces_title,
icon = MaterialSymbols.AutoAwesome,
resolveRoute = { Route.BuzzWorkspaces },
),
NavBarItem.CONCORD to
NavBarItemDef(
id = NavBarItem.CONCORD,
@@ -496,6 +504,7 @@ val BottomBarCategories: List<NavBarCategory> =
listOf(
NavBarItem.PUBLIC_CHATS,
NavBarItem.RELAY_GROUPS,
NavBarItem.BUZZ,
NavBarItem.CONCORD,
NavBarItem.GEOHASH_CHATS,
),

View File

@@ -714,6 +714,8 @@ sealed class Route {
@Serializable object RelayGroups : Route()
@Serializable object BuzzWorkspaces : Route()
@Serializable object RelayGroupBrowse : Route()
// Concord Channels (encrypted communities). Addressed by community id + channel id

View File

@@ -137,6 +137,10 @@ private fun PreloadFor(
// this tab needs no extra preload.
NavBarItem.RELAY_GROUPS -> Unit
// Buzz workspaces are relay groups on Buzz-dialect relays; they ride the same
// always-on relay-group state subscription, so there's nothing extra to preload.
NavBarItem.BUZZ -> Unit
NavBarItem.CONCORD -> ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel)
NavBarItem.FOLLOW_PACKS -> FollowPacksFilterAssemblerSubscription(accountViewModel)

View File

@@ -0,0 +1,341 @@
/*
* 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.ui.screen.loggedIn.buzz
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.relayGroupChannelHasUnreadFlow
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
/**
* The **Workspaces** tab — a first-class navigation home for `block/buzz` workspaces,
* giving Buzz a distinct identity instead of hiding inside the generic relay-group list.
* A Buzz workspace is a NIP-29 group on a Buzz-dialect relay, so this filters the user's
* joined groups to those relays ([BuzzRelayDialect]) and adds the Buzz-only surfaces (the
* Agent Console) up top.
*
* Read-only aggregation: it reuses the always-on relay-group state subscription (mounted
* at login), so no per-screen fetch is needed — it just projects the joined set.
*/
@Composable
fun BuzzWorkspacesScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val joined by accountViewModel.account.relayGroupList.liveRelayGroupList
.collectAsStateWithLifecycle()
val buzzRelays by BuzzRelayDialect.flow.collectAsStateWithLifecycle()
val workspaces =
remember(joined, buzzRelays) {
joined
.mapNotNull { tag ->
val relay = RelayUrlNormalizer.normalizeOrNull(tag.relayUrl) ?: return@mapNotNull null
if (relay !in buzzRelays) return@mapNotNull null
GroupId(tag.groupId, relay)
}.sortedBy { it.id }
}
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
UserDrawerSearchTopBar(accountViewModel, nav) {
Text(
text = stringRes(R.string.buzz_workspaces_title),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
}
},
bottomBar = {
AppBottomBar(Route.BuzzWorkspaces, nav, accountViewModel) { route ->
nav.navBottomBar(route)
}
},
accountViewModel = accountViewModel,
) { padding ->
LazyColumn(
modifier = Modifier.padding(padding).fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { AgentConsoleHeroCard(onClick = { nav.nav(Route.AgentConsole) }) }
if (workspaces.isEmpty()) {
item { EmptyWorkspaces(onBrowse = { nav.nav(Route.RelayGroups) }) }
} else {
item {
Text(
text = stringRes(R.string.buzz_workspaces_section),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp, start = 4.dp),
)
}
items(workspaces, key = { it.id + it.relayUrl.url }) { groupId ->
WorkspaceRow(groupId, accountViewModel, nav)
}
}
}
}
}
/** A bold accent card leading to the Buzz-only Agent Console — the tab's signature surface. */
@Composable
private fun AgentConsoleHeroCard(onClick: () -> Unit) {
Card(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier =
Modifier
.size(44.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center,
) {
Icon(
symbol = MaterialSymbols.AutoAwesome,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(24.dp),
)
}
Spacer(Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringRes(R.string.buzz_console_card_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
Text(
text = stringRes(R.string.buzz_console_card_subtitle),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
Icon(
symbol = MaterialSymbols.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(22.dp),
)
}
}
}
/** One workspace: a colored monogram avatar, live name + host, member count, unread dot. */
@Composable
private fun WorkspaceRow(
groupId: GroupId,
accountViewModel: AccountViewModel,
nav: INav,
) {
val baseChannel = remember(groupId) { LocalCache.getOrCreateRelayGroupChannel(groupId) }
val channelState by observeChannel(baseChannel, accountViewModel)
val channel = channelState?.channel as? RelayGroupChannel ?: baseChannel
val hasUnread by remember(groupId) {
relayGroupChannelHasUnreadFlow(accountViewModel.account, groupId)
}.collectAsStateWithLifecycle(initialValue = false)
val name = channel.toBestDisplayName()
val memberCount = channel.memberCount()
Card(
onClick = { nav.nav(routeFor(channel)) },
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
WorkspaceAvatar(name = name, seed = groupId.id)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = name,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = groupId.relayUrl.displayUrl(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (memberCount > 0) {
Icon(
symbol = MaterialSymbols.Group,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(14.dp),
)
Spacer(Modifier.width(3.dp))
Text(
text = "$memberCount",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (hasUnread) {
Spacer(Modifier.width(10.dp))
Box(
modifier =
Modifier
.size(10.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
)
}
}
}
}
/** A round monogram whose color is derived deterministically from the workspace id. */
@Composable
private fun WorkspaceAvatar(
name: String,
seed: String,
) {
val hue = remember(seed) { (seed.hashCode().toLong() and 0xFFFFFF).toFloat() % 360f }
val color = remember(hue) { Color.hsl(hue, 0.55f, 0.5f) }
val initial =
remember(name) {
name
.trim()
.firstOrNull()
?.uppercaseChar()
?.toString() ?: "#"
}
Box(
modifier = Modifier.size(42.dp).clip(CircleShape).background(color),
contentAlignment = Alignment.Center,
) {
Text(
text = initial,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = Color.White,
)
}
}
/** Inviting empty state: what a Buzz workspace is + a way to go find one. */
@Composable
private fun EmptyWorkspaces(onBrowse: () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Box(
modifier = Modifier.size(56.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
symbol = MaterialSymbols.AutoAwesome,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(28.dp),
)
}
Text(
text = stringRes(R.string.buzz_workspaces_empty_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
text = stringRes(R.string.buzz_workspaces_empty_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 8.dp),
)
Spacer(Modifier.size(2.dp))
FilledTonalButton(onClick = onBrowse) {
Text(stringRes(R.string.buzz_workspaces_browse))
}
}
}
}

View File

@@ -3299,6 +3299,13 @@
<string name="buzz_typing_one">%1$s is typing…</string>
<string name="buzz_typing_two">%1$s and %2$s are typing…</string>
<string name="buzz_typing_many">Several people are typing…</string>
<string name="buzz_workspaces_title">Workspaces</string>
<string name="buzz_workspaces_section">Your workspaces</string>
<string name="buzz_workspaces_empty_title">No workspaces yet</string>
<string name="buzz_workspaces_empty_body">Buzz workspaces are relays where humans and AI agents build together. Join one to see it here.</string>
<string name="buzz_workspaces_browse">Browse groups</string>
<string name="buzz_console_card_title">Agent Console</string>
<string name="buzz_console_card_subtitle">Costs · Personas · Live observer</string>
<string name="chat_delivery_details_title">Message Delivery</string>
<string name="close">Close</string>

View File

@@ -128,10 +128,11 @@ class RelayGroupFilterBuildersTest {
fun `open chat tail is a single host h-filter with since only`() {
val f = buildRelayGroupOpenChatTailFilter(g1OnA, 5L)
assertEquals(relayA, f.relay)
// The open-channel tail always carries the Buzz timeline kinds in addition to
// the NIP-29 set: it is the Buzz-dialect detection bootstrap (see
// buildRelayGroupOpenChatTailFilter). Harmless on vanilla relays.
assertEquals(timelineKinds + BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS, f.filter.kinds)
// The open-channel tail carries the full Buzz timeline set PLUS the ephemeral
// kind-20002 typing indicator (RELAY_GROUP_OPEN_TAIL_KINDS) — typing is scoped to
// the one channel on screen, not the joined fleet. It is also the Buzz-dialect
// detection bootstrap. Harmless on vanilla relays.
assertEquals(RELAY_GROUP_OPEN_TAIL_KINDS, f.filter.kinds)
assertEquals(listOf("g1"), f.filter.tags!!["h"])
assertEquals(5L, f.filter.since)
assertNull(f.filter.until)

View File

@@ -189,12 +189,28 @@ stored, and served by the Buzz relay. The one social action NOT in Buzz's accept
(no 9734/9735) — but a zap is a Lightning payment whose receipt is published by the LN provider to
the author's relays, so zapping still works; only the receipt isn't stored on the Buzz relay.
Still not wired (write path): the forum/job/huddle/DM message composers — each a
send flow for a richer kind, and jobs/huddles are among the schema-inferred kinds, so
they want Buzz-side confirmation before UI. **Typing (KIND_TYPING_INDICATOR) and presence
(KIND_PRESENCE_UPDATE) ARE accepted by Buzz** but Amethyst consumes them as ephemeral and
does not render them yet. Persisting held attestations per-account (currently in-memory)
needs a KMP storage abstraction and is a follow-up.
**Live typing indicators (kind 20002)** are wired: `commons/.../model/buzz/BuzzTypingState`
is a process-wide, lock-guarded `channel -> typist -> heartbeat` registry (6 unit tests);
`LocalCache` records each ephemeral 20002 into it (still no feed row), the open channel's
live tail requests 20002 (`RELAY_GROUP_OPEN_TAIL_KINDS`, scoped to the room on screen),
`Account.sendBuzzTyping` fires a throttled heartbeat from the composer, and
`BuzzTypingIndicator` renders an animated "… is typing" row above the input. This matches
Concord's typing feature. (`requires_h_channel_scope(20002)` is false on the relay, but
Amethyst still `h`-scopes the send + subscription so it never leaks across channels.)
**Navigation identity — a "Workspaces" tab.** `NavBarItem.BUZZ` (→ `Route.BuzzWorkspaces`)
is a pinnable bottom-nav destination; `amethyst/.../buzz/BuzzWorkspacesScreen` is a hub that
filters the joined relay groups to Buzz-dialect relays and leads with an Agent-Console hero
card. Buzz workspaces still ALSO appear in the generic Relay Groups list (they are NIP-29
groups); the tab is a Buzz-branded lens over the same data, not a separate store.
Still not wired: the forum/job/huddle/DM message composers — each a send flow for a
richer kind, and jobs/huddles are among the schema-inferred kinds, so they want Buzz-side
confirmation before UI. **Presence (kind 20001)** is accepted by Buzz but collides with
Amethyst's `GeohashPresenceEvent` in `EventFactory` (20001 is registered as geohash
presence), so it needs disambiguation before it can render — unlike typing (20002), which
has no such collision. A **workspace→channels shell** (Concord-style channel sidebar) and
per-account persistence of held attestations remain follow-ups.
## Owner Attestation (NIP-OA) — implemented