Merge branch 'main' into test/buzz-agent-support

This commit is contained in:
Vitor Pamplona
2026-07-26 23:02:48 -04:00
62 changed files with 1548 additions and 234 deletions
@@ -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<String?, String?> = walletPrivkeyHex() to (signer as? NostrSignerInternal)?.keyPair?.privKey?.toHexKey()
private suspend fun walletPrivkeyHex(): String? =
_walletEvent.value?.let { evt ->
runCatching { evt.privkey(signer) }.getOrNull()
@@ -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.
@@ -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 ->
@@ -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) {
@@ -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<String>
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<String>,
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)
@@ -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))
@@ -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.
@@ -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<GroupId> { 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<GroupId> { 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,
@@ -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),
@@ -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<Boolean> {
val state = remember(channelId) { BuzzWorkspaceStates.getOrCreate(channelId) }
val version by state.canvasUpdates.collectAsStateWithLifecycle()
return remember(channelId, version) { mutableStateOf(state.canvasNote != null) }
}
@@ -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<HexKey> {
val latestByAuthor = HashMap<HexKey, Long>()
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<Int> {
val channel = LocalCache.getOrCreateRelayGroupChannel(groupId)
return combine(
account.loadLastReadFlow(relayGroupChannelLastReadRoute(groupId)),
channel.flow().notes.stateFlow,
) { lastRead, _ ->
channel.newMessagesSince(account, lastRead)
}
}
@@ -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))
}
}
}
@@ -2260,6 +2260,11 @@
<string name="remove_location">إزالة الموقع</string>
<string name="add_content_warning">إضافة تحذير محتوى</string>
<string name="remove_content_warning">إزالة تحذير المحتوى</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="add_expiration_date">إضافة تاريخ انتهاء الصلاحية</string>
<string name="remove_expiration_date">إزالة تاريخ انتهاء الصلاحية</string>
<string name="expiration_date_label">تاريخ انتهاء الصلاحية</string>
@@ -2147,6 +2147,11 @@
<string name="remove_location">অবস্থান সরান</string>
<string name="add_content_warning">কন্টেন্ট সতর্কতা যোগ করুন</string>
<string name="remove_content_warning">কন্টেন্ট সতর্কতা সরান</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="add_expiration_date">মেয়াদ শেষের তারিখ যোগ করুন</string>
<string name="remove_expiration_date">মেয়াদ শেষের তারিখ সরান</string>
<string name="expiration_date_label">মেয়াদ শেষের তারিখ</string>
@@ -3259,6 +3259,11 @@
<string name="chat_system_updated_channel">%1$s aktualizoval(a) profil kanálu</string>
<string name="buzz_message_edited">(upraveno)</string>
<string name="buzz_diff_truncated">(rozdíl zkrácen)</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="buzz_canvas_title">Plátno</string>
<string name="buzz_canvas_empty">V tomto pracovním prostoru zatím nebylo sdíleno žádné plátno.</string>
<string name="buzz_canvas_edit">Upravit plátno</string>
@@ -3142,6 +3142,11 @@
<string name="chat_system_updated_channel">%1$s hat das Kanalprofil aktualisiert</string>
<string name="buzz_message_edited">(bearbeitet)</string>
<string name="buzz_diff_truncated">(Diff gekürzt)</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="buzz_canvas_empty">In diesem Workspace wurde noch kein Canvas geteilt.</string>
<string name="buzz_canvas_edit">Canvas bearbeiten</string>
<string name="buzz_canvas_save">Canvas speichern</string>
@@ -2127,6 +2127,11 @@
<string name="remove_location">Αφαίρεση Τοποθεσίας</string>
<string name="add_content_warning">Προσθήκη προειδοποίησης περιεχομένου</string>
<string name="remove_content_warning">Αφαίρεση προειδοποίησης περιεχομένου</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="add_expiration_date">Προσθήκη ημερομηνίας λήξης</string>
<string name="remove_expiration_date">Αφαίρεση ημερομηνίας λήξης</string>
<string name="expiration_date_label">Ημερομηνία Λήξης</string>
@@ -63,6 +63,11 @@
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<!-- Reusable geohash location picker (shared, not group-specific) -->
<!-- Onchain zap send dialog -->
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<!-- %1$s is replaced at runtime by an inline star icon, not text. Keep the placeholder. -->
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
@@ -2151,6 +2151,11 @@
<string name="remove_location">Forigi Lokon</string>
<string name="add_content_warning">Aldoni enhavavertadon</string>
<string name="remove_content_warning">Forigi enhavavertadon</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="add_expiration_date">Aldoni limdaton</string>
<string name="remove_expiration_date">Forigi limdaton</string>
<string name="expiration_date_label">Limdato</string>
@@ -2200,6 +2200,11 @@
<string name="remove_location">Eliminar ubicación</string>
<string name="add_content_warning">Añadir advertencia de contenido</string>
<string name="remove_content_warning">Eliminar advertencia de contenido</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="add_expiration_date">Añadir fecha de expiración</string>
<string name="remove_expiration_date">Eliminar fecha de expiración</string>
<string name="expiration_date_label">Fecha de expiración</string>
@@ -2191,6 +2191,11 @@
<string name="remove_location">Eliminar ubicación</string>
<string name="add_content_warning">Agregar advertencia de contenido</string>
<string name="remove_content_warning">Eliminar advertencia de contenido</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="add_expiration_date">Añadir fecha de expiración</string>
<string name="remove_expiration_date">Eliminar fecha de expiración</string>
<string name="expiration_date_label">Fecha de expiración</string>
@@ -2191,6 +2191,11 @@
<string name="remove_location">Eliminar ubicación</string>
<string name="add_content_warning">Agregar advertencia de contenido</string>
<string name="remove_content_warning">Eliminar advertencia de contenido</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="add_expiration_date">Añadir fecha de expiración</string>
<string name="remove_expiration_date">Eliminar fecha de expiración</string>
<string name="expiration_date_label">Fecha de expiración</string>
@@ -2161,6 +2161,11 @@
<string name="remove_location">حذف موقعیت مکانی</string>
<string name="add_content_warning">افزودن هشدار محتوا</string>
<string name="remove_content_warning">حذف هشدار محتوا</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="add_expiration_date">افزودن تاریخ انقضا</string>
<string name="remove_expiration_date">حذف تاریخ انقضا</string>
<string name="expiration_date_label">تاریخ انقضا</string>
@@ -2133,6 +2133,11 @@
<string name="remove_location">Poista sijainti</string>
<string name="add_content_warning">Lisää sisältövaroitus</string>
<string name="remove_content_warning">Poista sisältövaroitus</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="add_expiration_date">Lisää vanhentumispäivä</string>
<string name="remove_expiration_date">Poista vanhentumispäivä</string>
<string name="expiration_date_label">Vanhentumispäivä</string>
@@ -2074,6 +2074,11 @@
<string name="remove_location">Retirer l\'Emplacement</string>
<string name="add_content_warning">Ajouter un avertissement de contenu</string>
<string name="remove_content_warning">Retirer l\'avertissement de contenu</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="add_expiration_date">Ajouter une date d\'expiration</string>
<string name="remove_expiration_date">Supprimer la date d\'expiration</string>
<string name="expiration_date_label">Date d\'expiration</string>
@@ -2374,6 +2374,11 @@
<string name="remove_location">Retirer l\'Emplacement</string>
<string name="add_content_warning">Ajouter un avertissement de contenu</string>
<string name="remove_content_warning">Retirer l\'avertissement de contenu</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="add_expiration_date">Ajouter une date d\'expiration</string>
<string name="remove_expiration_date">Supprimer la date d\'expiration</string>
<string name="expiration_date_label">Date d\'expiration</string>
@@ -3142,6 +3142,11 @@
<string name="chat_system_updated_channel">%1$s ने प्रणाली परिचय का अद्यतन किया</string>
<string name="buzz_message_edited">(सम्पादित)</string>
<string name="buzz_diff_truncated">(अन्तर ह्रस्वकृत)</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="buzz_canvas_title">चित्रपट</string>
<string name="buzz_canvas_empty">कोई चित्रपट बाँटा नहीं गया इस कार्यशाला में अब तक।</string>
<string name="buzz_canvas_edit">चित्रपट सम्पादन</string>
@@ -2738,6 +2738,11 @@
<string name="chat_system_created_channel">%1$s létrehozta a(z) %2$s nevű csatornát</string>
<string name="chat_system_created_channel_unnamed">%1$s létrehozta a csatornát</string>
<string name="chat_system_updated_channel">%1$s frissítette a csatorna profilját</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="chat_delivery_details_title">Üzenet kézbesítése</string>
<string name="close">Bezárás</string>
<string name="chat_delivery_pending">Várakozás egy átjátszóra az üzenet elfogadásához</string>
@@ -2087,6 +2087,11 @@ Seharusnya %3$s</string>
<string name="remove_location">Hapus Lokasi</string>
<string name="add_content_warning">Tambah peringatan konten</string>
<string name="remove_content_warning">Hapus peringatan konten</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="add_expiration_date">Tambah tanggal kedaluwarsa</string>
<string name="remove_expiration_date">Hapus tanggal kedaluwarsa</string>
<string name="expiration_date_label">Tanggal Kedaluwarsa</string>
@@ -2104,6 +2104,11 @@
<string name="remove_location">Rimuovi posizione</string>
<string name="add_content_warning">Aggiungi avviso contenuto</string>
<string name="remove_content_warning">Rimuovi avviso contenuto</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="add_expiration_date">Aggiungi data di scadenza</string>
<string name="remove_expiration_date">Rimuovi data di scadenza</string>
<string name="expiration_date_label">Data di scadenza</string>
@@ -2122,6 +2122,11 @@
<string name="remove_location">位置情報を削除</string>
<string name="add_content_warning">コンテンツ警告を追加</string>
<string name="remove_content_warning">コンテンツ警告を削除</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="add_expiration_date">有効期限を追加</string>
<string name="remove_expiration_date">有効期限を削除</string>
<string name="expiration_date_label">有効期限</string>
@@ -2112,6 +2112,11 @@
<string name="remove_location">위치 제거</string>
<string name="add_content_warning">콘텐츠 경고 추가</string>
<string name="remove_content_warning">콘텐츠 경고 제거</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="add_expiration_date">만료 날짜 추가</string>
<string name="remove_expiration_date">만료 날짜 제거</string>
<string name="expiration_date_label">만료 날짜</string>
@@ -2158,6 +2158,11 @@
<string name="remove_location">Noņemt atrašanās vietu</string>
<string name="add_content_warning">Pievienot satura brīdinājumu</string>
<string name="remove_content_warning">Noņemt satura brīdinājumu</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="add_expiration_date">Pievienot derīguma termiņu</string>
<string name="remove_expiration_date">Noņemt derīguma termiņu</string>
<string name="expiration_date_label">Derīguma termiņš</string>
@@ -2400,6 +2400,11 @@
<string name="remove_location">Locatie verwijderen</string>
<string name="add_content_warning">Inhoudswaarschuwing toevoegen</string>
<string name="remove_content_warning">Inhoudswaarschuwing verwijderen</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="add_expiration_date">Vervaldatum toevoegen</string>
<string name="remove_expiration_date">Vervaldatum verwijderen</string>
<string name="expiration_date_label">Vervaldatum</string>
@@ -3256,6 +3256,11 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="chat_system_updated_channel">%1$s zaktualizował profil kanału</string>
<string name="buzz_message_edited">(edytowane)</string>
<string name="buzz_diff_truncated">(diff skrócony)</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="buzz_canvas_title">Płótno</string>
<string name="buzz_canvas_empty">Żadne płótno nie zostało jeszcze udostępnione w tym projekcie.</string>
<string name="buzz_canvas_edit">Edytuj płótno</string>
@@ -3142,6 +3142,11 @@
<string name="chat_system_updated_channel">%1$s atualizou o perfil do canal</string>
<string name="buzz_message_edited">(editado)</string>
<string name="buzz_diff_truncated">(diff truncado)</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="buzz_canvas_empty">Nenhum canvas foi compartilhado neste espaço de trabalho ainda.</string>
<string name="buzz_canvas_edit">Editar canvas</string>
<string name="buzz_canvas_save">Salvar canvas</string>
@@ -2118,6 +2118,11 @@
<string name="remove_location">Remover Localização</string>
<string name="add_content_warning">Adicionar aviso de conteúdo</string>
<string name="remove_content_warning">Remover aviso de conteúdo</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="add_expiration_date">Adicionar data de expiração</string>
<string name="remove_expiration_date">Remover data de expiração</string>
<string name="expiration_date_label">Data de expiração</string>
@@ -2198,6 +2198,11 @@
<string name="remove_location">Удалить местоположение</string>
<string name="add_content_warning">Добавить предупреждение о контенте</string>
<string name="remove_content_warning">Убрать предупреждение о контенте</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="add_expiration_date">Добавить дату истечения</string>
<string name="remove_expiration_date">Убрать дату истечения</string>
<string name="expiration_date_label">Дата истечения</string>
@@ -2182,6 +2182,11 @@
<string name="remove_location">Удалить местоположение</string>
<string name="add_content_warning">Добавить предупреждение о контенте</string>
<string name="remove_content_warning">Убрать предупреждение о контенте</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="add_expiration_date">Добавить дату истечения</string>
<string name="remove_expiration_date">Убрать дату истечения</string>
<string name="expiration_date_label">Дата истечения</string>
@@ -2206,6 +2206,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="ui_feature_set_type_performance">Optimiziran</string>
<!-- Compact labels for the segmented control. -->
<string name="ui_feature_set_type_complete_short">Polno</string>
<string name="ui_feature_set_type_simplified_short">Preprosto</string>
<string name="ui_feature_set_type_performance_short">Hitro</string>
<string name="gallery_type_classic">Klasično</string>
<string name="gallery_type_modern">Moderno</string>
<string name="system">Sistemska</string>
@@ -2354,6 +2356,39 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="new_conversation_marmot_pro_2">Šifriran skupinski klepet</string>
<string name="new_conversation_marmot_con_1">ezano na napravo — ne bo prikazano, če se prijavite drugje</string>
<string name="new_conversation_concord_title">Concord skupnost</string>
<string name="new_conversation_concord_tagline">Skupnosti, razdeljene po kanalih</string>
<string name="new_conversation_concord_chip">Delovni prostori</string>
<string name="new_conversation_concord_best">Velike skupnosti, organizirane po kanalih ali potekih dela.</string>
<string name="new_conversation_concord_cta">Ustvari skupnost</string>
<string name="new_conversation_concord_pro_1">Prilagojeno za ogromne skupnosti</string>
<string name="new_conversation_concord_pro_2">Razdeljeno po kanalih ali potekih dela</string>
<string name="new_conversation_concord_con_1">Zahteva več nastavitev</string>
<string name="new_conversation_public_chat_title">Javni klepet</string>
<string name="new_conversation_public_chat_tagline">Javna sporočila na releju</string>
<string name="new_conversation_public_chat_chip">Brez moderiranja</string>
<string name="new_conversation_public_chat_best">Odprte javne sobe, kjer lahko objavlja vsak.</string>
<string name="new_conversation_public_chat_cta">Ustvari nov javen klepet</string>
<string name="new_conversation_public_chat_pro_1">Pridruži se lahko kdorkoli</string>
<string name="new_conversation_public_chat_pro_2">Preprosto in zlahka dosegljivo </string>
<string name="new_conversation_public_chat_con_1">Samo javno</string>
<string name="new_conversation_public_chat_con_2">Brez moderacije</string>
<string name="new_conversation_relay_group_title">Skupina releja</string>
<string name="new_conversation_relay_group_tagline">Na relejih — javno ali zasebno</string>
<string name="new_conversation_relay_group_chip">Moderirano</string>
<string name="new_conversation_relay_group_best">Skupine, ki jih moderira njihov gostiteljski rele</string>
<string name="new_conversation_relay_group_cta">Brskanje po skupinah na relejih</string>
<string name="new_conversation_relay_group_pro_1">Moderira gostiteljski rele</string>
<string name="new_conversation_relay_group_pro_2">Javno ali zasebno</string>
<string name="new_conversation_relay_group_con_1">Vezano na tisti posamezni rele</string>
<string name="new_conversation_ephemeral_title">Minljivi klepet</string>
<string name="new_conversation_ephemeral_tagline">Kdor koli je trenutno na spletu</string>
<string name="new_conversation_ephemeral_chip">Trenutno v živo</string>
<string name="new_conversation_ephemeral_best">Klepet v živo s tistimi, ki so pravkar na spletu.</string>
<string name="new_conversation_ephemeral_cta">Zaženi klepet</string>
<string name="new_conversation_ephemeral_pro_1">Pogovor s trenutno prisotnimi</string>
<string name="new_conversation_ephemeral_pro_2">Nič ni shranjeno</string>
<string name="new_conversation_ephemeral_con_1">Brez zgodovine</string>
<string name="new_conversation_ephemeral_con_2">Omejeno na rele</string>
<string name="relay_groups_title">Skupine relejev</string>
<string name="relay_group_view_inline">Vrstično</string>
<string name="relay_group_view_grouped">Po releju</string>
@@ -2363,7 +2398,24 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="relay_group_server_label">Skupine relejev</string>
<string name="relay_groups_button">Skupine</string>
<string name="messages_settings">Sporočila</string>
<string name="messages_load_types_title">Naloži pogovore</string>
<string name="messages_load_types_desc">Izberite, kateri klepeti naj bodo vidni v prejetih sporočilih. Z izklopom se klepet skrije in ne prenese več z vaših relejev.</string>
<string name="chat_type_nip17_title">Zasebna sporočila</string>
<string name="chat_type_nip17_desc">E2EE, gift-wrapped zasebna sporočila (NIP-17)</string>
<string name="chat_type_nip04_title">Zastarela direktna sporočila</string>
<string name="chat_type_nip04_desc">Starejša, manj zasebna šifrirana direktna sporočila (NIP-04)</string>
<string name="chat_type_nip28_title">Javni kanali</string>
<string name="chat_type_nip28_desc">Odprte, javne klepetalnice, ki jih lahko prebere in se jim pridruži vsak (NIP-28)</string>
<string name="chat_type_nip29_title">Skupine relejev</string>
<string name="chat_type_nip29_desc">Skupinski klepeti z moderiranjem in seznami članov (NIP-29)</string>
<string name="chat_type_marmot_title">Šifrirane skupine</string>
<string name="chat_type_marmot_desc">MLS- E2EE šifrirani skupinski klepeti (Marmot)</string>
<string name="chat_type_concord_title">Concord skupnosti</string>
<string name="chat_type_concord_desc">Šifrirane skupnosti z več tematskimi kanali (Concord).</string>
<string name="chat_type_geohash_title">Lokacijski klepeti</string>
<string name="chat_type_geohash_desc">Javni klepet glede na vašo lokacijo (Geohash)</string>
<string name="chat_type_ephemeral_title">Minljivi klepeti</string>
<string name="chat_type_ephemeral_desc">Hitri klepeti na releju, ki ne hranijo zgodovine sporočil</string>
<string name="relay_group_create_title">Ustvari skupino</string>
<string name="relay_group_relay_no_nip29">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.</string>
<string name="relay_group_create_name">Ime supine</string>
@@ -2387,6 +2439,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="relay_group_members_title">Člani</string>
<string name="relay_group_make_admin">Dodeli vlogo skrbnika</string>
<string name="relay_group_make_moderator">Dodeli vlogo moderatorja</string>
<string name="relay_group_assign_role">Dodeli vlogo: %1$s</string>
<string name="relay_group_demote_member">Odvzemi vlogo</string>
<string name="relay_group_remove_user">Odstrani iz skupine</string>
<string name="relay_group_remove_user_confirm">Odstrani %1$s iz te skupine? Izgubili bodo dostop, dokler jih ponovno ne dodate ali povabite.</string>
@@ -2399,11 +2452,19 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="relay_group_menu_members">Člani</string>
<string name="relay_group_menu_edit">Uredi skupino</string>
<string name="relay_group_threads_title">Nizi objav</string>
<string name="relay_group_pin_message">Pripni sporočilo</string>
<string name="relay_group_unpin_message">Odpni sporočilo</string>
<string name="relay_group_pinned_label">Pripeto</string>
<string name="relay_group_pinned_content_description">Pripeta sporočila</string>
<string name="relay_group_open">Odpri skupino</string>
<string name="relay_group_channels_empty">Na tem releju še ni skupin.</string>
<string name="relay_group_channels_not_nip29">Ta rele ne sporoča podpore za skupine (NIP-29), zato tukaj skupin morda ni.</string>
<string name="relay_group_relay_not_nip29">Skupine (NIP-29) morda niso podprte</string>
<string name="relay_group_invite_preparing">Pripravljam povabilo…</string>
<string name="relay_group_badge_private">Zasebno</string>
<string name="relay_group_badge_invite_only">samo s povabilom</string>
<string name="relay_group_badge_live">V ŽIVO</string>
<string name="relay_group_message_count_short_capped">%1$d+</string>
<string name="relay_group_browse_invalid_url">Vnesite veljaven URL releja (wss://…)</string>
<string name="relay_group_field_name">Ime</string>
<string name="relay_group_field_about">Opis</string>
@@ -2416,7 +2477,28 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="relay_group_field_topics_hint">bitcoin, nostr, umetnost</string>
<string name="relay_group_field_geohash">Lokacija (geohash)</string>
<string name="relay_group_field_geohash_hint">u0nd</string>
<string name="relay_group_location_add">Dodaj lokacijo</string>
<string name="relay_group_location_add_desc">Pripnite svojo skupino na zemljevid, da jo lahko odkrijejo ljudje v bližini.</string>
<string name="relay_group_location_edit">Spremeni lokacijo</string>
<string name="relay_group_location_clear">Odstrani lokacijo</string>
<string name="relay_group_location_manual">Ročno vnesite geohash</string>
<!-- Reusable geohash location picker (shared, not group-specific) -->
<string name="location_picker_title">Izberi lokacijo</string>
<string name="location_picker_hint">Premaknite zemljevid, vnesite lokacijo ali uporabite svojo trenutno legi.</string>
<string name="location_picker_search_hint">Išči po mestu ali naslovu</string>
<string name="location_picker_search_empty">Noben ustrezen kraj ni bil najden.</string>
<string name="location_picker_use_mine">Uporabi mojo trenutno lokacijo</string>
<string name="location_picker_area">Velikost območja</string>
<string name="location_picker_confirm">Uporabi to lokacijo</string>
<string name="relay_group_section_structure">Struktura</string>
<string name="relay_group_parent_desc">Dodajte v starševsko skupino za ustvarjanje hierarhije.</string>
<string name="relay_group_parent_label">Starševska skupina</string>
<string name="relay_group_parent_none">Krovna skupina</string>
<string name="relay_group_parent_pick_title">Izberite starševsko skupino</string>
<string name="relay_group_parent_search">Išči skupine</string>
<string name="relay_group_parent_top_level_option">Brez starševske skupine (krovna skupina)</string>
<string name="relay_group_parent_none_desc">Ta skupina je na najvišji ravni.</string>
<string name="relay_group_parent_empty">Tukaj trenutno ni nobenih drugih skupin.</string>
<string name="relay_group_section_permissions">Dovoljenja</string>
<string name="relay_group_flag_private">Zasebno</string>
<string name="relay_group_flag_private_desc">Samo člani lahko berejo sporočila.</string>
@@ -2430,6 +2512,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="relay_group_discovery_empty_filtered">S tem filtrom še ni najdenih nobenih skupin.</string>
<string name="relay_group_favorite_relay">Dodaj ta rele med priljubljene</string>
<string name="relay_group_threads_empty">Še ni nizov objav. Začni jih z + gumbom.</string>
<string name="relay_group_threads_loading_older">Nalaganje starejšega niza objav…</string>
<string name="relay_group_threads_all_caught_up">Nalaganje starejšega niza objav…</string>
<string name="relay_group_thread_new">Nov niz objav</string>
<string name="relay_group_thread_untitled">Brez naslova</string>
<string name="relay_group_thread_title_label">Naslov</string>
@@ -2439,9 +2523,18 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="relay_group_browse_description">Brskajte po skupinah, ki jih gosti rele. Prilepite naslov releja ali izberite enega spodaj.</string>
<string name="relay_group_browse_relay_label">Naslov releja</string>
<string name="relay_group_browse_go">Brskaj</string>
<string name="relay_tor_clearnet_title">Povezava s tem relejem preko omrežja Tor ni mogoča.</string>
<string name="relay_tor_clearnet_body">%1$s se ne odziva prek omrežja Tor — gostitelj morda blokirajo izstopna vozlišča Tor. Želite vzpostaviti povezavo prek običajnega spleta (clearnet)?</string>
<string name="relay_tor_clearnet_action">Uporabi običajni splet (clearnet)</string>
<string name="relay_group_browse_your_relays">Releji na katerih ste vi</string>
<string name="relay_group_browse_popular">Popularni releji</string>
<string name="relay_group_no_messages_yet">Ni sporočil</string>
<string name="channel_invite_title">Dodano v: %1$s</string>
<string name="channel_invite_body">%1$s vas je dodal v ta kanal na %2$s. Želite to prikazati med sporočili?</string>
<string name="channel_invite_unknown_actor">Nekdo</string>
<string name="channel_invite_accept">Prikaži</string>
<string name="channel_invite_ignore">Prezri</string>
<string name="channel_invite_leave">Zapusti</string>
<string name="relay_group_join_to_post">Pridružite se tej skupini, da boste lahko pošiljali sporočila.</string>
<string name="relay_group_invite_only_to_post">Ta skupina je zaprtega tipa za objavljanje potrebujete povabilo.</string>
<plurals name="relay_group_member_count">
@@ -2468,6 +2561,12 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<item quantity="few">%1$d uporabniki, ki jim sledite</item>
<item quantity="other">%1$d uporabnikov, ki jim sledite</item>
</plurals>
<plurals name="relay_group_relay_group_count">
<item quantity="one">%1$d skupina</item>
<item quantity="two">%1$d skupini</item>
<item quantity="few">%1$d skupine</item>
<item quantity="other">%1$d skupin</item>
</plurals>
<string name="messages_new_message">Zasebno</string>
<string name="messages_new_message_to">Za</string>
<string name="messages_new_message_subject">Zadeva</string>
@@ -2540,6 +2639,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="no_blossom_apps_found_description">Aplikacije Blossom niso bile najdene. Za ogled te datoteke namestite lokalno aplikacijo Blossom.</string>
<string name="hidden_words">Skrite besede</string>
<string name="hide_new_word_label">Skrij novo besedo ali stavek</string>
<string name="mute_hashtag">Utišaj ključnik</string>
<string name="unmute_hashtag">Vklopi zvok za ključnik</string>
<string name="settings_muted_threads_title">Utišaj nize objav</string>
<string name="settings_muted_threads_empty">Ni utišanih nizov objav</string>
<string name="settings_muted_threads_unknown">Neznan niz objav · %1$s</string>
@@ -2550,6 +2651,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="error_dialog_pay_withdraw_error">Ni bilo mogoče dvigniti</string>
<string name="cashu_failed_redemption">Cashu žetona ni bilo mogoče unovčiti</string>
<string name="cashu_failed_redemption_explainer_error_msg">Kovnica je posredovala naslednje sporočilo o napaki: %1$s</string>
<string name="cashu_unsafe_mint_url">Ta naslov strežnika Cashu kovnice ni varen.</string>
<string name="cashu_unsafe_mint_url_explainer">Amethyst se ni povezal z izdajateljem tega žetona. %1$s</string>
<string name="cashu_successful_redemption">Prejeli ste Cashu žeton</string>
<string name="cashu_successful_redemption_explainer">V vašo denarnico je bilo poslanih %1$s satoshi-jev. (Provizija: %2$s sat)</string>
<string name="cashu_no_wallet_found">Na sistemu ni najdene združljive Cashu denarnice</string>
@@ -3081,8 +3184,23 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov,
<string name="bottom_bar_settings">Spodnja navigacijska vrstica</string>
<string name="bottom_bar_settings_description">Povlecite za spremembo vrstnega reda. Preklopite za dodajanje ali odstranjevanje elementov s spodnje vrstice. Če ni izbran noben element, je spodnja vrstica skrita.</string>
<string name="bottom_bar_settings_available">Dostopno</string>
<string name="bottom_bar_settings_pinned">Vaša spodnja vrstica</string>
<string name="bottom_bar_settings_pinned_empty">Ni pripetih elementov. Spodnja vrstica bo skrita, dokler ne dodate vsaj enega.</string>
<string name="bottom_bar_settings_reorder">Razporedi</string>
<string name="bottom_bar_settings_expand">Prikaži možnosti</string>
<string name="bottom_bar_settings_no_favorites">Ni priljubljenih. Dodaj jih z zvezdico v brskalniku.</string>
<string name="bottom_bar_settings_no_groups">Niste še član nobene skupine.</string>
<string name="bottom_bar_settings_reorder_hint">Povleci za razvrščanje · ✕ za izbris</string>
<string name="bottom_bar_settings_add">Dodaj</string>
<string name="bottom_bar_settings_added">Dodano</string>
<string name="bottom_bar_settings_remove">Odstrani</string>
<string name="bottom_bar_settings_restore_default">Obnovi prevzete</string>
<string name="bottom_bar_category_main">Glavno</string>
<string name="bottom_bar_category_chats">Klepeti in skupine</string>
<string name="bottom_bar_category_you">Ti</string>
<string name="bottom_bar_category_feeds">Viri vsebin</string>
<string name="bottom_bar_category_apps">Aplikacije in Splet</string>
<string name="bottom_bar_category_other">Drugo</string>
<string name="home_tabs_settings">Domači zavihki</string>
<string name="home_tabs_settings_description">Izberite zavihke, ki se prikažejo na začetnem zaslonu. Če je aktiven le en zavihek, je vrstica z zavihki skrita.</string>
<string name="home_tab_everything">Vse</string>
@@ -3140,6 +3258,7 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov,
<string name="audio_visualizer_static">Statična slika</string>
<string name="profile_image_of_user">Slika profila %1$s</string>
<string name="relay_info">Rele %1$s</string>
<string name="accepted_by_relays">Sprejeto na relejih</string>
<string name="expand_relay_list">Razširi seznam relejev</string>
<string name="note_options">Možnosti zapiska</string>
<string name="poll">Anketa</string>
@@ -3152,10 +3271,16 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov,
<string name="remove_location">Odstrani lokacijo</string>
<string name="add_content_warning">Dodaj opozorilo o vsebini</string>
<string name="remove_content_warning">Odstrani opozorilo o vsebini</string>
<string name="chat_system_created_channel">%1$s je ustvaril/a kanal %2$s</string>
<string name="chat_system_created_channel_unnamed">%1$s je ustvaril/-a kanal</string>
<string name="chat_system_updated_channel">%1$s je posodobil/-a profil kanala</string>
<string name="buzz_message_edited">(urejeno)</string>
<string name="buzz_diff_truncated">(razlika je odrezana)</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="buzz_canvas_title">Delovna površina</string>
<string name="buzz_canvas_empty">V tem delovnem prostoru še ni bila deljena nobena delovna površina.</string>
<string name="buzz_canvas_edit">Uredi delovno površino</string>
@@ -3202,6 +3327,7 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov,
<string name="buzz_dm_opening">Odpiram…</string>
<string name="buzz_dm_remove">Odstrani</string>
<string name="buzz_invite_title">Povabilo v delovni prostor</string>
<string name="buzz_invite_heading">Pridruži se delovnemu prostoru</string>
<string name="buzz_invite_workspace">Delovni prostor</string>
<string name="buzz_invite_role">Vloga</string>
<string name="buzz_invite_body">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.</string>
@@ -3217,6 +3343,7 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov,
<string name="buzz_import_add_all">Dodaj vse</string>
<string name="buzz_import_added">Dodano</string>
<string name="buzz_import_empty_title">Ni najdenih kanalov</string>
<string name="buzz_import_empty_body">Videti je, da na tem releju še nisi član nobenega kanala. Najprej v brskalniku sprejmi vabilo v delovni prostor in poskusite znova.</string>
<string name="relay_group_section_channels">Kanali</string>
<string name="relay_group_section_forums">Forumi</string>
<string name="buzz_agent_working">Delam…</string>
@@ -3572,6 +3699,7 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov,
<string name="resource_usage_tile_relay">Uporaba releja danes</string>
<string name="resource_usage_tile_in_app">Danes v aplikaciji</string>
<string name="resource_usage_trend_section">Količina podatkov na dan</string>
<string name="resource_usage_legend_cellular">Mobilni podatki (MB)</string>
<string name="resource_usage_legend_wifi">Wi-Fi (MB)</string>
<string name="resource_usage_activity_section">Aktivnost (7 dni)</string>
<string name="resource_usage_cellular_bg">Mobilni podatki (v ozadju)</string>
@@ -3627,6 +3755,7 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov,
<string name="resource_usage_subsystem_money">Denarnica in zapi</string>
<string name="resource_usage_subsystem_nip05">Preverjanje naslova</string>
<string name="resource_usage_subsystem_preview">Predogled povezav</string>
<string name="resource_usage_subsystem_push">Registracija potisnih obvestil</string>
<string name="resource_usage_subsystem_other">Drugo</string>
<string name="resource_usage_empty">Še ni podatkov o uporabi</string>
<string name="resource_usage_memory_section">Trenutni pomnilnik</string>
@@ -2149,6 +2149,11 @@
<string name="remove_location">Ukloni lokaciju</string>
<string name="add_content_warning">Dodaj upozorenje o sadržaju</string>
<string name="remove_content_warning">Ukloni upozorenje o sadržaju</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="add_expiration_date">Dodaj datum isteka</string>
<string name="remove_expiration_date">Ukloni datum isteka</string>
<string name="expiration_date_label">Datum isteka</string>
@@ -3141,6 +3141,11 @@
<string name="chat_system_updated_channel">%1$s uppdaterade kanalprofilen</string>
<string name="buzz_message_edited">(redigerat)</string>
<string name="buzz_diff_truncated">(diff avkortad)</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="buzz_canvas_empty">Ingen canvas har delats i den här arbetsytan än.</string>
<string name="buzz_canvas_edit">Redigera canvas</string>
<string name="buzz_canvas_save">Spara canvas</string>
@@ -2136,6 +2136,11 @@
<string name="remove_location">Ondoa Mahali</string>
<string name="add_content_warning">Ongeza onyo la maudhui</string>
<string name="remove_content_warning">Ondoa onyo la maudhui</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="add_expiration_date">Ongeza tarehe ya kumalizika</string>
<string name="remove_expiration_date">Ondoa tarehe ya kumalizika</string>
<string name="expiration_date_label">Tarehe ya Kumalizika</string>
@@ -2141,6 +2141,11 @@
<string name="remove_location">இடத்தை அகற்று</string>
<string name="add_content_warning">உள்ளடக்க எச்சரிக்கையை சேர்</string>
<string name="remove_content_warning">உள்ளடக்க எச்சரிக்கையை அகற்று</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="add_expiration_date">காலாவதி தேதியை சேர்</string>
<string name="remove_expiration_date">காலாவதி தேதியை அகற்று</string>
<string name="expiration_date_label">காலாவதி தேதி</string>
@@ -2101,6 +2101,11 @@
<string name="remove_location">ลบตำแหน่งออก</string>
<string name="add_content_warning">เพิ่มคําเตือนเนื้อหา</string>
<string name="remove_content_warning">ลบคําเตือนเนื้อหา</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="add_expiration_date">เพิ่มวันหมดอายุ</string>
<string name="remove_expiration_date">ลบวันหมดอายุ</string>
<string name="expiration_date_label">วันหมดอายุ</string>
@@ -2145,6 +2145,11 @@
<string name="remove_location">Konumu Kaldır</string>
<string name="add_content_warning">İçerik uyarısı ekle</string>
<string name="remove_content_warning">İçerik uyarısını kaldır</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="add_expiration_date">Son kullanma tarihi ekle</string>
<string name="remove_expiration_date">Son kullanma tarihini kaldır</string>
<string name="expiration_date_label">Son Kullanma Tarihi</string>
@@ -2194,6 +2194,11 @@
<string name="remove_location">Видалити місце</string>
<string name="add_content_warning">Додати попередження про вміст</string>
<string name="remove_content_warning">Видалити попередження про вміст</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="add_expiration_date">Додати дату завершення</string>
<string name="remove_expiration_date">Видалити дату завершення</string>
<string name="expiration_date_label">Дата завершення</string>
@@ -2140,6 +2140,11 @@
<string name="remove_location">Joylashuvni olib tashlash</string>
<string name="add_content_warning">Kontent ogohlantirishini qo\'shish</string>
<string name="remove_content_warning">Kontent ogohlantirishini olib tashlash</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="add_expiration_date">Muddati tugash sanasini qo\'shish</string>
<string name="remove_expiration_date">Muddati tugash sanasini olib tashlash</string>
<string name="expiration_date_label">Muddati tugash sanasi</string>
@@ -2101,6 +2101,11 @@
<string name="remove_location">Xóa Vị trí</string>
<string name="add_content_warning">Thêm cảnh báo nội dung</string>
<string name="remove_content_warning">Xóa cảnh báo nội dung</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="add_expiration_date">Thêm ngày hết hạn</string>
<string name="remove_expiration_date">Xóa ngày hết hạn</string>
<string name="expiration_date_label">Ngày hết hạn</string>
@@ -2875,6 +2875,11 @@
<string name="chat_system_created_channel">%1$s 创建了频道 %2$s</string>
<string name="chat_system_created_channel_unnamed">%1$s 创建了频道</string>
<string name="chat_system_updated_channel">%1$s 更新了频道配置文件</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="chat_delivery_details_title">消息传递</string>
<string name="close">关闭</string>
<string name="chat_delivery_pending">正在等待中继接受此消息</string>
@@ -2143,6 +2143,11 @@
<string name="remove_location">移除地点</string>
<string name="add_content_warning">添加内容警告</string>
<string name="remove_content_warning">移除内容警告</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="add_expiration_date">添加过期日期</string>
<string name="remove_expiration_date">删除过期日期</string>
<string name="expiration_date_label">过期日期</string>
@@ -2143,6 +2143,11 @@
<string name="remove_location">移除地点</string>
<string name="add_content_warning">添加内容警告</string>
<string name="remove_content_warning">移除内容警告</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="add_expiration_date">添加过期日期</string>
<string name="remove_expiration_date">删除过期日期</string>
<string name="expiration_date_label">过期日期</string>
@@ -2121,6 +2121,11 @@
<string name="remove_location">移除地點</string>
<string name="add_content_warning">添加內容警告</string>
<string name="remove_content_warning">移除內容警告</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
<string name="add_expiration_date">新增到期日</string>
<string name="remove_expiration_date">移除到期日</string>
<string name="expiration_date_label">到期日</string>
+3 -1
View File
@@ -2636,6 +2636,8 @@
<string name="relay_group_discovery_empty_filtered">No groups found for this filter yet.</string>
<string name="relay_group_favorite_relay">Favorite this relay</string>
<string name="relay_group_threads_empty">No threads yet. Start one with the + button.</string>
<!-- Same, where this viewer cannot start one: not a member, or a Buzz chat channel (forum posts belong to forum channels). -->
<string name="relay_group_threads_empty_read_only">No threads yet.</string>
<string name="relay_group_threads_loading_older">Loading older threads…</string>
<string name="relay_group_threads_all_caught_up">No older threads</string>
<string name="relay_group_thread_new">New thread</string>
@@ -3490,7 +3492,7 @@
<string name="buzz_forum_upvoted">▲ %1$s upvoted a post</string>
<string name="buzz_forum_downvoted">▼ %1$s downvoted a post</string>
<string name="buzz_canvas_title">Canvas</string>
<string name="buzz_canvas_empty">No canvas has been shared in this workspace yet.</string>
<string name="buzz_canvas_empty">No canvas has been shared in this channel yet.</string>
<string name="buzz_canvas_edit">Edit canvas</string>
<string name="buzz_canvas_save">Save canvas</string>
<string name="buzz_canvas_body_label">Canvas (Markdown)</string>
@@ -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))
}
}
@@ -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))
}
@@ -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<CashuProof>,
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<String, String> =
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<CashuProof>,
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,
@@ -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) |
@@ -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,
@@ -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<CashuProof>,
signingKeyFor: (lockPubKeyXOnly: String) -> String?,
): List<CashuProof> =
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<CashuProof>,
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<CashuProof>.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()
@@ -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<P2PKUnredeemableException> {
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] })
}
}
@@ -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<CashuProof>,
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