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/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/BuzzCanvasScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzCanvasScreen.kt index 5899bb3cd3..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 @@ -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,26 @@ 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.buzz.workspace.isBuzzDm 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 +96,22 @@ fun BuzzCanvasScreen( val canvas = remember(version) { state.canvasNote } val content = canvas?.event?.content + // 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)) + } + } + 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) } if (editing) { @@ -101,10 +126,37 @@ 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)) + 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/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/buzz/BuzzImportRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt index 3c65271fcd..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 @@ -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,44 @@ 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. + * + * [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( @@ -72,22 +103,73 @@ 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) } + + // 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. 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 val name = channel.toBestDisplayName() val memberCount = channel.memberCount() + val isPrivate = channel.isPrivate() - // 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) + // 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 = @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,84 +182,179 @@ 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 + val preview: String = + if (event == null) { 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), - ) - } - } - 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, - ) + pluralStringResource(R.plurals.relay_group_member_count, memberCount, memberCount) + } else { + stringRes(R.string.relay_group_no_messages_yet) } } else { - OutlinedButton(onClick = onAdd) { - Text(stringRes(R.string.buzz_import_add)) + // 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 "author: message". + val summary = buzzTimelinePreviewSummary(event, accountViewModel) + when { + summary != null -> summary + 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 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), + ) + } + 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() + }, + ) + } + 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 +375,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/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. 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..8feb4ef174 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 @@ -214,20 +216,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. @@ -271,7 +283,7 @@ fun RelayGroupChannelListScreen( text = relay.displayUrl(), fontWeight = FontWeight.Bold, maxLines = 1, - overflow = TextOverflow.Ellipsis, + overflow = TextOverflow.MiddleEllipsis, ) }, showBackButton = canPop, @@ -282,6 +294,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 +376,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 +384,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 -> @@ -413,6 +423,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, ) } } @@ -456,11 +469,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 +574,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 +592,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 +620,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 +645,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 = buzzTimelinePreviewSummary(event, accountViewModel) + val preview: String = + when { + summary != null -> summary + 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 +689,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/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/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 2ba8e822f4..553c01ee79 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,21 +173,17 @@ 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. - 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, @@ -213,48 +212,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 = { @@ -378,3 +397,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) } +} 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 a26ea3dc1f..11eae20dea 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 @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.isMinichatReply +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 @@ -111,11 +112,59 @@ fun RelayGroupChannel.newestTimelineNote(account: Account): Note? = .sortedByDefaultFeedOrder() .firstOrNull() +/** + * 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 } +} + /** Whether this group's message store holds any acceptable timeline message created after [sinceSecs]. */ private fun RelayGroupChannel.hasChatNewerThan( account: Account, sinceSecs: Long, -): Boolean = +): Boolean = newMessagesSince(account, sinceSecs) > 0 + +/** 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 && isRelayGroupTimelineMessage(note, account) - } > 0 + } + +/** + * 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) + } +} 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..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 @@ -26,12 +26,15 @@ 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.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 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 @@ -905,10 +908,36 @@ 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). + // 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 + } + // 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 + .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/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 @@ 移除地點 添加內容警告 移除內容警告 + + + + + 新增到期日 移除到期日 到期日 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index ca3fdfadb1..b830adf5f7 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 @@ -3490,7 +3492,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) 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)) + } +} 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..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,7 +25,12 @@ 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 +import com.vitorpamplona.quartz.nip60Cashu.p2pk.P2PKUnredeemableException +import com.vitorpamplona.quartz.nip60Cashu.p2pk.anyP2pkLocked import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenB64Parser /** @@ -156,12 +161,31 @@ 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). + // 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 + // 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 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 +200,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..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 @@ -46,6 +46,8 @@ 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.p2pk.firstUnsignableP2pkLock import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent import com.vitorpamplona.quartz.nip60Cashu.token.CashuProof import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent @@ -630,9 +632,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 +1298,76 @@ 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()) { + // toHexKey() emits lowercase, matching the lowercased x-only the + // resolver is queried with (see P2PKRedeem.xOnly). + val xOnly = + Secp256k1 + .pubKeyCompress(Secp256k1.pubkeyCreate(hex.hexToByteArray())) + .toHexKey() + .lastHex64() + put(xOnly, hex) + } + } + } + +/** + * 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) { + 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.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 { + "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/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) | 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..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 @@ -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,11 +110,29 @@ 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 * 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 new file mode 100644 index 0000000000..f5b5ac258f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PKRedeem.kt @@ -0,0 +1,88 @@ +/* + * 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 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, 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 new file mode 100644 index 0000000000..0551dbbb83 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PKRedeemTest.kt @@ -0,0 +1,165 @@ +/* + * 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()) + } + + @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] }) + } +} 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