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 16c74593c7..834a673d88 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 @@ -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 { BuzzCanvasScreen(it.channelId, 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 1e2f693cdb..ede1134d88 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 @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzCanvasScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzCanvasScreen.kt new file mode 100644 index 0000000000..72253f6e2b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzCanvasScreen.kt @@ -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, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt index ed000cffd1..6d399ad967 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt @@ -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 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e2953d2978..0cba36fac3 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3292,6 +3292,8 @@ %1$s updated the channel profile (edited) (diff truncated) + Canvas + No canvas has been shared in this workspace yet. Message Delivery Close diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzWorkspaceStates.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzWorkspaceStates.kt index 0b32be4920..af8e5ae16e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzWorkspaceStates.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzWorkspaceStates.kt @@ -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 = 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 } }