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