From c788c3caa5e46c9d118da4cf96fc4dbd24c2592c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 13:06:55 +0000 Subject: [PATCH] =?UTF-8?q?feat(buzz):=20workspace=E2=86=92channels=20shel?= =?UTF-8?q?l,=20forum=20composer,=20attestation=20persistence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8 --- .../com/vitorpamplona/amethyst/AppModules.kt | 5 + .../preferences/BuzzAttestationPreferences.kt | 107 +++++++++++++ .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 5 + .../loggedIn/buzz/AgentAttestationScreen.kt | 4 +- .../loggedIn/buzz/BuzzForumPostScreen.kt | 142 ++++++++++++++++++ .../loggedIn/buzz/BuzzWorkspacesScreen.kt | 95 +++++++++++- .../relayGroup/RelayGroupThreadsScreen.kt | 20 ++- amethyst/src/main/res/values/strings.xml | 3 + .../model/buzz/BuzzHeldAttestations.kt | 9 ++ .../com/vitorpamplona/quartz/buzz/README.md | 36 +++-- 11 files changed, 403 insertions(+), 25 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzAttestationPreferences.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzForumPostScreen.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 649f70a47f..7b51c1623e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzAttestationPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzAttestationPreferences.kt new file mode 100644 index 0000000000..54d604318f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzAttestationPreferences.kt @@ -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>(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) { + 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") + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index c148755e93..d50c007839 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { BuzzCanvasScreen(it.channelId, accountViewModel, nav) } + composableFromBottomArgs { BuzzForumPostScreen(it.channelId, it.relayUrl, accountViewModel, nav) } composableFromEndArgs { RelayGroupCreateScreen( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 92569590b5..ce72348918 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt index 5dc5c071d1..3a708ace4e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt @@ -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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzForumPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzForumPostScreen.kt new file mode 100644 index 0000000000..46ecafb06b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzForumPostScreen.kt @@ -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(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)) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzWorkspacesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzWorkspacesScreen.kt index e5556d5019..f8a2f6057f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzWorkspacesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzWorkspacesScreen.kt @@ -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() } + 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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt index 20d640e620..6262a458e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt @@ -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, ) { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 4457453620..5bdbfc488e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3306,6 +3306,9 @@ Browse groups Agent Console Costs · Personas · Live observer + New forum topic + What do you want to discuss? + Post topic Message Delivery Close diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzHeldAttestations.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzHeldAttestations.kt index 1cafe1640c..2680dede13 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzHeldAttestations.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzHeldAttestations.kt @@ -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) { + heldByAgent.value = entries + } + /** Test-only: clears all held attestations so unit tests don't leak state. */ fun clearForTesting() { heldByAgent.value = emptyMap() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/README.md index d2662e486c..d96b7d2f06 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/README.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/README.md @@ -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