feat(buzz): workspace canvas viewer (kind 40100)

Adds a read surface for the Buzz canvas, which was consumed into overlay state
but had no UI. BuzzWorkspaceState gains a canvasUpdates flow; BuzzCanvasScreen
(Route.BuzzCanvas) renders the newest 40100 markdown for a channel and recomposes
when a newer revision lands. Entry point is a Dashboard icon in the relay-group
top bar, shown only on a Buzz-dialect relay once a canvas has arrived.

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 02:12:17 +00:00
parent 4a4baf6a23
commit f31e7a4c0b
6 changed files with 146 additions and 0 deletions
@@ -102,6 +102,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.WebAppScreen
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.calendars.CalendarCollectionsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarReminderSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarsScreen
@@ -758,6 +759,7 @@ fun BuildNavigation(
nav = nav,
)
}
composableFromEndArgs<Route.BuzzCanvas> { BuzzCanvasScreen(it.channelId, accountViewModel, nav) }
composableFromEndArgs<Route.RelayGroupCreate> {
RelayGroupCreateScreen(
@@ -699,6 +699,10 @@ sealed class Route {
val relayUrl: String,
) : Route()
@Serializable data class BuzzCanvas(
val channelId: String,
) : Route()
@Serializable data class RelayGroupCreate(
val relayUrl: String,
) : Route()
@@ -0,0 +1,111 @@
/*
* 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.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
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
/**
* Renders a Buzz workspace **canvas** (NIP kind 40100): the newest shared markdown
* document for a channel. The canvas is overlay state held in [BuzzWorkspaceStates]
* (never a timeline row — a workspace has one live canvas, last-write-wins), so this
* reads the registry by the channel's `h` id and re-composes off `canvasUpdates` when a
* newer revision lands.
*/
@Composable
fun BuzzCanvasScreen(
channelId: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val state = remember(channelId) { BuzzWorkspaceStates.getOrCreate(channelId) }
// Re-read canvasNote whenever a newer canvas is consumed.
val version by state.canvasUpdates.collectAsStateWithLifecycle()
val canvas = remember(version) { state.canvasNote }
val content = canvas?.event?.content
Scaffold(
topBar = { TopBarWithBackButton(stringRes(R.string.buzz_canvas_title), nav) },
) { padding ->
if (content.isNullOrBlank() || canvas == null) {
Box(
modifier = Modifier.padding(padding).fillMaxSize().padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringRes(R.string.buzz_canvas_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
return@Scaffold
}
val bgColor = remember { mutableStateOf(Color.Transparent) }
Box(
modifier =
Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
TranslatableRichTextViewer(
content = content,
canPreview = true,
quotesLeft = 1,
modifier = Modifier,
tags = EmptyTagList,
backgroundColor = bgColor,
id = canvas.idHex,
callbackUri = canvas.toNostrUri(),
authorPubKey = canvas.author?.pubkeyHex,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@@ -37,6 +37,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -50,6 +51,8 @@ import androidx.compose.ui.unit.dp
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.buzz.BuzzWorkspaceStates
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
@@ -164,6 +167,24 @@ fun RelayGroupTopBar(
)
}
// Buzz workspace canvas (kind 40100): shown only on a Buzz-dialect relay once
// a canvas has arrived for this channel. Observing canvasUpdates flips it on
// the moment the first canvas is consumed, without swapping the channel object.
if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) {
val canvasState = remember(channel.groupId.id) { BuzzWorkspaceStates.getOrCreate(channel.groupId.id) }
val canvasVersion by canvasState.canvasUpdates.collectAsState()
val hasCanvas = remember(canvasVersion) { canvasState.canvasNote != null }
if (hasCanvas) {
IconButton(onClick = { nav.nav(Route.BuzzCanvas(channel.groupId.id)) }) {
Icon(
symbol = MaterialSymbols.Dashboard,
contentDescription = stringRes(R.string.buzz_canvas_title),
modifier = Modifier.size(20.dp),
)
}
}
}
val naddr = channel.toNAddr()
if (naddr != null) {
val context = LocalContext.current
+2
View File
@@ -3292,6 +3292,8 @@
<string name="chat_system_updated_channel">%1$s updated the channel profile</string>
<string name="buzz_message_edited">(edited)</string>
<string name="buzz_diff_truncated">(diff truncated)</string>
<string name="buzz_canvas_title">Canvas</string>
<string name="buzz_canvas_empty">No canvas has been shared in this workspace yet.</string>
<string name="chat_delivery_details_title">Message Delivery</string>
<string name="close">Close</string>
@@ -56,6 +56,11 @@ class BuzzWorkspaceState {
var canvasNote: Note? = null
private set
private val canvasVersion = MutableStateFlow(0)
/** Bumps when [canvasNote] is replaced by a newer revision, so a canvas view re-reads it. */
val canvasUpdates: StateFlow<Int> = canvasVersion
/** Records a 40003 edit; keeps only the newest per target (last-write-wins by created_at). */
fun addEdit(
targetId: HexKey,
@@ -78,6 +83,7 @@ class BuzzWorkspaceState {
lock.withLock {
if ((note.createdAt() ?: 0L) > (canvasNote?.createdAt() ?: 0L)) {
canvasNote = note
canvasVersion.value = canvasVersion.value + 1
}
}