From 7fe7111b112bf0b3e456efbcfc0d44ffc25ddac2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 05:32:11 +0000 Subject: [PATCH 01/12] feat: modernize Buzz community screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesign the Buzz workspace community view to feel closer to the Concord server view — richer, more informative channel and DM rows, with actions tucked behind overflow menus so the list reads cleanly. - Title bar now uses a middle ellipsis on the community name so both ends stay visible instead of truncating the tail. - Channel cards (BuzzImportRow) now show a last-message preview (author + snippet, or the Buzz activity summary for system/diff/job rows), a recent-posters facepile, an unread-count badge, and the last-activity time — reusing the Concord facepile/unread-badge composables. Each card warms its recent messages while visible so previews fill in ahead of a tap. - Pin/Unpin and Add-to-my-list move off the channel row into a per-channel 3-dot overflow menu. - DM rows gain the same last-message preview line. - Add-all and Agent Console move from the inline header button / footer card into the community's top-bar 3-dot overflow menu. - Add relay-group timeline helpers (newestTimelineNote, recentAuthorHexes, relayGroupChannelUnreadCountFlow) mirroring the Concord ones. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FHnm6G9YytnLfs1ycYfK89 --- .../ui/screen/loggedIn/buzz/BuzzImportRow.kt | 302 +++++++++++++----- .../buzz/BuzzWorkspaceOverflowMenu.kt | 39 ++- .../relayGroup/RelayGroupChannelListScreen.kt | 116 ++++--- .../relayGroup/RelayGroupUnread.kt | 80 ++++- 4 files changed, 406 insertions(+), 131 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt index 3c65271fcd..fba902c9a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -31,17 +32,22 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Card +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton 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.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -49,19 +55,38 @@ 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.Note import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName +import com.vitorpamplona.amethyst.ui.note.timeAgo import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.relayGroupChannelHasUnreadFlow +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.buzzTimelinePreviewSummary +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordAuthorFacepile +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordUnreadBadge +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.newestTimelineNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.recentAuthorHexes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.relayGroupChannelUnreadCountFlow import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +/** How many recent-poster avatars a channel row's facepile shows at most. */ +private const val FACEPILE_MAX = 4 + +/** A first screen's worth of recent messages to prefetch per visible card, so previews fill in. */ +private const val CARD_WARMUP_LIMIT = 10 + /** - * One row in the "your channels on this workspace" section: a Buzz workspace channel the user is a - * member of (via kind-44100). Tapping the card opens the channel ([onOpen]); the trailing Add - * affordance appends it to the kind-10009 list so it surfaces in Messages / Relay Groups. Reused by - * the relay group-list screen where Buzz membership discovery is folded in. + * One channel row in a Buzz workspace's community view: a channel the user is a member of (via + * kind-44100), rendered like the Concord server view — a colored monogram, the channel name with a + * recent-posters facepile, a preview of the last message (author + snippet, or the Buzz activity + * summary for system/diff/job rows), the relative time of that message, and an unread-count badge. + * Tapping the card opens the channel ([onOpen]); the trailing overflow (3-dot) menu holds the + * per-channel actions — Pin/Unpin and Add-to-my-list — so the row stays clean. + * + * Reused by the relay group-list screen where Buzz membership discovery is folded in. */ @Composable fun BuzzImportRow( @@ -73,21 +98,57 @@ fun BuzzImportRow( isStarred: Boolean = false, onToggleStar: (() -> Unit)? = null, ) { + val account = accountViewModel.account val baseChannel = remember(groupId) { LocalCache.getOrCreateRelayGroupChannel(groupId) } + + // Warm a first screen's worth of recent messages while this card is visible (content only — the + // directory subscription already streams metadata), so the preview + facepile fill in ahead of a + // tap instead of staying blank until the channel is opened. Bounded to visible rows by the + // LazyColumn and released as they scroll off. + RelayGroupCardWarmupSubscription( + baseChannel, + accountViewModel.dataSources().relayGroupCardWarmup, + accountViewModel, + contentOnly = true, + contentLimit = CARD_WARMUP_LIMIT, + ) + val channelState by observeChannel(baseChannel, accountViewModel) val channel = channelState?.channel as? RelayGroupChannel ?: baseChannel + // The channel's own notes flow drives the preview/facepile so they update the moment a message + // folds in, independent of the metadata-scoped [observeChannel] above. + val notesState by channel + .flow() + .notes.stateFlow + .collectAsStateWithLifecycle() val name = channel.toBestDisplayName() val memberCount = channel.memberCount() - - // An unread dot when this group has chat newer than the last time this account opened it. - val hasUnread by remember(groupId) { - relayGroupChannelHasUnreadFlow(accountViewModel.account, groupId) - }.collectAsStateWithLifecycle(false) + val isPrivate = channel.isPrivate() + val lastNote = remember(notesState) { channel.newestTimelineNote(account) } + val faceAuthors = remember(notesState) { channel.recentAuthorHexes(account, FACEPILE_MAX) } + val unread by + remember(groupId) { relayGroupChannelUnreadCountFlow(account, groupId) } + .collectAsStateWithLifecycle(0) + val hasUnread = unread > 0 val content = @Composable { - BuzzImportRowContent(name, groupId.id, memberCount, isAdded, hasUnread, isStarred, onToggleStar, onAdd) + BuzzImportRowContent( + name = name, + seed = groupId.id, + isPrivate = isPrivate, + memberCount = memberCount, + lastNote = lastNote, + faceAuthors = faceAuthors, + unread = unread, + hasUnread = hasUnread, + isAdded = isAdded, + isStarred = isStarred, + onToggleStar = onToggleStar, + onAdd = onAdd, + accountViewModel = accountViewModel, + ) } if (onOpen != null) { Card(onClick = onOpen, modifier = Modifier.fillMaxWidth()) { content() } @@ -100,83 +161,172 @@ fun BuzzImportRow( private fun BuzzImportRowContent( name: String, seed: String, + isPrivate: Boolean, memberCount: Int, - isAdded: Boolean, + lastNote: Note?, + faceAuthors: List, + unread: Int, hasUnread: Boolean, + isAdded: Boolean, isStarred: Boolean, onToggleStar: (() -> Unit)?, onAdd: () -> Unit, + accountViewModel: AccountViewModel, ) { Row( - modifier = Modifier.padding(start = 12.dp, top = 8.dp, bottom = 8.dp, end = 4.dp), + modifier = Modifier.padding(start = 12.dp, top = 10.dp, bottom = 10.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { - Box { - BuzzImportAvatar(name = name, seed = seed) - if (hasUnread) { - Box( - Modifier - .align(Alignment.TopEnd) - .size(11.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surface) - .padding(2.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary), - ) - } - } + BuzzImportAvatar(name = name, seed = seed) Spacer(Modifier.width(12.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = name, - style = MaterialTheme.typography.titleSmall, - fontWeight = if (hasUnread) FontWeight.Bold else FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + // Line 1: a lock (private), the channel name, a pin marker (starred), and the recent- + // posters facepile pushed to the right. + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + if (isPrivate) { + Icon( + symbol = MaterialSymbols.Lock, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(14.dp), + ) + } + Text( + text = name, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.titleSmall, + fontWeight = if (hasUnread) FontWeight.Bold else FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (isStarred) { + Icon( + symbol = MaterialSymbols.PushPin, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(14.dp), + ) + } + ConcordAuthorFacepile(faceAuthors, accountViewModel) + } + // Line 2: the last-message preview, then the time + unread badge. + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Box(Modifier.weight(1f)) { + BuzzChannelPreviewLine(lastNote, memberCount, accountViewModel) + } + lastNote?.createdAt()?.let { ts -> + Text( + timeAgo(ts, LocalContext.current, prefix = ""), + style = MaterialTheme.typography.labelSmall, + color = if (hasUnread) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + ConcordUnreadBadge(unread) + } + } + BuzzChannelRowMenu( + isAdded = isAdded, + onAdd = onAdd, + isStarred = isStarred, + onToggleStar = onToggleStar, + ) + } +} + +/** + * The line under a channel name: the last message's author + a snippet ("author: hello"), the Buzz + * activity summary for a system/diff/job row, or — before anything has folded in — the member count + * (or a muted "No messages yet"). Author names resolve reactively (hex → profile name). + */ +@Composable +private fun BuzzChannelPreviewLine( + lastNote: Note?, + memberCount: Int, + accountViewModel: AccountViewModel, +) { + val event = lastNote?.event + val author = lastNote?.author + // A Buzz timeline row (system line, huddle/job activity, diff) carries JSON/diff in its content, + // so show its human-readable summary — the same text the in-chat row renders — rather than + // "author: {json}". A plain chat message falls through to the usual "author: message" framing. + val summary = remember(event) { event?.let { buzzTimelinePreviewSummary(it) } } + val preview: String = + when { + summary != null -> summary + event != null && author != null -> { + val authorName by observeUserName(author, accountViewModel) + val body = event.content.take(80) + if (body.isBlank()) authorName else "$authorName: $body" + } + event != null -> event.content.take(80) + memberCount > 0 -> pluralStringResource(R.plurals.relay_group_member_count, memberCount, memberCount) + else -> stringRes(R.string.relay_group_no_messages_yet) + } + Text( + preview, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +/** + * The per-channel overflow (3-dot) menu: Pin/Unpin and Add-to-my-list. Moved off the row itself so a + * channel card reads as a clean Concord-style row, with its actions one tap behind the kebab. + */ +@Composable +private fun BuzzChannelRowMenu( + isAdded: Boolean, + onAdd: () -> Unit, + isStarred: Boolean, + onToggleStar: (() -> Unit)?, +) { + var expanded by remember { mutableStateOf(false) } + Box { + IconButton(onClick = { expanded = true }) { + Icon( + symbol = MaterialSymbols.MoreVert, + contentDescription = stringRes(R.string.more_options), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), ) - if (memberCount > 0) { - Text( - text = "$memberCount", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } } - if (onToggleStar != null) { - IconButton(onClick = onToggleStar) { - Icon( - symbol = MaterialSymbols.PushPin, - contentDescription = stringRes(if (isStarred) R.string.buzz_unpin else R.string.buzz_pin), - tint = if (isStarred) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + if (onToggleStar != null) { + DropdownMenuItem( + leadingIcon = { + Icon( + symbol = MaterialSymbols.PushPin, + contentDescription = null, + tint = if (isStarred) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + }, + text = { Text(stringRes(if (isStarred) R.string.buzz_unpin else R.string.buzz_pin)) }, + onClick = { + expanded = false + onToggleStar() + }, ) } - } - if (isAdded) { - // Match the OutlinedButton's trailing content padding so the label doesn't jam against - // the row edge (and doesn't jump horizontally) when Add flips to Added. - Row( - modifier = Modifier.padding(end = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - symbol = MaterialSymbols.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.width(4.dp)) - Text( - text = stringRes(R.string.buzz_import_added), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - ) - } - } else { - OutlinedButton(onClick = onAdd) { - Text(stringRes(R.string.buzz_import_add)) - } + DropdownMenuItem( + leadingIcon = { + Icon( + symbol = if (isAdded) MaterialSymbols.Check else MaterialSymbols.Add, + contentDescription = null, + tint = if (isAdded) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + }, + text = { Text(stringRes(if (isAdded) R.string.buzz_import_added else R.string.buzz_import_add)) }, + enabled = !isAdded, + onClick = { + expanded = false + onAdd() + }, + ) } } } @@ -198,7 +348,7 @@ private fun BuzzImportAvatar( ?.toString() ?: "#" } Box( - modifier = Modifier.size(40.dp).clip(CircleShape).background(color), + modifier = Modifier.size(44.dp).clip(CircleShape).background(color), contentAlignment = Alignment.Center, ) { Text(text = initial, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = Color.White) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzWorkspaceOverflowMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzWorkspaceOverflowMenu.kt index 02772811db..eabc05d9bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzWorkspaceOverflowMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzWorkspaceOverflowMenu.kt @@ -54,17 +54,24 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch /** - * The Buzz workspace top-bar overflow (3-dot) menu. Holds the two workspace-owner actions that used - * to sit inline above the channel list: "Add people to this workspace" (kind-9030, delegated to the - * screen's [onAddPeople] dialog) and "Create invite link" (mints via the relay's `/api/invites` - * endpoint — see [BuzzInviteMinter]). Any member sees both, but the relay only serves owners/admins, - * so a rejection surfaces as the error dialog. + * The Buzz workspace top-bar overflow (3-dot) menu. Holds the community-wide actions that used to sit + * inline in the channel list: + * - "Add all channels" ([onAddAll], shown only when some channels aren't in the user's list yet) — + * appends every discovered channel to the kind-10009 list at once; + * - "Agent Console" ([onOpenAgentConsole]) — the owner's per-community fleet console; + * - "Add people to this workspace" (kind-9030, delegated to the screen's [onAddPeople] dialog); + * - "Create invite link" (mints via the relay's `/api/invites` endpoint — see [BuzzInviteMinter]). + * + * Any member sees all of them, but the relay only serves the owner/admin ones, so a rejection + * surfaces as the error dialog. */ @Composable fun BuzzWorkspaceOverflowMenu( relay: NormalizedRelayUrl, accountViewModel: AccountViewModel, onAddPeople: () -> Unit, + onOpenAgentConsole: () -> Unit, + onAddAll: (() -> Unit)? = null, ) { var menuOpen by remember { mutableStateOf(false) } var minting by remember { mutableStateOf(false) } @@ -83,6 +90,28 @@ fun BuzzWorkspaceOverflowMenu( } DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + if (onAddAll != null) { + DropdownMenuItem( + leadingIcon = { + Icon(symbol = MaterialSymbols.DoneAll, contentDescription = null, modifier = Modifier.size(20.dp)) + }, + text = { Text(stringRes(R.string.buzz_import_add_all)) }, + onClick = { + menuOpen = false + onAddAll() + }, + ) + } + DropdownMenuItem( + leadingIcon = { + Icon(symbol = MaterialSymbols.AutoAwesome, contentDescription = null, modifier = Modifier.size(20.dp)) + }, + text = { Text(stringRes(R.string.buzz_console_card_title)) }, + onClick = { + menuOpen = false + onOpenAgentConsole() + }, + ) DropdownMenuItem( leadingIcon = { Icon(symbol = MaterialSymbols.PersonAdd, contentDescription = null, modifier = Modifier.size(20.dp)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index c26cbd3390..ffc0a54bb4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -65,6 +65,7 @@ import com.vitorpamplona.amethyst.Amethyst 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.BuzzChannelStars import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect @@ -90,6 +91,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzImportRow import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzRelayImportViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzWorkspaceOverflowMenu import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.PresenceDot +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.buzzTimelinePreviewSummary import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsOnRelaySubscription import com.vitorpamplona.amethyst.ui.stringRes @@ -271,7 +273,7 @@ fun RelayGroupChannelListScreen( text = relay.displayUrl(), fontWeight = FontWeight.Bold, maxLines = 1, - overflow = TextOverflow.Ellipsis, + overflow = TextOverflow.MiddleEllipsis, ) }, showBackButton = canPop, @@ -282,6 +284,10 @@ fun RelayGroupChannelListScreen( relay = relay, accountViewModel = accountViewModel, onAddPeople = { showAddPeople = true }, + onOpenAgentConsole = { nav.nav(Route.AgentConsole(relay.url)) }, + // Only offer "Add all" when some discovered channel isn't in the user's + // list yet — hidden once everything is already added. + onAddAll = if (buzzChatChannels.any { it.id !in buzzAdded }) ({ buzzVm.addAll() }) else null, ) } }, @@ -360,7 +366,7 @@ fun RelayGroupChannelListScreen( } } - // -- CHANNELS -- + // -- CHANNELS -- (Add-all now lives in the community's top-bar overflow menu) if (buzzChatChannels.isNotEmpty()) { val channelsCollapsed = "channels" in collapsedSections item(key = "sec-channels") { @@ -368,13 +374,7 @@ fun RelayGroupChannelListScreen( title = stringRes(R.string.relay_group_section_channels), collapsed = channelsCollapsed, onToggle = { toggleSection("channels") }, - ) { - if (buzzChatChannels.any { it.id !in buzzAdded }) { - FilledTonalButton(onClick = { buzzVm.addAll() }) { - Text(stringRes(R.string.buzz_import_add_all)) - } - } - } + ) } if (!channelsCollapsed) { items(buzzChatChannels, key = { "chat-${it.id}" }) { groupId -> @@ -456,11 +456,7 @@ fun RelayGroupChannelListScreen( } } } - - // -- AGENT CONSOLE -- (owner's per-community fleet console, footer) - item(key = "sec-console") { - AgentConsoleFooter { nav.nav(Route.AgentConsole(relay.url)) } - } + // Agent Console now lives in the community's top-bar overflow menu, not a footer card. } else { // Vanilla NIP-29 relay: flat channel directory (no forums/DMs/console). itemsIndexed(channels, key = { _, channel -> channel.groupId.id }) { index, channel -> @@ -565,8 +561,9 @@ private fun RelayGroupSectionHeader( /** * One inline Direct-Message conversation row inside the community view: the counterpart's avatar + - * name (or a "+N" cluster label for a group DM) and a compact last-activity time. Tapping opens the - * DM as its relay-group chat. + * name (or a "+N" cluster label for a group DM), a preview of the last message, and a compact + * last-activity time. The channel's recent content is warmed while the row is visible so the preview + * fills in ahead of a tap. Tapping opens the DM as its relay-group chat. */ @Composable private fun BuzzDmInlineRow( @@ -582,6 +579,23 @@ private fun BuzzDmInlineRow( val leadName by observeUserName(leadUser, accountViewModel) val label = if (others.size > 1) "$leadName +${others.size - 1}" else leadName + val account = accountViewModel.account + val groupId = remember(row.channelId, row.relayUrl) { GroupId(row.channelId, row.relayUrl) } + val channel = remember(groupId) { LocalCache.getOrCreateRelayGroupChannel(groupId) } + // Warm a screen's worth of recent DM messages while visible, so the preview isn't blank until opened. + RelayGroupCardWarmupSubscription( + channel, + accountViewModel.dataSources().relayGroupCardWarmup, + accountViewModel, + contentOnly = true, + contentLimit = CHANNEL_LIST_WARMUP_LIMIT, + ) + val notesState by channel + .flow() + .notes.stateFlow + .collectAsStateWithLifecycle() + val lastNote = remember(notesState) { channel.newestTimelineNote(account) } + Row( modifier = Modifier @@ -593,19 +607,21 @@ private fun BuzzDmInlineRow( ) { // Only show a presence dot for a 1:1 DM — a cluster avatar can't carry one peer's status. Box { - UserPicture(leadHex, 40.dp, accountViewModel = accountViewModel, nav = nav) + UserPicture(leadHex, 44.dp, accountViewModel = accountViewModel, nav = nav) if (others.size == 1) { PresenceDot(leadHex, Modifier.align(Alignment.BottomEnd), ringColor = MaterialTheme.colorScheme.surface) } } - Text( - text = label, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + BuzzDmPreviewLine(lastNote, accountViewModel) + } if (row.lastActivity > 0) { Text( text = timeAgoShort(row.lastActivity, stringRes(R.string.now)), @@ -616,6 +632,34 @@ private fun BuzzDmInlineRow( } } +/** The last-message preview under a DM row: "author: snippet", or nothing before any message loads. */ +@Composable +private fun BuzzDmPreviewLine( + lastNote: Note?, + accountViewModel: AccountViewModel, +) { + val event = lastNote?.event ?: return + val author = lastNote.author + val summary = remember(event) { buzzTimelinePreviewSummary(event) } + val preview: String = + if (summary != null) { + summary + } else if (author != null) { + val authorName by observeUserName(author, accountViewModel) + val body = event.content.take(80) + if (body.isBlank()) authorName else "$authorName: $body" + } else { + event.content.take(80) + } + Text( + preview, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + /** The "See all N conversations" row that opens the full per-community DM inbox. */ @Composable private fun SeeAllRow( @@ -632,28 +676,6 @@ private fun SeeAllRow( } } -/** The owner's per-community Agent Console entry, pinned as the footer of a Buzz community view. */ -@Composable -private fun AgentConsoleFooter(onClick: () -> Unit) { - Card(onClick = onClick, modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 12.dp)) { - Row(Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically) { - Icon(symbol = MaterialSymbols.AutoAwesome, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(22.dp)) - Spacer(Modifier.size(12.dp)) - Column(Modifier.weight(1f)) { - Text(stringRes(R.string.buzz_console_card_title), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) - Text( - stringRes(R.string.buzz_console_card_subtitle), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - Icon(symbol = MaterialSymbols.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(20.dp)) - } - } -} - @Composable private fun RelayGroupChannelRow( channel: RelayGroupChannel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupUnread.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupUnread.kt index 75659f454e..cab481765a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupUnread.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupUnread.kt @@ -20,9 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup +import com.vitorpamplona.amethyst.commons.model.Channel +import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip29RelayGroups.GroupId @@ -82,7 +85,78 @@ fun relayGroupServerHasUnreadFlow( private fun RelayGroupChannel.hasChatNewerThan( account: Account, sinceSecs: Long, -): Boolean = +): Boolean = newMessagesSince(account, sinceSecs) > 0 + +/** + * True for a note the group's chat *timeline* actually renders — an acceptable (not muted/blocked) + * message whose event is real group-chat content ([isGroupChatContent], which also folds in the Buzz + * dialect's stream/system/activity rows). Every list-row surface that summarizes a channel — the + * unread badge ([relayGroupChannelUnreadCountFlow]), the last-message preview + time + * ([newestTimelineNote]) and the recent-posters facepile ([recentAuthorHexes]) — reuses this so none + * of them can disagree with the open channel's feed or with the unread dot. + */ +private fun isRelayGroupTimelineMessage( + note: Note, + account: Account, +): Boolean = note.event?.isGroupChatContent() == true && account.isAcceptable(note) + +/** + * The newest timeline message in this group (see [isRelayGroupTimelineMessage]), or null if none — + * the note a list row shows as the channel's "last message". Skips reactions/metadata and hidden + * authors so the preview matches what the channel feed renders and the unread badge counts. + */ +fun RelayGroupChannel.newestTimelineNote(account: Account): Note? = + notes + .filter { _, note -> isRelayGroupTimelineMessage(note, account) } + .minWithOrNull(Channel.DefaultFeedOrder) + +/** + * The pubkeys of the [limit] most-recent distinct posters in this group, newest first — the facepile + * shown on a channel row. One O(notes) pass keeps each author's latest post time, so a chatty author + * counts once (at their newest message) rather than crowding out quieter voices. + */ +fun RelayGroupChannel.recentAuthorHexes( + account: Account, + limit: Int, +): List { + val latestByAuthor = HashMap() + for (note in notes.values()) { + if (!isRelayGroupTimelineMessage(note, account)) continue + val author = note.author?.pubkeyHex ?: continue + val at = note.createdAt() ?: continue + val prev = latestByAuthor[author] + if (prev == null || at > prev) latestByAuthor[author] = at + } + return latestByAuthor.entries + .sortedByDescending { it.value } + .take(limit) + .map { it.key } +} + +/** The number of this group's timeline messages created strictly after [sinceSecs] (0 if none). */ +private fun RelayGroupChannel.newMessagesSince( + account: Account, + sinceSecs: Long, +): Int = notes.count { _, note -> - (note.createdAt() ?: 0L) > sinceSecs && account.isAcceptable(note) && note.event?.isGroupChatContent() == true - } > 0 + (note.createdAt() ?: 0L) > sinceSecs && isRelayGroupTimelineMessage(note, account) + } + +/** + * The count of chat messages in [groupId] newer than the timestamp this account last read it — the + * number the channel-row unread badge shows. Reactive: it recombines both when a fresh message folds + * in (the channel's notes flow ticks) and when the user opens the group (which advances the last-read + * marker), so opening a channel clears its badge. Mirrors [relayGroupChannelHasUnreadFlow]. + */ +fun relayGroupChannelUnreadCountFlow( + account: Account, + groupId: GroupId, +): Flow { + val channel = LocalCache.getOrCreateRelayGroupChannel(groupId) + return combine( + account.loadLastReadFlow(relayGroupChannelLastReadRoute(groupId)), + channel.flow().notes.stateFlow, + ) { lastRead, _ -> + channel.newMessagesSince(account, lastRead) + } +} From 8a284278fa83758bca594fb8b10470e35c447724 Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:29:10 +0000 Subject: [PATCH 02/12] chore: sync Crowdin translations and seed translator npub placeholders --- .../src/main/res/values-ar-rSA/strings.xml | 5 + .../src/main/res/values-bn-rBD/strings.xml | 5 + amethyst/src/main/res/values-cs/strings.xml | 5 + .../src/main/res/values-de-rDE/strings.xml | 5 + .../src/main/res/values-el-rGR/strings.xml | 5 + .../src/main/res/values-en-rGB/strings.xml | 5 + .../src/main/res/values-eo-rUY/strings.xml | 5 + .../src/main/res/values-es-rES/strings.xml | 5 + .../src/main/res/values-es-rMX/strings.xml | 5 + .../src/main/res/values-es-rUS/strings.xml | 5 + .../src/main/res/values-fa-rIR/strings.xml | 5 + .../src/main/res/values-fi-rFI/strings.xml | 5 + .../src/main/res/values-fr-rCA/strings.xml | 5 + .../src/main/res/values-fr-rFR/strings.xml | 5 + .../src/main/res/values-hi-rIN/strings.xml | 5 + .../src/main/res/values-hu-rHU/strings.xml | 5 + .../src/main/res/values-in-rID/strings.xml | 5 + .../src/main/res/values-it-rIT/strings.xml | 5 + .../src/main/res/values-ja-rJP/strings.xml | 5 + .../src/main/res/values-ko-rKR/strings.xml | 5 + .../src/main/res/values-lv-rLV/strings.xml | 5 + .../src/main/res/values-nl-rNL/strings.xml | 5 + .../src/main/res/values-pl-rPL/strings.xml | 5 + .../src/main/res/values-pt-rBR/strings.xml | 5 + .../src/main/res/values-pt-rPT/strings.xml | 5 + .../src/main/res/values-ru-rRU/strings.xml | 5 + .../src/main/res/values-ru-rUA/strings.xml | 5 + .../src/main/res/values-sl-rSI/strings.xml | 129 ++++++++++++++++++ .../src/main/res/values-sr-rSP/strings.xml | 5 + .../src/main/res/values-sv-rSE/strings.xml | 5 + amethyst/src/main/res/values-sw/strings.xml | 5 + .../src/main/res/values-ta-rIN/strings.xml | 5 + .../src/main/res/values-th-rTH/strings.xml | 5 + .../src/main/res/values-tr-rTR/strings.xml | 5 + .../src/main/res/values-uk-rUA/strings.xml | 5 + .../src/main/res/values-uz-rUZ/strings.xml | 5 + .../src/main/res/values-vi-rVN/strings.xml | 5 + .../src/main/res/values-zh-rCN/strings.xml | 5 + .../src/main/res/values-zh-rHK/strings.xml | 5 + .../src/main/res/values-zh-rSG/strings.xml | 5 + .../src/main/res/values-zh-rTW/strings.xml | 5 + 41 files changed, 329 insertions(+) diff --git a/amethyst/src/main/res/values-ar-rSA/strings.xml b/amethyst/src/main/res/values-ar-rSA/strings.xml index 9b0533fbec..530173dcf4 100644 --- a/amethyst/src/main/res/values-ar-rSA/strings.xml +++ b/amethyst/src/main/res/values-ar-rSA/strings.xml @@ -2260,6 +2260,11 @@ إزالة الموقع إضافة تحذير محتوى إزالة تحذير المحتوى + + + + + إضافة تاريخ انتهاء الصلاحية إزالة تاريخ انتهاء الصلاحية تاريخ انتهاء الصلاحية diff --git a/amethyst/src/main/res/values-bn-rBD/strings.xml b/amethyst/src/main/res/values-bn-rBD/strings.xml index 94b61f42e3..775cc6b7bf 100644 --- a/amethyst/src/main/res/values-bn-rBD/strings.xml +++ b/amethyst/src/main/res/values-bn-rBD/strings.xml @@ -2147,6 +2147,11 @@ অবস্থান সরান কন্টেন্ট সতর্কতা যোগ করুন কন্টেন্ট সতর্কতা সরান + + + + + মেয়াদ শেষের তারিখ যোগ করুন মেয়াদ শেষের তারিখ সরান মেয়াদ শেষের তারিখ diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index de567ca9b0..54c5b8c0c4 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -3259,6 +3259,11 @@ %1$s aktualizoval(a) profil kanálu (upraveno) (rozdíl zkrácen) + + + + + Plátno V tomto pracovním prostoru zatím nebylo sdíleno žádné plátno. Upravit plátno diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 3d681f691f..cb5b6a6530 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -3142,6 +3142,11 @@ %1$s hat das Kanalprofil aktualisiert (bearbeitet) (Diff gekürzt) + + + + + In diesem Workspace wurde noch kein Canvas geteilt. Canvas bearbeiten Canvas speichern diff --git a/amethyst/src/main/res/values-el-rGR/strings.xml b/amethyst/src/main/res/values-el-rGR/strings.xml index 155f1b1faf..6090198ab2 100644 --- a/amethyst/src/main/res/values-el-rGR/strings.xml +++ b/amethyst/src/main/res/values-el-rGR/strings.xml @@ -2127,6 +2127,11 @@ Αφαίρεση Τοποθεσίας Προσθήκη προειδοποίησης περιεχομένου Αφαίρεση προειδοποίησης περιεχομένου + + + + + Προσθήκη ημερομηνίας λήξης Αφαίρεση ημερομηνίας λήξης Ημερομηνία Λήξης diff --git a/amethyst/src/main/res/values-en-rGB/strings.xml b/amethyst/src/main/res/values-en-rGB/strings.xml index 12112e81be..779ada9845 100644 --- a/amethyst/src/main/res/values-en-rGB/strings.xml +++ b/amethyst/src/main/res/values-en-rGB/strings.xml @@ -63,6 +63,11 @@ Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. --> + + + + + diff --git a/amethyst/src/main/res/values-eo-rUY/strings.xml b/amethyst/src/main/res/values-eo-rUY/strings.xml index f977e5c462..bfca2668a4 100644 --- a/amethyst/src/main/res/values-eo-rUY/strings.xml +++ b/amethyst/src/main/res/values-eo-rUY/strings.xml @@ -2151,6 +2151,11 @@ Forigi Lokon Aldoni enhavavertadon Forigi enhavavertadon + + + + + Aldoni limdaton Forigi limdaton Limdato diff --git a/amethyst/src/main/res/values-es-rES/strings.xml b/amethyst/src/main/res/values-es-rES/strings.xml index a4a344b100..88ea227fa4 100644 --- a/amethyst/src/main/res/values-es-rES/strings.xml +++ b/amethyst/src/main/res/values-es-rES/strings.xml @@ -2200,6 +2200,11 @@ Eliminar ubicación Añadir advertencia de contenido Eliminar advertencia de contenido + + + + + Añadir fecha de expiración Eliminar fecha de expiración Fecha de expiración diff --git a/amethyst/src/main/res/values-es-rMX/strings.xml b/amethyst/src/main/res/values-es-rMX/strings.xml index 8915faddbf..492d9117b3 100644 --- a/amethyst/src/main/res/values-es-rMX/strings.xml +++ b/amethyst/src/main/res/values-es-rMX/strings.xml @@ -2191,6 +2191,11 @@ Eliminar ubicación Agregar advertencia de contenido Eliminar advertencia de contenido + + + + + Añadir fecha de expiración Eliminar fecha de expiración Fecha de expiración diff --git a/amethyst/src/main/res/values-es-rUS/strings.xml b/amethyst/src/main/res/values-es-rUS/strings.xml index a330cca444..3924163794 100644 --- a/amethyst/src/main/res/values-es-rUS/strings.xml +++ b/amethyst/src/main/res/values-es-rUS/strings.xml @@ -2191,6 +2191,11 @@ Eliminar ubicación Agregar advertencia de contenido Eliminar advertencia de contenido + + + + + Añadir fecha de expiración Eliminar fecha de expiración Fecha de expiración diff --git a/amethyst/src/main/res/values-fa-rIR/strings.xml b/amethyst/src/main/res/values-fa-rIR/strings.xml index 9151fc8042..f84cd6b65b 100644 --- a/amethyst/src/main/res/values-fa-rIR/strings.xml +++ b/amethyst/src/main/res/values-fa-rIR/strings.xml @@ -2161,6 +2161,11 @@ حذف موقعیت مکانی افزودن هشدار محتوا حذف هشدار محتوا + + + + + افزودن تاریخ انقضا حذف تاریخ انقضا تاریخ انقضا diff --git a/amethyst/src/main/res/values-fi-rFI/strings.xml b/amethyst/src/main/res/values-fi-rFI/strings.xml index 21d147a43d..94b9c4bee0 100644 --- a/amethyst/src/main/res/values-fi-rFI/strings.xml +++ b/amethyst/src/main/res/values-fi-rFI/strings.xml @@ -2133,6 +2133,11 @@ Poista sijainti Lisää sisältövaroitus Poista sisältövaroitus + + + + + Lisää vanhentumispäivä Poista vanhentumispäivä Vanhentumispäivä diff --git a/amethyst/src/main/res/values-fr-rCA/strings.xml b/amethyst/src/main/res/values-fr-rCA/strings.xml index 9562f6c5f6..d793f0c06c 100644 --- a/amethyst/src/main/res/values-fr-rCA/strings.xml +++ b/amethyst/src/main/res/values-fr-rCA/strings.xml @@ -2074,6 +2074,11 @@ Retirer l\'Emplacement Ajouter un avertissement de contenu Retirer l\'avertissement de contenu + + + + + Ajouter une date d\'expiration Supprimer la date d\'expiration Date d\'expiration diff --git a/amethyst/src/main/res/values-fr-rFR/strings.xml b/amethyst/src/main/res/values-fr-rFR/strings.xml index 87f2e6d401..4bd2d0a187 100644 --- a/amethyst/src/main/res/values-fr-rFR/strings.xml +++ b/amethyst/src/main/res/values-fr-rFR/strings.xml @@ -2374,6 +2374,11 @@ Retirer l\'Emplacement Ajouter un avertissement de contenu Retirer l\'avertissement de contenu + + + + + Ajouter une date d\'expiration Supprimer la date d\'expiration Date d\'expiration diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index be0eb38a2f..7aba094975 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -3142,6 +3142,11 @@ %1$s ने प्रणाली परिचय का अद्यतन किया (सम्पादित) (अन्तर ह्रस्वकृत) + + + + + चित्रपट कोई चित्रपट बाँटा नहीं गया इस कार्यशाला में अब तक। चित्रपट सम्पादन diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index 33fa619c82..de4fd086e5 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -2738,6 +2738,11 @@ %1$s létrehozta a(z) %2$s nevű csatornát %1$s létrehozta a csatornát %1$s frissítette a csatorna profilját + + + + + Üzenet kézbesítése Bezárás Várakozás egy átjátszóra az üzenet elfogadásához diff --git a/amethyst/src/main/res/values-in-rID/strings.xml b/amethyst/src/main/res/values-in-rID/strings.xml index cd42e135d2..71348f99bd 100644 --- a/amethyst/src/main/res/values-in-rID/strings.xml +++ b/amethyst/src/main/res/values-in-rID/strings.xml @@ -2087,6 +2087,11 @@ Seharusnya %3$s Hapus Lokasi Tambah peringatan konten Hapus peringatan konten + + + + + Tambah tanggal kedaluwarsa Hapus tanggal kedaluwarsa Tanggal Kedaluwarsa diff --git a/amethyst/src/main/res/values-it-rIT/strings.xml b/amethyst/src/main/res/values-it-rIT/strings.xml index 2bbf5ab875..fe31ea0bd8 100644 --- a/amethyst/src/main/res/values-it-rIT/strings.xml +++ b/amethyst/src/main/res/values-it-rIT/strings.xml @@ -2104,6 +2104,11 @@ Rimuovi posizione Aggiungi avviso contenuto Rimuovi avviso contenuto + + + + + Aggiungi data di scadenza Rimuovi data di scadenza Data di scadenza diff --git a/amethyst/src/main/res/values-ja-rJP/strings.xml b/amethyst/src/main/res/values-ja-rJP/strings.xml index 73dc2e9837..2afd2152a7 100644 --- a/amethyst/src/main/res/values-ja-rJP/strings.xml +++ b/amethyst/src/main/res/values-ja-rJP/strings.xml @@ -2122,6 +2122,11 @@ 位置情報を削除 コンテンツ警告を追加 コンテンツ警告を削除 + + + + + 有効期限を追加 有効期限を削除 有効期限 diff --git a/amethyst/src/main/res/values-ko-rKR/strings.xml b/amethyst/src/main/res/values-ko-rKR/strings.xml index 489b31af90..723c1b2adc 100644 --- a/amethyst/src/main/res/values-ko-rKR/strings.xml +++ b/amethyst/src/main/res/values-ko-rKR/strings.xml @@ -2112,6 +2112,11 @@ 위치 제거 콘텐츠 경고 추가 콘텐츠 경고 제거 + + + + + 만료 날짜 추가 만료 날짜 제거 만료 날짜 diff --git a/amethyst/src/main/res/values-lv-rLV/strings.xml b/amethyst/src/main/res/values-lv-rLV/strings.xml index e41a99c870..3f84b83670 100644 --- a/amethyst/src/main/res/values-lv-rLV/strings.xml +++ b/amethyst/src/main/res/values-lv-rLV/strings.xml @@ -2158,6 +2158,11 @@ Noņemt atrašanās vietu Pievienot satura brīdinājumu Noņemt satura brīdinājumu + + + + + Pievienot derīguma termiņu Noņemt derīguma termiņu Derīguma termiņš diff --git a/amethyst/src/main/res/values-nl-rNL/strings.xml b/amethyst/src/main/res/values-nl-rNL/strings.xml index 1c346bcabf..7d30708ed2 100644 --- a/amethyst/src/main/res/values-nl-rNL/strings.xml +++ b/amethyst/src/main/res/values-nl-rNL/strings.xml @@ -2400,6 +2400,11 @@ Locatie verwijderen Inhoudswaarschuwing toevoegen Inhoudswaarschuwing verwijderen + + + + + Vervaldatum toevoegen Vervaldatum verwijderen Vervaldatum diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 672de72032..e680a0f0f2 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -3256,6 +3256,11 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest %1$s zaktualizował profil kanału (edytowane) (diff skrócony) + + + + + Płótno Żadne płótno nie zostało jeszcze udostępnione w tym projekcie. Edytuj płótno diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 8f93db05ac..ded385631b 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -3142,6 +3142,11 @@ %1$s atualizou o perfil do canal (editado) (diff truncado) + + + + + Nenhum canvas foi compartilhado neste espaço de trabalho ainda. Editar canvas Salvar canvas diff --git a/amethyst/src/main/res/values-pt-rPT/strings.xml b/amethyst/src/main/res/values-pt-rPT/strings.xml index 4acc42dce6..0e06f7e64b 100644 --- a/amethyst/src/main/res/values-pt-rPT/strings.xml +++ b/amethyst/src/main/res/values-pt-rPT/strings.xml @@ -2118,6 +2118,11 @@ Remover Localização Adicionar aviso de conteúdo Remover aviso de conteúdo + + + + + Adicionar data de expiração Remover data de expiração Data de expiração diff --git a/amethyst/src/main/res/values-ru-rRU/strings.xml b/amethyst/src/main/res/values-ru-rRU/strings.xml index e406614543..a3351ecb50 100644 --- a/amethyst/src/main/res/values-ru-rRU/strings.xml +++ b/amethyst/src/main/res/values-ru-rRU/strings.xml @@ -2198,6 +2198,11 @@ Удалить местоположение Добавить предупреждение о контенте Убрать предупреждение о контенте + + + + + Добавить дату истечения Убрать дату истечения Дата истечения diff --git a/amethyst/src/main/res/values-ru-rUA/strings.xml b/amethyst/src/main/res/values-ru-rUA/strings.xml index 943e6912f5..c605e15916 100644 --- a/amethyst/src/main/res/values-ru-rUA/strings.xml +++ b/amethyst/src/main/res/values-ru-rUA/strings.xml @@ -2182,6 +2182,11 @@ Удалить местоположение Добавить предупреждение о контенте Убрать предупреждение о контенте + + + + + Добавить дату истечения Убрать дату истечения Дата истечения diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index 6a388b3ae1..43c9768bc5 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -2206,6 +2206,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Optimiziran Polno + Preprosto + Hitro Klasično Moderno Sistemska @@ -2354,6 +2356,39 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Šifriran skupinski klepet ezano na napravo — ne bo prikazano, če se prijavite drugje Concord skupnost + Skupnosti, razdeljene po kanalih + Delovni prostori + Velike skupnosti, organizirane po kanalih ali potekih dela. + Ustvari skupnost + Prilagojeno za ogromne skupnosti + Razdeljeno po kanalih ali potekih dela + Zahteva več nastavitev + Javni klepet + Javna sporočila na releju + Brez moderiranja + Odprte javne sobe, kjer lahko objavlja vsak. + Ustvari nov javen klepet + Pridruži se lahko kdorkoli + Preprosto in zlahka dosegljivo + Samo javno + Brez moderacije + Skupina releja + Na relejih — javno ali zasebno + Moderirano + Skupine, ki jih moderira njihov gostiteljski rele + Brskanje po skupinah na relejih + Moderira gostiteljski rele + Javno ali zasebno + Vezano na tisti posamezni rele + Minljivi klepet + Kdor koli je trenutno na spletu + Trenutno v živo + Klepet v živo s tistimi, ki so pravkar na spletu. + Zaženi klepet + Pogovor s trenutno prisotnimi + Nič ni shranjeno + Brez zgodovine + Omejeno na rele Skupine relejev Vrstično Po releju @@ -2363,7 +2398,24 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Skupine relejev Skupine Sporočila + Naloži pogovore + Izberite, kateri klepeti naj bodo vidni v prejetih sporočilih. Z izklopom se klepet skrije in ne prenese več z vaših relejev. + Zasebna sporočila + E2EE, gift-wrapped zasebna sporočila (NIP-17) + Zastarela direktna sporočila + Starejša, manj zasebna šifrirana direktna sporočila (NIP-04) + Javni kanali + Odprte, javne klepetalnice, ki jih lahko prebere in se jim pridruži vsak (NIP-28) + Skupine relejev + Skupinski klepeti z moderiranjem in seznami članov (NIP-29) + Šifrirane skupine + MLS- E2EE šifrirani skupinski klepeti (Marmot) + Concord skupnosti Šifrirane skupnosti z več tematskimi kanali (Concord). + Lokacijski klepeti + Javni klepet glede na vašo lokacijo (Geohash) + Minljivi klepeti + Hitri klepeti na releju, ki ne hranijo zgodovine sporočil Ustvari skupino Ta relej ne oglašuje podpore za skupine NIP-29. Skupina, ustvarjena tukaj, ne bo delovala – relej ne bo upravljal njenega imena, članov in sporočil. Izberite rele, ki podpira skupine na ravni releja. Ime supine @@ -2387,6 +2439,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Člani Dodeli vlogo skrbnika Dodeli vlogo moderatorja + Dodeli vlogo: %1$s Odvzemi vlogo Odstrani iz skupine Odstrani %1$s iz te skupine? Izgubili bodo dostop, dokler jih ponovno ne dodate ali povabite. @@ -2399,11 +2452,19 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Člani Uredi skupino Nizi objav + Pripni sporočilo + Odpni sporočilo + Pripeto + Pripeta sporočila Odpri skupino Na tem releju še ni skupin. + Ta rele ne sporoča podpore za skupine (NIP-29), zato tukaj skupin morda ni. + Skupine (NIP-29) morda niso podprte Pripravljam povabilo… Zasebno samo s povabilom + V ŽIVO + %1$d+ Vnesite veljaven URL releja (wss://…) Ime Opis @@ -2416,7 +2477,28 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem bitcoin, nostr, umetnost Lokacija (geohash) u0nd + Dodaj lokacijo + Pripnite svojo skupino na zemljevid, da jo lahko odkrijejo ljudje v bližini. + Spremeni lokacijo + Odstrani lokacijo + Ročno vnesite geohash + Izberi lokacijo + Premaknite zemljevid, vnesite lokacijo ali uporabite svojo trenutno legi. + Išči po mestu ali naslovu + Noben ustrezen kraj ni bil najden. + Uporabi mojo trenutno lokacijo + Velikost območja + Uporabi to lokacijo + Struktura + Dodajte v starševsko skupino za ustvarjanje hierarhije. + Starševska skupina + Krovna skupina + Izberite starševsko skupino + Išči skupine + Brez starševske skupine (krovna skupina) + Ta skupina je na najvišji ravni. + Tukaj trenutno ni nobenih drugih skupin. Dovoljenja Zasebno Samo člani lahko berejo sporočila. @@ -2430,6 +2512,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem S tem filtrom še ni najdenih nobenih skupin. Dodaj ta rele med priljubljene Še ni nizov objav. Začni jih z + gumbom. + Nalaganje starejšega niza objav… + Nalaganje starejšega niza objav… Nov niz objav Brez naslova Naslov @@ -2439,9 +2523,18 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Brskajte po skupinah, ki jih gosti rele. Prilepite naslov releja ali izberite enega spodaj. Naslov releja Brskaj + Povezava s tem relejem preko omrežja Tor ni mogoča. + %1$s se ne odziva prek omrežja Tor — gostitelj morda blokirajo izstopna vozlišča Tor. Želite vzpostaviti povezavo prek običajnega spleta (clearnet)? + Uporabi običajni splet (clearnet) Releji na katerih ste vi Popularni releji Ni sporočil + Dodano v: %1$s + %1$s vas je dodal v ta kanal na %2$s. Želite to prikazati med sporočili? + Nekdo + Prikaži + Prezri + Zapusti Pridružite se tej skupini, da boste lahko pošiljali sporočila. Ta skupina je zaprtega tipa – za objavljanje potrebujete povabilo. @@ -2468,6 +2561,12 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem %1$d uporabniki, ki jim sledite %1$d uporabnikov, ki jim sledite + + %1$d skupina + %1$d skupini + %1$d skupine + %1$d skupin + Zasebno Za Zadeva @@ -2540,6 +2639,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Aplikacije Blossom niso bile najdene. Za ogled te datoteke namestite lokalno aplikacijo Blossom. Skrite besede Skrij novo besedo ali stavek + Utišaj ključnik + Vklopi zvok za ključnik Utišaj nize objav Ni utišanih nizov objav Neznan niz objav · %1$s @@ -2550,6 +2651,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Ni bilo mogoče dvigniti Cashu žetona ni bilo mogoče unovčiti Kovnica je posredovala naslednje sporočilo o napaki: %1$s + Ta naslov strežnika Cashu kovnice ni varen. + Amethyst se ni povezal z izdajateljem tega žetona. %1$s Prejeli ste Cashu žeton V vašo denarnico je bilo poslanih %1$s satoshi-jev. (Provizija: %2$s sat) Na sistemu ni najdene združljive Cashu denarnice @@ -3081,8 +3184,23 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, Spodnja navigacijska vrstica Povlecite za spremembo vrstnega reda. Preklopite za dodajanje ali odstranjevanje elementov s spodnje vrstice. Če ni izbran noben element, je spodnja vrstica skrita. Dostopno + Vaša spodnja vrstica + Ni pripetih elementov. Spodnja vrstica bo skrita, dokler ne dodate vsaj enega. Razporedi + Prikaži možnosti + Ni priljubljenih. Dodaj jih z zvezdico v brskalniku. + Niste še član nobene skupine. + Povleci za razvrščanje · ✕ za izbris + Dodaj + Dodano + Odstrani Obnovi prevzete + Glavno + Klepeti in skupine + Ti + Viri vsebin + Aplikacije in Splet + Drugo Domači zavihki Izberite zavihke, ki se prikažejo na začetnem zaslonu. Če je aktiven le en zavihek, je vrstica z zavihki skrita. Vse @@ -3140,6 +3258,7 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, Statična slika Slika profila %1$s Rele %1$s + Sprejeto na relejih Razširi seznam relejev Možnosti zapiska Anketa @@ -3152,10 +3271,16 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, Odstrani lokacijo Dodaj opozorilo o vsebini Odstrani opozorilo o vsebini + %1$s je ustvaril/a kanal %2$s %1$s je ustvaril/-a kanal %1$s je posodobil/-a profil kanala (urejeno) (razlika je odrezana) + + + + + Delovna površina V tem delovnem prostoru še ni bila deljena nobena delovna površina. Uredi delovno površino @@ -3202,6 +3327,7 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, Odpiram… Odstrani Povabilo v delovni prostor + Pridruži se delovnemu prostoru Delovni prostor Vloga Delovni prostor se bo odprl v varnem brskalniku znotraj aplikacije, kjer si boste lahko ogledali pogoje uporabe in dokončali pridružitev. Pripet se boste z vašim ključem Amethyst — brez gesla. @@ -3217,6 +3343,7 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, Dodaj vse Dodano Ni najdenih kanalov + Videti je, da na tem releju še nisi član nobenega kanala. Najprej v brskalniku sprejmi vabilo v delovni prostor in poskusite znova. Kanali Forumi Delam… @@ -3572,6 +3699,7 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, Uporaba releja danes Danes v aplikaciji Količina podatkov na dan + Mobilni podatki (MB) Wi-Fi (MB) Aktivnost (7 dni) Mobilni podatki (v ozadju) @@ -3627,6 +3755,7 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, Denarnica in zapi Preverjanje naslova Predogled povezav + Registracija potisnih obvestil Drugo Še ni podatkov o uporabi Trenutni pomnilnik diff --git a/amethyst/src/main/res/values-sr-rSP/strings.xml b/amethyst/src/main/res/values-sr-rSP/strings.xml index e11eca6ef7..ca5dbac685 100644 --- a/amethyst/src/main/res/values-sr-rSP/strings.xml +++ b/amethyst/src/main/res/values-sr-rSP/strings.xml @@ -2149,6 +2149,11 @@ Ukloni lokaciju Dodaj upozorenje o sadržaju Ukloni upozorenje o sadržaju + + + + + Dodaj datum isteka Ukloni datum isteka Datum isteka diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index e48dd0b27b..99d8e4d475 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -3141,6 +3141,11 @@ %1$s uppdaterade kanalprofilen (redigerat) (diff avkortad) + + + + + Ingen canvas har delats i den här arbetsytan än. Redigera canvas Spara canvas diff --git a/amethyst/src/main/res/values-sw/strings.xml b/amethyst/src/main/res/values-sw/strings.xml index 490e5b9f24..8c571f9fd3 100644 --- a/amethyst/src/main/res/values-sw/strings.xml +++ b/amethyst/src/main/res/values-sw/strings.xml @@ -2136,6 +2136,11 @@ Ondoa Mahali Ongeza onyo la maudhui Ondoa onyo la maudhui + + + + + Ongeza tarehe ya kumalizika Ondoa tarehe ya kumalizika Tarehe ya Kumalizika diff --git a/amethyst/src/main/res/values-ta-rIN/strings.xml b/amethyst/src/main/res/values-ta-rIN/strings.xml index a4bbdc7f05..f577c19df8 100644 --- a/amethyst/src/main/res/values-ta-rIN/strings.xml +++ b/amethyst/src/main/res/values-ta-rIN/strings.xml @@ -2141,6 +2141,11 @@ இடத்தை அகற்று உள்ளடக்க எச்சரிக்கையை சேர் உள்ளடக்க எச்சரிக்கையை அகற்று + + + + + காலாவதி தேதியை சேர் காலாவதி தேதியை அகற்று காலாவதி தேதி diff --git a/amethyst/src/main/res/values-th-rTH/strings.xml b/amethyst/src/main/res/values-th-rTH/strings.xml index 49dedc612b..30ab45e96e 100644 --- a/amethyst/src/main/res/values-th-rTH/strings.xml +++ b/amethyst/src/main/res/values-th-rTH/strings.xml @@ -2101,6 +2101,11 @@ ลบตำแหน่งออก เพิ่มคําเตือนเนื้อหา ลบคําเตือนเนื้อหา + + + + + เพิ่มวันหมดอายุ ลบวันหมดอายุ วันหมดอายุ diff --git a/amethyst/src/main/res/values-tr-rTR/strings.xml b/amethyst/src/main/res/values-tr-rTR/strings.xml index 3bc1d46ced..9fcfa8dee0 100644 --- a/amethyst/src/main/res/values-tr-rTR/strings.xml +++ b/amethyst/src/main/res/values-tr-rTR/strings.xml @@ -2145,6 +2145,11 @@ Konumu Kaldır İçerik uyarısı ekle İçerik uyarısını kaldır + + + + + Son kullanma tarihi ekle Son kullanma tarihini kaldır Son Kullanma Tarihi diff --git a/amethyst/src/main/res/values-uk-rUA/strings.xml b/amethyst/src/main/res/values-uk-rUA/strings.xml index 0ee41296ed..1c7a12b047 100644 --- a/amethyst/src/main/res/values-uk-rUA/strings.xml +++ b/amethyst/src/main/res/values-uk-rUA/strings.xml @@ -2194,6 +2194,11 @@ Видалити місце Додати попередження про вміст Видалити попередження про вміст + + + + + Додати дату завершення Видалити дату завершення Дата завершення diff --git a/amethyst/src/main/res/values-uz-rUZ/strings.xml b/amethyst/src/main/res/values-uz-rUZ/strings.xml index 0ea217420b..9fcb5fcdbb 100644 --- a/amethyst/src/main/res/values-uz-rUZ/strings.xml +++ b/amethyst/src/main/res/values-uz-rUZ/strings.xml @@ -2140,6 +2140,11 @@ Joylashuvni olib tashlash Kontent ogohlantirishini qo\'shish Kontent ogohlantirishini olib tashlash + + + + + Muddati tugash sanasini qo\'shish Muddati tugash sanasini olib tashlash Muddati tugash sanasi diff --git a/amethyst/src/main/res/values-vi-rVN/strings.xml b/amethyst/src/main/res/values-vi-rVN/strings.xml index a2a1ae99f7..05de889e0f 100644 --- a/amethyst/src/main/res/values-vi-rVN/strings.xml +++ b/amethyst/src/main/res/values-vi-rVN/strings.xml @@ -2101,6 +2101,11 @@ Xóa Vị trí Thêm cảnh báo nội dung Xóa cảnh báo nội dung + + + + + Thêm ngày hết hạn Xóa ngày hết hạn Ngày hết hạn diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 44eadd4046..8994c5f7fb 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -2875,6 +2875,11 @@ %1$s 创建了频道 %2$s %1$s 创建了频道 %1$s 更新了频道配置文件 + + + + + 消息传递 关闭 正在等待中继接受此消息 diff --git a/amethyst/src/main/res/values-zh-rHK/strings.xml b/amethyst/src/main/res/values-zh-rHK/strings.xml index 994a853807..f29a4d7732 100644 --- a/amethyst/src/main/res/values-zh-rHK/strings.xml +++ b/amethyst/src/main/res/values-zh-rHK/strings.xml @@ -2143,6 +2143,11 @@ 移除地点 添加内容警告 移除内容警告 + + + + + 添加过期日期 删除过期日期 过期日期 diff --git a/amethyst/src/main/res/values-zh-rSG/strings.xml b/amethyst/src/main/res/values-zh-rSG/strings.xml index c1dd049822..e122cb806e 100644 --- a/amethyst/src/main/res/values-zh-rSG/strings.xml +++ b/amethyst/src/main/res/values-zh-rSG/strings.xml @@ -2143,6 +2143,11 @@ 移除地点 添加内容警告 移除内容警告 + + + + + 添加过期日期 删除过期日期 过期日期 diff --git a/amethyst/src/main/res/values-zh-rTW/strings.xml b/amethyst/src/main/res/values-zh-rTW/strings.xml index c162071168..8b97fa83c5 100644 --- a/amethyst/src/main/res/values-zh-rTW/strings.xml +++ b/amethyst/src/main/res/values-zh-rTW/strings.xml @@ -2121,6 +2121,11 @@ 移除地點 添加內容警告 移除內容警告 + + + + + 新增到期日 移除到期日 到期日 From eff0a30a392dff890fa5b02a70edd0d1d4a6e0ae Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 22:57:49 +0000 Subject: [PATCH 03/12] perf: skip chat warmup + activity preview for Buzz forum rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forum channels store their posts as threads (a separate store), not in the chat notes the activity preview reads — so a forum row was opening a kind-9 chat warmup subscription that returns nothing and could never render a last-message preview, facepile, or unread badge. Add a `showActivityPreview` flag (default true) to BuzzImportRow that gates the warmup, the notes-flow collection, and the preview/facepile/unread reads. The forum section passes false, so a forum row skips the useless subscription and shows a member-count summary instead. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FHnm6G9YytnLfs1ycYfK89 --- .../ui/screen/loggedIn/buzz/BuzzImportRow.kt | 59 +++++++++++++------ .../relayGroup/RelayGroupChannelListScreen.kt | 3 + 2 files changed, 43 insertions(+), 19 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt index 83faebeeff..06486b8b0d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt @@ -87,6 +87,12 @@ private const val CARD_WARMUP_LIMIT = 10 * per-channel actions — Pin/Unpin and Add-to-my-list — so the row stays clean. * * Reused by the relay group-list screen where Buzz membership discovery is folded in. + * + * [showActivityPreview] gates the chat-activity machinery — the recent-message warmup, the + * last-message preview, the recent-posters facepile and the unread badge. Enable it for **chat** + * channels (whose content lives in [RelayGroupChannel.notes]); leave it off for **forum** channels, + * whose posts are threads (a separate store), so the row doesn't open a kind-9 chat subscription that + * would return nothing and drives a member-count summary instead. */ @Composable fun BuzzImportRow( @@ -97,6 +103,7 @@ fun BuzzImportRow( onOpen: (() -> Unit)? = null, isStarred: Boolean = false, onToggleStar: (() -> Unit)? = null, + showActivityPreview: Boolean = true, ) { val account = accountViewModel.account val baseChannel = remember(groupId) { LocalCache.getOrCreateRelayGroupChannel(groupId) } @@ -104,32 +111,46 @@ fun BuzzImportRow( // Warm a first screen's worth of recent messages while this card is visible (content only — the // directory subscription already streams metadata), so the preview + facepile fill in ahead of a // tap instead of staying blank until the channel is opened. Bounded to visible rows by the - // LazyColumn and released as they scroll off. - RelayGroupCardWarmupSubscription( - baseChannel, - accountViewModel.dataSources().relayGroupCardWarmup, - accountViewModel, - contentOnly = true, - contentLimit = CARD_WARMUP_LIMIT, - ) + // LazyColumn and released as they scroll off. Skipped for forum channels (no chat to warm). + if (showActivityPreview) { + RelayGroupCardWarmupSubscription( + baseChannel, + accountViewModel.dataSources().relayGroupCardWarmup, + accountViewModel, + contentOnly = true, + contentLimit = CARD_WARMUP_LIMIT, + ) + } val channelState by observeChannel(baseChannel, accountViewModel) val channel = channelState?.channel as? RelayGroupChannel ?: baseChannel - // The channel's own notes flow drives the preview/facepile so they update the moment a message - // folds in, independent of the metadata-scoped [observeChannel] above. - val notesState by channel - .flow() - .notes.stateFlow - .collectAsStateWithLifecycle() val name = channel.toBestDisplayName() val memberCount = channel.memberCount() val isPrivate = channel.isPrivate() - val lastNote = remember(notesState) { channel.newestTimelineNote(account) } - val faceAuthors = remember(notesState) { channel.recentAuthorHexes(account, FACEPILE_MAX) } - val unread by - remember(groupId) { relayGroupChannelUnreadCountFlow(account, groupId) } - .collectAsStateWithLifecycle(0) + + // The channel's own notes flow drives the preview/facepile so they update the moment a message + // folds in, independent of the metadata-scoped [observeChannel] above. Only collected for chat + // channels; a forum row shows a member-count summary with no facepile/unread. + val lastNote: Note? + val faceAuthors: List + val unread: Int + if (showActivityPreview) { + val notesState by channel + .flow() + .notes.stateFlow + .collectAsStateWithLifecycle() + lastNote = remember(notesState) { channel.newestTimelineNote(account) } + faceAuthors = remember(notesState) { channel.recentAuthorHexes(account, FACEPILE_MAX) } + unread = + remember(groupId) { relayGroupChannelUnreadCountFlow(account, groupId) } + .collectAsStateWithLifecycle(0) + .value + } else { + lastNote = null + faceAuthors = emptyList() + unread = 0 + } val hasUnread = unread > 0 val content = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index c762bc06be..e57252f94c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -413,6 +413,9 @@ fun RelayGroupChannelListScreen( onOpen = { nav.nav(Route.RelayGroupThreads(groupId.id, relay.url)) }, isStarred = groupId.id in starred, onToggleStar = { BuzzChannelStars.toggle(groupId.id) }, + // Forum posts live in a separate thread store, not the chat notes the + // activity preview reads — so don't warm a kind-9 sub that returns nothing. + showActivityPreview = false, ) } } From 59646917e91ca8f05c1ed83b15028efe674866ba Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 26 Jul 2026 18:59:04 -0400 Subject: [PATCH 04/12] refactor(chats): demote Threads and Share to the channel overflow menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The relay-group top bar carried four affordances — Threads, Canvas, Share and the overflow — which crowded the title to the point that a channel called personalized-knowledge-graphs rendered as "personalized-..." with its relay truncated too. Threads was the worst offender because it advertises a feature that is usually not there. On a Buzz `t=stream` channel the threads list is forum posts (kind 45001), and Buzz only ever creates those in `t=forum` channels — which the relay's channel list already surfaces in their own Forums section, opening straight into this same view. So on every chat channel the button led to "No threads yet", and its + would have published a 45001 into a stream channel, where Buzz's own client never renders it (it mounts ForumChannelContent only for channelType === "forum"). It stays in the menu rather than being gated off, because on vanilla NIP-29 relays the same screen is the legitimate NIP-7D kind-11 thread view. Canvas keeps its icon: it is this channel's shared document — content — while Threads and Share are navigation you reach for occasionally. Also fixes the overflow only existing in the member branch: a pending join or a gated group you don't belong to replaced the whole menu with the Join button, so Members was unreachable while browsing. The menu is now always present, with the membership actions (Members/Edit/Invite/Leave) gated as before and Threads/Share always available — you can want to hand out a group you are still only browsing. Canvas is per-channel, not per-workspace: the relay requires an `h` channel tag on kind-40100 (its own channel_scoped_content_kinds_require_h_tags invariant), so correct the empty state from "shared in this workspace" to "in this channel", say so in the KDoc, and name the channel under the title the way Threads does. Verified on emulator-5554: Buzz channel shows Canvas + overflow (Threads, Share, Members, Leave) and the full title now fits; a non-Buzz group (basspistol.org) shows only the overflow with the same four items; the canvas screen names its channel. Co-Authored-By: Claude Opus 5 (1M context) --- .../screen/loggedIn/buzz/BuzzCanvasScreen.kt | 50 +++++++- .../relayGroup/RelayGroupTopBar.kt | 108 ++++++++++-------- amethyst/src/main/res/values/strings.xml | 2 +- 3 files changed, 106 insertions(+), 54 deletions(-) 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 5899bb3cd3..7fa6ad1a7a 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 @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState @@ -45,7 +46,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R @@ -53,20 +56,25 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates +import com.vitorpamplona.amethyst.model.LocalCache 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.navigation.topbars.TopBarExtensibleWithBackButton 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.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext /** - * 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 + * A Buzz **canvas** (kind 40100): the newest shared markdown document for ONE channel. The relay + * requires an `h` channel tag on every canvas (its own `channel_scoped_content_kinds_require_h_tags` + * invariant), so each channel has its own — this is not a workspace-wide document. + * + * The canvas is overlay state held in [BuzzWorkspaceStates] (never a timeline row — a channel 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. * @@ -87,6 +95,15 @@ 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 = + remember(channelId, relayUrl) { + RelayUrlNormalizer.normalizeOrNull(relayUrl)?.let { relay -> + LocalCache.getRelayGroupChannelIfExists(GroupId(channelId, relay))?.toBestDisplayName() + } + } + var editing by remember { mutableStateOf(false) } if (editing) { @@ -101,7 +118,32 @@ fun BuzzCanvasScreen( } Scaffold( - topBar = { TopBarWithBackButton(stringRes(R.string.buzz_canvas_title), nav) }, + // Name the channel under the title, like the Threads screen: a canvas belongs to one channel, + // and arriving here from a chat should not lose track of which. + topBar = { + TopBarExtensibleWithBackButton( + title = { + Column { + Text( + text = stringRes(R.string.buzz_canvas_title), + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (channelName != null) { + Text( + text = channelName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + }, + popBack = nav::popBack, + ) + }, floatingActionButton = { FloatingActionButton(onClick = { editing = true }) { Icon(symbol = MaterialSymbols.Edit, contentDescription = stringRes(R.string.buzz_canvas_edit)) 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 90ae2ba15f..88a5fe3405 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 @@ -170,20 +170,10 @@ fun RelayGroupTopBar( } }, actions = { - if (!isDm) { - IconButton(onClick = { - nav.nav(Route.RelayGroupThreads(channel.groupId.id, channel.groupId.relayUrl.url)) - }) { - Icon( - symbol = MaterialSymbols.Forum, - contentDescription = stringRes(R.string.relay_group_threads_title), - modifier = Modifier.size(20.dp), - ) - } - } - // 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. + // 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)) { IconButton(onClick = { nav.nav(Route.BuzzCanvas(channel.groupId.id, channel.groupId.relayUrl.url)) }) { Icon( @@ -197,48 +187,68 @@ fun RelayGroupTopBar( // remember the bech32 (naddr) encode — this top bar recomposes on every roster/metadata // emission (observeChannel), and the encode is otherwise redone each time. val naddr = remember(channel.groupId, isDm) { if (isDm) null else channel.toNAddr() } - if (naddr != null) { - val context = LocalContext.current - IconButton(onClick = { shareRelayGroup(context, naddr) }) { - Icon( - symbol = MaterialSymbols.Share, - contentDescription = stringRes(R.string.quick_action_share), - modifier = Modifier.size(20.dp), - ) + + if (displayMembership == RelayGroupMembership.PENDING) { + Text( + text = stringRes(R.string.relay_group_pending), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else if (!displayMembership.isMember() && channel.requiresMembershipToPost()) { + FilledTonalButton(onClick = { + // Closed groups need an invite code; open groups join directly. + if (channel.isClosed()) { + showJoinCode = true + } else { + requested = true + accountViewModel.joinRelayGroup(channel) + } + }) { + Text(stringRes(R.string.join)) } } - when { - displayMembership == RelayGroupMembership.PENDING -> { - Text( - text = stringRes(R.string.relay_group_pending), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + + // The membership actions are only meaningful once the relay lets you in: while a join is + // pending, and on a gated group that doesn't list you, the Join affordance above stands in + // for them. Threads and Share stay available either way — you can want to hand out a group + // you are still only browsing. + val showMembershipActions = + displayMembership != RelayGroupMembership.PENDING && + !(!displayMembership.isMember() && channel.requiresMembershipToPost()) + + // Everything here used to be a top-bar icon. Threads especially over-advertised itself: on a + // Buzz `t=stream` channel it is always empty (forum posts live in `t=forum` channels, which + // the relay's channel list already surfaces in their own section), so it read as a broken + // feature on every chat. Demoted to the overflow, where the frequency of use actually is. + if (!isDm || naddr != null || showMembershipActions) { + IconButton(onClick = { menuOpen = true }) { + Icon( + symbol = MaterialSymbols.MoreVert, + contentDescription = stringRes(R.string.more_options), + modifier = Modifier.size(22.dp), ) } - - !displayMembership.isMember() && channel.requiresMembershipToPost() -> { - FilledTonalButton(onClick = { - // Closed groups need an invite code; open groups join directly. - if (channel.isClosed()) { - showJoinCode = true - } else { - requested = true - accountViewModel.joinRelayGroup(channel) - } - }) { - Text(stringRes(R.string.join)) - } - } - - else -> { - IconButton(onClick = { menuOpen = true }) { - Icon( - symbol = MaterialSymbols.MoreVert, - contentDescription = stringRes(R.string.more_options), - modifier = Modifier.size(22.dp), + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + if (!isDm) { + DropdownMenuItem( + text = { Text(stringRes(R.string.relay_group_threads_title)) }, + onClick = { + menuOpen = false + nav.nav(Route.RelayGroupThreads(channel.groupId.id, channel.groupId.relayUrl.url)) + }, ) } - DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + if (naddr != null) { + val context = LocalContext.current + DropdownMenuItem( + text = { Text(stringRes(R.string.quick_action_share)) }, + onClick = { + menuOpen = false + shareRelayGroup(context, naddr) + }, + ) + } + if (showMembershipActions) { DropdownMenuItem( text = { Text(stringRes(R.string.relay_group_menu_members)) }, onClick = { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 167a715f3d..eddb89c5a6 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3490,7 +3490,7 @@ ▲ %1$s upvoted a post ▼ %1$s downvoted a post Canvas - No canvas has been shared in this workspace yet. + No canvas has been shared in this channel yet. Edit canvas Save canvas Canvas (Markdown) From 6a5d55aa9b7552ed042f219b4437e748efe69fa1 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 26 Jul 2026 19:18:53 -0400 Subject: [PATCH 05/12] docs(quartz): correct the Buzz kind-conflict table and document how to read Buzz's source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two rows of "Kind conflicts — implemented but NOT registered in EventFactory" were stale: 20001 and 39005 are both dispatched now, disambiguated by tag shape inside the kind's block (`g` for BitChat presence, `h` for a Buzz thread summary). Split the table into the disambiguated pair and the three where the incumbent really does keep the slot, and record why the 39005 signal is safe — the NIP-29 relay-generated 39xxx family is `d`-addressed and never emits `h` — plus the fact that nothing throws when it is wrong, so the failure is silent. Also document reading Buzz's Rust without a checkout, which currently costs everyone the same detour: KDoc across this package cites crate-relative paths (`buzz-relay/src/handlers/...`) while the repo puts everything under `crates/`, and `raw.githubusercontent.com` 404s on those paths with plain curl usually sandboxed — `gh api ... contents/... | base64 -d` is the way in. Notes where the answers live (handlers for what the relay emits, buzz-db/channel.rs for channel_type and the two visibility values, desktop/src/features for what Buzz's own client renders — which decides whether an event we publish is visible to anyone), and that the relay's tests are the best spec: `channel_scoped_content_kinds_require_h_tags` is what establishes that canvas and the forum kinds are per-channel, not per-workspace. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/vitorpamplona/quartz/buzz/README.md | 59 +++++++++++++++++-- 1 file changed, 53 insertions(+), 6 deletions(-) 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 1712dd3c2e..cd8bc2c7ac 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/README.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/README.md @@ -25,6 +25,37 @@ The prose specs under Buzz's `docs/nips/` are **drafts and lag the code**. Every here is confirmed against the authoritative Rust — `crates/buzz-core` (the per-kind modules), `crates/buzz-sdk` (event builders, `nip_oa.rs`) — not the markdown. +### Reading Buzz's source without a checkout + +KDoc across this package cites Rust files as `buzz-relay/src/handlers/side_effects.rs`. +Those are **crate-relative**: on disk everything lives under `crates/`, so the real path is +`crates/buzz-relay/src/handlers/side_effects.rs`. Prefix accordingly or the fetch 404s. + +`gh` is the way in (the repo is public, but `raw.githubusercontent.com` 404s on these paths +and plain `curl` is usually sandboxed): + +```bash +gh api -X GET search/code -f q='emit_system_message repo:block/buzz' --jq '.items[].path' +gh api repos/block/buzz/contents/crates/buzz-relay/src/handlers/side_effects.rs --jq '.content' | base64 -d +``` + +Worth knowing where the answers tend to live: + +- `crates/buzz-relay/src/handlers/` — `side_effects.rs` (what the relay emits and when: + system messages, discovery, thread summaries), `event.rs` (ingest + acceptance), + `command_executor.rs` (the 90xx command verbs). +- `crates/buzz-db/src/channel.rs` — the channel row: `channel_type` (`stream` / `forum` / + `dm` / `workflow`) and `visibility` (exactly two values, `open` = searchable + anyone can + join, `private` = hidden + invite-only). +- `desktop/src/features/**` — what Buzz's own client actually *renders*, which is what + decides whether an event we publish is visible to anyone else. Worth checking before + adding a write path: e.g. forum posts only ever surface in `channelType === "forum"`. + +The relay's **tests are the best spec** — several encode invariants as named assertions. +`channel_scoped_content_kinds_require_h_tags` in `handlers/event.rs` is the one to know: +canvas (40100) and the forum kinds (45001/45002/45003) are per-**channel**, never +per-workspace, because an `h` tag is mandatory on all of them. + Compliance is verified against **vectors generated by Buzz's own code**, not hand-transcribed schemas: @@ -64,17 +95,33 @@ conflicts below). | `presence` / `huddles` / `pairing` / `audit` / `media` | presence + misc | 20001, 20002 / 24810, 48100-48106 / 24134 / 48001 / 49001 | | `rsReadState` | NIP-RS | (helpers on `AppSpecificDataEvent`, kind 30078) | -### Kind conflicts — implemented but NOT registered in EventFactory +### Kind conflicts — where a Buzz kind number is already owned -These Buzz kind numbers are already owned by an existing Amethyst/Nostr class, so the -Buzz model exists (build/parse it explicitly) but the incumbent keeps the `EventFactory` -dispatch slot: +Several Buzz kind numbers collide with an existing Amethyst/Nostr class. `EventFactory` +resolves what it can by **tag shape inside the kind's block** — the two meanings never +coexist on one relay, and each carries a tag the other never emits, so one `if` inside the +branch routes both. The rest keep the incumbent and are built/parsed explicitly. + +**Disambiguated — both classes reachable:** + +| Kind | Discriminator | Present → | Absent → | +|---|---|---|---| +| 20001 | `g` (geohash) tag | `bitchat.geohash.GeohashPresenceEvent` | `presence.PresenceUpdateEvent` | +| 39005 | `h` (channel) tag | `cwChannelWindow.ThreadSummaryEvent` | `nip29RelayGroups.metadata.GroupPinnedEvent` | + +Both discriminators are load-bearing in *both* directions, since outbound signing goes +through the same factory — check any new one against the two builders as well as the wire. +For 39005 the signal is that the whole NIP-29 relay-generated 39xxx family (metadata, +admins, members, participants, supported-roles, pinned) is addressed by `d` alone and never +emits `h`, while a Buzz thread summary always carries one. Nothing throws on a mismatch, so +getting this wrong is silent: a summary parsed as a pin list would have reported the thread +root as a pinned message. Covered by `nip29RelayGroups/PinEventsTest`. + +**Incumbent keeps the slot — build/parse the Buzz model explicitly:** | Kind | Buzz class | Incumbent (registered) | |---|---|---| | 9041 | `moderation.ModerationUnbanEvent` | `nip75ZapGoals.GoalEvent` | -| 20001 | `presence.PresenceUpdateEvent` | `experimental.bitchat.geohash.GeohashPresenceEvent` | -| 39005 | `cwChannelWindow.ThreadSummaryEvent` | `nip29RelayGroups.metadata.GroupPinnedEvent` | | 49001 | `media.MediaUploadEvent` | — (Buzz's own `kind.rs` marks 49001 "Not a relay event kind") | | 30078 | `rsReadState` (helpers) | `nip78AppData.AppSpecificDataEvent` (NIP-RS reuses 30078) | From 63ff055b5392e922db384fea0d358bd7ee7ab25b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 23:39:49 +0000 Subject: [PATCH 06/12] feat(cashu): claim P2PK-locked tokens and clear errors when we can't MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pasting a P2PK-locked cashu token (NUT-11) into the wallet sent the proofs to /v1/swap with no witness, so the mint rejected them with an opaque `witness is missing for p2pk signature` 400. Only the NIP-61 nutzap path signed witnesses; the generic redeem path had no P2PK support at all. - quartz: add `signP2pkWitnesses` (pure, resolver-driven) + the `P2PKUnredeemableException` it throws when a locked proof's key is unknown, and a `CashuMintOperations.redeemToken` that signs then swaps. - commons: `CashuWalletOps.redeemToken` now takes the wallet P2PK key and (local-signer-only) identity key, indexes them by x-only pubkey, and routes through the P2PK-aware path. Add `describeRedeemError`, which tells a user whose token is locked to their own identity key (e.g. Bey Wallet's P2PK send) — but who is on a bunker/external signer that can't sign a raw witness — to import their nsec elsewhere to claim it. - amethyst: `CashuWalletState.redeemSigningKeys()` surfaces both keys (identity key only for a local NostrSignerInternal); the wallet ViewModel wires them in and reports via `describeRedeemError`. - cli: `amy cashu receive token` passes the same keys and reports a distinct `p2pk_locked` error code. Adds P2PKRedeemTest covering pass-through, x-only + compressed locks, verifiable witnesses, and the unredeemable case. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QKeRaX749TYnJ7oR8UpqA4 --- .../model/nip60Cashu/CashuWalletState.kt | 14 ++ .../loggedIn/wallet/CashuWalletViewModel.kt | 20 ++- .../commands/cashu/CashuReceiveCommands.kt | 20 ++- .../commons/cashu/ops/CashuWalletOps.kt | 66 ++++++++- .../quartz/nip60Cashu/p2pk/P2PKRedeem.kt | 67 +++++++++ .../quartz/nip60Cashu/p2pk/P2PKRedeemTest.kt | 136 ++++++++++++++++++ .../nip60Cashu/mintApi/CashuMintOperations.kt | 23 +++ 7 files changed, 342 insertions(+), 4 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PKRedeem.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PKRedeemTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt index 9ff58c3534..3f9c610a9d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent import com.vitorpamplona.quartz.nip60Cashu.mintApi.DeterministicSecretFactory @@ -313,6 +314,19 @@ class CashuWalletState( */ suspend fun exportP2pkPrivkeyHex(): String? = walletPrivkeyHex() + /** + * Private keys that can sign a NUT-11 P2PK witness when redeeming a pasted + * `cashuA`/`cashuB` token — see [CashuWalletOps.redeemToken]. + * + * `first` is the wallet's kind:17375 P2PK key (for tokens locked to our + * wallet key, e.g. an inbound nutzap handed over out-of-band). `second` is + * the account identity key, present ONLY for a local nsec signer — some + * senders (e.g. Bey Wallet's P2PK send) lock ecash directly to the + * recipient's npub, and only a local key can produce that raw signature. + * A remote (NIP-46) / external (NIP-55) signer yields null there. + */ + suspend fun redeemSigningKeys(): Pair = walletPrivkeyHex() to (signer as? NostrSignerInternal)?.keyPair?.privKey?.toHexKey() + private suspend fun walletPrivkeyHex(): String? = _walletEvent.value?.let { evt -> runCatching { evt.privkey(signer) }.getOrNull() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt index e48933c529..4b9ee64355 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.commons.cashu.ops.CashuWalletOps import com.vitorpamplona.amethyst.commons.cashu.ops.MintQuoteStarted import com.vitorpamplona.amethyst.commons.cashu.ops.TokenEntry import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError +import com.vitorpamplona.amethyst.commons.cashu.ops.describeRedeemError import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -905,10 +906,25 @@ class CashuWalletViewModel : ViewModel() { _redeemState.value = CashuRedeemFlowState.Redeeming vm.launchSigner { try { - val total = parsedTokens.sumOf { ops.redeemToken(trimmed, it.proofs, it.mint).amount } + // Keys that can unlock a P2PK-locked token: our wallet key, and + // — for a local nsec login only — the identity key (some senders, + // e.g. Bey Wallet, P2PK-lock ecash straight to the recipient npub). + val (walletKey, identityKey) = state.redeemSigningKeys() + val total = + parsedTokens.sumOf { + ops + .redeemToken( + cashuToken = trimmed, + proofs = it.proofs, + mintUrl = it.mint, + walletP2pkPrivkeyHex = walletKey, + identityPrivkeyHex = identityKey, + ).amount + } _redeemState.value = CashuRedeemFlowState.Completed(total) } catch (e: Exception) { - _redeemState.value = CashuRedeemFlowState.Error(describeMintError(e)) + _redeemState.value = + CashuRedeemFlowState.Error(describeRedeemError(e, account!!.signer.pubKey)) } } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuReceiveCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuReceiveCommands.kt index a1eb3b0ca8..a195a4d870 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuReceiveCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuReceiveCommands.kt @@ -26,6 +26,9 @@ import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.amethyst.cli.commands.route import com.vitorpamplona.quartz.lightning.LnInvoiceUtil +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip60Cashu.p2pk.P2PKUnredeemableException import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenB64Parser /** @@ -156,12 +159,25 @@ object CashuReceiveCommands { Context.open(dataDir).use { ctx -> ctx.prepare() return try { + // Keys that can unlock a P2PK-locked token: the wallet's kind:17375 + // key, plus — for a local key account — the identity key (some + // senders, e.g. Bey Wallet, P2PK-lock ecash to the recipient npub). + val snap = ctx.cashuSnapshot() + val walletKey = snap.walletEvent?.let { runCatching { it.privkey(ctx.signer) }.getOrNull() } + val identityKey = (ctx.signer as? NostrSignerInternal)?.keyPair?.privKey?.toHexKey() var total = 0L var lastTokenEventId: String? = null var lastHistoryEventId: String? = null var mint = "" for (t in parsed) { - val redeemed = ctx.cashuOps().redeemToken(raw, t.proofs, t.mint) + val redeemed = + ctx.cashuOps().redeemToken( + cashuToken = raw, + proofs = t.proofs, + mintUrl = t.mint, + walletP2pkPrivkeyHex = walletKey, + identityPrivkeyHex = identityKey, + ) total += redeemed.amount lastTokenEventId = redeemed.tokenEvent.id lastHistoryEventId = redeemed.historyEvent.id @@ -176,6 +192,8 @@ object CashuReceiveCommands { ), ) 0 + } catch (e: P2PKUnredeemableException) { + Output.error("p2pk_locked", "token is P2PK-locked to a key this wallet can't sign for (${e.lockPubKeyHex})") } catch (e: Exception) { Output.error("mint_proofs_spent", describe(e)) } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt index 530006ad33..67508412be 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt @@ -46,6 +46,7 @@ import com.vitorpamplona.quartz.nip60Cashu.mintApi.RandomSecretFactory import com.vitorpamplona.quartz.nip60Cashu.mintApi.SecretFactory import com.vitorpamplona.quartz.nip60Cashu.mintApi.splitAmountIntoDenominations import com.vitorpamplona.quartz.nip60Cashu.p2pk.P2PK +import com.vitorpamplona.quartz.nip60Cashu.p2pk.P2PKUnredeemableException import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent import com.vitorpamplona.quartz.nip60Cashu.token.CashuProof import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent @@ -630,9 +631,29 @@ class CashuWalletOps( proofs: List, mintUrl: String, nutzapEventId: String? = null, + /** + * The wallet's NIP-60 P2PK private key (kind:17375 `privkey`, hex). + * Used to sign the NUT-11 witness when the token is locked to our + * wallet key. Null when the wallet hasn't decrypted its kind:17375. + */ + walletP2pkPrivkeyHex: String? = null, + /** + * The account's Nostr identity private key (hex), available ONLY for a + * local nsec signer. Some senders (e.g. Bey Wallet's P2PK send) lock + * ecash directly to the recipient's npub, so we sign the witness with + * the identity key when the lock targets it. Null for remote (NIP-46) + * or external (NIP-55) signers — they can't produce a raw witness + * signature, so such a token surfaces as [P2PKUnredeemableException]. + */ + identityPrivkeyHex: String? = null, ): RedeemCompleted { if (proofs.isEmpty()) throw IllegalArgumentException("Token has no proofs") - val swap = ops(mintUrl).swap(proofs, targetSplit = null) + // Index our candidate signing keys by their x-only pubkey so a locked + // proof can be matched to the key that unlocks it. Empty when we hold + // no keys (e.g. CLI callers) — a locked token then throws a clear + // P2PKUnredeemableException instead of an unsigned swap. + val signingKeys = p2pkKeyIndex(walletP2pkPrivkeyHex, identityPrivkeyHex) + val swap = ops(mintUrl).redeemToken(proofs) { lockXOnly -> signingKeys[lockXOnly] } val total = swap.keep.sumOf { it.amount } // All output goes to "keep" since targetSplit was null. @@ -1276,14 +1297,57 @@ data class RestoreOutcome( /** Drop the leading parity byte if present so two pubkeys can be compared. */ private fun String.lastHex64(): String = if (length == 66) substring(2) else this +/** + * Index the given private keys by their 32-byte x-only pubkey hex, skipping + * blanks. Used by [CashuWalletOps.redeemToken] to resolve which of our keys (if + * any) unlocks a P2PK proof, comparing against the lock's x-only `data`. + */ +private fun p2pkKeyIndex(vararg privKeysHex: String?): Map = + buildMap { + privKeysHex.forEach { hex -> + if (!hex.isNullOrBlank()) { + val xOnly = + Secp256k1 + .pubKeyCompress(Secp256k1.pubkeyCreate(hex.hexToByteArray())) + .toHexKey() + .lastHex64() + put(xOnly, hex) + } + } + } + /** Catches mint HTTP / protocol errors and surfaces their detail message. */ fun describeMintError(e: Throwable): String = when (e) { + is P2PKUnredeemableException -> "This ecash is locked to a public key this wallet can't sign for." is MintHttpException -> "Mint error (HTTP ${e.httpStatus}): ${e.detail ?: e.message}" is MintProtocolException -> "Mint refused: ${e.message}" else -> e.message ?: e::class.simpleName ?: "Unknown error" } +/** + * Error text for the redeem-token flow. Adds context [describeMintError] can't: + * when a token is P2PK-locked to the user's own [identityPubKeyHex] but we + * couldn't sign for it, it means the current signer is a bunker/external one + * that can't produce a raw witness — so point the user at claiming it elsewhere. + * Falls back to [describeMintError] for everything else. + */ +fun describeRedeemError( + e: Throwable, + identityPubKeyHex: String?, +): String = + if (e is P2PKUnredeemableException) { + val lock = e.lockPubKeyHex.lastHex64() + if (identityPubKeyHex != null && lock == identityPubKeyHex.lastHex64()) { + "This ecash is locked to your Nostr identity key, which the current login can't sign for " + + "(only a local key / nsec login can). Import your nsec into a Cashu wallet to claim it." + } else { + "This ecash is locked to a public key you don't control (${lock.take(12)}…) and can't be claimed here." + } + } else { + describeMintError(e) + } + /** A decrypted, unspent token event ready to be spent. */ data class TokenEntry( val event: CashuTokenEvent, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PKRedeem.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PKRedeem.kt new file mode 100644 index 0000000000..e1cbd959a8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PKRedeem.kt @@ -0,0 +1,67 @@ +/* + * 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.quartz.nip60Cashu.p2pk + +import com.vitorpamplona.quartz.nip60Cashu.token.CashuProof + +/** + * Thrown when a proof set contains a NUT-11 P2PK-locked proof that this wallet + * has no private key to sign for. [lockPubKeyHex] is the pubkey the proof is + * locked to, exactly as it appears in the secret (32-byte x-only or 33-byte + * compressed) — callers may compare it against the user's own keys to craft a + * tailored message (e.g. "locked to your identity key, redeem it elsewhere"). + */ +class P2PKUnredeemableException( + val lockPubKeyHex: String, +) : RuntimeException("This ecash is locked to a public key this wallet can't sign for ($lockPubKeyHex).") + +/** + * Attach NUT-11 unlock witnesses to any P2PK-locked proofs in [proofs] so the + * set can be spent at `/v1/swap`. + * + * Each proof's secret is inspected via [P2PK.parseSecret]: + * - a plain (non-P2PK) secret passes through unchanged; + * - a P2PK secret is signed with the private key returned by [signingKeyFor], + * which is invoked with the lock's 32-byte **x-only** pubkey hex (the parity + * prefix of a 33-byte compressed `data` is stripped first, since BIP-340 + * verification — what the mint runs — is x-only). + * + * When [signingKeyFor] returns null for a locked proof, we hold no key for it + * and [P2PKUnredeemableException] is thrown (naming the original lock pubkey) + * rather than sending an unsigned swap the mint would reject with an opaque + * `witness is missing for p2pk signature` 400. + */ +fun signP2pkWitnesses( + proofs: List, + signingKeyFor: (lockPubKeyXOnly: String) -> String?, +): List = + proofs.map { proof -> + val parsed = P2PK.parseSecret(proof.secret) ?: return@map proof + val xOnly = parsed.pubKeyHex.xOnly() + val privKeyHex = signingKeyFor(xOnly) ?: throw P2PKUnredeemableException(parsed.pubKeyHex) + proof.copy(witness = P2PK.signWitness(proof.secret, privKeyHex)) + } + +/** True when any proof in the set carries a NUT-11 P2PK-locked secret. */ +fun List.anyP2pkLocked(): Boolean = any { P2PK.parseSecret(it.secret) != null } + +/** Drop a 33-byte compressed pubkey's parity prefix, yielding the 32-byte x-only hex. */ +private fun String.xOnly(): String = if (length == 66) substring(2) else this diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PKRedeemTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PKRedeemTest.kt new file mode 100644 index 0000000000..1c07d49076 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PKRedeemTest.kt @@ -0,0 +1,136 @@ +/* + * 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.quartz.nip60Cashu.p2pk + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip60Cashu.token.CashuProof +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * [signP2pkWitnesses] — the redeem-side counterpart to spending a pasted + * cashu token that may (or may not) be NUT-11 P2PK-locked. + * + * Reproduces the interop scenario reported against Bey Wallet, which + * P2PK-locks ecash to the recipient's Nostr identity pubkey: before the fix + * the redeem path sent the locked proofs to /v1/swap with no witness and the + * mint rejected them with `witness is missing for p2pk signature`. Here we + * assert the witness is produced and verifies under the lock pubkey. + */ +class P2PKRedeemTest { + // Deterministic key (== 1) — same construction as P2PKTest. + private val priv = "1".padStart(64, '0') + private val xOnlyPub = + Secp256k1Instance + .compressedPubKeyFor(priv.hexToByteArray()) + .copyOfRange(1, 33) + .toHexKey() + + private fun lockedProof(lockPubKeyHex: String) = CashuProof(id = "keyset1", amount = 4, secret = P2PK.lockedSecret(lockPubKeyHex), c = "c-hex") + + private fun plainProof() = CashuProof(id = "keyset1", amount = 1, secret = "9a1b...plain-secret", c = "c-hex") + + private fun witnessVerifies( + proof: CashuProof, + xOnlyHex: String, + ): Boolean { + val witness = proof.witness ?: return false + val sigs = (Json.parseToJsonElement(witness) as JsonObject)["signatures"] as JsonArray + val sigHex = (sigs[0] as JsonPrimitive).content + return Secp256k1Instance.verifySchnorr( + signature = sigHex.hexToByteArray(), + hash = sha256(proof.secret.encodeToByteArray()), + pubKey = xOnlyHex.hexToByteArray(), + ) + } + + @Test + fun plainProofPassesThroughUnsigned() { + val proof = plainProof() + val out = signP2pkWitnesses(listOf(proof)) { error("resolver must not be called for a plain proof") } + assertEquals(1, out.size) + assertNull(out[0].witness, "a non-P2PK proof must not gain a witness") + assertEquals(proof, out[0]) + } + + @Test + fun lockedProofGetsVerifiableWitness() { + // Locked to the x-only key (Nostr-identity style, 64 hex). + val out = + signP2pkWitnesses(listOf(lockedProof(xOnlyPub))) { lockXOnly -> + assertEquals(xOnlyPub, lockXOnly, "resolver is queried by the lock's x-only pubkey") + priv + } + assertTrue(witnessVerifies(out[0], xOnlyPub), "witness must verify under the lock pubkey") + } + + @Test + fun compressedLockResolvesByXOnly() { + // Locked to the 33-byte compressed form (02/03 prefix) — the resolver + // must still be asked by the 32-byte x-only pubkey, and the witness + // must verify (the mint runs x-only BIP-340). + val compressed = "02$xOnlyPub" + val out = + signP2pkWitnesses(listOf(lockedProof(compressed))) { lockXOnly -> + assertEquals(xOnlyPub, lockXOnly, "the parity prefix must be stripped before resolving") + priv + } + assertTrue(witnessVerifies(out[0], xOnlyPub)) + } + + @Test + fun unknownLockThrowsNamingThePubkey() { + val compressed = "02$xOnlyPub" + val e = + assertFailsWith { + signP2pkWitnesses(listOf(lockedProof(compressed))) { null } + } + // The exception carries the lock exactly as it appears in the secret so + // callers can compare it against the user's own keys. + assertEquals(compressed, e.lockPubKeyHex) + } + + @Test + fun mixedSetSignsOnlyTheLockedProofs() { + val plain = plainProof() + val locked = lockedProof(xOnlyPub) + val out = signP2pkWitnesses(listOf(plain, locked)) { priv } + assertNull(out[0].witness, "plain proof stays unsigned") + assertTrue(witnessVerifies(out[1], xOnlyPub), "locked proof is signed") + } + + @Test + fun anyP2pkLockedDetectsLockedProofs() { + assertFalse(listOf(plainProof()).anyP2pkLocked()) + assertTrue(listOf(plainProof(), lockedProof(xOnlyPub)).anyP2pkLocked()) + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt index 87bfe8e55d..bd43bfc9ca 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip60Cashu.bdhke.Bdhke import com.vitorpamplona.quartz.nip60Cashu.p2pk.P2PK +import com.vitorpamplona.quartz.nip60Cashu.p2pk.signP2pkWitnesses import com.vitorpamplona.quartz.nip60Cashu.seed.CashuDeterministic import com.vitorpamplona.quartz.nip60Cashu.token.CashuProof import com.vitorpamplona.quartz.nip60Cashu.token.TokenContent @@ -215,6 +216,28 @@ class CashuMintOperations( return swap(unlocked, targetSplit = null) } + /** + * Redeem the proofs of an out-of-band token (a pasted `cashuA`/`cashuB` + * string) into fresh proofs in our wallet. + * + * Unlike [redeemNutzap] — which assumes every proof is P2PK-locked to our + * single wallet key — a pasted token may be plain, P2PK-locked, or a mix, + * and the lock may target any key. [signP2pkWitnesses] inspects each secret + * and, for locked proofs, asks [signingKeyFor] for the matching private key + * (by the lock's x-only pubkey). Plain proofs pass straight through. + * + * Throws [P2PKUnredeemableException] if a locked proof's key is unknown — + * caught upstream to show "this ecash is locked to a key you don't control" + * instead of leaking the mint's raw `witness is missing` 400. + */ + suspend fun redeemToken( + proofs: List, + signingKeyFor: (lockPubKeyXOnly: String) -> String?, + ): SwapResult { + if (proofs.isEmpty()) throw IllegalArgumentException("Nothing to redeem") + return swap(signP2pkWitnesses(proofs, signingKeyFor), targetSplit = null) + } + /** * Swap our unlocked proofs so that [targetSplit] sats are returned as * NUT-11 P2PK-locked proofs (recipient-spendable only with their From 7d4e4f1cc94fbf21753c2afbbb153c7b13208756 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 26 Jul 2026 19:48:41 -0400 Subject: [PATCH 07/12] fix(buzz): only offer "new thread" where a forum post belongs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Threads compose FAB keyed on relay dialect, not channel type: on any Buzz relay it published a kind-45001 forum post into whatever channel you were in. Buzz only ever creates those in a `t=forum` channel — its client mounts the forum view for `channelType === "forum"` alone — so a 45001 in a `t=stream` chat channel is a post that no Buzz user can see. The relay accepts it (nothing there gates 45001 by channel type), which is exactly why the client has to. Gate the FAB on the channel's declared type when the host speaks Buzz, and leave every other relay alone: there the same button writes a NIP-7D kind-11, which is valid in any group. A Buzz channel whose kind-39000 hasn't landed yet reads as null and fails closed — a FAB appearing a moment later beats publishing into the wrong channel. Reading is untouched. An existing thread still renders wherever it came from; this only removes the offer to create one where it would be invisible. The empty state said "No threads yet. Start one with the + button." while the FAB was hidden, which was already wrong for non-members and is now wrong for chat channels too. Added a read-only variant. The gate is a named function rather than an inline condition so the allow-path has a test: no relay reachable in a manual pass exposes a `t=forum` channel, and a gate that only ever denies is indistinguishable from deleting the feature. Denials are covered on-device — a Buzz stream channel loses the FAB, a NIP-29 group (basspistol.org) keeps it. Co-Authored-By: Claude Opus 5 (1M context) --- .../relayGroup/RelayGroupThreadsScreen.kt | 36 +++++++++- amethyst/src/main/res/values/strings.xml | 2 + .../relayGroup/CanStartThreadHereTest.kt | 68 +++++++++++++++++++ 3 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/CanStartThreadHereTest.kt 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 3f4663ba3e..f49f63c876 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 @@ -70,6 +70,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayG import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.quartz.buzz.forum.ForumPostEvent +import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_FORUM +import com.vitorpamplona.quartz.buzz.workspace.buzzChannelType import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip29RelayGroups.GroupId @@ -99,6 +101,26 @@ fun RelayGroupThreadsScreen( } } +/** + * Whether *starting* a thread belongs on this channel, given its Buzz `t` [channelType] (null on a + * relay that doesn't declare one) and whether its host speaks the Buzz dialect. + * + * On a Buzz relay the compose FAB writes a kind-45001 forum post, and Buzz only ever puts those in a + * `t=forum` channel — its own client mounts the forum view for `channelType === "forum"` alone, so a + * 45001 in a `t=stream` chat channel is a post nobody outside Amethyst can see. The relay itself + * accepts it (nothing there gates 45001 by channel type), which is exactly why the client has to. + * + * A Buzz channel whose kind-39000 hasn't landed yet reads as null and is treated as not-a-forum: the + * FAB appearing a moment later is a smaller error than publishing into the wrong channel. + * + * This gates writing only. Reading stays open on every channel — an existing thread is worth showing + * wherever it came from — and non-Buzz relays are untouched, where the FAB writes a NIP-7D kind-11. + */ +fun canStartThreadHere( + channelType: String?, + isBuzzRelay: Boolean, +): Boolean = !isBuzzRelay || channelType == BUZZ_CHANNEL_TYPE_FORUM + @Composable private fun RelayGroupThreads( channel: RelayGroupChannel, @@ -121,7 +143,10 @@ private fun RelayGroupThreads( // Hide the compose FAB where the relay would reject the kind-11: on membership-gated groups // that don't list me. Open Buzz channels accept any authenticated member. See [RelayGroupChannel.canPost]. - val canPost = channel.canPost(accountViewModel.userProfile().pubkeyHex) + val isBuzz = remember(channel.groupId.relayUrl) { BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) } + val canPost = + channel.canPost(accountViewModel.userProfile().pubkeyHex) && + canStartThreadHere(channel.event?.buzzChannelType(), isBuzz) Scaffold( topBar = { @@ -153,7 +178,7 @@ private fun RelayGroupThreads( // 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)) { + if (isBuzz) { nav.nav(Route.BuzzForumPost(channel.groupId.id, channel.groupId.relayUrl.url)) } else { nav.nav( @@ -178,7 +203,12 @@ private fun RelayGroupThreads( if (threads.isEmpty()) { Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { Text( - text = stringRes(R.string.relay_group_threads_empty), + text = + if (canPost) { + stringRes(R.string.relay_group_threads_empty) + } else { + stringRes(R.string.relay_group_threads_empty_read_only) + }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(32.dp), diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index eddb89c5a6..0253dfbb19 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2636,6 +2636,8 @@ No groups found for this filter yet. Favorite this relay No threads yet. Start one with the + button. + + No threads yet. Loading older threads… No older threads New thread diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/CanStartThreadHereTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/CanStartThreadHereTest.kt new file mode 100644 index 0000000000..624d5bdf15 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/CanStartThreadHereTest.kt @@ -0,0 +1,68 @@ +/* + * 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.chats.publicChannels.relayGroup + +import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_DM +import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_FORUM +import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_STREAM +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Which channels may *start* a thread. The Threads screen's compose FAB writes a Buzz kind-45001 + * forum post on a Buzz relay and a NIP-7D kind-11 everywhere else, so the gate has to key on the + * channel's declared type — not just on whether the relay would accept the event, which it does. + * + * The forum case is here rather than on-device because no relay we can reach in a manual pass + * currently exposes a `t=forum` channel, and the allow-path is the half that must not regress: a + * gate that only ever denies is indistinguishable from deleting the feature. + */ +class CanStartThreadHereTest { + @Test + fun buzzForumChannelCanStartAThread() { + assertTrue(canStartThreadHere(BUZZ_CHANNEL_TYPE_FORUM, isBuzzRelay = true)) + } + + @Test + fun buzzChatAndDmChannelsCannot() { + // A 45001 here is accepted by the relay and then rendered by nobody — Buzz's own client + // mounts its forum view for `channelType === "forum"` alone. + assertFalse(canStartThreadHere(BUZZ_CHANNEL_TYPE_STREAM, isBuzzRelay = true)) + assertFalse(canStartThreadHere(BUZZ_CHANNEL_TYPE_DM, isBuzzRelay = true)) + } + + @Test + fun buzzChannelWithUnknownOrUnloadedTypeCannot() { + // kind-39000 not in yet, or a type this version predates: fail closed rather than publish + // into a channel that may not be a forum. + assertFalse(canStartThreadHere(null, isBuzzRelay = true)) + assertFalse(canStartThreadHere("workflow", isBuzzRelay = true)) + } + + @Test + fun nonBuzzRelaysAreUntouchedWhateverTheTypeSays() { + // Vanilla NIP-29: the FAB writes a kind-11 thread, which is valid in any group, and these + // relays declare no `t` at all. + assertTrue(canStartThreadHere(null, isBuzzRelay = false)) + assertTrue(canStartThreadHere(BUZZ_CHANNEL_TYPE_STREAM, isBuzzRelay = false)) + } +} From 3acec7ee878adda357ad184a3e5c0fd8ecbd3ce2 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 26 Jul 2026 21:20:05 -0400 Subject: [PATCH 08/12] fix(buzz): don't offer a canvas on a DM unless one already exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../screen/loggedIn/buzz/BuzzCanvasScreen.kt | 22 ++++++++---- .../relayGroup/RelayGroupTopBar.kt | 34 ++++++++++++++++--- 2 files changed, 45 insertions(+), 11 deletions(-) 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) } +} From ee5efba88d731de6c0ae2ede7bf52be7b27449f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 01:21:29 +0000 Subject: [PATCH 09/12] perf(cashu): avoid needless wallet-key decrypt and secret parse on redeem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the P2PK redeem support: - Gate the redeem signing-key gathering behind an actual P2PK lock. The wallet P2PK key is decrypted from kind:17375 via the signer — a network round-trip on a NIP-46 bunker (and a possible approval prompt). The common case (a plain, unlocked token) needs none of it, so only fetch keys when `anyP2pkLocked()` is true. Applies to both the wallet ViewModel and the amy CLI token-redeem path. - Fast-reject in `P2PK.parseSecret`: NUT-10 well-known secrets are JSON arrays, so bail before the throwing JSON parse when the string isn't one. Redeem parses every proof's secret once, so this drops a thrown+caught exception per plain proof (also benefits the nutzap redeem path). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QKeRaX749TYnJ7oR8UpqA4 --- .../loggedIn/wallet/CashuWalletViewModel.kt | 11 ++++++++++- .../commands/cashu/CashuReceiveCommands.kt | 10 +++++++--- .../quartz/nip60Cashu/p2pk/P2PK.kt | 19 +++++++++++++++++-- 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt index 4b9ee64355..04ef93ea46 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.nip60Cashu.mintApi.MeltQuoteBolt11ResponseDto import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintHttpException +import com.vitorpamplona.quartz.nip60Cashu.p2pk.anyP2pkLocked import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenB64Parser import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent import com.vitorpamplona.quartz.utils.Log @@ -909,7 +910,15 @@ class CashuWalletViewModel : ViewModel() { // Keys that can unlock a P2PK-locked token: our wallet key, and // — for a local nsec login only — the identity key (some senders, // e.g. Bey Wallet, P2PK-lock ecash straight to the recipient npub). - val (walletKey, identityKey) = state.redeemSigningKeys() + // Only gathered when a proof is actually locked: redeemSigningKeys() + // decrypts the kind:17375 privkey, which is a signer round-trip on + // a bunker/external signer we shouldn't pay for a plain token. + val (walletKey, identityKey) = + if (parsedTokens.any { it.proofs.anyP2pkLocked() }) { + state.redeemSigningKeys() + } else { + null to null + } val total = parsedTokens.sumOf { ops diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuReceiveCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuReceiveCommands.kt index a195a4d870..99c013e9f8 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuReceiveCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuReceiveCommands.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip60Cashu.p2pk.P2PKUnredeemableException +import com.vitorpamplona.quartz.nip60Cashu.p2pk.anyP2pkLocked import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenB64Parser /** @@ -162,9 +163,12 @@ object CashuReceiveCommands { // Keys that can unlock a P2PK-locked token: the wallet's kind:17375 // key, plus — for a local key account — the identity key (some // senders, e.g. Bey Wallet, P2PK-lock ecash to the recipient npub). - val snap = ctx.cashuSnapshot() - val walletKey = snap.walletEvent?.let { runCatching { it.privkey(ctx.signer) }.getOrNull() } - val identityKey = (ctx.signer as? NostrSignerInternal)?.keyPair?.privKey?.toHexKey() + // Only decrypt the wallet key when a proof is actually locked (a + // signer round-trip on bunker accounts, wasted on a plain token). + val locked = parsed.any { it.proofs.anyP2pkLocked() } + val walletKey = + if (locked) ctx.cashuSnapshot().walletEvent?.let { runCatching { it.privkey(ctx.signer) }.getOrNull() } else null + val identityKey = if (locked) (ctx.signer as? NostrSignerInternal)?.keyPair?.privKey?.toHexKey() else null var total = 0L var lastTokenEventId: String? = null var lastHistoryEventId: String? = null diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PK.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PK.kt index 2d6393c896..e20e8f50a0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PK.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PK.kt @@ -92,8 +92,13 @@ object P2PK { * Parse a Cashu P2PK secret string. Returns null if the string is not a * NUT-11 P2PK secret. */ - fun parseSecret(secret: String): ParsedP2pk? = - try { + fun parseSecret(secret: String): ParsedP2pk? { + // Fast reject before the (throwing) JSON parse: NUT-10 well-known + // secrets are a JSON array (`["P2PK", …]`), while a plain Cashu secret + // is opaque hex/base64. Redeeming a token calls this once per proof, so + // skipping the parse+exception for the common plain case matters. + if (!secret.looksLikeJsonArray()) return null + return try { val arr = json.parseToJsonElement(secret) as? JsonArray ?: return null if (arr.size < 2) return null val kind = (arr[0] as? JsonPrimitive)?.content @@ -105,6 +110,16 @@ object P2PK { } catch (_: Exception) { null } + } + + /** True when the first non-whitespace char is `[` — i.e. it may be a JSON array. */ + private fun String.looksLikeJsonArray(): Boolean { + for (c in this) { + if (c.isWhitespace()) continue + return c == '[' + } + return false + } /** * BIP-340 Schnorr signature over `sha256(secret_bytes)` used as the unlock From 3c440a6d2754e0a522a8a1a35f8d4c380f2c875a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 01:26:36 +0000 Subject: [PATCH 10/12] fix(cashu): case-insensitive P2PK lock match + all-or-nothing redeem Two bugs found auditing the P2PK redeem path: - Hex case: a lock's `data` pubkey is sender-formatted and NUT-11 doesn't mandate a case, but our key index is keyed by lowercase x-only (Hex.encode is lowercase). An uppercase/mixed-case lock we actually hold the key for was falsely rejected as unredeemable. Normalize the lock to lowercase before the lookup, and compare identity-key locks case-insensitively. - Multi-mint partial redeem: callers redeem one mint-group at a time, each swapping + publishing. An unsignable P2PK lock in a later group threw only after earlier groups were already spent + published, leaving a half-redeemed state the user was told had failed. Add `firstUnsignableP2pkLock` / `requireP2pkRedeemable` and pre-flight every group before redeeming any, mirroring the existing unknown-mint pre-check (wallet ViewModel + amy CLI). Also document that P2PK.signWitness's `["P2PK"` prefix guard is load-bearing for safety (prevents cross-protocol signature reuse when signing with the identity key), not just for parsing. Adds tests for case-insensitive matching and the pre-flight helper. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QKeRaX749TYnJ7oR8UpqA4 --- .../loggedIn/wallet/CashuWalletViewModel.kt | 4 +++ .../commands/cashu/CashuReceiveCommands.kt | 4 +++ .../commons/cashu/ops/CashuWalletOps.kt | 22 +++++++++++++- .../quartz/nip60Cashu/p2pk/P2PK.kt | 8 +++++ .../quartz/nip60Cashu/p2pk/P2PKRedeem.kt | 29 ++++++++++++++++--- .../quartz/nip60Cashu/p2pk/P2PKRedeemTest.kt | 29 +++++++++++++++++++ 6 files changed, 91 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt index 04ef93ea46..680b41702f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.cashu.ops.MintQuoteStarted import com.vitorpamplona.amethyst.commons.cashu.ops.TokenEntry import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError import com.vitorpamplona.amethyst.commons.cashu.ops.describeRedeemError +import com.vitorpamplona.amethyst.commons.cashu.ops.requireP2pkRedeemable import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -919,6 +920,9 @@ class CashuWalletViewModel : ViewModel() { } else { null to null } + // All-or-nothing: reject an unsignable P2PK lock before redeeming + // any group, so a multi-mint token never ends up half-redeemed. + requireP2pkRedeemable(parsedTokens.flatMap { it.proofs }, walletKey, identityKey) val total = parsedTokens.sumOf { ops diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuReceiveCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuReceiveCommands.kt index 99c013e9f8..d20eaceb68 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuReceiveCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuReceiveCommands.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.amethyst.cli.commands.route +import com.vitorpamplona.amethyst.commons.cashu.ops.requireP2pkRedeemable import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal @@ -169,6 +170,9 @@ object CashuReceiveCommands { val walletKey = if (locked) ctx.cashuSnapshot().walletEvent?.let { runCatching { it.privkey(ctx.signer) }.getOrNull() } else null val identityKey = if (locked) (ctx.signer as? NostrSignerInternal)?.keyPair?.privKey?.toHexKey() else null + // All-or-nothing: reject an unsignable P2PK lock before redeeming + // any group, so a multi-mint token never ends up half-redeemed. + if (locked) requireP2pkRedeemable(parsed.flatMap { it.proofs }, walletKey, identityKey) var total = 0L var lastTokenEventId: String? = null var lastHistoryEventId: String? = null diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt index 67508412be..5b461d08d2 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt @@ -47,6 +47,7 @@ import com.vitorpamplona.quartz.nip60Cashu.mintApi.SecretFactory import com.vitorpamplona.quartz.nip60Cashu.mintApi.splitAmountIntoDenominations import com.vitorpamplona.quartz.nip60Cashu.p2pk.P2PK import com.vitorpamplona.quartz.nip60Cashu.p2pk.P2PKUnredeemableException +import com.vitorpamplona.quartz.nip60Cashu.p2pk.firstUnsignableP2pkLock import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent import com.vitorpamplona.quartz.nip60Cashu.token.CashuProof import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent @@ -1306,6 +1307,8 @@ private fun p2pkKeyIndex(vararg privKeysHex: String?): Map = buildMap { privKeysHex.forEach { hex -> if (!hex.isNullOrBlank()) { + // toHexKey() emits lowercase, matching the lowercased x-only the + // resolver is queried with (see P2PKRedeem.xOnly). val xOnly = Secp256k1 .pubKeyCompress(Secp256k1.pubkeyCreate(hex.hexToByteArray())) @@ -1316,6 +1319,23 @@ private fun p2pkKeyIndex(vararg privKeysHex: String?): Map = } } +/** + * Pre-flight a token's proofs against the keys we hold: throws + * [P2PKUnredeemableException] (naming the offending lock) if any P2PK-locked + * proof can't be signed. Callers redeem a multi-group token one group at a time, + * each swapping + publishing, so checking every group up front avoids redeeming + * some and then failing on an unsignable one — mirroring the unknown-mint + * pre-check. Plain (unlocked) proofs are ignored. + */ +fun requireP2pkRedeemable( + proofs: List, + walletP2pkPrivkeyHex: String?, + identityPrivkeyHex: String?, +) { + val signingKeys = p2pkKeyIndex(walletP2pkPrivkeyHex, identityPrivkeyHex) + firstUnsignableP2pkLock(proofs) { signingKeys[it] }?.let { throw P2PKUnredeemableException(it) } +} + /** Catches mint HTTP / protocol errors and surfaces their detail message. */ fun describeMintError(e: Throwable): String = when (e) { @@ -1338,7 +1358,7 @@ fun describeRedeemError( ): String = if (e is P2PKUnredeemableException) { val lock = e.lockPubKeyHex.lastHex64() - if (identityPubKeyHex != null && lock == identityPubKeyHex.lastHex64()) { + if (identityPubKeyHex != null && lock.equals(identityPubKeyHex.lastHex64(), ignoreCase = true)) { "This ecash is locked to your Nostr identity key, which the current login can't sign for " + "(only a local key / nsec login can). Import your nsec into a Cashu wallet to claim it." } else { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PK.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PK.kt index e20e8f50a0..3e92ac1895 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PK.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PK.kt @@ -125,6 +125,14 @@ object P2PK { * BIP-340 Schnorr signature over `sha256(secret_bytes)` used as the unlock * witness. Returns the witness JSON string ready to drop into a Cashu * proof's `witness` field. + * + * SECURITY: [secret] is attacker-controlled (it comes from a pasted token), + * and when [privKeyHex] is the account's Nostr identity key this signs + * `sha256(secret)` with that key. Cross-protocol reuse against Nostr event + * signing is prevented only because a valid P2PK secret must start with + * `["P2PK"` while a Nostr event serialization starts with `[0,` — so callers + * MUST only reach here for secrets that already passed [parseSecret]; that + * prefix check is load-bearing for safety, not just parsing. */ fun signWitness( secret: String, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PKRedeem.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PKRedeem.kt index e1cbd959a8..f5b5ac258f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PKRedeem.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PKRedeem.kt @@ -55,13 +55,34 @@ fun signP2pkWitnesses( ): List = proofs.map { proof -> val parsed = P2PK.parseSecret(proof.secret) ?: return@map proof - val xOnly = parsed.pubKeyHex.xOnly() - val privKeyHex = signingKeyFor(xOnly) ?: throw P2PKUnredeemableException(parsed.pubKeyHex) + val privKeyHex = signingKeyFor(parsed.pubKeyHex.xOnly()) ?: throw P2PKUnredeemableException(parsed.pubKeyHex) proof.copy(witness = P2PK.signWitness(proof.secret, privKeyHex)) } +/** + * The first P2PK lock across [proofs] that [signingKeyFor] can't resolve, or + * null if every locked proof is signable (plain proofs are ignored). Lets a + * caller pre-flight a multi-group token so it never swaps some groups and then + * discovers a later group is unredeemable — leaving a half-redeemed state. + */ +fun firstUnsignableP2pkLock( + proofs: List, + signingKeyFor: (lockPubKeyXOnly: String) -> String?, +): String? { + proofs.forEach { proof -> + val parsed = P2PK.parseSecret(proof.secret) ?: return@forEach + if (signingKeyFor(parsed.pubKeyHex.xOnly()) == null) return parsed.pubKeyHex + } + return null +} + /** True when any proof in the set carries a NUT-11 P2PK-locked secret. */ fun List.anyP2pkLocked(): Boolean = any { P2PK.parseSecret(it.secret) != null } -/** Drop a 33-byte compressed pubkey's parity prefix, yielding the 32-byte x-only hex. */ -private fun String.xOnly(): String = if (length == 66) substring(2) else this +/** + * Drop a 33-byte compressed pubkey's parity prefix, yielding the 32-byte x-only + * hex, and lowercase it. The `data` field is formatted by the sender and NUT-11 + * doesn't mandate a case, so normalize before matching against our (lowercase) + * key index — otherwise an uppercase lock we *can* sign for is falsely rejected. + */ +private fun String.xOnly(): String = (if (length == 66) substring(2) else this).lowercase() diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PKRedeemTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PKRedeemTest.kt index 1c07d49076..0551dbbb83 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PKRedeemTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PKRedeemTest.kt @@ -133,4 +133,33 @@ class P2PKRedeemTest { assertFalse(listOf(plainProof()).anyP2pkLocked()) assertTrue(listOf(plainProof(), lockedProof(xOnlyPub)).anyP2pkLocked()) } + + @Test + fun uppercaseLockMatchesLowercaseKeyIndex() { + // NUT-11 doesn't mandate a hex case for `data`. Our key index is keyed + // by lowercase x-only (Hex.encode is lowercase), so an uppercase lock we + // hold the key for must still resolve — not be reported unredeemable. + val upperLock = "02${xOnlyPub.uppercase()}" + val index = mapOf(xOnlyPub to priv) + val out = signP2pkWitnesses(listOf(lockedProof(upperLock))) { index[it] } + assertTrue(witnessVerifies(out[0], xOnlyPub), "an uppercase lock we hold the key for must sign") + } + + @Test + fun firstUnsignableReturnsNullWhenAllSignable() { + assertNull(firstUnsignableP2pkLock(listOf(plainProof(), lockedProof(xOnlyPub))) { priv }) + } + + @Test + fun firstUnsignableNamesTheUnredeemableLock() { + val other = "02${"b".repeat(64)}" + assertEquals(other, firstUnsignableP2pkLock(listOf(plainProof(), lockedProof(other))) { null }) + } + + @Test + fun firstUnsignableIsCaseInsensitive() { + val upperLock = "02${xOnlyPub.uppercase()}" + val index = mapOf(xOnlyPub to priv) + assertNull(firstUnsignableP2pkLock(listOf(lockedProof(upperLock))) { index[it] }) + } } From fc758e889b8f52b495a55f00925ef230f7313880 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 02:42:32 +0000 Subject: [PATCH 11/12] fix: remove community banner from Concord channels list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the CORD-02 §6 banner hero that showed when a community was expanded to OPEN in the Concord home screen, so opening a community and viewing its channels no longer displays the banner. Removes the now-unused CommunityBanner composable and its ContentScale/height imports. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01F6rz1csKDADKc5nr7R4a1b --- .../concord/ConcordHomeScreen.kt | 28 ------------------- 1 file changed, 28 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt index 6211adcf9d..8cd9809865 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt @@ -27,7 +27,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn @@ -52,7 +51,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.font.FontWeight @@ -204,12 +202,6 @@ fun ConcordHomeScreen( } if (mode != ChannelExpand.CLOSED && state != null) { - // A banner hero (CORD-02 §6) belongs to the full view, not the compact unread peek. - if (mode == ChannelExpand.OPEN) { - state.metadata?.banner?.let { banner -> - item(key = "banner-${entry.id}") { CommunityBanner(banner, accountViewModel) } - } - } // Channels, most-recently-active first, each with its last message + unread state. // In UNREAD mode a row hides itself unless it has new messages (peek). val channels = @@ -357,26 +349,6 @@ private fun CommunityHeader( } } -/** The community's decrypted CORD-02 §6 banner as a hero strip; renders nothing until it resolves. */ -@Composable -private fun CommunityBanner( - banner: ImagePointer, - accountViewModel: AccountViewModel, -) { - val model = rememberConcordImageModel(banner, accountViewModel) ?: return - val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle() - RobohashFallbackAsyncImage( - robot = "", - model = model, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxWidth().height(110.dp), - loadProfilePicture = accountViewModel.settings.showProfilePictures(), - loadRobohash = false, - autoPlayGif = autoPlayGif, - ) -} - /** * One channel row with its last message (author + snippet), relative time, and an unread marker — * bold + a dot when there's a message newer than this account last read the channel. From 654c33fee81c881f9cd992b9c07b04e028df23d8 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 26 Jul 2026 22:45:16 -0400 Subject: [PATCH 12/12] fix(chats): render the community tab correct on its first frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leaving the Buzz community tab and coming back rebuilt the screen: Direct Messages sat empty for about a second, and the channel list settled into a different order than it had a moment earlier — every visit, and a different order each time. Three causes, all fixed here. **The tab was destroyed, not left.** navBottomBar popped siblings without `saveState`, so the entry — and its ViewModelStore — was thrown away on every tab switch. Returning built new BuzzRelayImportViewModel / BuzzDmListViewModel instances, whose bind() self-guard could not help because the guard lives on an object that no longer existed. Adding saveState/restoreState keeps each tab's state; other tabs get their scroll position back as a side effect. **The DM inbox waited on the network to show what it already had.** bind() called refresh() → discoverMemberChannels(), an 8s-timeout relay round-trip, before anything could render — even though rebuildRows() reads nothing but LocalCache and an in-memory map, and the always-on BuzzDmDiscovery has already recorded those channel ids process-wide in BuzzDmChannels. Seed memberChannels from that registry and project the rows before any network work; refresh still runs behind it and corrects anything stale. **The channel order was arrival order.** buzzGroupIds is "membership ids as the ViewModel emitted them, then directory ids", and the only sort was `sortedByDescending { it.id in starred }` — a stable sort over a boolean, which preserves whatever landed first. Order by (starred, name) so the first frame is the final order; a channel whose 39000 hasn't arrived sorts by id until its name lands. Verified on emulator-5554, capturing ~0.45s after the tab switch: channels in alphabetical order and both DMs present, with no reshuffle in later frames. Previously the same capture showed an empty Direct Messages section and a list that reordered within the second. Co-Authored-By: Claude Opus 5 (1M context) --- .../amethyst/ui/navigation/navs/Nav.kt | 8 +++++ .../loggedIn/buzz/BuzzDmListViewModel.kt | 30 +++++++++++++++++++ .../relayGroup/RelayGroupChannelListScreen.kt | 18 ++++++++--- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt index 1578cbc853..c8081a2cd1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt @@ -94,10 +94,18 @@ class Nav( // Clear sibling bottom-nav entries but keep Home (the start // destination) below, so back-swipe from any tab returns to // Home and back-swipe from Home leaves the app. + // + // saveState/restoreState is what makes a tab survive being left. Without them the + // popped entry is DESTROYED, taking its ViewModelStore with it — so every return to + // a tab rebuilt its screen-scoped ViewModels from nothing and re-fetched. On the + // Buzz community tab that is a visible ~1s of empty Direct Messages plus a channel + // list that reshuffles as data lands; other tabs pay it as lost scroll position. popUpTo(Route.Home) { inclusive = false + saveState = true } launchSingleTop = true + restoreState = true } // Mark this entry as a tab root: hides the back arrow in canPop // and skips the horizontal slide in composableFromEnd. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt index 9c15d9d7bd..2c07d8ccf5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmChannels import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces @@ -126,10 +127,39 @@ class BuzzDmListViewModel : ViewModel() { // challenge was spent unauthenticated, so reconnect to re-challenge and authenticate. if (newlyJoined) account.client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true) + // Paint from cache BEFORE any network work. [discoverMemberChannels] learns the channel ids + // from a relay round-trip, so waiting on it left the Direct Messages section visibly empty + // for about a second on every visit — even though the always-on [BuzzDmDiscovery] already + // recorded those ids process-wide and [rebuildRows] reads nothing but caches. Seeding from + // that registry makes the first frame the right frame; the refresh below still runs and + // corrects anything stale. + seedFromDiscovery(account, relay) + refresh() startLive() } + /** + * Fills [memberChannels] from the app-wide [BuzzDmChannels] registry (scoped to this community's + * relay) and projects the rows straight away, so the inbox renders from cache instead of after a + * fetch. A no-op the first time a viewer ever opens a Buzz relay, when discovery genuinely has + * nothing yet. + */ + private fun seedFromDiscovery( + account: Account, + relay: NormalizedRelayUrl, + ) { + val known = BuzzDmChannels.channelsFor(account.userProfile().pubkeyHex) + var seeded = false + known.forEach { (channelId, discoveredOn) -> + if (discoveredOn == relay) { + memberChannels[channelId] = discoveredOn + seeded = true + } + } + if (seeded) rebuildRows(account) + } + fun refresh() { val account = account ?: return viewModelScope.launch(Dispatchers.IO) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index c26cbd3390..d9a6cea737 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -214,20 +214,30 @@ fun RelayGroupChannelListScreen( } fun buzzTypeOf(groupId: GroupId): String? = channelsById[groupId.id]?.event?.buzzChannelType() - // Starred channels float to the top of their section (stable sort keeps the alphabetical order - // within the starred and unstarred buckets). + + // Starred channels float to the top of their section, then alphabetical. + // + // The name is the tie-break on purpose: [buzzGroupIds] is in *arrival* order (membership ids as + // the ViewModel emitted them, then directory ids), so sorting on `starred` alone — a stable sort + // over a boolean — left the underlying order at the mercy of whatever landed first. The list + // visibly reshuffled in the second after opening, and came back differently each visit. Ordering + // by a property of the channel instead makes the first frame the final order; a channel whose + // 39000 hasn't arrived sorts by its id until the name lands. val starred by BuzzChannelStars.flow.collectAsStateWithLifecycle() + + fun buzzSortKey(groupId: GroupId): String = channelsById[groupId.id]?.toBestDisplayName()?.lowercase() ?: groupId.id + val buzzChatChannels = remember(buzzGroupIds, channelsById, starred) { buzzGroupIds .filter { buzzTypeOf(it).let { t -> t != BUZZ_CHANNEL_TYPE_FORUM && t != BUZZ_CHANNEL_TYPE_DM } } - .sortedByDescending { it.id in starred } + .sortedWith(compareByDescending { it.id in starred }.thenBy { buzzSortKey(it) }) } val buzzForumChannels = remember(buzzGroupIds, channelsById, starred) { buzzGroupIds .filter { buzzTypeOf(it) == BUZZ_CHANNEL_TYPE_FORUM } - .sortedByDescending { it.id in starred } + .sortedWith(compareByDescending { it.id in starred }.thenBy { buzzSortKey(it) }) } // Which sections the user has collapsed (session-scoped). Keyed by section id below.