feat(buzz): workspace→channels shell, forum composer, attestation persistence

- Workspace shell: BuzzWorkspacesScreen now groups joined Buzz-dialect groups
  BY RELAY into workspace→channels (a Buzz workspace IS a relay/tenant, per
  buzz-core relay_url_authority), each an expandable section with its channels
  and a + to the relay's directory (Concord-style community→channels).
- Forum composer (45001): the Threads-tab FAB opens BuzzForumPostScreen on a
  Buzz relay, publishing a ForumPostEvent (mirrors build_forum_post) instead of
  the vanilla kind-11 thread a NIP-29 relay uses.
- Held-attestation persistence: BuzzAttestationPreferences mirrors
  BuzzHeldAttestations to the device DataStore and reloads at startup,
  re-verifying each credential against its agent key (drops tampered entries).

Deliberately NOT built: job/huddle composers — jobs (43xxx) and huddles (48xxx)
have no builder in buzz-sdk (reserved kinds), so a composer would encode an
unconfirmed schema. Presence (20001) skipped per request (EventFactory collision
with GeohashPresenceEvent).

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 13:06:55 +00:00
parent 12aa9fe954
commit c788c3caa5
11 changed files with 403 additions and 25 deletions
@@ -46,6 +46,7 @@ import com.vitorpamplona.amethyst.model.nip03Timestamp.BitcoinExplorerEndpoint
import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier
import com.vitorpamplona.amethyst.model.nip03Timestamp.TorAwareOkHttpOtsResolverBuilder
import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever
import com.vitorpamplona.amethyst.model.preferences.BuzzAttestationPreferences
import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences
import com.vitorpamplona.amethyst.model.preferences.OtsSharedPreferences
import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences
@@ -270,6 +271,10 @@ class AppModules(
}
}
// Restore + persist held NIP-OA attestations across restarts (device-global). Eager (not
// lazy) so it loads before the first Buzz-relay AUTH and mirrors later changes to disk.
val buzzAttestationPrefs = BuzzAttestationPreferences(appContext, applicationIOScope)
// Service that will run at all times to receive events from Pokey
val pokeyReceiver = PokeyReceiver()
@@ -0,0 +1,107 @@
/*
* 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.model.preferences
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlin.coroutines.cancellation.CancellationException
/**
* Device-global persistence for the NIP-OA attestations this device holds
* ([BuzzHeldAttestations]), so a held credential survives an app restart instead of
* needing to be re-pasted. Uses the app-wide [sharedPreferencesDataStore] like
* [NamecoinSharedPreferences] (not per-account — the store is already keyed by the agent
* pubkey each attestation authorizes).
*
* On construction it loads the saved entries into the singleton — **re-verifying each
* against its agent key**, so a tampered on-disk credential is dropped rather than trusted
* — then mirrors every later change back to disk. Construct once, eagerly, at startup.
*/
@Stable
class BuzzAttestationPreferences(
private val context: Context,
private val scope: CoroutineScope,
) {
private val json = Json { ignoreUnknownKeys = true }
@Serializable
private data class Entry(
val agent: HexKey,
val owner: HexKey,
val conditions: String,
val sig: HexKey,
)
init {
scope.launch {
restoreFromDisk()
// Persist on every change AFTER the initial restore (drop(1) skips the value
// present at collection start, which restoreFromDisk already wrote).
BuzzHeldAttestations.flow.drop(1).collect { persist(it) }
}
}
private suspend fun restoreFromDisk() {
try {
val raw = context.sharedPreferencesDataStore.data.first()[KEY] ?: return
val verified =
json
.decodeFromString<List<Entry>>(raw)
.mapNotNull { e ->
val attestation = OwnerAttestation(e.owner, e.conditions, e.sig)
// Only reinstate a credential that still verifies for its agent key.
if (attestation.verify(e.agent)) e.agent to attestation else null
}.toMap()
if (verified.isNotEmpty()) BuzzHeldAttestations.restore(verified)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("BuzzAttestationPrefs") { "Error reading held attestations: ${e.message}" }
}
}
private suspend fun persist(entries: Map<HexKey, OwnerAttestation>) {
try {
val list = entries.map { (agent, a) -> Entry(agent, a.ownerPubKey, a.conditions, a.sig) }
context.sharedPreferencesDataStore.edit { prefs ->
prefs[KEY] = json.encodeToString(list)
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("BuzzAttestationPrefs") { "Error writing held attestations: ${e.message}" }
}
}
companion object {
private val KEY = stringPreferencesKey("buzz.heldAttestations")
}
}
@@ -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.BuzzForumPostScreen
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
@@ -762,6 +763,7 @@ fun BuildNavigation(
)
}
composableFromEndArgs<Route.BuzzCanvas> { BuzzCanvasScreen(it.channelId, accountViewModel, nav) }
composableFromBottomArgs<Route.BuzzForumPost> { BuzzForumPostScreen(it.channelId, it.relayUrl, accountViewModel, nav) }
composableFromEndArgs<Route.RelayGroupCreate> {
RelayGroupCreateScreen(
@@ -703,6 +703,11 @@ sealed class Route {
val channelId: String,
) : Route()
@Serializable data class BuzzForumPost(
val channelId: String,
val relayUrl: String,
) : Route()
@Serializable data class RelayGroupCreate(
val relayUrl: String,
) : Route()
@@ -115,8 +115,8 @@ fun AgentAttestationScreen(
/**
* Agent-side: paste an `auth` tag an owner issued to this account's key. It is verified
* against [myPubkey] and, on success, stored in [BuzzHeldAttestations] so the auth
* coordinator attaches it when this account AUTHs to a Buzz relay. In-memory only for
* now — re-paste after an app restart.
* coordinator attaches it when this account AUTHs to a Buzz relay. Persisted across
* restarts (device-global) by `BuzzAttestationPreferences`.
*/
@Composable
private fun HoldAttestationSection(myPubkey: String) {
@@ -0,0 +1,142 @@
/*
* 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.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.buzz.forum.ForumPostEvent
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Composer for a Buzz **forum post** (NIP kind 45001) — the root of a forum thread in a
* workspace channel. The post body is the event content (Buzz forum posts carry no
* separate title). Published to the channel's host relay via `build_forum_post`'s Quartz
* equivalent ([ForumPostEvent.build]). Reached from the Threads tab's compose FAB on a
* Buzz-dialect relay.
*/
@Composable
fun BuzzForumPostScreen(
channelId: String,
relayUrl: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
var body by remember { mutableStateOf("") }
var isPosting by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
Scaffold(
topBar = { TopBarWithBackButton(stringRes(R.string.buzz_forum_new_title), nav) },
) { padding ->
Column(
modifier =
Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = body,
onValueChange = {
body = it
error = null
},
label = { Text(stringRes(R.string.buzz_forum_body_label)) },
minLines = 5,
modifier = Modifier.fillMaxWidth(),
enabled = !isPosting,
)
error?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
}
Button(
onClick = {
val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl)
if (relay == null) {
error = "Invalid relay url"
return@Button
}
isPosting = true
error = null
// Publish on IO, then pop back on the composition's main scope.
scope.launch {
try {
withContext(Dispatchers.IO) {
accountViewModel.account.signAndSendPrivatelyOrBroadcast(
ForumPostEvent.build(channelId, body.trim()),
) { listOf(relay) }
}
nav.popBack()
} catch (e: Exception) {
isPosting = false
error = "Failed to post: ${e.message ?: e::class.simpleName}"
}
}
},
enabled = body.isNotBlank() && !isPosting,
modifier = Modifier.fillMaxWidth(),
) {
if (isPosting) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
} else {
Text(stringRes(R.string.buzz_forum_post_action))
}
}
}
}
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -35,6 +36,7 @@ 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.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilledTonalButton
@@ -42,6 +44,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -67,6 +70,7 @@ 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.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
@@ -90,6 +94,9 @@ fun BuzzWorkspacesScreen(
.collectAsStateWithLifecycle()
val buzzRelays by BuzzRelayDialect.flow.collectAsStateWithLifecycle()
// A Buzz workspace IS a relay (a tenant, per buzz-core's `relay_url_authority`), and its
// channels are the NIP-29 groups on it — so group the joined Buzz-dialect groups by relay
// into workspace → channels, the Concord community→channels shape.
val workspaces =
remember(joined, buzzRelays) {
joined
@@ -97,9 +104,15 @@ fun BuzzWorkspacesScreen(
val relay = RelayUrlNormalizer.normalizeOrNull(tag.relayUrl) ?: return@mapNotNull null
if (relay !in buzzRelays) return@mapNotNull null
GroupId(tag.groupId, relay)
}.sortedBy { it.id }
}.groupBy { it.relayUrl }
.toList()
.sortedBy { it.first.url }
.map { (relay, channels) -> relay to channels.sortedBy { it.id } }
}
// Which workspaces are expanded (channels shown). Defaults to all-open the first time.
val expanded = remember { mutableStateMapOf<String, Boolean>() }
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
@@ -137,8 +150,23 @@ fun BuzzWorkspacesScreen(
modifier = Modifier.padding(top = 4.dp, start = 4.dp),
)
}
items(workspaces, key = { it.id + it.relayUrl.url }) { groupId ->
WorkspaceRow(groupId, accountViewModel, nav)
workspaces.forEach { (relay, channels) ->
val relayKey = relay.url
val isOpen = expanded[relayKey] ?: true
item(key = "ws-$relayKey") {
WorkspaceHeader(
relay = relay,
channelCount = channels.size,
expanded = isOpen,
onToggle = { expanded[relayKey] = !isOpen },
onBrowse = { nav.nav(Route.RelayGroupServer(relayKey)) },
)
}
if (isOpen) {
items(channels, key = { "ch-${it.relayUrl.url}-${it.id}" }) { groupId ->
ChannelRow(groupId, accountViewModel, nav)
}
}
}
}
}
@@ -196,9 +224,66 @@ private fun AgentConsoleHeroCard(onClick: () -> Unit) {
}
}
/** One workspace: a colored monogram avatar, live name + host, member count, unread dot. */
/**
* A workspace header (one per Buzz relay): its host, a channel count, an expand chevron to
* fold its channels, and a "browse all" affordance to the relay's full directory. Tapping
* the row toggles; the folder icon opens the relay's channel directory.
*/
@Composable
private fun WorkspaceRow(
private fun WorkspaceHeader(
relay: NormalizedRelayUrl,
channelCount: Int,
expanded: Boolean,
onToggle: () -> Unit,
onBrowse: () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.clickable(onClick = onToggle)
.padding(vertical = 8.dp, horizontal = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
symbol = if (expanded) MaterialSymbols.ExpandMore else MaterialSymbols.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
Spacer(Modifier.width(6.dp))
Text(
text = relay.displayUrl(),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Text(
text = "$channelCount",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(6.dp))
Icon(
symbol = MaterialSymbols.Add,
contentDescription = stringRes(R.string.buzz_workspaces_browse),
tint = MaterialTheme.colorScheme.primary,
modifier =
Modifier
.clip(CircleShape)
.clickable(onClick = onBrowse)
.padding(4.dp)
.size(18.dp),
)
}
}
/** One channel (a NIP-29 group): a colored monogram avatar, live name + host, member count, unread dot. */
@Composable
private fun ChannelRow(
groupId: GroupId,
accountViewModel: AccountViewModel,
nav: INav,
@@ -55,6 +55,7 @@ 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.Note
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReplyCount
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -148,12 +149,19 @@ private fun RelayGroupThreads(
if (canPost) {
FloatingActionButton(
onClick = {
nav.nav(
Route.NewShortNote(
groupThreadId = channel.groupId.id,
groupThreadRelayUrl = channel.groupId.relayUrl.url,
),
)
// On a Buzz workspace, "new thread" is a Buzz forum post (kind 45001);
// vanilla NIP-29 relays use a kind-11 thread. Buzz relays reject
// unknown kinds, so a kind-11 thread would be refused there.
if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) {
nav.nav(Route.BuzzForumPost(channel.groupId.id, channel.groupId.relayUrl.url))
} else {
nav.nav(
Route.NewShortNote(
groupThreadId = channel.groupId.id,
groupThreadRelayUrl = channel.groupId.relayUrl.url,
),
)
}
},
shape = CircleShape,
) {
+3
View File
@@ -3306,6 +3306,9 @@
<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="buzz_forum_new_title">New forum topic</string>
<string name="buzz_forum_body_label">What do you want to discuss?</string>
<string name="buzz_forum_post_action">Post topic</string>
<string name="chat_delivery_details_title">Message Delivery</string>
<string name="close">Close</string>
@@ -84,6 +84,15 @@ object BuzzHeldAttestations {
}
}
/**
* Replaces the whole store with [entries] — used to restore from disk at startup. The
* caller must have re-verified each attestation against its agent key (the same gate
* [put] documents), so a tampered on-disk credential can't be reinstated.
*/
fun restore(entries: Map<HexKey, OwnerAttestation>) {
heldByAgent.value = entries
}
/** Test-only: clears all held attestations so unit tests don't leak state. */
fun clearForTesting() {
heldByAgent.value = emptyMap()
@@ -198,19 +198,31 @@ live tail requests 20002 (`RELAY_GROUP_OPEN_TAIL_KINDS`, scoped to the room on s
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.
**Navigation identity — a "Workspaces" tab with a channel shell.** `NavBarItem.BUZZ`
(→ `Route.BuzzWorkspaces`) is a pinnable bottom-nav destination; `BuzzWorkspacesScreen` is a
hub that **groups the joined Buzz-dialect groups by relay into workspace → channels** — a
Buzz workspace IS a relay (a tenant, per buzz-core's `relay_url_authority`), and its channels
are the NIP-29 groups on it. Each workspace is an expandable section (Concord-style) with its
channels; a `+` opens the relay's full channel directory (`Route.RelayGroupServer`). Leads
with an Agent-Console hero card. The groups still ALSO appear in the generic Relay Groups
list; the tab is a Buzz-branded lens over the same 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.
**Forum posts (kind 45001) are composable.** On a Buzz relay the Threads-tab FAB opens
`BuzzForumPostScreen` (`Route.BuzzForumPost`), which publishes a `ForumPostEvent` (mirroring
`build_forum_post`) to the channel's host relay, instead of the vanilla kind-11 thread a
NIP-29 relay uses. (Forum posts already render in the chat feed; a dedicated forum reader in
the Threads tab is the read-side follow-up.)
**Held attestations persist** across restarts: `amethyst/.../model/preferences/BuzzAttestationPreferences`
mirrors `BuzzHeldAttestations` to the device-global DataStore and reloads it at startup,
**re-verifying each against its agent key** so a tampered on-disk credential is dropped.
Still not wired: **presence (kind 20001)** — accepted by Buzz but it collides with Amethyst's
`GeohashPresenceEvent` in `EventFactory` (20001 is registered as geohash presence), so it needs
disambiguation before it can render (typing/20002 has no such collision). The **job/huddle/DM
composers** are deferred: jobs (43xxx) and huddles (48xxx) have **no builder** in `buzz-sdk`
(reserved kinds), so a composer would encode an unconfirmed schema; DM (`build_dm_open`, 41001)
is a full encrypted-DM subsystem for a later pass.
## Owner Attestation (NIP-OA) — implemented