mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
Merge branch 'main' into test/buzz-agent-support
This commit is contained in:
+14
@@ -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.
|
||||
|
||||
+58
-6
@@ -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 ->
|
||||
|
||||
+30
@@ -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) {
|
||||
|
||||
+252
-75
@@ -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)
|
||||
|
||||
+34
-5
@@ -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))
|
||||
|
||||
-28
@@ -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.
|
||||
|
||||
+86
-51
@@ -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,
|
||||
|
||||
+33
-3
@@ -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),
|
||||
|
||||
+85
-51
@@ -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) }
|
||||
}
|
||||
|
||||
+51
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+31
-2
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+68
@@ -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))
|
||||
}
|
||||
}
|
||||
+27
-1
@@ -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))
|
||||
}
|
||||
|
||||
+85
-1
@@ -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()
|
||||
+165
@@ -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] })
|
||||
}
|
||||
}
|
||||
+23
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user