fix(buzz): don't offer a canvas on a DM unless one already exists

The Canvas icon showed on every Buzz channel including DMs, and its edit FAB
offered to create one there. Buzz itself never does: its canvas entry needs
`hasCanvas || canEditNarrative`, and `canEditNarrative` is
`canManageChannel && selfMember !== null && channelType !== "dm"` — so a DM can
only ever *display* a canvas that already exists, never write one
(ChannelManagementSheet.tsx). We were advertising "start a shared document" in a
two-person conversation, and a canvas created there would have been ours alone
to see.

Mirror both halves: a DM gets the icon only when a canvas is already in cache,
and the canvas screen drops its edit FAB for DMs so what remains is read-only.
Non-DM Buzz channels are unchanged — icon always, editing always.

The has-a-canvas check reads the same BuzzWorkspaceStates registry the canvas
screen renders from and recomposes on `canvasUpdates`, so the icon appears when
the document lands rather than on the next visit.

Verified on emulator-5554: the Buzz DM with Vitor (no canvas) shows only the
overflow, where it used to show Canvas + overflow; `general` on the same relay
still shows Canvas + overflow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-07-26 21:20:05 -04:00
co-authored by Claude Opus 5
parent 7d4e4f1cc9
commit 3acec7ee87
2 changed files with 45 additions and 11 deletions
@@ -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 ->
@@ -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<Boolean> {
val state = remember(channelId) { BuzzWorkspaceStates.getOrCreate(channelId) }
val version by state.canvasUpdates.collectAsStateWithLifecycle()
return remember(channelId, version) { mutableStateOf(state.canvasNote != null) }
}