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 index 7fa6ad1a7a..61790f520b 100644 --- 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 @@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBack import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.buzz.stream.CanvasEvent +import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import kotlinx.coroutines.Dispatchers @@ -95,14 +96,21 @@ fun BuzzCanvasScreen( val canvas = remember(version) { state.canvasNote } val content = canvas?.event?.content - // The channel this canvas belongs to, for the subtitle. Null until its kind-39000 lands, which - // only costs the subtitle — the canvas itself is keyed by the raw channel id. - val channelName = + // The channel this canvas belongs to, for the subtitle and the edit gate. Null until its + // kind-39000 lands, which only costs the subtitle — the canvas itself is keyed by the raw id. + val channel = remember(channelId, relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl)?.let { relay -> - LocalCache.getRelayGroupChannelIfExists(GroupId(channelId, relay))?.toBestDisplayName() + LocalCache.getRelayGroupChannelIfExists(GroupId(channelId, relay)) } } + val channelName = channel?.toBestDisplayName() + + // Buzz never lets a DM's canvas be written: its editor's `canEdit` is `canEditNarrative`, which + // excludes `channelType === "dm"` outright. A DM reaching this screen at all means a canvas + // already exists (see the top bar's gate), so render it read-only rather than offering an edit + // that Buzz's own client would never show. + val canEdit = channel?.event?.isBuzzDm() != true var editing by remember { mutableStateOf(false) } @@ -145,8 +153,10 @@ fun BuzzCanvasScreen( ) }, floatingActionButton = { - FloatingActionButton(onClick = { editing = true }) { - Icon(symbol = MaterialSymbols.Edit, contentDescription = stringRes(R.string.buzz_canvas_edit)) + if (canEdit) { + FloatingActionButton(onClick = { editing = true }) { + Icon(symbol = MaterialSymbols.Edit, contentDescription = stringRes(R.string.buzz_canvas_edit)) + } } }, ) { padding -> 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 88a5fe3405..bb984b4ba6 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.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -47,10 +48,12 @@ import androidx.compose.ui.platform.LocalContext 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.buzz.BuzzWorkspaceStates import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership import com.vitorpamplona.amethyst.model.LocalCache @@ -170,11 +173,17 @@ fun RelayGroupTopBar( } }, actions = { - // Buzz workspace canvas (kind 40100): shown on any Buzz-dialect relay so a member can - // open the shared markdown doc — or create one when the channel has none yet. The only - // affordance that stays an icon: it is this channel's shared document, i.e. content, while - // Threads and Share are navigation the reader needs once in a while. - if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) { + // Buzz canvas (kind 40100): shown on any Buzz-dialect relay so a member can open the + // channel's shared markdown doc — or create one when it has none yet. The only affordance + // that stays an icon: it is this channel's shared document, i.e. content, while Threads and + // Share are navigation the reader needs once in a while. + // + // Except on a DM, where Buzz itself never offers to *write* one: its canvas entry needs + // `hasCanvas || canEditNarrative`, and `canEditNarrative` excludes `channelType === "dm"` + // outright. So a DM shows the icon only when a canvas already exists — which is also what + // keeps us from advertising "start a shared doc" in a two-person conversation. + val hasCanvas by observeBuzzCanvas(channel.groupId.id) + if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) && (!isDm || hasCanvas)) { IconButton(onClick = { nav.nav(Route.BuzzCanvas(channel.groupId.id, channel.groupId.relayUrl.url)) }) { Icon( symbol = MaterialSymbols.Dashboard, @@ -372,3 +381,18 @@ private fun RoleBadge(membership: RelayGroupMembership) { ) } } + +/** + * Whether this Buzz channel has a canvas (kind 40100) in cache, recomposing when one lands. + * + * Only the top bar's DM case needs this: a DM gets the canvas affordance solely when a document + * already exists, mirroring Buzz's own `hasCanvas || canEditNarrative` where `canEditNarrative` + * excludes DMs. Reads the same [BuzzWorkspaceStates] registry the canvas screen renders from, so the + * icon appears the moment the document arrives rather than on the next visit. + */ +@Composable +private fun observeBuzzCanvas(channelId: String): State { + val state = remember(channelId) { BuzzWorkspaceStates.getOrCreate(channelId) } + val version by state.canvasUpdates.collectAsStateWithLifecycle() + return remember(channelId, version) { mutableStateOf(state.canvasNote != null) } +}