Merge remote-tracking branch 'origin/main' into claude/relay-auth-cache-91pa8f

# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
This commit is contained in:
Claude
2026-08-19 16:49:25 +00:00
118 changed files with 6228 additions and 1017 deletions
@@ -51,6 +51,12 @@ Shipped as designed. Where it diverged or went further:
connection forever. A second challenge for the same (relay, account) rides along
on the owner's answer with no deadline of its own — running one would let it
resolve the shared deferred and tear down a dialog mid-read.
- **Corrected later:** this plan left the decision model alone, including the
blanket `isFirstParty` gate on `CUSTOM`. That gate turned out to make
`readFollows` ("…I'm reading someone I follow") unreachable — a follow's outbox
relay is theirs, so it is never first-party for us, and every follow produced a
prompt with the toggle explicitly on. `RelayAuthResolver.customAllows` now
checks that one category ahead of the gate; the other three still require it.
- **Still not done:** what a timeout should *look like*. It is now an honest 60s of
visible time rather than a clock the user never saw, but it is still a dialog
that vanishes and an event left pending in the outbox with no feedback. That
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
import com.vitorpamplona.amethyst.service.logging.Logging
import com.vitorpamplona.amethyst.service.nests.AppForegroundRecycleHook
import com.vitorpamplona.amethyst.service.priority.WorkerThreadPriorityGovernor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabHost
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.LogLevel
@@ -119,6 +120,11 @@ class Amethyst : Application() {
instance = AppModules(this)
// Keeps the ~650 relay/ingest worker threads a cold start spawns from starving the UI
// thread out of its frames — worth ~45% off time-to-first-paint on a release build.
// Override or disable with the `amethyst_worker_nice` global setting.
WorkerThreadPriorityGovernor.start(this)
// Hydrate the device-local favorite-apps list (main process only; the sandbox never reads it).
FavoriteAppsRegistry.init(this)
@@ -287,18 +287,6 @@ class AppModules(
}
}
// Restore + persist held NIP-OA attestations across restarts (device-global). Eager (not
// lazy) so it loads before the first Buzz-relay AUTH and mirrors later changes to disk.
val buzzAttestationPrefs = BuzzAttestationPreferences(appContext, applicationIOScope)
// Restore + persist the joined Buzz workspace relays across restarts (device-global). Eager so
// the app knows which relays to sync as workspaces on cold start (Buzz membership is
// server-side; there is no join event to rebuild the set from).
val buzzWorkspacePrefs = BuzzWorkspacePreferences(appContext, applicationIOScope)
// Restore + persist the user's starred Buzz workspace channels across restarts (device-global).
val buzzChannelStarPrefs = BuzzChannelStarPreferences(appContext, applicationIOScope)
// Restore + persist the set of relay-group channels deleted (kind-9008) on this device, so a
// deleted channel stays hidden across a restart even if the host relay re-announces a stale
// kind-44100 for it (device-global; a delete is authoritative and terminal for everyone).
@@ -905,6 +893,17 @@ class AppModules(
meterSigner = { MeteringNostrSigner(it, resourceUsage) },
signerPermissionStore = signerPermissionStore,
nip46ClientStore = nip46ClientStore,
// Restore + persist the Buzz bookkeeping that has no Nostr event to rebuild from: the
// joined workspace relays (so the app knows which relays to sync as workspaces on cold
// start — Buzz membership is server-side) and the starred channels. Per account: the
// joined set makes a relay first-party for NIP-42, and a star is personal.
startBuzzPersistence = { account ->
BuzzWorkspacePreferences(appContext, account.scope, account.pubKey, account.buzzWorkspaces)
BuzzChannelStarPreferences(appContext, account.scope, account.pubKey, account.buzzChannelStars)
// Eager like the rest, so a held NIP-OA attestation is loaded before this account's
// first Buzz-relay AUTH rather than after it.
BuzzAttestationPreferences(appContext, account.scope, account.pubKey, account.buzzAttestation)
},
)
val sessionManager =
@@ -214,6 +214,7 @@ private object PrefKeys {
const val HAS_DONATED_IN_VERSION = "has_donated_in_version"
const val DISMISSED_POLL_NOTE_IDS = "dismissed_poll_note_ids"
const val DISMISSED_CHANNEL_INVITES = "dismissed_channel_invites"
const val MUTED_PUBLIC_CHATS = "muted_public_chats"
const val VIEWED_POLL_RESULT_NOTE_IDS = "viewed_poll_result_note_ids"
const val PENDING_ATTESTATIONS = "pending_attestations"
@@ -650,6 +651,7 @@ object LocalPreferences {
putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, settings.hasDonatedInVersion.value)
putStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, settings.dismissedPollNoteIds.value)
putStringSet(PrefKeys.DISMISSED_CHANNEL_INVITES, settings.dismissedChannelInvites.value)
putStringSet(PrefKeys.MUTED_PUBLIC_CHATS, settings.mutedPublicChats.value)
putString(
PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS,
JsonMapper.toJson(settings.viewedPollResultNoteIds.value),
@@ -789,6 +791,7 @@ object LocalPreferences {
val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf()
val dismissedPollNoteIds = getStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, null) ?: setOf()
val dismissedChannelInvites = getStringSet(PrefKeys.DISMISSED_CHANNEL_INVITES, null) ?: setOf()
val mutedPublicChats = getStringSet(PrefKeys.MUTED_PUBLIC_CHATS, null) ?: setOf()
val viewedPollResultNoteIdsStr = getString(PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, null)
val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf()
@@ -1048,6 +1051,7 @@ object LocalPreferences {
hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion),
dismissedPollNoteIds = MutableStateFlow(dismissedPollNoteIds),
dismissedChannelInvites = MutableStateFlow(dismissedChannelInvites),
mutedPublicChats = MutableStateFlow(mutedPublicChats),
viewedPollResultNoteIds = MutableStateFlow(viewedPollResultNoteIdsResolved),
pendingAttestations = MutableStateFlow(pendingAttestationsResolved),
backupNipA3PaymentTargets = latestPaymentTargetsResolved,
@@ -34,7 +34,10 @@ import com.vitorpamplona.amethyst.commons.defaults.Constants
import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList
import com.vitorpamplona.amethyst.commons.marmot.MarmotManager
import com.vitorpamplona.amethyst.commons.model.IAccount
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannelListState
import com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager
@@ -74,6 +77,7 @@ import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode
import com.vitorpamplona.amethyst.logTime
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator
import com.vitorpamplona.amethyst.model.bolt12Offers.Bolt12OfferListState
import com.vitorpamplona.amethyst.model.buzz.ChannelInvitesState
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState
import com.vitorpamplona.amethyst.model.localRelays.ForwardKind0ToLocalRelayState
@@ -407,6 +411,23 @@ class Account(
// answered without a disk read. Backed by a per-account file (see AccountCacheState).
val relayAuthPermissions = RelayAuthPermissionCache(relayAuthPermissionStore, scope)
// The `block/buzz` workspaces THIS account joined. Per account, not per device: the invite was
// redeemed by this key and the relay grants membership to it alone — and this set makes the
// relay first-party for NIP-42 (see AuthCoordinator.isFirstParty), so a device-global set would
// hand every other logged-in account an automatic login on a workspace it never joined.
// Restored/persisted per account by BuzzWorkspacePreferences (see AccountCacheState).
val buzzWorkspaces = BuzzWorkspaces()
// The Buzz channels THIS account pinned. A star says which channels this user wants at the top
// of the community view, so a shared set let one account reorder and badge every other one's
// channel list. Restored/persisted per account by BuzzChannelStarPreferences.
val buzzChannelStars = BuzzChannelStars()
// The NIP-OA attestation an owner issued to THIS account's key, attached to its Buzz-relay
// AUTH so the relay grants virtual membership. Restored/persisted per account by
// BuzzAttestationPreferences.
val buzzAttestation = BuzzHeldAttestations(pubKey)
// The relays this account approved by answering the NIP-42 prompt *without* the "remember"
// switch. Deliberately in-memory only: it dies with this Account (i.e. with the process, or at
// logout), which is what makes it a session grant rather than a stored ALLOW.
@@ -559,6 +580,21 @@ class Account(
val relayGroupListDecryptionCache = RelayGroupListDecryptionCache(signer)
val relayGroupList = RelayGroupListState(signer, cache, relayGroupListDecryptionCache, scope, settings)
/**
* Buzz channels somebody else added me to that I haven't answered yet, projected from the cached
* kind-44100/44101 verdicts. Account state rather than screen state because the notifications DAL
* reads it to decide whether a cached 44100 is still a live question.
*/
val channelInvites =
ChannelInvitesState(
me = signer.pubKey,
cache = cache,
buzzWorkspaces = buzzWorkspaces,
relayGroupList = relayGroupList,
dismissed = settings.dismissedChannelInvites,
scope = scope,
)
val concordChannelList = ConcordChannelListState(signer, cache, scope, settings)
/**
@@ -711,11 +747,18 @@ class Account(
// the history loader ([AccountNotificationsHistoryEoseManager]) binds its orchestrator to these.
val notificationHistory = RelayLoadingCursors()
// Per-relay backward-paging cursors for the NIP-60 spending history (kind:7376): how far back each
// outbox relay has been paged by until+limit. Same lifetime rule as notificationHistory — held here
// so paging progress survives leaving and re-entering the wallet screen; the history loader
// ([CashuWalletHistoryEoseManager]) binds its orchestrator to these.
val cashuHistory = RelayLoadingCursors()
val cashuWalletState =
com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState(
pubKey = signer.pubKey,
signer = signer,
cache = cache,
client = client,
scope = scope,
outboxRelaysFlow = outboxRelays.flow,
inboxRelaysFlow = notificationRelays.flow,
@@ -1041,6 +1084,15 @@ class Account(
sendNewAppSpecificData()
}
/**
* Local state first, then publish. The local write is what every suppression point
* reads, so it must not wait on the signer — publishing is best-effort sync.
*/
suspend fun toggleMutedPublicChat(channelId: String) {
settings.toggleMutedPublicChat(channelId)
sendNewAppSpecificData()
}
suspend fun updateZapAmounts(
amountSet: List<Long>,
selectedZapType: LnZapEvent.ZapType,
@@ -330,6 +330,14 @@ class AccountSettings(
* still lists you, and Leave (kind 9022) is the separate action that actually removes you.
*/
val dismissedChannelInvites: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
/**
* NIP-28 channel ids the user has silenced. Local device state ON PURPOSE, even
* though it also syncs via NIP-78: the push dispatcher must answer "is this muted?"
* during a cold start, before (or without) the settings blob having been decrypted —
* for a NIP-55 account that decrypt is an Amber IPC round-trip that may never
* complete in the background. See AppSpecificState.kt:70-75.
*/
val mutedPublicChats: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
val viewedPollResultNoteIds: MutableStateFlow<Map<String, Long>> = MutableStateFlow(mapOf()),
val pendingAttestations: MutableStateFlow<Map<HexKey, String>> = MutableStateFlow(mapOf()),
var backupNipA3PaymentTargets: PaymentTargetsEvent? = null,
@@ -1515,6 +1523,14 @@ class AccountSettings(
backupAppSpecificData = appSettings
syncedSettings.updateFrom(newSyncedSettings)
// Null means an older client rewrote the blob without this key — leave the
// local set alone rather than treating "absent" as "unmute everything".
// The decision lives in mergeMutedPublicChats so it is unit-testable; this
// class cannot be constructed in a JVM test.
mutedPublicChats.tryEmit(
mergeMutedPublicChats(mutedPublicChats.value, newSyncedSettings.chats.mutedPublicChats),
)
saveAccountSettings()
}
}
@@ -1614,6 +1630,17 @@ class AccountSettings(
saveAccountSettings()
}
// ---
// muted public chats
// ---
fun toggleMutedPublicChat(channelId: String) {
mutedPublicChats.update {
if (channelId in it) it - channelId else it + channelId
}
saveAccountSettings()
}
// ---
// viewed poll results
// ---
@@ -90,7 +90,7 @@ class AccountSyncedSettings(
MutableStateFlow(DrawerItemVisibility.sanitize(navBarItemsFromNames(internalSettings.navigation.hiddenDrawerItems))),
)
fun toInternal(): AccountSyncedSettingsInternal =
fun toInternal(mutedPublicChats: Set<String>): AccountSyncedSettingsInternal =
AccountSyncedSettingsInternal(
reactions = AccountReactionPreferencesInternal(reactions.reactionChoices.value, reactions.reactionRowItems.value),
zaps =
@@ -120,7 +120,12 @@ class AccountSyncedSettings(
),
videoPlayer = AccountVideoPlayerPreferencesInternal(videoPlayer.buttonItems.value),
media = AccountMediaPreferencesInternal(media.audioVisualizer.value.name),
chats = AccountChatPreferencesInternal(chats.pinnedChatrooms.value.map { it.users.sorted() }),
chats =
AccountChatPreferencesInternal(
chats.pinnedChatrooms.value.map { it.users.sorted() },
// sorted so the serialized form is deterministic
mutedPublicChats.sorted(),
),
proofOfWork =
AccountPoWPreferencesInternal(
proofOfWork.difficulty.value,
@@ -242,4 +242,14 @@ class AccountChatPreferencesInternal(
// pubkeys (hex) sorted ascending, so the serialized form is deterministic
// regardless of set iteration order.
var pinnedRooms: List<List<String>> = emptyList(),
// NIP-28 channel ids (hex) whose notifications are silenced, sorted ascending
// for the same determinism reason as pinnedRooms.
//
// NULLABLE ON PURPOSE. The default has to tell two cases apart:
// null = key absent — an older client rewrote the blob and dropped it, so
// the local mute set must be left alone.
// [] = an explicit "unmute everything" from a client that knows the field.
// A non-null default would collapse them and let an old client silently erase
// the user's mutes on every launch. See updateAppSpecificData.
var mutedPublicChats: List<String>? = null,
)
@@ -72,7 +72,15 @@ class AccountZapActions(
lnurl: String? = null,
) = LnZapRequestEvent.create(
zappedEvent = event,
relays = account.nip65RelayList.inboxFlow.value + (additionalRelays ?: emptySet()),
// Where the provider should publish the receipt. Zapping group content pins that to the room's
// host relay: the receipt belongs where the message it pays for lives, so the room can show it
// and the recipient's group query can find it — and, for a private or closed group, so a
// kind-9735 naming the room never lands on a relay outside it. Everything else keeps the
// ordinary NIP-65 inbox routing.
relays =
account.cache.relayGroupHostsFor(event).ifEmpty {
account.nip65RelayList.inboxFlow.value
} + (additionalRelays ?: emptySet()),
signer = account.signer,
pollOption = pollOption,
message = message,
@@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent
import com.vitorpamplona.quartz.nip29RelayGroups.isGroupScoped
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
@@ -111,6 +112,11 @@ class EventBroadcaster(
val channelRelays = account.cache.getAnyChannel(event)?.relays()
if (channelRelays != null && channelRelays.isNotEmpty()) return false
// A group-scoped event whose room this cache doesn't know yet: it still must not go to the
// broadcast list. Its `h` tag names a room only its host can serve, so broadcasting it says
// "I am in this group" to relays that can do nothing with the content.
if (event.isGroupScoped()) return false
return true
}
@@ -143,6 +149,16 @@ class EventBroadcaster(
return emptySet()
}
// NIP-29 group content, and everything that refers to it — a kind-9 message, a kind-1111 comment,
// a like, a zap request — exists in a room on a host relay and nowhere else. The room's members
// read it there; the author's outbox and the broadcast list can neither serve it to them nor do
// anything else useful with it, and for a private or closed group publishing it there advertises
// who is in which room. So the host wins outright rather than being one more relay in the union.
// Same rule the group reply composer already applies (CommentPostViewModel), applied to every
// group-scoped event instead of just that one path.
val groupHosts = account.cache.relayGroupHostsFor(event)
if (groupHosts.isNotEmpty()) return groupHosts
val includeBroadcast = wantsBroadcastRelays(event)
val broadcastRelays = if (includeBroadcast) account.broadcastRelayList.flow.value else emptySet()
@@ -27,7 +27,6 @@ import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.cashu.MintDirectoryIndex
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvites
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzPresenceState
@@ -750,6 +749,21 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
return relayGroupChannels.filter { key, _ -> key.id == groupId }.singleOrNull()
}
/**
* Every host relay of the NIP-29 group [event] is scoped to (its `h` tag), or an empty set when the
* event carries no group scope or the group is unknown to this cache.
*
* Keyed by group id alone rather than by [GroupId]: an event about to be *sent* (a reaction, a zap
* request, a comment) knows which room it belongs to but not which relay hosts it — that is exactly
* what this resolves. Group ids are relay-minted UUIDs, so the same id on two hosts is a
* theoretical case, and answering with both is the safe reading of it: the content reaches every
* host that claims the room, and none that don't.
*/
fun relayGroupHostsFor(event: Event): Set<NormalizedRelayUrl> {
val groupId = event.groupId() ?: return emptySet()
return relayGroupChannels.filter { key, _ -> key.id == groupId }.mapTo(mutableSetOf()) { it.groupId.relayUrl }
}
fun getLiveActivityChannelIfExists(key: Address): LiveActivitiesChannel? = liveChatChannels.get(key)
fun getNoteIfExists(event: Event): Note? =
@@ -2327,20 +2341,19 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
}
/**
* A kind-44101 "you were removed from a channel". Consumed like any other Buzz event, then used to
* withdraw any pending add-prompt for that channel: once the relay has taken the membership away
* there is nothing left to accept, so leaving the card up would offer an action that cannot succeed.
* A kind-44101 "you were removed from a channel". Stored like any other Buzz event and nothing more:
* withdrawing the matching add-prompt is not a side effect of ingest but a consequence of the stored
* event, since
* [com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvites.pendingInvites] resolves each
* channel to its newest verdict. That ordering is what makes the two kinds arriving out of order —
* routine on a re-subscribe, where the relay replays the whole history — produce the same answer as
* them arriving in order.
*/
private fun consume(
event: MemberRemovedNotificationEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean =
consumeBuzzRegularEvent(event, relay, wasVerified).also {
val target = event.target() ?: return@also
val channelId = event.channel() ?: return@also
BuzzChannelInvites.remove(target, channelId)
}
): Boolean = consumeBuzzRegularEvent(event, relay, wasVerified)
/**
* Attach a group-scoped content event (a kind-9 chat, kind-1068 poll, …
@@ -0,0 +1,81 @@
/*
* 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.model
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
/**
* The NIP-28 channel an event belongs to, or null when [event] is not one of the three
* public-chat event types a channel row's newest event can be.
*
* This is THE definition of "which event identifies a public-chat room" — the row dispatch
* (ChatroomHeaderCompose), the last-read route (ChatroomRowUnread.rowLastReadRoute), the
* feed's row de-duplication (ChatroomListKnownFeedFilter) and the mute predicate below all
* call it. It used to be copied into each of those by hand, and the copies drifted: one of
* them matched only [ChannelMessageEvent], so a channel whose latest activity was a topic
* edit silently lost its unread dot. Keep it a single function.
*
* Deliberately NOT `threadRootIdOrSelf()`. That returns the same channel id here — a
* NIP-28 message's NIP-10 root marker IS its channel — but it means something else
* (the NIP-51 "muted thread" key, which HIDES content). Keeping the two apart is what
* stops "mute notifications" and "mute thread" from bleeding into each other.
*
* Matched on concrete types rather than the IsInPublicChatChannel interface, which the
* channel-admin events ChannelHideMessageEvent/ChannelMuteUserEvent also implement: those
* must fall through to null rather than be treated as room activity.
*/
fun publicChatChannelIdOf(event: Event?): HexKey? =
when (event) {
is ChannelMessageEvent, is ChannelMetadataEvent -> event.channelId()
is ChannelCreateEvent -> event.id
else -> null
}
/** True when [event] is a public-chat message in a channel the user has silenced. */
fun isMutedPublicChatMessage(
event: Event?,
mutedChannels: Set<HexKey>,
): Boolean {
if (mutedChannels.isEmpty()) return false
val channelId = publicChatChannelIdOf(event) ?: return false
return channelId in mutedChannels
}
/**
* The inbound-sync decision for the mute set, kept separate from [AccountSettings] so it can be
* tested: [AccountSettings] builds a default `AccountSyncedSettingsInternal`, whose language
* preferences call `Resources.getSystem()`, so it cannot be constructed in a JVM unit test.
*
* [remote] is `null` when an older client rewrote the NIP-78 blob without the key. The local set
* must survive that — and because `AppSpecificState` replays the cached backup event on every app
* start, treating absent as empty would re-clear the user's mutes on every single launch.
*
* An explicitly empty list is different: it is a real "unmute everything" from a client that knows
* the field, and is adopted.
*/
fun mergeMutedPublicChats(
local: Set<HexKey>,
remote: List<HexKey>?,
): Set<HexKey> = remote?.toSet() ?: local
@@ -73,6 +73,13 @@ class AccountCacheState(
val signerPermissionStore: NostrSignerPermissionStore = InMemoryNostrSignerPermissionStore(),
/** App-global store of connected NIP-46 client display + relay info. */
val nip46ClientStore: Nip46ClientStore = InMemoryNip46ClientStore(),
/**
* Starts per-account persistence of the Buzz client-side bookkeeping that has no Nostr event to
* rebuild from — the joined workspaces and the starred channels (restore now, mirror later
* changes). A lambda because those stores need an Android `Context` and this class deliberately
* takes none; no-op by default so tests and non-Android hosts build an Account without it.
*/
val startBuzzPersistence: (Account) -> Unit = { },
) {
val accounts = MutableStateFlow<Map<HexKey, Account>>(emptyMap())
@@ -286,6 +293,10 @@ class AccountCacheState(
signerPermissionStore = signerPermissionStore,
nip46ClientStore = nip46ClientStore,
).also { newAccount ->
// Per account, not per device: the joined set makes a relay first-party for NIP-42, so a
// shared one hands every other logged-in account an automatic login on a workspace it
// never joined, and a shared star set reorders everyone's channel list at once.
startBuzzPersistence(newAccount)
accounts.update { existingAccounts ->
existingAccounts.plus(Pair(signer.pubKey, newAccount))
}
@@ -0,0 +1,171 @@
/*
* 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.model.buzz
import com.vitorpamplona.amethyst.commons.model.buzz.ChannelClassification
import com.vitorpamplona.amethyst.commons.model.buzz.MembershipNotice
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.filterIntoSet
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.buzz.MembershipNotificationKinds
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
import com.vitorpamplona.quartz.buzz.notifications.MemberRemovedNotificationEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
/*
* The cache-side view of the Buzz membership stream: everything that reads kind-44100/44101 out of
* LocalCache instead of asking a relay for its own copy.
*
* The relay subscription lives in BuzzMembershipEoseManager, mounted with the rest of the account
* loaders. Every consumer of the stream — the Notifications feed's invite cards and Buzz DM discovery —
* observes the cache through here, so there is exactly one `#p=me` REQ per workspace relay for all of
* them.
*
* This is model code, not UI: the notifications DAL reads it from `acceptableEvent`, so it must not
* live under `ui/`.
*/
/** Every membership verdict addressed to [me], as the cache observers want it. */
fun membershipNoticeFilter(me: HexKey) =
Filter(
kinds = MembershipNotificationKinds,
tags = mapOf("p" to listOf(me)),
)
/**
* The workspace relay that vouched for this notice.
*
* A note records every relay it was seen on, and a Buzz membership notification is only meaningful on
* the relay that issued it — the channel UUID it names is that relay's. So prefer a relay we joined as a
* workspace; fall back to whatever else delivered it, which keeps a notice usable when the workspace set
* hasn't been restored from disk yet.
*
* [workspaces] is passed in rather than read from a singleton because the joined set is per account
* (`Account.buzzWorkspaces`): whose workspaces to prefer is a question only the caller can answer.
*/
private fun Note.membershipRelay(workspaces: Set<NormalizedRelayUrl>): NormalizedRelayUrl? {
val seen = relays
if (seen.isEmpty()) return null
return seen.firstOrNull { it in workspaces } ?: seen.first()
}
/** Flattens a cached kind-44100/44101 into a [MembershipNotice], or null when it isn't usable. */
fun Note.toMembershipNotice(workspaces: Set<NormalizedRelayUrl>): MembershipNotice? {
val relay = membershipRelay(workspaces) ?: return null
return when (val noteEvent = event) {
is MemberAddedNotificationEvent ->
noteEvent.channel()?.let {
MembershipNotice(noteEvent.id, it, relay, noteEvent.actor(), noteEvent.createdAt, removed = false)
}
is MemberRemovedNotificationEvent ->
noteEvent.channel()?.let {
MembershipNotice(noteEvent.id, it, relay, noteEvent.actor(), noteEvent.createdAt, removed = true)
}
else -> null
}
}
fun List<Note>.toMembershipNotices(workspaces: Set<NormalizedRelayUrl>): List<MembershipNotice> = mapNotNull { it.toMembershipNotice(workspaces) }
private fun Note.isMembershipNoticeFor(me: HexKey): Boolean =
when (val noteEvent = event) {
is MemberAddedNotificationEvent -> noteEvent.target().equals(me, ignoreCase = true)
is MemberRemovedNotificationEvent -> noteEvent.target().equals(me, ignoreCase = true)
else -> false
}
/**
* Every membership verdict for [me] currently in the cache.
*
* Scanned off [LocalCache.notes] rather than read from an `observeNotes` snapshot, because that
* snapshot cannot contain these kinds. `LocalCache.filter` only yields addressables plus notes whose
* `kind.isRegular()` — and `isRegular()` is `> 0 && < 10_000`, so a Buzz 44100/44101 matches none of
* its branches and the seed comes back empty every time. Live arrivals are fine (the observer's `new()`
* applies no such gate), which is why a cold start looked correct: the observer registers before the
* relay answers. What broke was any projection built *after* the events had landed — switching to
* another account and back builds a fresh one, and `consumeRegularEvent` never re-notifies a duplicate,
* so it would have stayed empty for the rest of the session.
*
* So the observer is kept purely as the change signal and this scan is the data. It is the same shape
* `NotificationFeedFilter.feed()` uses over the same map, for the same reason.
*/
fun LocalCache.membershipNotices(
me: HexKey,
workspaces: Set<NormalizedRelayUrl>,
): List<MembershipNotice> =
notes
.filterIntoSet { _, note -> note.isMembershipNoticeFor(me) }
.toList()
.toMembershipNotices(workspaces)
/**
* The Buzz type of every channel whose kind-39000 the cache already holds, keyed by group id.
*
* Built from the metadata events themselves rather than from the [RelayGroupChannel]s they populate,
* because the two are filled in at different moments. `LocalCache.consume(GroupMetadataEvent)` loads the
* event onto its addressable note and wakes the cache observers *first*, and only then copies it into
* the channel — so a projection woken by that very emission reads a channel that is still empty, gets
* [ChannelClassification.UNKNOWN], and, because nothing emits a second time, stays wrong until an
* unrelated membership notice happens to arrive. (It also covers the case where the channel is never
* populated at all: `consume` only touches it when the event carried relay provenance.) Reading the
* event that caused the emission cannot race with itself.
*
* Keyed by group id alone, without the host relay: this is a fallback for [classifyBuzzChannel], which
* still prefers the relay-scoped channel whenever that one has already been filled in.
*/
fun buzzChannelTypes(metadataNotes: List<Note>): Map<String, ChannelClassification> {
val types = HashMap<String, ChannelClassification>(metadataNotes.size)
metadataNotes.forEach { note ->
val metadata = note.event as? GroupMetadataEvent ?: return@forEach
types[metadata.groupId()] =
if (metadata.isBuzzDmChannel()) ChannelClassification.DM else ChannelClassification.NAMED
}
return types
}
/**
* What [cache] currently knows about a channel's type, from its kind-39000.
*
* [ChannelClassification.UNKNOWN] until the directory lands — callers decide what to do with that, and
* the invite projection deliberately withholds rather than guessing (see
* [com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvites.pendingInvites]).
*
* [knownTypes] (from [buzzChannelTypes]) is consulted when the channel has no metadata yet, which is
* what makes the answer stable at the instant the directory lands — see that function for why the
* channel alone is not enough.
*/
fun classifyBuzzChannel(
cache: LocalCache,
channelId: String,
relay: NormalizedRelayUrl,
knownTypes: Map<String, ChannelClassification> = emptyMap(),
): ChannelClassification {
val metadata =
cache.getRelayGroupChannelIfExists(GroupId(channelId, relay))?.event
?: return knownTypes[channelId] ?: ChannelClassification.UNKNOWN
return if (metadata.isBuzzDmChannel()) ChannelClassification.DM else ChannelClassification.NAMED
}
@@ -0,0 +1,144 @@
/*
* 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.model.buzz
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvite
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvites
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListState
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
/**
* The channels somebody else added the viewer to that are still awaiting a decision.
*
* A pure projection of what the cache already holds — the relay's kind-44100/44101 membership verdicts
* addressed to me, the channels' kind-39000 types, my kind-10009 joined list, and my local dismissals.
* Nothing here asserts membership (the relay already granted that); it only decides whose call it is to
* surface the channel.
*
* ### Account state, not screen state
*
* This hangs off [com.vitorpamplona.amethyst.model.Account] rather than a feed holder because the
* notifications DAL reads it: `NotificationFeedFilter.acceptableEvent` consults [pendingByEventId] to
* decide whether a cached kind-44100 is still a live question, and `convertToCard` uses the same map to
* build the row. A projection only the UI could reach would have forced the DAL to re-derive it.
*
* ### Derived, not recorded
*
* This used to read a process-wide registry that the Buzz DM discovery pass wrote into and its
* classification step deleted from. Because the deletion was remembered nowhere, any re-delivery of the
* same kind-44100 re-added an invite that had already been withdrawn, and the prompt appeared and
* disappeared on a loop. Deriving from the cache removes the second source of truth: the same events
* always produce the same answer, in any order, however many times they arrive.
*/
@Stable
class ChannelInvitesState(
private val me: HexKey,
private val cache: LocalCache,
private val buzzWorkspaces: BuzzWorkspaces,
relayGroupList: RelayGroupListState,
dismissed: StateFlow<Set<String>>,
scope: CoroutineScope,
) {
/**
* What every group whose kind-39000 has landed turns out to be, which is what makes an
* [com.vitorpamplona.amethyst.commons.model.buzz.ChannelClassification.UNKNOWN] channel decidable.
* Without this the projection would never recompute when the directory arrives.
*
* It carries the classification rather than merely signalling that it changed, and that is the
* point: `LocalCache.consume(GroupMetadataEvent)` wakes this observer *before* it copies the event
* into the [com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel], so a
* recompute that went back to the channel for the answer read one that was still empty, concluded
* UNKNOWN, and — with nothing left to emit — kept the invite hidden until an unrelated membership
* notice arrived. Device-confirmed: a kind-44100 followed 20s later by its kind-39000 produced no
* card at all, and one unrelated kind-44101 made it appear instantly.
*
* De-duplicated on the map, so a busy account's metadata traffic still only re-runs the projection
* when a group's type actually becomes known or changes.
*/
private val knownChannelTypes =
cache
.observeNotes(Filter(kinds = listOf(GroupMetadataEvent.KIND)))
.map { buzzChannelTypes(it) }
.distinctUntilChanged()
/**
* The membership verdicts themselves, re-scanned only when something can actually change them.
*
* The scan walks every note in the cache, so it is deliberately NOT part of the combine below: the
* three inputs there (dismissals, my kind-10009, known channel types) change what the notices *mean*
* but never what they *are*, and folding them in would re-walk the whole cache on every list edit.
*
* The observer emission is the arrival signal — it cannot be the data, because `observeNotes`'
* initial snapshot can't hold these kinds at all (see [membershipNotices]). The workspace set is the
* second trigger: a notice's relay is resolved by preferring a joined workspace over whatever else
* delivered it, and restore-from-disk can land after the cache already holds notices, changing which
* relay a channel resolves against — and with it whether its kind-39000 is ever found.
*/
private val notices =
combine(
cache.observeNotes(membershipNoticeFilter(me)),
buzzWorkspaces.flow,
) { _, workspaces -> cache.membershipNotices(me, workspaces) }
/** Pending invites keyed by the kind-44100 that produced them — what the notifications DAL reads. */
val pendingByEventId: StateFlow<Map<HexKey, BuzzChannelInvite>> =
combine(
notices,
knownChannelTypes,
dismissed,
relayGroupList.liveRelayGroupList,
) { verdicts, knownTypes, dismissals, joined ->
BuzzChannelInvites.pendingInvitesByEventId(
viewer = me,
notices = verdicts,
dismissed = dismissals,
joined = joined.mapTo(HashSet()) { it.groupId },
classify = { channelId, relay -> classifyBuzzChannel(cache, channelId, relay, knownTypes) },
)
}.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
/** The same set as a newest-first list, for surfaces that render it directly. */
val flow: StateFlow<List<BuzzChannelInvite>> =
pendingByEventId
.map { it.values.sortedByDescending { invite -> invite.createdAt } }
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, emptyList())
/** Whether this cached kind-44100 is still an unanswered question. Hot path — a map lookup. */
fun isPending(eventId: HexKey) = eventId in pendingByEventId.value
fun inviteFor(eventId: HexKey): BuzzChannelInvite? = pendingByEventId.value[eventId]
}
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.cashu.ops.RestoreOutcome
import com.vitorpamplona.amethyst.commons.cashu.ops.SendTokenCompleted
import com.vitorpamplona.amethyst.commons.cashu.ops.TokenEntry
import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.cashuProofBackfillFilters
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -35,6 +36,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
@@ -61,12 +64,14 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.OkHttpClient
import java.util.concurrent.ConcurrentHashMap
@@ -99,6 +104,7 @@ class CashuWalletState(
private val pubKey: HexKey,
private val signer: NostrSigner,
private val cache: LocalCache,
private val client: INostrClient,
private val scope: CoroutineScope,
private val outboxRelaysFlow: StateFlow<Set<NormalizedRelayUrl>>,
private val inboxRelaysFlow: StateFlow<Set<NormalizedRelayUrl>>,
@@ -519,6 +525,150 @@ class CashuWalletState(
if (ids.isNotEmpty()) removeEvents(ids)
}
}
// Page the full proof set back, once, as soon as we know there is a
// wallet and where to ask about it. The live subscription above cannot
// do this on its own — see [resyncProofsFromRelays].
//
// Gated on a wallet existing so an Account object that is only resident
// to decrypt a pushed gift wrap never pages a wallet nobody has: that
// is the same reason the wallet's relay subscription lives in
// CashuWalletEoseManager rather than here.
jobs +=
scope.launch(Dispatchers.IO) {
val ready =
withTimeoutOrNull(BACKFILL_READY_WAIT_MS) {
_walletEvent.first { it != null }
outboxRelaysFlow.first { it.isNotEmpty() }
}
if (ready == null) {
Log.d("CashuWallet") { "No wallet + outbox relays within ${BACKFILL_READY_WAIT_MS}ms; skipping proof backfill" }
} else {
resyncProofsFromRelays()
}
}
}
// ============================================================
// Proof backfill — paging past the relay's REQ cap
// ============================================================
/**
* True once a paged proof walk has completed for this session. Guards the
* automatic backfill only; [resyncProofsFromRelays] with `force` ignores it.
*/
@Volatile private var proofBackfillDone = false
private val proofBackfillMutex = Mutex()
/**
* Re-download **every** kind:7375 this account ever published, by paging
* each outbox relay with `until` cursors, and then reconcile the result
* against the mints.
*
* ### Why this is needed
*
* [balanceSats] is a pure function of [_tokenEntries], which is a pure
* function of the kind:7375 events we happen to hold. Those arrive over the
* live wallet subscription, which sends one unbounded REQ per relay. A relay
* answers an unbounded REQ with its own cap (NIP-11 `limitation.max_limit`,
* or a hard-coded default) applied to the **newest** matching events — and
* the same filter also asks for kind:7376 history, which outnumbers the
* proofs by an order of magnitude on any wallet with a few hundred
* transactions. The proofs that lose that race are the ones at mints the
* user has not touched recently, so what drops off the bottom is precisely
* the balance the user forgot they had.
*
* Nothing recovers from it afterwards: a capped page and a complete page
* both just EOSE, and [PerUserEoseManager] records that EOSE as the `since`
* for every later REQ to that relay, so the events below the cap are never
* asked for again. The subset is stable across cold starts (same filter,
* same cap, same events) but differs between devices whose relay set,
* arrival order or uptime differ — which is why one account can read 39 sat
* on one phone, 1443 on another and 2522 on a third, with none of them
* being the wallet's actual balance.
*
* ### What this does
*
* `fetchAllPagesFromPool` walks each relay backwards page by page until a
* page comes back empty, so the cap bounds a page instead of the download.
* Events land in [LocalCache] through the client-wide `EventCollector`, but
* we also index what we receive directly rather than waiting on the bundled
* cache round-trip, so the balance is correct the moment the walk returns.
*
* ### Why the scrub afterwards
*
* Spent proofs are retired with a NIP-09 kind:5, and a relay that ignores
* deletions will happily hand those kind:7375 events back on a paged walk.
* Taken alone, this would trade an under-count for an over-count. So when
* the walk actually recovered something, we finish with the NUT-07
* [scrubLocallyStaleProofs] sweep: the mint — not the relay — decides which
* proofs are still unspent, and anything it calls SPENT is dropped and
* re-deleted. The sweep is skipped when the walk found nothing new, so a
* steady-state launch costs no mint traffic.
*
* Returns the number of kind:7375 events the walk delivered that we did not
* already hold, or null when it could not run (not started, no relays, or
* already done and not forced).
*/
suspend fun resyncProofsFromRelays(force: Boolean = false): Int? {
if (!started) return null
if (proofBackfillDone && !force) return null
return proofBackfillMutex.withLock {
if (proofBackfillDone && !force) return@withLock null
val relays = outboxRelaysFlow.value
// Don't latch on an empty relay set — the NIP-65 list may simply not
// have arrived yet, and the caller retries once it does.
if (relays.isEmpty()) return@withLock null
val filters = cashuProofBackfillFilters(pubKey)
// The callback runs on the relay reader thread and must not suspend,
// so collect first and index after the walk.
val collected = ConcurrentHashMap<HexKey, CashuTokenEvent>()
runCatching {
client.fetchAllPagesFromPool(
filters = relays.associateWith { filters },
idleTimeoutMs = BACKFILL_IDLE_TIMEOUT_MS,
) { event, _ ->
if (event is CashuTokenEvent && event.pubKey == pubKey) {
collected.putIfAbsent(event.id, event)
}
}
}.onFailure {
Log.w("CashuWallet", "Paged proof backfill failed", it)
}.onSuccess {
// Latch only on a walk that actually completed. A walk that
// blew up (offline at launch, every relay unreachable) has
// proved nothing about what the relays hold, and latching on it
// would leave the wallet showing the truncated balance for the
// rest of the session with no automatic second attempt.
proofBackfillDone = true
}
val fresh = collected.values.filter { !tokenEvents.containsKey(it.id) }
Log.i("CashuWallet") {
"Proof backfill over ${relays.size} relay(s): ${collected.size} kind:7375 seen, ${fresh.size} new"
}
if (fresh.isNotEmpty()) {
applyEvents(fresh)
// A relay that ignores NIP-09 just handed back proofs the mint
// already burned. Let the mint arbitrate before the user sees a
// number.
runCatching { scrubLocallyStaleProofs() }
.onFailure { Log.w("CashuWallet", "Post-backfill NUT-07 sweep failed", it) }
} else if (undecryptedTokenCount() > 0) {
// Nothing new off the relays, but we are still holding proofs
// we could not read. A decrypt failure hides money exactly as
// effectively as a missing event does, and the retry inside
// recomputeUnspent only fires when some *other* change marks
// the tokens dirty — which, in a wallet that has gone quiet, may
// be never. A user asking for a refresh is asking for that
// retry too.
recomputeUnspent()
}
fresh.size
}
}
fun destroy() {
@@ -704,8 +854,10 @@ class CashuWalletState(
private suspend fun recomputeUnspent() {
val all = tokenEvents.values.toList()
// Decrypt anything we haven't seen before; reuse cached TokenContent
// for events we've already decrypted. Decryption failures are
// skipped — the proof set rebuilds the next time a re-key happens.
// for events we've already decrypted. Only successes are cached, so a
// failure is retried on the next recompute rather than being pinned as
// "empty" for the session.
var undecryptable = 0
all.forEach { evt ->
if (!tokenContents.containsKey(evt.id)) {
val content =
@@ -715,7 +867,19 @@ class CashuWalletState(
"Failed to decrypt token ${evt.id.take(8)}: ${it.message}"
}
}.getOrNull()
if (content != null) tokenContents[evt.id] = content
if (content != null) tokenContents[evt.id] = content else undecryptable++
}
}
// A token we cannot decrypt is money we cannot see, and it drops out of
// the balance as silently as a token a relay never delivered. The
// retry above only fires when something else triggers a recompute, so
// say it out loud: with this counter, a wallet reading low because an
// external signer refused N decrypts is diagnosable from a log instead
// of looking identical to a wallet that is genuinely empty.
if (undecryptable > 0) {
Log.w("CashuWallet") {
"$undecryptable of ${all.size} kind:7375 event(s) failed to decrypt — balance excludes them"
}
}
@@ -723,6 +887,9 @@ class CashuWalletState(
_tokenEntries.value = CashuWalletReader.computeUnspent(all, tokenContents)
}
/** Token events we hold but have never managed to decrypt. See [recomputeUnspent]. */
private fun undecryptedTokenCount(): Int = tokenEvents.keys.count { it !in tokenContents.keys }
private fun recomputePending() {
// Shared destroyed/expired filter with the headless reader.
_pendingQuotes.value = CashuWalletReader.computePending(quoteEvents.values, historyEvents.values)
@@ -747,8 +914,14 @@ class CashuWalletState(
private suspend fun redeemPendingNutzapsSerialized() {
if (!redeemMutex.tryLock()) return // a sweep is already in flight
try {
val privkey = walletPrivkeyHex() ?: return
val pubkey = p2pkPubkeyHex() ?: return
// Establish there is work BEFORE touching the signer. This sweep
// fires from every relevant cache bundle, and the two key reads
// below are NIP-44 decrypts of kind:17375 — for a NIP-46 bunker or
// a NIP-55 external signer that is a round-trip out of the process
// (Amber even prompts on some configurations), paid on every bundle
// by a wallet whose nutzaps were all redeemed months ago. Nothing
// above the candidate filter needs a key, so hoist the filter.
if (nutzapEvents.isEmpty()) return
val skipIds = HashSet<HexKey>()
historyEvents.values.forEach { h ->
h.redeemedReferences().forEach { skipIds.add(it.eventId) }
@@ -759,6 +932,17 @@ class CashuWalletState(
val candidates = nutzapEvents.values.filter { it.id !in skipIds }
if (candidates.isEmpty()) return
val privkey = walletPrivkeyHex() ?: return
// Derived from the same key the line above just decrypted — pass it
// in rather than letting p2pkPubkeyHex() decrypt kind:17375 a
// second time for the identical bytes.
val pubkey =
runCatching {
Secp256k1
.pubKeyCompress(Secp256k1.pubkeyCreate(privkey.hexToByteArray()))
.toHexKey()
}.getOrNull() ?: return
for (ev in candidates) {
try {
ops.redeemNutzap(ev, privkey, pubkey)
@@ -918,11 +1102,26 @@ class CashuWalletState(
val sharedMints = info.mints().map { it.mintUrl }.filter { it in ourMints }
if (sharedMints.isEmpty()) return null
// One pass over the entries, not one per shared mint. This runs inside
// a composable `remember {}` on every zap chip, so it is per rendered
// note — and `_tokenEntries` is no longer the handful of events a
// truncated relay delivery used to leave behind, it is the wallet's
// whole proof set. The old filter-per-mint form was
// O(sharedMints × entries) with a throwaway list allocated per mint.
val entries = _tokenEntries.value
val satsPerMint = HashMap<String, Long>(sharedMints.size)
var totalWalletSats = 0L
entries.forEach { entry ->
val amount = entry.content.totalAmount()
totalWalletSats += amount
val mint = entry.content.mint
if (mint in ourMints) satsPerMint[mint] = (satsPerMint[mint] ?: 0L) + amount
}
var bestMint = sharedMints.first()
var bestMintSats = 0L
for (mint in sharedMints) {
val balance = entries.filter { it.content.mint == mint }.sumOf { it.content.totalAmount() }
val balance = satsPerMint[mint] ?: 0L
if (balance > bestMintSats) {
bestMintSats = balance
bestMint = mint
@@ -932,7 +1131,7 @@ class CashuWalletState(
return NutzapFunding(
target = NutzapTarget(mintUrl = bestMint, recipientP2pkPubkeyHex = recipientPubkeyHex),
bestSingleMintSats = bestMintSats,
totalWalletSats = entries.sumOf { it.content.totalAmount() },
totalWalletSats = totalWalletSats,
)
}
@@ -1203,11 +1402,26 @@ class CashuWalletState(
entry to entry.content.proofs.mapTo(HashSet()) { it.secret }
}
// Index secret → entries holding it. A superset of B must share every
// one of B's secrets, so the only entries that can possibly cover B are
// the ones indexed under B's first secret — which is a handful, not the
// whole wallet. The previous all-pairs scan was O(entries²) with a
// set-containment test inside; that was invisible while a truncated
// relay delivery kept the wallet at a few entries, and is not once the
// whole proof set is present.
val holdersOfSecret = HashMap<String, MutableList<Pair<TokenEntry, HashSet<String>>>>()
withSecrets.forEach { pair ->
pair.second.forEach { secret ->
holdersOfSecret.getOrPut(secret) { mutableListOf() }.add(pair)
}
}
val redundant = mutableListOf<TokenEntry>()
for ((entry, secrets) in withSecrets) {
if (secrets.isEmpty()) continue
val candidates = holdersOfSecret[secrets.first()] ?: continue
val isRedundant =
withSecrets.any { (other, otherSecrets) ->
candidates.any { (other, otherSecrets) ->
other.event.id != entry.event.id &&
otherSecrets.containsAll(secrets) &&
(
@@ -1567,6 +1781,22 @@ class CashuWalletState(
*/
const val DISCOVERY_TIMEOUT_MS = 8_000L
/**
* How long the startup proof backfill waits for a wallet event plus a
* non-empty outbox relay set before giving up. Both are restored from
* AccountSettings almost immediately on a returning launch; this window
* only matters on a first sign-in, where they have to come off the
* network before we know there is a wallet and where its events live.
*/
private const val BACKFILL_READY_WAIT_MS = 60_000L
/**
* Per-page idle window for the paged proof walk — measured from the
* relay's last message, not from the page's start, so a relay actively
* streaming a long backlog is never cut off mid-page.
*/
private const val BACKFILL_IDLE_TIMEOUT_MS = 30_000L
private const val NOT_STARTED_MESSAGE = "CashuWalletState.start() not called"
}
}
@@ -53,7 +53,7 @@ class AppSpecificState(
fun getAppSpecificDataFlow(): StateFlow<NoteState> = amethystSettingsNote.flow().metadata.stateFlow
suspend fun saveNewAppSpecificData(): AppSpecificDataEvent {
val toInternal = settings.syncedSettings.toInternal()
val toInternal = settings.syncedSettings.toInternal(settings.mutedPublicChats.value)
return signer.sign(
AppSpecificDataEvent.build(
dTag = APP_SPECIFIC_DATA_D_TAG,
@@ -37,25 +37,36 @@ import kotlinx.serialization.json.Json
import kotlin.coroutines.cancellation.CancellationException
/**
* Device-global persistence for the NIP-OA attestations this device holds
* ([BuzzHeldAttestations]), so a held credential survives an app restart instead of
* needing to be re-pasted. Uses the app-wide [sharedPreferencesDataStore] like
* [NamecoinSharedPreferences] (not per-account — the store is already keyed by the agent
* pubkey each attestation authorizes).
* Per-account persistence for the NIP-OA attestation this account holds
* ([BuzzHeldAttestations]), so a held credential survives an app restart instead of needing to be
* re-pasted. The key is namespaced by pubkey; the store used to be one device-global list because
* each entry carried the agent key it authorized, which made the file a per-account store with
* extra steps.
*
* On construction it loads the saved entries into the singleton — **re-verifying each
* against its agent key**, so a tampered on-disk credential is dropped rather than trusted
* — then mirrors every later change back to disk. Construct once, eagerly, at startup.
* On construction it loads this account's saved attestation and mirrors every later change back to
* disk. Re-verification on restore is no longer done here: [BuzzHeldAttestations.put] verifies
* against the agent key itself and rejects what fails, so a tampered on-disk credential is dropped
* by the same gate that rejects a mistyped one. Construct once per account, eagerly.
*/
@Stable
class BuzzAttestationPreferences(
private val context: Context,
private val scope: CoroutineScope,
private val pubKeyHex: HexKey,
private val attestation: BuzzHeldAttestations,
) {
private val json = Json { ignoreUnknownKeys = true }
private val key = stringPreferencesKey("$KEY_PREFIX$pubKeyHex")
@Serializable
private data class Entry(
val owner: HexKey,
val conditions: String,
val sig: HexKey,
)
/** The pre-namespacing on-disk shape: one list for the whole device, each entry agent-keyed. */
@Serializable
private data class LegacyEntry(
val agent: HexKey,
val owner: HexKey,
val conditions: String,
@@ -67,41 +78,76 @@ class BuzzAttestationPreferences(
restoreFromDisk()
// Persist on every change AFTER the initial restore (drop(1) skips the value
// present at collection start, which restoreFromDisk already wrote).
BuzzHeldAttestations.flow.drop(1).collect { persist(it) }
attestation.flow.drop(1).collect { persist(it) }
}
}
private suspend fun restoreFromDisk() {
try {
val raw = context.sharedPreferencesDataStore.data.first()[KEY] ?: return
val verified =
json
.decodeFromString<List<Entry>>(raw)
.mapNotNull { e ->
val attestation = OwnerAttestation(e.owner, e.conditions, e.sig)
// Only reinstate a credential that still verifies for its agent key.
if (attestation.verify(e.agent)) e.agent to attestation else null
}.toMap()
if (verified.isNotEmpty()) BuzzHeldAttestations.restore(verified)
val prefs = context.sharedPreferencesDataStore.data.first()
// put() verifies, so a credential that no longer checks out is dropped either way.
restoreFrom(prefs[key], prefs[LEGACY_KEY], pubKeyHex)?.let(attestation::put)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("BuzzAttestationPrefs") { "Error reading held attestations: ${e.message}" }
Log.e("BuzzAttestationPrefs") { "Error reading held attestation: ${e.message}" }
}
}
private suspend fun persist(entries: Map<HexKey, OwnerAttestation>) {
private suspend fun persist(held: OwnerAttestation?) {
try {
val list = entries.map { (agent, a) -> Entry(agent, a.ownerPubKey, a.conditions, a.sig) }
context.sharedPreferencesDataStore.edit { prefs ->
prefs[KEY] = json.encodeToString(list)
// Write [NONE] rather than removing the key: removing it is indistinguishable from
// never having migrated, which would let the legacy list re-seed a credential the
// user just deleted. See [restoreFrom].
prefs[key] = if (held == null) NONE else json.encodeToString(Entry(held.ownerPubKey, held.conditions, held.sig))
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("BuzzAttestationPrefs") { "Error writing held attestations: ${e.message}" }
Log.e("BuzzAttestationPrefs") { "Error writing held attestation: ${e.message}" }
}
}
companion object {
private val KEY = stringPreferencesKey("buzz.heldAttestations")
private const val KEY_PREFIX = "buzz.heldAttestation."
/** The device-global key written before the store became per-account; read-only now. */
private val LEGACY_KEY = stringPreferencesKey("buzz.heldAttestations")
/**
* Tombstone for "this account has been migrated and holds nothing", which an *absent* key
* cannot express — absent still means "never migrated" and is allowed to seed from
* [LEGACY_KEY]. Without it, removing a held attestation lasted only until the next launch,
* because nothing ever clears the legacy list. (The starred-channel and joined-workspace
* stores get this for free: they persist an empty *set*, which reads back present.)
*/
private const val NONE = ""
private val json = Json { ignoreUnknownKeys = true }
/**
* Which attestation to reinstate, given this account's saved value and the pre-namespacing
* device-global list. Pure, so the migration precedence is testable without a `Context`.
*
* [saved] wins whenever it is present, [NONE] included. Only a never-migrated account falls
* back to [legacy], and it takes just the entry issued to its own key — that list was
* already agent-keyed, so no other account's credential can match. Nothing is verified here;
* [BuzzHeldAttestations.put] is the gate that rejects a tampered credential.
*/
internal fun restoreFrom(
saved: String?,
legacy: String?,
agentPubKey: HexKey,
): OwnerAttestation? {
if (saved != null) {
if (saved == NONE) return null
val entry = json.decodeFromString<Entry>(saved)
return OwnerAttestation(entry.owner, entry.conditions, entry.sig)
}
val list = legacy ?: return null
return json
.decodeFromString<List<LegacyEntry>>(list)
.firstOrNull { it.agent == agentPubKey }
?.let { OwnerAttestation(it.owner, it.conditions, it.sig) }
}
}
}
@@ -25,6 +25,7 @@ import androidx.compose.runtime.Stable
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.drop
@@ -33,28 +34,39 @@ import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
/**
* Device-global persistence for the set of starred Buzz workspace channels ([BuzzChannelStars]),
* so favorites survive a restart. Mirrors [BuzzWorkspacePreferences]: app-wide (not per-account),
* loads the saved ids into the singleton on construction, then writes every later change back.
* Construct once, eagerly.
* Per-account persistence for the set of starred Buzz workspace channels ([BuzzChannelStars]), so
* favorites survive a restart. Mirrors [BuzzWorkspacePreferences] in both shape and reasoning: the
* key is namespaced by pubkey because a star is personal — it says which channels *this* user wants
* pinned — and one device-global set meant one account's favorites reordered and badged every other
* logged-in account's channel list. Loads this account's saved ids into [stars] on construction,
* then writes every later change back. Construct once per account, eagerly.
*/
@Stable
class BuzzChannelStarPreferences(
private val context: Context,
private val scope: CoroutineScope,
private val pubKeyHex: HexKey,
private val stars: BuzzChannelStars,
) {
private val key = stringSetPreferencesKey("$KEY_PREFIX$pubKeyHex")
init {
scope.launch {
restoreFromDisk()
// drop(1) skips the value present at collection start, which restoreFromDisk already wrote.
BuzzChannelStars.flow.drop(1).collect { persist(it) }
stars.flow.drop(1).collect { persist(it) }
}
}
private suspend fun restoreFromDisk() {
try {
val raw = context.sharedPreferencesDataStore.data.first()[KEY] ?: return
if (raw.isNotEmpty()) BuzzChannelStars.restore(raw)
val prefs = context.sharedPreferencesDataStore.data.first()
// Fall back to the pre-namespacing device-global key so an upgrade doesn't unpin
// everything. That set is what every account already saw; the next toggle writes to this
// account's own key and takes over. The legacy key is left for other accounts to seed
// from and is never written again.
val raw = prefs[key] ?: prefs[LEGACY_KEY] ?: return
if (raw.isNotEmpty()) stars.restore(raw)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("BuzzChannelStarPrefs") { "Error reading starred channels: ${e.message}" }
@@ -63,7 +75,10 @@ class BuzzChannelStarPreferences(
private suspend fun persist(ids: Set<String>) {
try {
context.sharedPreferencesDataStore.edit { prefs -> prefs[KEY] = ids }
// Always write the starred set, empty included — never remove the key. An absent key
// means "never migrated" and re-seeds from the legacy one above, so removing it
// would undo the user's last removal on the next launch.
context.sharedPreferencesDataStore.edit { prefs -> prefs[key] = ids }
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("BuzzChannelStarPrefs") { "Error writing starred channels: ${e.message}" }
@@ -71,6 +86,9 @@ class BuzzChannelStarPreferences(
}
companion object {
private val KEY = stringSetPreferencesKey("buzz.starredChannels")
private const val KEY_PREFIX = "buzz.starredChannels."
/** The device-global key written before the set became per-account; read-only now. */
private val LEGACY_KEY = stringSetPreferencesKey("buzz.starredChannels")
}
}
@@ -25,6 +25,7 @@ import androidx.compose.runtime.Stable
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
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.utils.Log
@@ -35,36 +36,56 @@ import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
/**
* Device-global persistence for the set of joined `block/buzz` workspaces ([BuzzWorkspaces]),
* so the app knows which relays to connect + NIP-42-authenticate + run member-channel discovery
* against on a cold start — Buzz membership is server-side (granted by the HTTP invite claim),
* with no NIP-51/kind-10009 join event to rebuild the set from. Uses the app-wide
* [sharedPreferencesDataStore] like [BuzzAttestationPreferences] (not per-account: a joined
* relay is workspace-wide, and restoring only marks relays to sync — the relay still gates every
* read/write by the authenticated key).
* Per-account persistence for the set of joined `block/buzz` workspaces ([BuzzWorkspaces]), so the
* app knows which relays to connect + NIP-42-authenticate + run member-channel discovery against on
* a cold start — Buzz membership is server-side (granted by the HTTP invite claim), with no
* NIP-51/kind-10009 join event to rebuild the set from.
*
* On construction it loads the saved relay URLs into the singleton (re-normalizing each, dropping
* any that no longer parse), then mirrors every later change back to disk. Construct once, eagerly.
* **Per account, not per device.** The set used to be one device-global key shared by every logged-in
* account, on the reasoning that restoring only marks relays to sync and the relay gates each
* read/write by the authenticated key anyway. That missed one consumer: the joined set also makes a
* relay first-party in `AuthCoordinator.isFirstParty`, so one account joining a workspace silently
* gave *every* other logged-in account an automatic NIP-42 login there — the bystander-account leak
* the per-account gate exists to prevent. The key is namespaced by pubkey for the same reason the
* relay-auth overrides moved to a per-account file.
*
* Still on the app-wide [sharedPreferencesDataStore] file — the namespacing, not the file, is what
* separates accounts, and one file avoids a second DataStore per logged-in account.
*
* On construction it loads this account's saved relay URLs into [workspaces] (re-normalizing each,
* dropping any that no longer parse), then mirrors every later change back to disk. Construct once
* per account, eagerly.
*/
@Stable
class BuzzWorkspacePreferences(
private val context: Context,
private val scope: CoroutineScope,
private val pubKeyHex: HexKey,
private val workspaces: BuzzWorkspaces,
) {
private val key = stringSetPreferencesKey("$KEY_PREFIX$pubKeyHex")
init {
scope.launch {
restoreFromDisk()
// Persist on every change AFTER the initial restore (drop(1) skips the value present
// at collection start, which restoreFromDisk already wrote).
BuzzWorkspaces.flow.drop(1).collect { persist(it) }
workspaces.flow.drop(1).collect { persist(it) }
}
}
private suspend fun restoreFromDisk() {
try {
val raw = context.sharedPreferencesDataStore.data.first()[KEY] ?: return
val prefs = context.sharedPreferencesDataStore.data.first()
// Fall back to the pre-namespacing device-global key so an upgrade doesn't empty the
// workspaces hub. That set is whatever any account joined, which is exactly what every
// account already saw before this became per-account — so seeding from it changes
// nothing that was true yesterday, and the first join after the upgrade writes to this
// account's own key and takes over. The legacy key is left in place for the other
// accounts to seed from; nothing writes it again.
val raw = prefs[key] ?: prefs[LEGACY_KEY] ?: return
val relays = raw.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
if (relays.isNotEmpty()) BuzzWorkspaces.restore(relays)
if (relays.isNotEmpty()) workspaces.restore(relays)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("BuzzWorkspacePrefs") { "Error reading joined workspaces: ${e.message}" }
@@ -73,8 +94,11 @@ class BuzzWorkspacePreferences(
private suspend fun persist(relays: Set<NormalizedRelayUrl>) {
try {
// Always write the joined set, empty included — never remove the key. An absent key
// means "never migrated" and re-seeds from the legacy one above, so removing it
// would undo the user's last removal on the next launch.
context.sharedPreferencesDataStore.edit { prefs ->
prefs[KEY] = relays.map { it.url }.toSet()
prefs[key] = relays.map { it.url }.toSet()
}
} catch (e: Exception) {
if (e is CancellationException) throw e
@@ -83,6 +107,9 @@ class BuzzWorkspacePreferences(
}
companion object {
private val KEY = stringSetPreferencesKey("buzz.joinedWorkspaces")
private const val KEY_PREFIX = "buzz.joinedWorkspaces."
/** The device-global key written before the set became per-account; read-only now. */
private val LEGACY_KEY = stringSetPreferencesKey("buzz.joinedWorkspaces")
}
}
@@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.isMutedPublicChatMessage
import com.vitorpamplona.amethyst.service.call.notification.CallNotifier
import com.vitorpamplona.amethyst.service.notifications.renderers.ArticleNotification
import com.vitorpamplona.amethyst.service.notifications.renderers.BadgeNotification
@@ -218,11 +219,20 @@ class EventNotificationConsumer(
// Don't push-notify events this account authored.
if (event.pubKey == account.signer.pubKey) return
// Drop reactions/zaps/reposts whose target note lives on a muted thread
// (matches the in-app feed, which mutes all four).
// Drop reactions/zaps/reposts whose target note lives on a muted thread, or in a
// public chat the user has silenced (matches the in-app feed, which mutes all four).
// Without the second check, muting a channel still let a like on your own message
// there notify you — the row's glyph promises silence, so it has to mean it.
if (event is ReactionEvent || event is LnZapEvent || event is RepostEvent || event is GenericRepostEvent) {
val target = LocalCache.getNoteIfExists(event)?.replyTo?.lastOrNull()
if (target != null && account.isThreadMuted(account.resolveThreadRoot(target))) return
if (target != null &&
(
account.isThreadMuted(account.resolveThreadRoot(target)) ||
isMutedPublicChatMessage(target.event, account.settings.mutedPublicChats.value)
)
) {
return
}
}
when (event) {
@@ -315,6 +325,12 @@ class EventNotificationConsumer(
event: ChannelMessageEvent,
account: Account,
) {
// Reads local device state, NOT the NIP-78 blob: on a push-driven cold start
// AppSpecificState may not have decrypted yet (and for a NIP-55 account that is
// an Amber IPC round-trip that can fail outright in the background). Losing this
// race would post exactly the notification the mute exists to prevent.
if (isMutedPublicChatMessage(event, account.settings.mutedPublicChats.value)) return
val note = LocalCache.getNoteIfExists(event.id) ?: return
if (NotificationFeedFilter.isNotifiablePublicChatReply(note, account.signer.pubKey)) {
@@ -0,0 +1,241 @@
/*
* 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.service.priority
import android.content.Context
import android.os.Process
import android.os.SystemClock
import android.provider.Settings
import com.vitorpamplona.quartz.utils.Log
import java.io.File
/**
* Demotes the app's network/ingest worker threads below the UI thread so a cold-start relay storm
* cannot starve the main thread out of its frames.
*
* **Why this exists.** On a cold start the outbox model dials ~190 relays at once and the process
* grows to ~650 threads (OkHttp's TaskRunner pool, OkHttp dispatchers, the kotlinx scheduler and
* Arti's tokio workers). Every one of them is born at nice 0. The main thread is nice -10, but a
* single -10 thread against dozens of simultaneously-runnable nice-0 threads still loses a large
* share of its schedulable time to the runqueue — long enough that the first feed frame takes
* seconds and the "Loading account" screen stays on-screen well after the account itself has
* loaded.
*
* **Measured on a release-codegen build** (`:amethyst:installPlayBenchmark`, i.e. R8-minified +
* baseline-profile AOT), SM-T220, 5-round round-robin, every run valid:
*
* | workers at | starvation | runqueue wait | time to first paint | spread |
* |---|---|---|---|---|
* | nice 0 (off) | 26.7% | 2300 ms | 11.1 s | 5.63 s |
* | nice 5 | 22.0% | 1774 ms | 8.0 s | 4.05 s |
* | nice 9 | 17.1% | 1280 ms | 8.4 s | 3.05 s |
* | **nice 10** | **14.6%** | **1234 ms** | **6.2 s** | **1.55 s** |
*
* nice 10 beat the control in 5 of 5 paired rounds (median 5.0 s faster, ~45%) and collapsed the
* run-to-run spread from 5.6 s to 1.6 s, so [DEFAULT_NICE] is 10.
*
* **This effect only exists in a release build, so never re-validate it on a debug one.** In a
* debug build the same sweep changes nothing measurable: there the main thread is ~70% busy,
* saturated with ART interpretation, so scheduling was never the constraint (starvation is 15% in
* debug vs 27% in release). R8 collapses main's own work while leaving the relay storm untouched,
* which is what promotes starvation to the binding constraint. An emulator is equally misleading
* for the opposite reason — its shared cores manufacture contention real hardware does not have.
*
* **Why a /proc sweep instead of thread factories.** The largest pool by far is OkHttp's
* `TaskRunner` backend, a process-wide singleton whose thread factory OkHttp does not expose per
* client, so there is no injection point to set a priority at creation time. Sweeping
* `/proc/self/task` catches every pool uniformly — including threads OkHttp renames after the host
* they are currently serving. A nice value is per-OS-thread and survives renaming, so seeing a
* thread once is enough; the sweep only has to be frequent enough to catch newly-spawned ones.
*
* Note that [Thread.setPriority] does NOT map to a Linux nice level on Android — only
* [Process.setThreadPriority] does. (`AudioTrackPlayer` documents the same trap for audio.)
*
* **Scope.** [DENYLIST] holds the threads that must keep their scheduling: the main thread,
* RenderThread (it draws the frames we are trying to protect), the ART daemons (demoting
* HeapTaskDaemon would make the GC pressure *worse*, not better) and binder threads (IPC replies
* the system waits on). Everything else is app work that should yield to the UI.
*
* **Cost.** Each thread is touched once, not once per sweep, and the interval backs off whenever a
* sweep finds nothing new — the storm front-loads thread creation, so most sweeps after the first
* few seconds are empty. Threads that exit are pruned so a recycled tid is re-evaluated.
*
* **Runtime override.** [SETTING_KEY] overrides [DEFAULT_NICE] without a rebuild, and is also the
* off switch (any value <= 0 disables the governor entirely):
* ```
* adb shell settings put global amethyst_worker_nice 5 # demote to nice 5 instead
* adb shell settings put global amethyst_worker_nice 0 # disable
* adb shell settings delete global amethyst_worker_nice # back to DEFAULT_NICE
* ```
* Despite AOSP's `androidSetThreadPriority` calling `set_sched_policy(SP_BACKGROUND)` at nice >= 10,
* no cpuset/schedtune move was observed on real hardware (SM-T220 / Android 14): at nice 5, 9 and 10
* every worker kept main's exact membership (`schedtune:/top-app`, `cpuset:/top-app`, `cpu:/`) and
* only the nice value changed — so 10 carries no hidden cgroup penalty over 9.
*/
object WorkerThreadPriorityGovernor {
/** `Settings.Global` key overriding [DEFAULT_NICE]; any value <= 0 disables the governor. */
const val SETTING_KEY = "amethyst_worker_nice"
/** Best measured value on a release-codegen build — see the table in the class doc. */
const val DEFAULT_NICE = 10
/** Sweep cadence while threads are still appearing. */
private const val MIN_INTERVAL_MS = 250L
/** Ceiling the interval backs off to while a sweep keeps finding nothing new. */
private const val MAX_BURST_INTERVAL_MS = 2_000L
/** How long to stay in the adaptive burst before settling at [IDLE_INTERVAL_MS]. */
private const val BURST_DURATION_MS = 120_000L
/** Steady-state cadence; relay reconnects still spawn threads long after boot. */
private const val IDLE_INTERVAL_MS = 5_000L
/**
* Threads whose scheduling must not be touched. Matched as prefixes against the kernel `comm`
* (which the kernel caps at 15 characters, so these are deliberately short).
*/
private val DENYLIST =
listOf(
// Draws the frames this whole exercise is meant to protect.
"RenderThread",
"hwuiTask",
"GPU completion",
// ART daemons — demoting the GC would deepen the very stalls we are fixing.
"HeapTaskDaemon",
"ReferenceQueueD",
"FinalizerDaemon",
"FinalizerWatchd",
"Signal Catcher",
"Jit thread pool",
"Runtime worker",
"perfetto_hprof",
// Debugger/profiler plumbing.
"ADB-JDWP",
"JDWP",
// Synchronous IPC the system framework blocks on.
"binder:",
)
@Volatile private var started = false
fun start(context: Context) {
if (started) return
val targetNice = resolveTargetNice(context)
if (targetNice == null) {
Log.i("ThreadPriority") { "Worker thread governor disabled via $SETTING_KEY" }
return
}
started = true
Log.i("ThreadPriority") { "Worker thread governor on, target nice=$targetNice" }
Thread({ sweepLoop(targetNice) }, "worker-nice-governor")
.apply {
isDaemon = true
start()
}
}
/** Returns the nice level to apply, or null when the governor should not run at all. */
private fun resolveTargetNice(context: Context): Int? {
val configured =
runCatching {
Settings.Global.getInt(context.contentResolver, SETTING_KEY, DEFAULT_NICE)
}.getOrDefault(DEFAULT_NICE)
// Only a demotion makes sense here; <= 0 is the documented off switch and anything above
// the nice ceiling is a typo we should not act on.
return configured.takeIf { it in 1..19 }
}
private fun sweepLoop(targetNice: Int) {
// The governor must keep running while the pools it polices saturate the CPU, so it runs
// slightly above default rather than as background work.
runCatching { Process.setThreadPriority(Process.THREAD_PRIORITY_FOREGROUND) }
val startedAt = SystemClock.elapsedRealtime()
val mainTid = Process.myPid()
// Tids already dealt with — demoted, or skipped because they are on the denylist. Both are
// permanent decisions, so keeping them here means a thread costs one `comm` read for its
// whole life instead of one per sweep. Seeded with our own tid so the sweep can't demote
// the governor itself.
val handled = HashSet<Int>().apply { add(Process.myTid()) }
var interval = MIN_INTERVAL_MS
while (true) {
val demoted = sweepOnce(mainTid, targetNice, handled)
if (demoted > 0) {
Log.d("ThreadPriority") { "Demoted $demoted thread(s) to nice $targetNice" }
}
// Thread creation is front-loaded into the connect storm, so once a sweep comes back
// empty the next one almost certainly will too — back off instead of spinning.
interval =
when {
SystemClock.elapsedRealtime() - startedAt >= BURST_DURATION_MS -> IDLE_INTERVAL_MS
demoted > 0 -> MIN_INTERVAL_MS
else -> (interval * 2).coerceAtMost(MAX_BURST_INTERVAL_MS)
}
runCatching { Thread.sleep(interval) }.onFailure { return }
}
}
private fun sweepOnce(
mainTid: Int,
targetNice: Int,
handled: MutableSet<Int>,
): Int {
// list() rather than listFiles(): this runs hundreds of times over a boot and the File
// objects would be pure garbage on an already GC-pressured heap.
val tidNames = File("/proc/self/task").list() ?: return 0
val live = HashSet<Int>(tidNames.size * 2)
var demoted = 0
for (tidName in tidNames) {
val tid = tidName.toIntOrNull() ?: continue
live.add(tid)
if (tid == mainTid || tid in handled) continue
// A thread can exit between listing and reading; treat any failure as "skip" and let
// the next sweep retry, since it is not yet recorded in `handled`.
val name =
runCatching {
File("/proc/self/task/$tidName/comm").readText().trim()
}.getOrNull() ?: continue
if (DENYLIST.any { name.startsWith(it) }) {
handled.add(tid)
continue
}
if (runCatching { Process.setThreadPriority(tid, targetNice) }.isSuccess) {
handled.add(tid)
demoted++
}
}
// Drop tids that have exited so the kernel recycling one into a new thread doesn't leave
// that thread permanently un-demoted.
handled.retainAll(live)
return demoted
}
}
@@ -21,9 +21,7 @@
package com.vitorpamplona.amethyst.service.relayClient.authCommand.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthContext
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict
@@ -103,6 +101,10 @@ class AuthCoordinator(
// question. Returning early here instead made "decide per relay" mean "deny, and
// don't mention it" for every purpose that names someone else — the exact case the
// prompt was built to explain.
//
// It does not reach the "…I'm reading someone I follow" toggle at all: a follow's
// outbox relay can never be first-party for us, so applying it there emptied the
// category instead of narrowing it. RelayAuthResolver.customAllows has the detail.
val firstParty = isFirstParty(account, relayUrl)
val approve =
@@ -115,8 +117,9 @@ class AuthCoordinator(
// reveal @b — an answer is only about the identity it was shown for.
// The bus still collapses concurrent challenges for the same
// (relay, account) pair, which is the case the shared prompt was for.
// In practice this rarely means two dialogs: isFirstParty already
// drops every account without its own reason to be on this relay.
// In practice this rarely means two dialogs: for everything except
// reading a follow, isFirstParty already drops every account without
// its own reason to be on this relay.
//
// But never block the derived stream-key AUTH behind that dialog: on a
// relay that hosts our Concord planes we DISMISS the user-auth ASK
@@ -165,7 +168,7 @@ class AuthCoordinator(
// Remember why we granted this relay so the settings screen can explain it.
account.relayAuthLedger.recordGrant(context)
try {
signed.add(account.signer.sign(buzzAugmented(authTemplate, account.pubKey, relayUrl)))
signed.add(account.signer.sign(buzzAugmented(authTemplate, account, relayUrl)))
} catch (e: Exception) {
Log.e("AuthCoordinator", "Failed trying to authenticate a writeable account", e)
}
@@ -207,23 +210,23 @@ class AuthCoordinator(
}
/**
* If [relayUrl] speaks the Buzz dialect and this device holds a NIP-OA attestation
* authorizing [accountPubKey], returns [template] with the owner-signed `auth` tag
* appended — so the relay grants virtual membership to an un-enrolled agent key while
* its owner stays a member. Otherwise returns [template] unchanged.
* If [relayUrl] speaks the Buzz dialect and [account] holds a NIP-OA attestation authorizing
* its key, returns [template] with the owner-signed `auth` tag appended — so the relay grants
* virtual membership to an un-enrolled agent key while its owner stays a member. Otherwise
* returns [template] unchanged.
*
* Applied ONLY to an account's own AUTH (the caller passes the account pubkey), never
* to the Concord stream-key AUTHs that share the same [template] object, and it is a
* no-op on non-Buzz relays and for accounts with no held attestation — so it can never
* add an `auth` tag where one isn't wanted.
* Applied ONLY to an account's own AUTH (the caller passes the account being signed for), never
* to the Concord stream-key AUTHs that share the same [template] object, and it is a no-op on
* non-Buzz relays and for accounts with no held attestation — so it can never add an `auth` tag
* where one isn't wanted.
*/
private fun buzzAugmented(
template: EventTemplate<RelayAuthEvent>,
accountPubKey: HexKey,
account: Account,
relayUrl: NormalizedRelayUrl,
): EventTemplate<RelayAuthEvent> {
if (!BuzzRelayDialect.isBuzz(relayUrl)) return template
val authTag = BuzzHeldAttestations.authTagFor(accountPubKey) ?: return template
val authTag = account.buzzAttestation.authTag() ?: return template
return EventTemplate(template.createdAt, template.kind, template.tags + arrayOf(authTag), template.content)
}
@@ -238,18 +241,24 @@ class AuthCoordinator(
* Merely *following* the counterparty of someone else's traffic is deliberately NOT first-party:
* that is exactly how a bystander account got dragged into a paid inbox relay's AUTH (the shared
* auth context carries the OTHER account's counterparties, evaluated against this account's
* follow graph). Reads of a followed author's outbox on an auth-gated relay this account doesn't
* use are therefore no longer auto-authed — a deliberate privacy-positive trade-off.
* follow graph).
*
* Reading a followed author's outbox is the one case this cannot speak to. That relay is the
* author's, so nothing here can ever return true for it, which is why
* [com.vitorpamplona.amethyst.commons.relayauth.RelayAuthResolver] applies the
* [com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles.readFollows] category
* without consulting this — otherwise the toggle would be permanently off.
*/
private fun isFirstParty(
account: Account,
relayUrl: NormalizedRelayUrl,
): Boolean =
// A Buzz workspace the user explicitly joined is a first-party reason to authenticate: its
// channel/DM discovery is read-only (`#p` = me), which is otherwise deliberately NOT
// A Buzz workspace THIS account explicitly joined is a first-party reason to authenticate:
// its channel/DM discovery is read-only (`#p` = me), which is otherwise deliberately NOT
// first-party, so without this the p-gated 44100/30622 reads would never be served and the
// workspace would stay empty.
BuzzWorkspaces.isJoined(relayUrl) ||
// workspace would stay empty. Read off the account, never a device-global set: the invite was
// redeemed by one key, and a shared set made every other logged-in account first-party here.
account.buzzWorkspaces.isJoined(relayUrl) ||
RelayAuthFirstParty.hasReason(
me = account.pubKey,
relayUrl = relayUrl,
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.buzz.BuzzMembershipEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.drafts.AccountDraftsEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.marmot.MarmotGroupEventsEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata.AccountMetadataEoseManager
@@ -33,6 +34,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip47Wa
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsHistoryEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip60Cashu.CashuWalletEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip60Cashu.CashuWalletHistoryEoseManager
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@@ -96,6 +98,11 @@ class AccountFilterAssembler(
// History: older notifications, paged backward by until+limit per relay, driven by the feed's markers.
val notificationsHistory = AccountNotificationsHistoryEoseManager(client, ::preferredKeys)
// History: older NIP-60 spending rows (kind:7376), paged backward by until+limit per outbox relay,
// driven by the wallet's transaction list. The live wallet subscription below reads six kinds in one
// uncapped REQ, so history — the most numerous of them — is exactly what a relay's cap truncates.
val cashuWalletHistory = CashuWalletHistoryEoseManager(client, ::preferredKeys)
val group =
listOf(
AccountMetadataEoseManager(client, ::preferredKeys),
@@ -109,7 +116,12 @@ class AccountFilterAssembler(
// NIP-60 wallet + NIP-61 nutzap inbox. Mounted here rather than run from a collector
// inside CashuWalletState, so it starts and stops with every other account-level loader.
CashuWalletEoseManager(client, ::preferredKeys),
cashuWalletHistory,
MarmotGroupEventsEoseManager(client, ::preferredKeys),
// What a Buzz workspace relay addresses to me personally: membership verdicts (44100/44101)
// and my hidden-DM snapshot (30622). Feeds both the channel-invite prompts and Buzz DM
// discovery, which read them back out of LocalCache rather than each opening a `#p=me` REQ.
BuzzMembershipEoseManager(client, ::preferredKeys),
)
/**
@@ -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.amethyst.service.relayClient.reqCommand.account.buzz
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.buzz.dvDmVisibility.DmVisibilityEvent
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
import com.vitorpamplona.quartz.buzz.notifications.MemberRemovedNotificationEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
/** The relay's membership verdicts addressed to me: 44100 "you were added", 44101 "you were removed". */
val MembershipNotificationKinds =
listOf(
MemberAddedNotificationEvent.KIND,
MemberRemovedNotificationEvent.KIND,
)
/**
* Everything the workspace relay addresses to me personally: the membership verdicts plus the kind-30622
* snapshot of which DMs I have hidden. One filter class — `#p` = me, channel-less, workspace relay — so
* they share a subscription.
*/
private val WorkspaceInboxKinds = MembershipNotificationKinds + DmVisibilityEvent.KIND
/**
* One workspace relay's worth of "things addressed to me personally": membership verdicts and my
* hidden-DM snapshot, `#p` = me.
*
* Empty for a missing pubkey so a not-yet-loaded account asks for nothing rather than for everyone's.
*/
fun filterWorkspaceInboxToPubkey(
relay: NormalizedRelayUrl,
pubkey: HexKey?,
since: Long?,
): List<RelayBasedFilter> {
if (pubkey.isNullOrEmpty()) return emptyList()
return listOf(
RelayBasedFilter(
relay = relay,
filter =
ExplainedFilter(
purpose = SubPurpose.NOTIFICATIONS,
accountPubKeys = listOf(pubkey),
kinds = WorkspaceInboxKinds,
tags = mapOf("p" to listOf(pubkey)),
since = since,
),
),
)
}
/**
* Always-on read of the Buzz relay's membership notifications addressed to me (`#p` = me) across every
* joined workspace — the events behind the "somebody added you to a channel" prompts and behind Buzz DM
* discovery.
*
* On a Buzz relay membership is server-side: another member issues the add, the relay writes me into the
* channel's kind-39002 roster, and then addresses me a kind-44100 naming the actor. There is no queryable
* channel list, so this `#p`-gated stream *is* the enumeration; the matching kind-44101 withdraws one.
*
* ### Why its own subscription
*
* This filter is channel-less by nature — it is the query that *discovers* which channels exist for me,
* so there is no `#h` to scope it with. `block/buzz` downgrades any subscription carrying a channel-less
* (or multi-channel) filter to "global", a class that by design never receives channel-scoped events;
* that is exactly why NIP-29 group activity was moved out of
* [com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromInboxRelaysManager]
* and onto the per-channel tails. Global is the correct class for 44100/44101 — they are stored globally
* so their target can find them — but only as long as nothing channel-scoped shares the subscription. So
* these ride a manager of their own rather than joining the inbox notification filters.
*
* Also note the relays: workspace relays are not the account's `notificationRelays`, so the inbox manager
* would not query them even if the filter class allowed it.
*
* ### Auth
*
* The read is `#p`-gated, so the relay answers `auth-required:`. A joined workspace is first-party, so
* each one is pre-approved on the account's auth ledger here — the restore-from-disk path doesn't set
* that, unlike the invite/import/console entry points. [com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator]
* re-signs on the refusal using the connection's stored challenge and the OK re-drives the REQ, so the
* subscription recovers on its own rather than needing a warm-auth one-shot fetch.
*/
class BuzzMembershipEoseManager(
client: INostrClient,
allKeys: () -> Set<AccountQueryState>,
) : PerUserEoseManager<AccountQueryState>(client, allKeys) {
override fun user(key: AccountQueryState) = key.account.userProfile()
override fun updateFilter(
key: AccountQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val me = key.account.userProfile().pubkeyHex
// No `since` floor on a cold start. LocalCache is in-memory, so a relaunch has to re-read the
// whole membership history to know which channels I am in — and the EOSE map that would supply
// a floor is in-memory too, so it is null exactly when the full read is needed. The filter is
// `#p`-scoped to my own key, so an all-time query costs one index scan.
return key.account.buzzWorkspaces.flow.value.flatMap { relay ->
filterWorkspaceInboxToPubkey(relay, me, since?.get(relay)?.time)
}
}
private val userJobMap = mutableMapOf<User, List<Job>>()
override fun newSub(key: AccountQueryState): Subscription {
val user = user(key)
userJobMap[user]?.forEach { it.cancel() }
userJobMap[user] =
listOf(
key.account.scope.launch(Dispatchers.IO) {
key.account.buzzWorkspaces.flow.collectLatest { relays ->
// Idempotent, and re-run per joined set rather than once at mount so a workspace
// joined later is pre-approved too.
relays.forEach { key.account.relayAuthLedger.setDecision(it.url, RelayAuthDecision.ALLOW) }
invalidateFilters()
}
},
)
return super.newSub(key)
}
override fun endSub(
key: User,
subId: String,
) {
super.endSub(key, subId)
userJobMap.remove(key)?.forEach { it.cancel() }
}
}
@@ -0,0 +1,225 @@
/*
* 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.service.relayClient.reqCommand.account.nip60Cashu
import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager
import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
/**
* Loads the account's NIP-60 spending **history** (kind:7376) by **`until`+`limit` paging, per relay, on
* demand**, so the wallet's transaction list can be scrolled back through a wallet's whole lifetime
* instead of showing whatever suffix one uncapped REQ happened to return.
*
* ### Why history needs its own loader
*
* [CashuWalletEoseManager] opens one live subscription per outbox relay covering six kinds at once, with
* no `limit`. A relay answers that with its own cap applied to the newest matching events, and history
* rows are the most numerous kind in the query — so on a wallet with a few hundred transactions the list
* is truncated to a recent-N view that differs per device, and no later REQ ever asks for the rest (the
* EOSE moves `since` forward). That is the same truncation that was silently costing balance, except
* here the fix is paging rather than a one-shot walk: history is display-only and unbounded, so pulling
* all of it at launch would be a large download for something the user may never scroll.
*
* ### How it pages
*
* There is no proactive walk. Each relay advances exactly one page when the transaction list asks
* ([advance] / [advanceAll]), then **parks** — a relay that finished a page keeps the same `until` in
* [updateFilter], so re-assembly triggered by *another* relay advancing does not re-REQ it. The list is
* the driver: it pulls a page on open and another whenever the user scrolls near the end, so nothing is
* fetched while the wallet is off screen.
*
* Paged over the account's **outbox** relays — the same set the wallet publishes its own events to, and
* so the same set [CashuWalletEoseManager] reads its own kinds back from.
*
* ### Floor: no live tail
*
* The DM/notification pagers floor at `now − liveTail` because a separate live loader is known to cover
* everything newer. The wallet has no such guarantee: its live REQ carries no `since` and no `limit`, so
* how far back it actually reaches is whatever the relay decided — which is the very thing being fixed
* here. Flooring at a fixed tail would therefore leave a gap between the relay's cap and the tail
* boundary that neither loader ever asks for. So this pager floors at **now** and overlaps the live
* subscription completely; duplicates cost nothing (both `LocalCache` and `CashuWalletState.historyEvents`
* are keyed by event id) and gaplessness is worth more than the overlap.
*
* The per-relay cursors live on the [Account] (so they share the account's lifetime); this class binds
* the single-active [BackwardRelayPager] to them on [newSub], builds the REQ filters, and forwards relay
* callbacks into the pager. A relay is *done* once it answers an empty page; one that will not answer
* (auth CLOSE, unreachable, silent) is flagged *stalled* but kept, and [PagingStatus.exhausted] flips
* once every relay is done or stalled — callers rendering a terminal state should split on
* [PagingStatus.stalledCount].
*/
class CashuWalletHistoryEoseManager(
client: INostrClient,
allKeys: () -> Set<AccountQueryState>,
) : PerUserEoseManager<AccountQueryState>(client, allKeys) {
override fun user(key: AccountQueryState) = key.account.userProfile()
// liveTailSeconds = 0 pins the floor at `now` — see the class doc on why the wallet, unlike DMs,
// cannot assume a live loader already covers a recent window.
private val pager = BackwardRelayPager("cashu.history", pageLimit = PAGE_LIMIT, liveTailSeconds = 0L)
val loadingMore: StateFlow<Boolean> = pager.loadingMore
val status: StateFlow<PagingStatus> = pager.status
/** The relays this account pages its own NIP-60 history back through: where it publishes. */
private fun historyRelaySet(account: Account): Set<NormalizedRelayUrl> = account.outboxRelays.flow.value
override fun updateFilter(
key: AccountQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val pubkey = user(key).pubkeyHex
val relays = historyRelaySet(key.account)
// Only relays that have been advanced (armed) and aren't done carry a REQ. A relay that finished
// a page keeps the same `until` here, so re-assembly (triggered when ANOTHER relay advances)
// doesn't re-REQ it — it stays parked until the list advances it again.
val armed = pager.armedRelays(relays)
if (armed.isEmpty()) return emptyList()
return armed.flatMap { relay ->
val until = pager.requestedUntilFor(relay) ?: return@flatMap emptyList()
Log.d(TAG) { "[cashu.history] REQ ${relay.url} until=$until limit=${pager.pageLimit}" }
filterCashuHistoryToPubkey(relay, pubkey, until, pager.pageLimit)
}
}
/** Steps a single [relay] to its next, older page. */
fun advance(relay: NormalizedRelayUrl) {
if (pager.advance(relay)) invalidateFilters()
}
/** Steps every not-done, not-in-flight relay one page. What the transaction list drives. */
fun advanceAll() {
if (pager.advanceAll()) {
Log.d(TAG) { "[cashu.history] advanceAll" }
invalidateFilters()
}
}
private val userJobMap = mutableMapOf<User, List<Job>>()
@OptIn(FlowPreview::class)
override fun newSub(key: AccountQueryState): Subscription {
// Repoint the single-active orchestrator at this account's cashu-history cursors and the relay
// set it fans out to, refreshing the display flows from the restored progress.
pager.bind(key.account.cashuHistory, key.account.scope) { historyRelaySet(key.account) }
val user = user(key)
userJobMap[user]?.forEach { it.cancel() }
userJobMap[user] =
listOf(
// A relay joining/leaving the outbox set re-issues the REQ so a newly-added relay can be
// armed and a removed one drops out. Sampled — a relay-list edit lands as a burst.
key.account.scope.launch(Dispatchers.IO) {
key.account.outboxRelays.flow
.sample(1000)
.collectLatest { invalidateFilters() }
},
)
return requestNewSubscription(historyListener(key))
}
private fun historyListener(key: AccountQueryState): SubscriptionListener {
// A just-backgrounded account's subscription can still deliver after the orchestrator rebinds to
// another account; gate the pager (single-active) on whether it's still bound to THIS account's
// cursors so a late callback can't move another account's cursors. newEose runs regardless.
val myCursors = key.account.cashuHistory
return object : SubscriptionListener {
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onEvent(relay, event.createdAt)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (pager.isBoundTo(myCursors) && pager.onEose(relay)) {
Log.d(TAG) { "[cashu.history] ${relay.url} reached the bottom (done)" }
}
// No auto-advance: the relay parks here until the transaction list asks for another page.
newEose(key, relay, TimeUtils.now(), forFilters)
}
override fun onClosed(
message: String,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onClosed(relay, message)
}
override fun onCannotConnect(
relay: NormalizedRelayUrl,
message: String,
forFilters: List<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onCannotConnect(relay, message)
}
}
}
override fun endSub(
key: User,
subId: String,
) {
super.endSub(key, subId)
userJobMap[key]?.forEach { it.cancel() }
}
companion object {
private const val TAG = "CashuPagination"
/**
* Rows pulled per relay per advance. Smaller than the notification pager's 500: every kind:7376
* row costs a NIP-44 decrypt to render, which on an external signer is an out-of-process
* round-trip, so a page is sized to fill a screen or two rather than to fill memory.
*/
const val PAGE_LIMIT = 100
}
}
@@ -0,0 +1,65 @@
/*
* 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.service.relayClient.reqCommand.account.nip60Cashu
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
/**
* One backward-paging page of the account's NIP-60 spending history (kind:7376) on one of its outbox
* relays: my own history rows, strictly older than [until], newest-first, capped at [limit].
*
* Deliberately kind:7376 only. The proofs (kind:7375) are not paged on demand — a balance computed from
* a partial proof set is simply wrong, so those are walked to exhaustion in one shot by
* `CashuWalletState.resyncProofsFromRelays`. History is the opposite: it is display-only, unbounded in
* length, and the user reads it newest-first, so it is exactly the shape `until`+`limit` paging is for.
*
* `until`+`limit` rather than a `since`/`until` window for the reason in `RelayLoadingCursors`: an empty
* time slice cannot distinguish "nothing older here" from "a quiet month", whereas an empty
* `until`+`limit` page is gap-proof proof of the bottom.
*/
fun filterCashuHistoryToPubkey(
relay: NormalizedRelayUrl,
pubkey: HexKey?,
until: Long,
limit: Int,
): List<RelayBasedFilter> {
if (pubkey.isNullOrEmpty()) return emptyList()
return listOf(
RelayBasedFilter(
relay = relay,
filter =
ExplainedFilter(
purpose = SubPurpose.WALLET,
accountPubKeys = listOfNotNull(pubkey),
kinds = listOf(CashuSpendingHistoryEvent.KIND),
authors = listOf(pubkey),
limit = limit,
until = until,
),
),
)
}
@@ -151,21 +151,16 @@ fun painterRes(
@DrawableRes resourceId: Int,
sizeReference: Int,
): Painter {
val cached = iconCache.get(resourceId)
if (cached != null) {
val composition = cached.get(sizeReference)
if (composition != null) {
return composition
}
}
val bySize = iconCache.get(resourceId)
bySize?.get(sizeReference)?.let { return it }
val loaded = painterResource(resourceId)
if (cached == null) {
iconCache.put(resourceId, LruCache<Int, Painter>(10))
} else {
cached.put(sizeReference, loaded)
}
// Store on the FIRST miss as well. This previously created the per-size cache but never
// put `loaded` into it, so a resource had to be requested three times before it could
// ever hit: once to install the (empty) inner cache, once to populate it, once to read it.
val sizes = bySize ?: LruCache<Int, Painter>(10).also { iconCache.put(resourceId, it) }
sizes.put(sizeReference, loaded)
return loaded
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.commons.ui.notifications.Card
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.ChannelInviteCard
import com.vitorpamplona.quartz.nip01Core.core.Event
val DefaultFeedOrder: Comparator<Note> =
@@ -33,6 +34,20 @@ val DefaultFeedOrderEvent: Comparator<Event> =
val DefaultFeedOrderCard: Comparator<Card> =
compareByDescending<Card> { it.createdAt() }.thenBy { it.id() }
/**
* Notifications order: unanswered channel invites first, then newest-first like everything else.
*
* An invite is a standing question — it stays actionable until it is answered — so ranking it purely by
* `created_at` would let a week of reactions bury a decision the user still has to make, and a long
* enough feed could page it off the end entirely. Everything else on the tab is a dated event and keeps
* the ordinary ordering. Pending is the only state that reaches a card: answering one drops it from the
* projection, so it never lingers at the top.
*/
val NotificationFeedOrderCard: Comparator<Card> =
compareBy<Card> { if (it is ChannelInviteCard) 0 else 1 }
.thenByDescending { it.createdAt() }
.thenBy { it.id() }
// Snapshots createdAt once per note so the comparator stays consistent even if
// another thread swaps a Note's event mid-sort (e.g. a newer AddressableEvent
// arriving from a relay). Avoids TimSort's "Comparison method violates its
@@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent
@@ -117,6 +118,15 @@ fun routeFor(
return routeFor(relayGroup)
}
// A channel invite (kind-44100) has two destinations and gives each its own target: the room block
// inside the card carries its own click and opens the room, so the rest of the row — the header, the
// body around the block, the space below the author — opens the reply page instead. Replying is the
// one thing you can do with an invite that the card itself doesn't already offer a button for, and
// the generic thread view has nothing to show for a relay-signed notification nobody replied to yet.
if (note.event is MemberAddedNotificationEvent) {
return Route.GenericCommentPost(replyTo = note.idHex)
}
// Concord channel content (kind 9 chat, 1111 reply, 7 reaction) lands in LocalCache as a real
// Note attached to its ConcordChannel gatherer. Route to the Concord chat instead of the generic
// thread view it would otherwise fall through to: a minichat reply (kind-1111) opens its thread
@@ -135,6 +135,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarCollectionEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarRSVPEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarTimeSlotEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderChannelInvite
import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage
import com.vitorpamplona.amethyst.ui.note.types.RenderChat
import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessage
@@ -231,6 +232,7 @@ import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
@@ -1080,6 +1082,10 @@ private fun RenderNoteRow(
RenderBadgeAward(baseNote, backgroundColor, accountViewModel, nav)
}
is MemberAddedNotificationEvent -> {
RenderChannelInvite(baseNote, accountViewModel, nav)
}
is BadgeDefinitionEvent -> {
BadgeDisplay(baseNote = baseNote, accountViewModel = accountViewModel, nav = nav)
}
@@ -84,6 +84,7 @@ import com.vitorpamplona.amethyst.ui.theme.SmallestBorder
import com.vitorpamplona.amethyst.ui.theme.isLight
import com.vitorpamplona.amethyst.ui.theme.secondaryButtonBackground
import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import kotlinx.coroutines.launch
@@ -369,22 +370,29 @@ fun CardBody(
VerticalDivider(color = primaryLight)
val isMuted = accountViewModel.isThreadMutedFor(note)
NoteQuickActionItem(
MaterialSymbols.AutoMirrored.VolumeOff,
stringRes(
// Every NIP-28 message shares one thread root — the channel — so "Mute thread" on a
// public chat can only ever hide that channel's ENTIRE content, and invisibly: the
// Messages row has no such check, so the room still lists while its messages vanish.
// "Mute notifications" owns silencing a public chat now. Unmute stays reachable so
// anyone already caught by a legacy mute can escape from the message in front of them.
if (note.event !is ChannelMessageEvent || isMuted) {
NoteQuickActionItem(
MaterialSymbols.AutoMirrored.VolumeOff,
stringRes(
if (isMuted) {
R.string.quick_action_unmute_thread
} else {
R.string.quick_action_mute_thread
},
),
) {
if (isMuted) {
R.string.quick_action_unmute_thread
accountViewModel.unmuteThread(note)
} else {
R.string.quick_action_mute_thread
},
),
) {
if (isMuted) {
accountViewModel.unmuteThread(note)
} else {
accountViewModel.muteThread(note)
accountViewModel.muteThread(note)
}
onDismiss()
}
onDismiss()
}
}
}
@@ -43,6 +43,7 @@ import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -352,19 +353,26 @@ fun noteActionSections(
val moderation =
buildList {
val isThreadMuted = accountViewModel.isThreadMutedFor(note)
add(
NoteAction(
MaterialSymbols.AutoMirrored.VolumeOff,
stringRes(if (isThreadMuted) R.string.quick_action_unmute_thread else R.string.quick_action_mute_thread),
) {
if (isThreadMuted) {
accountViewModel.unmuteThread(note)
} else {
accountViewModel.muteThread(note)
}
handlers.onDismiss()
},
)
// Every NIP-28 message shares one thread root — the channel — so "Mute thread" on a
// public chat can only ever hide that channel's ENTIRE content, and invisibly: the
// Messages row has no such check, so the room still lists while its messages vanish.
// "Mute notifications" owns silencing a public chat now. Unmute stays reachable so
// anyone already caught by a legacy mute can escape from the message in front of them.
if (note.event !is ChannelMessageEvent || isThreadMuted) {
add(
NoteAction(
MaterialSymbols.AutoMirrored.VolumeOff,
stringRes(if (isThreadMuted) R.string.quick_action_unmute_thread else R.string.quick_action_mute_thread),
) {
if (isThreadMuted) {
accountViewModel.unmuteThread(note)
} else {
accountViewModel.muteThread(note)
}
handlers.onDismiss()
},
)
}
// Own messages always get a delete affordance (the surface routes private
// rumors through the gift-wrapped deletion); reporting yourself never
@@ -0,0 +1,368 @@
/*
* 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.note.types
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
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
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.buzz.toMembershipNotice
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.ObserveAndDrawInnerUserPicture
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size22dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
private val BannerHeight = 84.dp
private val BlockShape = RoundedCornerShape(12.dp)
private val RosterFaceSize = 19.dp
/**
* The body of a "somebody added you to a channel" row (kind 44100).
*
* Only the body: the row itself is an ordinary [com.vitorpamplona.amethyst.ui.note.NoteCompose], so the
* author header, overflow menu, reaction bar, last-read background and click-through all come from the
* same code every other notification uses.
*
* ### Why the body carries the identity
*
* A kind-44100 is signed by the **relay keypair** — the relay is reporting a membership change it made,
* so it really is the author. That has a visible consequence: `NoteCompose` draws its header from
* `note.author`, a relay keypair has no kind-0 and no NIP-05, so line one falls through
* `UsernameDisplay` to a bare `npub1…` and line two (`ObserveDisplayNip05Status`) renders nothing at
* all. The header therefore identifies nobody a reader recognises, and everything that says what this
* row is about has to live down here: who added you, and what they added you to.
*/
@Composable
fun RenderChannelInvite(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val workspaces = accountViewModel.account.buzzWorkspaces.flow.value
val notice = remember(note, workspaces) { note.toMembershipNotice(workspaces) } ?: return
// A withdrawn membership is not an invite. The projection already excludes these before a card is
// built; re-checked here because this renderer is reachable from any NoteCompose over a 44100.
if (notice.removed) return
val groupId = remember(notice) { GroupId(notice.channelId, notice.relay) }
val baseChannel = remember(groupId) { LocalCache.getOrCreateRelayGroupChannel(groupId) }
// Recompose in place as the relay-signed metadata and roster land, so name, picture, member count
// and description fill in without the row being rebuilt. observeChannel also mounts the channel
// subscription, which is what actually goes and fetches the kind-39000 for a group the viewer has
// never opened — the common case for an invite.
val channelState by observeChannel(baseChannel, accountViewModel)
val channel = channelState?.channel as? RelayGroupChannel ?: baseChannel
val actorUser = remember(notice.actor) { notice.actor?.let { LocalCache.getOrCreateUser(it) } }
Column(Modifier.fillMaxWidth()) {
// Who did this. The header above is an npub belonging to the relay, so without this the row
// never names a person — and the actor is the whole difference between "I joined this" and "a
// stranger put me here".
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth(),
) {
if (actorUser != null) {
UserPicture(actorUser, Size22dp, accountViewModel = accountViewModel, nav = nav)
UsernameDisplay(actorUser, Modifier.weight(1f, fill = false), accountViewModel = accountViewModel)
} else {
Text(
text = stringRes(R.string.channel_invite_unknown_actor),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(
text = stringRes(R.string.channel_invite_added_you),
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
ChannelBanner(channel, accountViewModel) {
// Opening a group that is not on my kind-10009 yet is exactly what this route supports, so
// the viewer can look at the channel before answering.
nav.nav(Route.RelayGroup(channel.groupId.id, channel.groupId.relayUrl.url))
}
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.End),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
) {
// Leave is separate from Ignore on purpose: Ignore is a local display choice that leaves you
// in the roster, Leave is the kind-9022 that actually removes you from the channel. It is
// also the destructive one, so it stays the lightest of the three.
TextButton(onClick = { accountViewModel.leaveChannelInvite(channel) }) {
Text(stringRes(R.string.channel_invite_leave), color = MaterialTheme.colorScheme.error)
}
TextButton(onClick = { accountViewModel.dismissChannelInvite(notice.channelId) }) {
Text(stringRes(R.string.channel_invite_ignore))
}
// Accepting *is* `addRelayGroupToMessages`, the same call behind the channel top bar's
// "Add to Messages", so it carries that label rather than a second word for one action.
//
// Compact rather than a stock Button: the default 40dp height with 24dp of horizontal
// padding is a call-to-action size, and on a row that already offers two other choices it
// dominated them. This keeps the fill — Accept is unmistakably primary — at roughly the
// optical height of the text buttons beside it.
Button(
onClick = { accountViewModel.acceptChannelInvite(channel) },
contentPadding = ButtonDefaults.TextButtonContentPadding,
modifier = Modifier.height(34.dp),
) {
Text(stringRes(R.string.add_to_messages), fontSize = 13.sp)
}
}
}
}
/**
* The channel as a cover block: its picture edge to edge, name reversed out over a scrim, visibility
* badge, then roster and description.
*
* The picture is drawn *over* a gradient rather than falling back to one, so a channel with no
* `picture` — and one whose picture fails to load — both land on the same stable colour instead of an
* empty band. No branch, no placeholder state.
*/
@Composable
private fun ChannelBanner(
channel: RelayGroupChannel,
accountViewModel: AccountViewModel,
onClick: () -> Unit,
) {
val name = channel.toBestDisplayName()
val picture = channel.profilePicture()
val description = channel.summary()?.takeIf { it.isNotBlank() }
val memberCount = channel.memberCount()
val gradient = remember(channel.groupId.id) { identityGradient(channel.groupId.id) }
val badge =
when {
// Closed (invite-only) is the more actionable signal to somebody deciding whether to stay
// than private is, so it wins when both are set.
channel.isClosed() -> stringRes(R.string.relay_group_badge_invite_only)
channel.isPrivate() -> stringRes(R.string.relay_group_badge_private)
else -> null
}
Column(
Modifier
.fillMaxWidth()
.padding(top = 8.dp)
.clip(BlockShape)
.clickable(onClick = onClick),
) {
Box(Modifier.fillMaxWidth().height(BannerHeight).background(gradient)) {
if (picture != null) {
AsyncImage(
model = picture,
contentDescription = name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
// Scrim only over the lower half, where the name sits — a full-height wash would mute the
// picture the block exists to show.
Box(
Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
0.45f to Color.Transparent,
1f to Color.Black.copy(alpha = 0.68f),
),
),
)
if (badge != null) {
Text(
text = badge,
color = Color.White,
fontSize = 10.sp,
fontWeight = FontWeight.SemiBold,
modifier =
Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
.clip(RoundedCornerShape(6.dp))
.background(Color.Black.copy(alpha = 0.45f))
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
Text(
text = name,
color = Color.White,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.align(Alignment.BottomStart).padding(horizontal = 12.dp, vertical = 8.dp),
)
}
Column(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 9.dp)) {
ChannelRosterLine(channel, memberCount, accountViewModel)
if (description != null) {
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 4.dp),
)
}
}
}
}
/**
* "3 people you follow · 24 members · relay.host".
*
* The faces are [RelayGroupChannel.participatingFollows], not an arbitrary slice of the roster: whether
* anyone you already follow is in a channel says far more about whether you want to be there than three
* strangers' avatars do. Falls back to the bare count when the answer is nobody.
*/
@Composable
private fun ChannelRosterLine(
channel: RelayGroupChannel,
memberCount: Int,
accountViewModel: AccountViewModel,
) {
val follows by accountViewModel.account.kind3FollowList.flow
.collectAsStateWithLifecycle()
val known =
remember(channel, follows) {
channel.participatingFollows(follows.authors).take(3)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth(),
) {
known.forEach { pubkey ->
key(pubkey) {
FollowedMemberFace(pubkey, accountViewModel)
}
}
// "24 members · relay.host". The count carries its unit through the same plural every other
// group surface uses (the workspace channel list, discovery, the parent picker), because a bare
// number sitting immediately after the faces reads as counting the faces — "3 people you
// follow" — which is the one thing it does not mean.
val host = channel.groupId.relayUrl.displayUrl()
Text(
text =
if (memberCount > 0) {
pluralStringResource(R.plurals.relay_group_member_count, memberCount, memberCount) + " · " + host
} else {
host
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun FollowedMemberFace(
pubkey: HexKey,
accountViewModel: AccountViewModel,
) {
val user = remember(pubkey) { LocalCache.getOrCreateUser(pubkey) }
// The plain inner picture rather than BaseUserPicture: everyone on this line is by definition
// somebody the viewer follows, so the following badge BaseUserPicture stacks on top would be three
// identical checkmarks at 19dp saying nothing.
ObserveAndDrawInnerUserPicture(user, RosterFaceSize, accountViewModel)
}
/**
* A stable two-stop gradient derived from the group id, so every channel keeps the same colour across
* launches and devices without anything being stored. Hue is the only thing the id chooses;
* saturation and lightness are fixed so no channel can land on something unreadable behind white text.
*/
private fun identityGradient(groupId: String): Brush {
var hash = 0
groupId.forEach { hash = it.code + ((hash shl 5) - hash) }
val hue = ((hash % 360) + 360) % 360
return Brush.linearGradient(
listOf(
Color.hsl(hue.toFloat(), 0.52f, 0.42f),
Color.hsl(((hue + 42) % 360).toFloat(), 0.58f, 0.28f),
),
)
}
@@ -60,7 +60,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.dal.MusicPlaylistsFee
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.dal.MusicTracksFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.dal.NestsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedContentState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.ChannelInvitesState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationSummaryState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.OpenPollsState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter
@@ -142,8 +141,6 @@ class AccountFeedContentStates(
val notificationsOpenPolls = OpenPollsState(account, scope)
/** Channels somebody added the viewer to, awaiting a show-on-Messages decision. */
val channelInvites = ChannelInvitesState(account, scope)
val notificationSummary = NotificationSummaryState(account)
val feedListOptions = TopNavFilterState(account, scope)
@@ -187,6 +184,32 @@ class AccountFeedContentStates(
}
}
// A pending channel invite is a row on Notifications and on Messages › New Requests, but
// nothing about answering one flows through newEventBundles: accepting writes my kind-10009,
// dismissing touches only local settings, and classification lands a kind-39000 that is not
// itself a notification. Each of those changes whether the 44100 still belongs in either
// feed, so rebuild when the projection moves — otherwise an answered invite would sit on the
// tab until an unrelated event refreshed it. Arriving invites come through here too: neither
// filter picks a 44100 up additively, so this is what puts a new one on screen.
//
// The card feeds need `clear()` first, because answering an invite REMOVES a row and their
// additive refresh cannot express that: it diffs `feed()` against `lastNotes`, finds no *new*
// notes, and bails without touching the list — leaving the answered invite in place. Clearing
// drops the additive fast path so the refresh rebuilds the whole list, which is the only
// branch that can shrink. FeedContentState (dmNew) rebuilds from feed() on invalidateData()
// regardless, so it shrinks on its own.
scope.launch(Dispatchers.IO) {
account.channelInvites.pendingByEventId
.drop(1)
.collect {
listOf(notifications, notificationsFollowing, notificationsEveryone).forEach {
it.clear()
it.invalidateData()
}
dmNew.invalidateData()
}
}
// Joining/leaving a geohash location channel (kind 10081 list) changes the Messages list but
// no event flows through LocalCache, so force a rebuild — otherwise a just-joined cell (whose
// ephemeral messages haven't arrived yet) wouldn't show its placeholder row until later.
@@ -43,7 +43,6 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError
import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvites
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel
@@ -105,7 +104,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.Marm
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotGroupIconUpload
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotGroupIconUploader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.markRoomNoteAsRead
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.rowHasUnreadFlow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.rowHasUnread
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NOTIFICATION_LAST_READ_KEY
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync
@@ -444,7 +443,7 @@ class AccountViewModel(
/**
* The bottom-bar envelope dot: true when ANY Messages row is showing its blue dot.
*
* Per-row via [rowHasUnreadFlow], which mirrors what each row composable computes for itself.
* Per-row via [rowHasUnread], which mirrors what each row composable computes for itself.
* This used to call `unreadPrivateChatRoute` directly, which returns null for anything that is not
* `ChatroomKeyable` — so only NIP-17/NIP-04 DMs counted, and a public chat, ephemeral room, geohash
* cell, Marmot group, NIP-29/Buzz channel or Concord channel could sit there with a visible dot
@@ -460,7 +459,7 @@ class AccountViewModel(
MutableStateFlow(null)
}
}.flatMapLatest { loadedFeedState ->
val flows = loadedFeedState?.list?.mapNotNull { chat -> rowHasUnreadFlow(chat, account) }
val flows = loadedFeedState?.list?.mapNotNull { chat -> rowHasUnread(chat, account)?.flow }
if (!flows.isNullOrEmpty()) {
combine(flows) { newItems ->
@@ -1731,7 +1730,6 @@ class AccountViewModel(
launchSigner {
account.settings.undismissChannelInvite(channel.groupId.id)
account.follow(channel)
BuzzChannelInvites.remove(account.userProfile().pubkeyHex, channel.groupId.id)
}
/**
@@ -1763,14 +1761,21 @@ class AccountViewModel(
*/
fun dismissChannelInvite(channelId: String) {
account.settings.dismissChannelInvite(channelId)
BuzzChannelInvites.remove(account.userProfile().pubkeyHex, channelId)
}
/** Actually leave: kind-9022 to the host relay, and drop it from my list and the pending set. */
/**
* Actually leave: kind-9022 to the host relay, which answers with a kind-44101 that supersedes the
* add, so the projection drops the card on its own.
*
* Deliberately does NOT record a local dismissal. That would clear the card a relay round-trip
* sooner, but `dismissedChannelInvites` is keyed by channel id and persisted forever, so it would
* also swallow a *later, legitimate* re-add to the same channel — the viewer would be put back in
* and never told. Waiting for the relay's own withdrawal keeps "am I a member" answerable from the
* events alone. Ignore is the action for "don't ask me again"; this one is for "take me out".
*/
fun leaveChannelInvite(channel: RelayGroupChannel) =
launchSigner {
account.relayGroups.leaveRelayGroup(channel)
BuzzChannelInvites.remove(account.userProfile().pubkeyHex, channel.groupId.id)
}
/**
@@ -2036,6 +2041,13 @@ class AccountViewModel(
account.toggleChatroomPin(room)
}
fun mutedPublicChatsFlow(): StateFlow<Set<String>> = account.settings.mutedPublicChats
fun toggleMutedPublicChat(channelId: String) =
launchSigner {
account.toggleMutedPublicChat(channelId)
}
fun updateZapAmounts(
amountSet: List<Long>,
selectedZapType: LnZapEvent.ZapType,
@@ -139,7 +139,7 @@ fun AgentAttestationScreen(
// Agent side: hold an attestation an owner gave you, so this account
// authenticates to the owner's Buzz relays as a virtual member. Available to
// any signer — holding a credential doesn't require the raw key.
HoldAttestationSection(myPubkey = myPubkey)
HoldAttestationSection(myPubkey = myPubkey, attestation = accountViewModel.account.buzzAttestation)
// Owner side: issue an attestation for an agent key. Needs the raw private key.
val privKey = keyPair.privKey
@@ -153,15 +153,17 @@ fun AgentAttestationScreen(
}
/**
* Agent-side: paste an `auth` tag an owner issued to this account's key. It is verified
* against [myPubkey] and, on success, stored in [BuzzHeldAttestations] so the auth
* coordinator attaches it when this account AUTHs to a Buzz relay. Persisted across
* restarts (device-global) by `BuzzAttestationPreferences`.
* Agent-side: paste an `auth` tag an owner issued to this account's key. [parseHeldAttestation]
* turns it into a typed failure the field can show, and [BuzzHeldAttestations.put] re-checks the
* signature before storing, so the auth coordinator attaches it when this account AUTHs to a Buzz
* relay. Persisted across restarts, per account, by `BuzzAttestationPreferences`.
*/
@Composable
private fun HoldAttestationSection(myPubkey: String) {
val held by BuzzHeldAttestations.flow.collectAsState()
val mine = held[myPubkey]
private fun HoldAttestationSection(
myPubkey: String,
attestation: BuzzHeldAttestations,
) {
val mine = attestation.flow.collectAsState().value
var input by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
@@ -183,7 +185,7 @@ private fun HoldAttestationSection(myPubkey: String) {
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedButton(onClick = { BuzzHeldAttestations.remove(myPubkey) }) {
OutlinedButton(onClick = { attestation.clear() }) {
Text(stringRes(R.string.buzz_attest_remove))
}
} else {
@@ -210,9 +212,15 @@ private fun HoldAttestationSection(myPubkey: String) {
when (val outcome = parseHeldAttestation(input, myPubkey)) {
is HoldOutcome.Failure -> error = outcome.message
is HoldOutcome.Success -> {
BuzzHeldAttestations.put(myPubkey, outcome.attestation)
input = ""
error = null
// put() re-checks the signature, so honour its answer instead of
// assuming it stored: clearing the field on a rejected paste would
// read as success and leave the account holding nothing.
if (attestation.put(outcome.attestation)) {
input = ""
error = null
} else {
error = NOT_FOR_THIS_ACCOUNT
}
}
}
},
@@ -236,6 +244,9 @@ private sealed interface HoldOutcome {
) : HoldOutcome
}
/** Shown for both rejection paths — the parse-time check and [BuzzHeldAttestations.put]'s. */
private const val NOT_FOR_THIS_ACCOUNT = "This attestation does not authorize the current account, or its signature is invalid."
/**
* Parses a pasted `["auth", owner, conditions, sig]` JSON array and verifies it
* authorizes [myPubkey]. Returns a human-readable failure on malformed JSON, a
@@ -259,7 +270,7 @@ private fun parseHeldAttestation(
OwnerAttestation.parse(tag)
?: return HoldOutcome.Failure("Not a NIP-OA auth tag.")
if (!attestation.verify(myPubkey)) {
return HoldOutcome.Failure("This attestation does not authorize the current account, or its signature is invalid.")
return HoldOutcome.Failure(NOT_FOR_THIS_ACCOUNT)
}
return HoldOutcome.Success(attestation)
}
@@ -26,7 +26,6 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.model.buzz.AgentFleetAggregator
import com.vitorpamplona.amethyst.commons.model.buzz.AgentFleetMetrics
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
@@ -107,7 +106,7 @@ class AgentConsoleViewModel : ViewModel() {
this.scopeRelay = relay
this.account = account
relay?.let {
val newlyJoined = BuzzWorkspaces.join(it)
val newlyJoined = account.buzzWorkspaces.join(it)
viewModelScope.launch { account.relayAuthLedger.setDecision(it.url, RelayAuthDecision.ALLOW) }
if (newlyJoined) reconnectPoolAfterJoin(account.client)
}
@@ -22,174 +22,117 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvite
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvites
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmChannels
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
import com.vitorpamplona.amethyst.commons.model.buzz.ChannelClassification
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.buzz.buzzChannelTypes
import com.vitorpamplona.amethyst.model.buzz.classifyBuzzChannel
import com.vitorpamplona.amethyst.model.buzz.membershipNoticeFilter
import com.vitorpamplona.amethyst.model.buzz.membershipNotices
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RELAY_GROUP_METADATA_KINDS
import com.vitorpamplona.quartz.buzz.dvDmVisibility.DmVisibilityEvent
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
/**
* Always-on discovery of the viewer's Buzz **DM channels** across every joined workspace relay, mounted
* once high in the logged-in tree ([com.vitorpamplona.amethyst.ui.screen.loggedIn.LoggedInPage]).
*
* The deployed relay does not expose a queryable DM list; it addresses each member a kind-44100
* member-added notification (`#p` = me). This warm-auths a `#p=me` fetch of 44100 (+ the 30622 visibility
* snapshot) across the joined relays, records the channels into [BuzzDmChannels], fetches each channel's
* 39000-39003 directory (so its `t`=dm marker + participants land in `LocalCache`), and keeps a live
* `#p=me` 44100 subscription open for new DMs. The companion [BuzzDmJoinedChatTailPreload] then keeps the
* discovered channels' messages warm app-wide — which is what lets a Buzz DM show on the Notifications tab
* and in push without the viewer opening the conversation first.
* member-added notification (`#p` = me). Those arrive through the ordinary subscription pipeline —
* [com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.buzz.BuzzMembershipEoseManager]
* owns the one `#p=me` REQ per workspace relay — so this reads them back out of [LocalCache], fetches
* each discovered channel's 39000-39003 directory (so its `t`=dm marker + participants land), and
* records the ones that turn out to be DMs into [BuzzDmChannels]. The companion
* [com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.BuzzDmJoinedChatTailPreload]
* then keeps those channels' messages warm app-wide — which is what lets a Buzz DM show on the
* Notifications tab and in push without the viewer opening the conversation first.
*
* This mirrors [BuzzDmListViewModel]'s discovery, but account-scoped and always-on rather than bound to
* the open inbox screen; the inbox keeps its own scoped copy for its per-relay projection.
* Everything else somebody added the viewer to is a named channel; it must NOT be silently subscribed,
* so it stays out of [BuzzDmChannels] and surfaces as a prompt instead — see
* [com.vitorpamplona.amethyst.model.buzz.ChannelInvitesState], which projects the
* same cached notices.
*
* ### Recompute, don't accumulate
*
* Each pass derives the whole membership picture from the cache and *declares* the result
* ([BuzzDmChannels.replace]). The previous incremental version — record every 44100, then delete the
* ones classification rejected — had no memory of its own rejections, so the next delivery of the same
* event re-added them and the two steps fought each other in a loop. A recomputation cannot fight
* itself: the same events always produce the same set.
*/
@Composable
fun BuzzDmDiscoveryPreload(accountViewModel: AccountViewModel) {
val account = accountViewModel.account
val joined by BuzzWorkspaces.flow.collectAsStateWithLifecycle()
// Restart the whole discovery (initial warm-auth fetch + live 44100 subs) whenever the joined
// workspace set changes; the LaunchedEffect scope owns the live subscriptions and cancels them on
// account switch or dispose.
LaunchedEffect(account, joined) {
if (joined.isEmpty()) return@LaunchedEffect
runBuzzDmDiscovery(account, joined)
LaunchedEffect(account) {
runBuzzDmDiscovery(account)
}
}
/**
* Warm-auth the initial 44100/30622 `#p=me` read across [relays], record every discovered channel, fetch
* their directories, then keep a live 44100 subscription per relay open until the caller's scope is
* cancelled. Suspends for the lifetime of the live subscriptions.
* Recompute the viewer's DM set from the cached membership notices, fetching any directory still
* missing, and keep doing it for the lifetime of the caller's scope.
*/
private suspend fun runBuzzDmDiscovery(
account: Account,
relays: Set<NormalizedRelayUrl>,
) = coroutineScope {
private suspend fun runBuzzDmDiscovery(account: Account) {
val me = account.userProfile().pubkeyHex
// A joined workspace is first-party: pre-approve NIP-42 so the `#p=me` DM reads authenticate (the
// restore-from-disk path doesn't set this, unlike the inbox/import/console entry points).
relays.forEach { account.relayAuthLedger.setDecision(it.url, RelayAuthDecision.ALLOW) }
val discoveryFilters =
listOf(
Filter(kinds = listOf(MemberAddedNotificationEvent.KIND), tags = mapOf("p" to listOf(me))),
Filter(kinds = listOf(DmVisibilityEvent.KIND), tags = mapOf("p" to listOf(me))),
combine(
LocalCache.observeNotes(membershipNoticeFilter(me)),
// A channel's type is only decidable once its kind-39000 is in the cache, and that lands
// *after* the notice that revealed the channel — so the directory arriving has to re-run the
// classification. The emission carries the types themselves rather than a count of notes,
// because `LocalCache.consume(GroupMetadataEvent)` wakes this observer before it copies the
// event into the channel: classifying off the channel at this instant reads one that is still
// empty and answers UNKNOWN, and nothing emits again to correct it. See [buzzChannelTypes].
LocalCache
.observeNotes(Filter(kinds = listOf(GroupMetadataEvent.KIND)))
.map { buzzChannelTypes(it) }
.distinctUntilChanged(),
) { _, knownTypes ->
// Read the joined set per pass, not once: which relay vouched for a notice depends on it,
// and restore-from-disk can land after the cache already holds notices.
val workspaces = account.buzzWorkspaces.flow.value
BuzzChannelInvites.currentMemberships(LocalCache.membershipNotices(me, workspaces)) to knownTypes
}.collectLatest { (memberships, knownTypes) ->
fetchMissingDirectories(account, memberships, knownTypes)
BuzzDmChannels.replace(
me,
memberships.filter { (id, relay) ->
classifyBuzzChannel(LocalCache, id, relay, knownTypes) == ChannelClassification.DM
},
)
// `#p`-gated reads: use the warm-auth fetch so an `auth-required` CLOSED authenticates and retries
// rather than returning empty.
account.client.fetchAllWithHooks(
filters = relays.associateWith { discoveryFilters },
idleTimeoutMs = 8_000,
pendingOnAuthRequired = true,
) { relay, event ->
(event as? MemberAddedNotificationEvent)?.let { recordDiscovery(me, it, relay) }
false
}
fetchDmMetadata(account, me)
classifyDiscoveredChannels(account, me)
relays.forEach { relay ->
launch {
val filter = Filter(kinds = listOf(MemberAddedNotificationEvent.KIND), tags = mapOf("p" to listOf(me)))
account.client.subscribeAsFlow(relay, filter).collect { events ->
var changed = false
events.filterIsInstance<MemberAddedNotificationEvent>().forEach { e ->
if (recordDiscovery(me, e, relay)) changed = true
}
if (changed) {
fetchDmMetadata(account, me)
classifyDiscoveredChannels(account, me)
}
}
}
}
}
/** Fetch the NIP-29 directory (39000-39003) of every known DM channel so its `t`=dm marker + roster load. */
private suspend fun fetchDmMetadata(
/**
* Fetch the NIP-29 directory (39000-39003) of every channel whose type we don't know yet, so its `t`=dm
* marker and roster load.
*
* Only the unclassified ones: a channel keeps its metadata in the cache for the session, so re-asking
* for it on every pass would put a burst of `#d` reads on the relay each time a single new notice
* arrives. This converges — the fetch lands the 39000, which re-runs the pass, which now finds nothing
* missing.
*/
private suspend fun fetchMissingDirectories(
account: Account,
viewer: HexKey,
memberships: Map<String, NormalizedRelayUrl>,
knownTypes: Map<String, ChannelClassification>,
) {
val byRelay =
BuzzDmChannels
.channelsFor(viewer)
memberships
.filterKeys { id -> memberships[id]?.let { classifyBuzzChannel(LocalCache, id, it, knownTypes) } == ChannelClassification.UNKNOWN }
.entries
.groupBy({ it.value }, { it.key })
.mapValues { (_, ids) -> listOf(Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf("d" to ids))) }
if (byRelay.isEmpty()) return
account.client.fetchAllWithHooks(filters = byRelay, idleTimeoutMs = 8_000, pendingOnAuthRequired = true) { _, _ -> false }
}
/**
* Records a kind-44100 "you were added" into [BuzzDmChannels] so its directory can be fetched, and — when
* somebody *else* did the adding — into [BuzzChannelInvites] as well.
*
* The relay emits this same kind for a self-join with `actor == me`, so the actor is what separates "I
* joined this" from "a stranger put me in this". Everything is provisionally treated as a DM here because
* the channel's type only becomes knowable once its kind-39000 lands; [classifyDiscoveredChannels] sorts
* them out immediately afterwards.
*/
private fun recordDiscovery(
me: HexKey,
event: MemberAddedNotificationEvent,
relay: NormalizedRelayUrl,
): Boolean {
val channelId = event.channel() ?: return false
val changed = BuzzDmChannels.record(me, channelId, relay)
val actor = event.actor()
if (actor == null || !actor.equals(me, ignoreCase = true)) {
BuzzChannelInvites.record(me, BuzzChannelInvite(channelId, relay, actor, event.createdAt))
}
return changed
}
/**
* Splits what discovery found into DMs and named channels, now that each channel's kind-39000 has loaded.
*
* A `t = dm` channel is a real DM: it stays in [BuzzDmChannels], whose always-on tail keeps it warm so it
* can reach Notifications without being opened, and it is never an "invite". Anything else is a named
* channel somebody added the viewer to; it must NOT be silently subscribed, so it is dropped from
* [BuzzDmChannels] and left in [BuzzChannelInvites] for the viewer to accept or dismiss.
*
* Channels already in the viewer's kind-10009 (accepted earlier, or joined from this device) are not
* invites — the ordinary joined-group path owns them.
*/
private fun classifyDiscoveredChannels(
account: Account,
me: HexKey,
) {
val joined =
account.relayGroupList.liveRelayGroupList.value
.mapNotNullTo(mutableSetOf()) { it.groupId }
BuzzDmChannels.channelsFor(me).forEach { (channelId, relay) ->
val metadata = LocalCache.getRelayGroupChannelIfExists(GroupId(channelId, relay))?.event
when {
// Type not known yet — leave both entries alone and re-run when the directory lands.
metadata == null -> Unit
metadata.isBuzzDmChannel() -> BuzzChannelInvites.remove(me, channelId)
else -> {
BuzzDmChannels.remove(me, channelId)
if (channelId in joined) BuzzChannelInvites.remove(me, channelId)
}
}
}
}
@@ -23,13 +23,15 @@ 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.BuzzChannelInvites
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
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.buzz.membershipNoticeFilter
import com.vitorpamplona.amethyst.model.buzz.membershipNotices
import com.vitorpamplona.amethyst.model.filter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RELAY_GROUP_METADATA_KINDS
import com.vitorpamplona.quartz.buzz.dvDmVisibility.DmVisibilityEvent
@@ -39,7 +41,6 @@ import com.vitorpamplona.quartz.buzz.workspace.buzzParticipants
import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
@@ -111,7 +112,14 @@ class BuzzDmListViewModel : ViewModel() {
val lastActivity: Long,
)
private fun relays(): Set<NormalizedRelayUrl> = scopeRelay?.let { setOf(it) } ?: (BuzzWorkspaces.flow.value + BuzzRelayDialect.flow.value)
private fun relays(): Set<NormalizedRelayUrl> =
scopeRelay?.let { setOf(it) } ?: (
account
?.buzzWorkspaces
?.flow
?.value
.orEmpty() + BuzzRelayDialect.flow.value
)
/**
* Binds to [account] scoped to the community [relayUrl]. Marks that relay a joined workspace and
@@ -128,7 +136,7 @@ class BuzzDmListViewModel : ViewModel() {
val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl) ?: return
this.scopeRelay = relay
val newlyJoined = BuzzWorkspaces.join(relay)
val newlyJoined = account.buzzWorkspaces.join(relay)
viewModelScope.launch { account.relayAuthLedger.setDecision(relay.url, RelayAuthDecision.ALLOW) }
if (newlyJoined) reconnectPoolAfterJoin(account.client)
@@ -284,8 +292,14 @@ class BuzzDmListViewModel : ViewModel() {
).maxOfOrNull { it.createdAt() ?: 0L } ?: 0L
/**
* Keeps a live 44100 + 30622 REQ open (so new DMs / hide changes arrive) and re-projects the
* inbox when the registry or dialect set moves. Idempotent; torn down with the ViewModel.
* Re-projects the inbox as new DMs and hide changes arrive. Idempotent; torn down with the ViewModel.
*
* The 44100/30622 stream itself is **not** subscribed here. `bind` marks this community's relay a
* joined workspace, which is exactly what
* [com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.buzz.BuzzMembershipEoseManager]
* keys its always-on `#p=me` subscription on — so opening this screen used to put a second, identical
* REQ on the same relay. Observing [LocalCache] instead means the screen sees the same events at the
* same time for free, and the relay sees one subscription.
*/
private fun startLive() {
val account = account ?: return
@@ -294,28 +308,50 @@ class BuzzDmListViewModel : ViewModel() {
liveJob =
viewModelScope.launch(Dispatchers.IO) {
relays().forEach { relay ->
launch {
val filter = Filter(kinds = listOf(MemberAddedNotificationEvent.KIND), tags = mapOf("p" to listOf(myPubkey)))
account.client.subscribeAsFlow(relay, filter).collect { events ->
var changed = false
events.filterIsInstance<MemberAddedNotificationEvent>().forEach { e ->
e.channel()?.let { if (memberChannels.put(it, relay) == null) changed = true }
}
if (changed) {
fetchMetadata(account)
rebuildRows(account)
}
launch {
LocalCache.observeNotes(membershipNoticeFilter(myPubkey)).collect {
// The emission is only the signal; the notices come from a cache scan, because
// `observeNotes` cannot seed these kinds (see [membershipNotices]).
//
// Re-read the relay scope per pass rather than snapshotting it: a workspace
// joined while this screen is open should bring its channels with it.
val scoped = relays()
val workspaces = account.buzzWorkspaces.flow.value
val memberships =
BuzzChannelInvites
.currentMemberships(LocalCache.membershipNotices(myPubkey, workspaces))
.filterValues { it in scoped }
var changed = false
memberships.forEach { (channelId, relay) ->
if (memberChannels.put(channelId, relay) == null) changed = true
}
// A kind-44101 takes the membership away: drop the row rather than leaving a
// conversation the relay no longer lets us read.
//
// Only channels the scan actually has a *removal* for. Anything else in
// `memberChannels` was put there by the seed or the one-shot fetch, which see
// relays this scan may not cover — treating "absent from this pass" as "gone"
// would let one pass wipe rows nothing withdrew.
val withdrawn =
LocalCache
.membershipNotices(myPubkey, workspaces)
.let { BuzzChannelInvites.latestPerChannel(it) }
.filterValues { it.removed }
.keys
val gone = memberChannels.keys.filter { it in withdrawn }
if (gone.isNotEmpty()) {
gone.forEach { memberChannels.remove(it) }
changed = true
}
if (changed) {
fetchMetadata(account)
rebuildRows(account)
}
}
launch {
val filter = Filter(kinds = listOf(DmVisibilityEvent.KIND), tags = mapOf("p" to listOf(myPubkey)))
account.client.subscribeAsFlow(relay, filter).collect { /* consumed → BuzzDmRegistry.hidden */ }
}
}
// Re-project when my hidden set (30622) or the joined-relay set changes.
launch {
combine(BuzzDmRegistry.hidden, BuzzWorkspaces.flow, BuzzRelayDialect.flow) { _, _, _ -> }
combine(BuzzDmRegistry.hidden, account.buzzWorkspaces.flow, BuzzRelayDialect.flow) { _, _, _ -> }
.collect { rebuildRows(account) }
}
}
@@ -55,7 +55,6 @@ import androidx.compose.ui.unit.dp
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.BuzzWorkspaces
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -189,7 +188,7 @@ fun BuzzInviteScreen(
// authenticates without a prompt, then hand off to the in-app window.nostr
// browser to accept terms + sign the claim.
RelayUrlNormalizer.normalizeOrNull(invite.relayUrl())?.let { relay ->
BuzzWorkspaces.join(relay)
accountViewModel.account.buzzWorkspaces.join(relay)
scope.launch { accountViewModel.account.relayAuthLedger.setDecision(relay.url, RelayAuthDecision.ALLOW) }
}
FavoriteAppLauncher.launchUrl(context, link)
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
import com.vitorpamplona.amethyst.model.Account
@@ -104,7 +103,7 @@ class BuzzRelayImportViewModel : ViewModel() {
// The user came here to import from THIS relay: remember it as a joined workspace (persisted,
// marks the Buzz dialect) and pre-approve NIP-42 auth so the `#p=me` read below is served.
val newlyJoined = BuzzWorkspaces.join(normalized)
val newlyJoined = account.buzzWorkspaces.join(normalized)
viewModelScope.launch { account.relayAuthLedger.setDecision(normalized.url, RelayAuthDecision.ALLOW) }
// Unlocks the persistent group-roster (39002) subscription — see [reconnectPoolAfterJoin].
@@ -58,6 +58,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions.EditButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions.LeaveChatButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions.LinkChatButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions.MuteChatButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions.OpenChatButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions.ShareChatButton
import com.vitorpamplona.amethyst.ui.stringRes
@@ -188,6 +189,8 @@ fun LongChannelActionOptions(
ShareChatButton(channel, accountViewModel, nav)
MuteChatButton(channel, accountViewModel)
EditButtonIfIamCreator(channel, accountViewModel, nav)
LeaveButtonIfFollowing(channel, accountViewModel, nav)
@@ -0,0 +1,67 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.FilledTonalButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
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.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.ZeroPadding
@Composable
fun MuteChatButton(
channel: PublicChatChannel,
accountViewModel: AccountViewModel,
) {
val mutedChats by accountViewModel.mutedPublicChatsFlow().collectAsStateWithLifecycle()
val isMuted = channel.idHex in mutedChats
val label =
stringRes(
if (isMuted) R.string.unmute_notifications else R.string.mute_notifications,
)
FilledTonalButton(
modifier =
Modifier
.padding(horizontal = 3.dp)
.width(50.dp),
onClick = { accountViewModel.toggleMutedPublicChat(channel.idHex) },
contentPadding = ZeroPadding,
) {
Icon(
symbol = if (isMuted) MaterialSymbols.NotificationsOff else MaterialSymbols.Notifications,
contentDescription = label,
modifier = Size20Modifier,
)
}
}
@@ -39,16 +39,18 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
/**
* Pin/Unpin a Buzz channel (a device-local favorite — [BuzzChannelStars]). Moved off the per-channel
* list row into the opened channel's/forum's top-bar overflow, so the list row stays a clean
* tap-to-open target. Reads the live starred set so the label + icon reflect the current state.
* Pin/Unpin a Buzz channel (a local favorite of this account — [BuzzChannelStars]). Moved off the
* per-channel list row into the opened channel's/forum's top-bar overflow, so the list row stays a
* clean tap-to-open target. Reads the live starred set so the label + icon reflect the current state.
*/
@Composable
fun BuzzPinDropdownItem(
groupId: GroupId,
accountViewModel: AccountViewModel,
closeMenu: () -> Unit,
) {
val starred by BuzzChannelStars.flow.collectAsStateWithLifecycle()
val stars = accountViewModel.account.buzzChannelStars
val starred by stars.flow.collectAsStateWithLifecycle()
val isStarred = groupId.id in starred
DropdownMenuItem(
leadingIcon = {
@@ -62,7 +64,7 @@ fun BuzzPinDropdownItem(
text = { Text(stringRes(if (isStarred) R.string.buzz_unpin else R.string.buzz_pin)) },
onClick = {
closeMenu()
BuzzChannelStars.toggle(groupId.id)
stars.toggle(groupId.id)
},
)
}
@@ -68,7 +68,6 @@ 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
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
@@ -289,7 +288,8 @@ fun RelayGroupChannelListScreen(
// 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()
val starred by accountViewModel.account.buzzChannelStars.flow
.collectAsStateWithLifecycle()
fun buzzSortKey(groupId: GroupId): String = channelsById[groupId.id]?.toBestDisplayName()?.lowercase() ?: groupId.id
@@ -188,7 +188,7 @@ private fun RelayGroupThreads(
)
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
BuzzPinDropdownItem(channel.groupId) { menuOpen = false }
BuzzPinDropdownItem(channel.groupId, accountViewModel) { menuOpen = false }
RelayGroupMessagesDropdownItem(channel, accountViewModel) { menuOpen = false }
if (isAdmin) {
val archived = channel.isArchived()
@@ -255,7 +255,7 @@ fun RelayGroupTopBar(
// Pin/Unpin moved here off the community-list row. A local favorite, so it's offered
// for any Buzz channel/forum regardless of membership; DMs are never pinned.
if (isBuzzRelay && !isDm) {
BuzzPinDropdownItem(channel.groupId) { menuOpen = false }
BuzzPinDropdownItem(channel.groupId, accountViewModel) { menuOpen = false }
}
// A DM's Add/Remove-from-Messages, moved off the DM list row. It rides the per-viewer
// 30622 hide snapshot (kind-41012 hide / re-open), not the kind-10009 list, and is
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
@@ -60,6 +61,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.buzz.toMembershipNotice
import com.vitorpamplona.amethyst.model.chatMessageMarksRoomAsRead
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.model.privateChatLastReadRoute
@@ -81,6 +83,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.ui.note.elements.ToggleableTimeAgoText
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.buzzTimelinePreviewSummary
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.loadMarmotRelayIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotGroupLastReadRoute
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.rememberMarmotGroupIconUrl
@@ -107,6 +110,7 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.newItemBubbleModifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
@@ -119,6 +123,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.groupId
import com.vitorpamplona.quartz.nip29RelayGroups.isGroupScoped
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import kotlinx.coroutines.flow.emptyFlow
@Composable
fun ChatroomHeaderCompose(
@@ -208,6 +213,16 @@ private fun ChatroomEntry(
return
}
// A relay's "somebody added you" verdict (kind 44100) stands in for the group it invites me to.
// Matched before the generic group-scoped fallback below, which would otherwise render it as an
// ordinary joined-group row: with the relay keypair's npub and the raw JSON body as the preview,
// and a long-press menu offering to leave a group I never agreed to join.
val inviteEvent = lastMessage.event as? MemberAddedNotificationEvent
if (inviteEvent != null) {
ChannelInviteRoomCompose(lastMessage, accountViewModel, nav)
return
}
// A NIP-29 group message whose channel gatherer didn't attach (e.g. loaded before its channel
// existed, or via a path that skips attach) has no case in the when() below and would blank out.
// Resolve the group from its `h` tag + provenance relay and render the group row anyway.
@@ -292,15 +307,34 @@ private fun ChannelRoomCompose(
noteEvent?.content?.take(200)
}
val lastReadTime by accountViewModel.account.loadLastReadFlow("Channel/${channel.idHex}").collectAsStateWithLifecycle()
// One predicate for the row dot and the bottom-bar badge — see rowHasUnread.
// `emptyFlow()` is a shared singleton, so a row that can never be unread allocates nothing.
// The seed matters: collection only starts after the first composition, so without it every
// row would paint dotless for a frame and then correct itself while scrolling.
val unread = remember(lastMessage) { rowHasUnread(lastMessage, accountViewModel.account) }
val hasNewMessages by (unread?.flow ?: emptyFlow()).collectAsStateWithLifecycle(unread?.initial ?: false)
var menuOpen by remember { mutableStateOf(false) }
// Kept as a State (no `by`) so `.value` is read only inside the title and menu-text
// slots, confining mute-toggle invalidations to those scopes.
val mutedChats = accountViewModel.mutedPublicChatsFlow().collectAsStateWithLifecycle()
ChannelName(
channelIdHex = channel.idHex,
channelPicture = channelPicture,
channelTitle = { modifier -> ChannelTitleWithLabelInfo(channelName, MaterialSymbols.Public, R.string.public_chat, modifier) },
channelTitle = { modifier ->
val isMuted = channel.idHex in mutedChats.value
ChannelTitleWithLabelInfo(
channelName,
if (isMuted) MaterialSymbols.NotificationsOff else MaterialSymbols.Public,
R.string.public_chat,
modifier,
labelContentDescription = if (isMuted) stringRes(R.string.muted_chat_content_description) else null,
)
},
channelLastTime = lastMessage.createdAt(),
channelLastContent = "$authorName: $description",
hasNewMessages = (noteEvent?.createdAt ?: Long.MIN_VALUE) > lastReadTime,
hasNewMessages = hasNewMessages,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
autoPlayGif =
@@ -308,7 +342,31 @@ private fun ChannelRoomCompose(
.collectAsStateWithLifecycle()
.value,
onClick = { nav.nav(routeFor(channel)) },
onLongClick = { menuOpen = true },
)
DropdownMenu(
expanded = menuOpen,
onDismissRequest = { menuOpen = false },
) {
DropdownMenuItem(
text = {
Text(
stringRes(
if (channel.idHex in mutedChats.value) {
R.string.unmute_notifications
} else {
R.string.mute_notifications
},
),
)
},
onClick = {
accountViewModel.toggleMutedPublicChat(channel.idHex)
menuOpen = false
},
)
}
}
@Composable
@@ -441,9 +499,6 @@ private fun RelayGroupRoomCompose(
accountViewModel: AccountViewModel,
nav: INav,
) {
val channelState by observeChannel(baseChannel, accountViewModel)
val channel = channelState?.channel as? RelayGroupChannel ?: baseChannel
val author = lastMessage.author
val noteEvent = lastMessage.event
val lastContent =
@@ -469,6 +524,54 @@ private fun RelayGroupRoomCompose(
}
}
RelayGroupRow(
baseChannel = baseChannel,
lastContent = lastContent,
lastTime = lastMessage.createdAt(),
accountViewModel = accountViewModel,
nav = nav,
) { channel, dismiss ->
// Long-press brings the group's membership actions to the Messages row itself, mirroring the
// group top bar so "Remove from Messages" (drop from my list, stay a member) and "Leave"
// (kind-9022) are reachable without opening the group first.
DropdownMenuItem(
text = { Text(stringRes(R.string.remove_from_messages)) },
onClick = {
dismiss()
accountViewModel.removeRelayGroupFromMessages(channel)
},
)
DropdownMenuItem(
text = { Text(stringRes(R.string.leave), color = MaterialTheme.colorScheme.error) },
onClick = {
dismiss()
accountViewModel.leaveRelayGroup(channel)
},
)
}
}
/**
* One NIP-29 group as a Messages row: its picture, name, host-relay chip, and a caller-supplied
* "last message" line and long-press menu.
*
* Everything except those two is fixed here, because every list that shows a group has to agree on it —
* the picture fallback, the unread rule, and where a tap goes. A pending invite is a row in the same
* sense a joined group is (see `ChannelInvitesSection`); it differs only in what its newest line says
* and what you can do to it, which is exactly the two slots.
*/
@Composable
fun RelayGroupRow(
baseChannel: RelayGroupChannel,
lastContent: String?,
lastTime: Long?,
accountViewModel: AccountViewModel,
nav: INav,
menuContent: @Composable ColumnScope.(channel: RelayGroupChannel, dismiss: () -> Unit) -> Unit,
) {
val channelState by observeChannel(baseChannel, accountViewModel)
val channel = channelState?.channel as? RelayGroupChannel ?: baseChannel
val groupPicture = channel.profilePicture()?.ifBlank { null }
val channelPicture =
if (groupPicture != null) {
@@ -485,9 +588,6 @@ private fun RelayGroupRoomCompose(
// A placeholder row (no messages yet) has a null createdAt and never lights the dot.
val lastReadTime by accountViewModel.account.loadLastReadFlow(relayGroupChannelLastReadRoute(channel.groupId)).collectAsStateWithLifecycle()
// Long-press brings the group's membership actions to the Messages row itself, mirroring the group
// top bar so "Remove from Messages" (drop from my list, stay a member) and "Leave" (kind-9022) are
// reachable without opening the group first.
var menuOpen by remember { mutableStateOf(false) }
Box {
@@ -510,9 +610,9 @@ private fun RelayGroupRoomCompose(
)
}
},
channelLastTime = lastMessage.createdAt(),
channelLastTime = lastTime,
channelLastContent = lastContent,
hasNewMessages = (lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime,
hasNewMessages = (lastTime ?: Long.MIN_VALUE) > lastReadTime,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
autoPlayGif =
@@ -524,24 +624,75 @@ private fun RelayGroupRoomCompose(
)
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
DropdownMenuItem(
text = { Text(stringRes(R.string.remove_from_messages)) },
onClick = {
menuOpen = false
accountViewModel.removeRelayGroupFromMessages(channel)
},
)
DropdownMenuItem(
text = { Text(stringRes(R.string.leave), color = MaterialTheme.colorScheme.error) },
onClick = {
menuOpen = false
accountViewModel.leaveRelayGroup(channel)
},
)
menuContent(channel) { menuOpen = false }
}
}
}
/**
* A pending channel invite as a Messages row: the group it invites me to, with the invitation itself
* as the row's newest line.
*
* New Requests is a list of rooms awaiting a decision and this is one, so it sorts in among the
* unaccepted DMs by when the invite landed rather than being pinned above them. Deciding happens the
* same way it does for a joined group: tap opens the channel so it can be read first (its top bar
* offers "Add to Messages"), long-press brings the three answers to the row.
*/
@Composable
private fun ChannelInviteRoomCompose(
inviteNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val workspaces = accountViewModel.account.buzzWorkspaces.flow.value
val notice = remember(inviteNote, workspaces) { inviteNote.toMembershipNotice(workspaces) } ?: return
val baseChannel =
remember(notice) { LocalCache.getOrCreateRelayGroupChannel(GroupId(notice.channelId, notice.relay)) }
// The actor, not the signer: a kind-44100 is signed by the relay keypair reporting the membership
// change it made, so naming its author here would put an npub on every invite.
val actorName = observeUserNameByHex(notice.actor, accountViewModel)
val lastContent =
if (notice.actor != null) {
stringRes(R.string.channel_invite_row_added_you_by, actorName)
} else {
stringRes(R.string.channel_invite_row_added_you)
}
RelayGroupRow(
baseChannel = baseChannel,
lastContent = lastContent,
lastTime = notice.createdAt,
accountViewModel = accountViewModel,
nav = nav,
) { channel, dismiss ->
DropdownMenuItem(
text = { Text(stringRes(R.string.add_to_messages)) },
onClick = {
dismiss()
accountViewModel.acceptChannelInvite(channel)
},
)
// Ignore is a local display choice that leaves me in the roster; Leave is the kind-9022 that
// actually removes me from the channel. Keeping both means "get this off my list" never has
// to mean "announce to the relay that I left".
DropdownMenuItem(
text = { Text(stringRes(R.string.channel_invite_ignore)) },
onClick = {
dismiss()
accountViewModel.dismissChannelInvite(channel.groupId.id)
},
)
DropdownMenuItem(
text = { Text(stringRes(R.string.channel_invite_leave), color = MaterialTheme.colorScheme.error) },
onClick = {
dismiss()
accountViewModel.leaveChannelInvite(channel)
},
)
}
}
@Composable
private fun ConcordRoomCompose(
lastMessage: Note,
@@ -753,6 +904,7 @@ private fun ChannelTitleWithLabelInfo(
labelIcon: MaterialSymbol,
label: Int,
modifier: Modifier,
labelContentDescription: String? = null,
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
Text(
@@ -768,6 +920,7 @@ private fun ChannelTitleWithLabelInfo(
symbol = labelIcon,
text = stringRes(id = label),
modifier = Modifier.widthIn(max = ChatLabelMaxWidth),
contentDescription = labelContentDescription,
)
}
}
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.publicChatChannelIdOf
import com.vitorpamplona.amethyst.model.unreadPrivateChatRoute
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotGroupLastReadRoute
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordChannelLastReadRoute
@@ -36,8 +37,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ConcordServ
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.RelayGroupServerRoomNote
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
/**
@@ -51,23 +56,71 @@ import kotlinx.coroutines.flow.map
* silently skipped: their row could show a dot while the envelope stayed clean.
*
* Returns null when the row cannot be unread at all (no event, my own newest message in a DM, everyone
* hidden), so callers can skip it rather than subscribe to a flow that is always false.
* hidden), so callers can skip it rather than subscribe to a flow that is always false. A muted public
* chat is deliberately NOT one of these cases: mute is a runtime-toggleable setting, not a structural
* fact about the row, so it is folded into the emitted `Flow<Boolean>` (via [publicChatChannelIdOf] combined
* with the mute set) instead of being resolved as a one-shot snapshot at construction time. Early-return
* on a snapshot of the mute set would freeze the dot's mute state as of whenever the flow was built —
* do not "simplify" this back into an early return.
*
* The two collapsed rows are why this returns a `Flow<Boolean>` rather than a `(route, createdAt)`
* The two collapsed rows are why this carries a `Flow<Boolean>` rather than a `(route, createdAt)`
* pair: their dot is a fan-in over every child channel, not one timestamp against one marker, and
* approximating them by the newest child would miss an older channel that is still unread.
*/
fun rowHasUnreadFlow(
class RowUnread(
/**
* The answer as of right now, for the caller's FIRST frame.
*
* A composable collecting [flow] only starts collecting after its first composition, so without
* a seed every row would paint dotless and correct itself a frame later — a dot flash on every
* recycled row while scrolling. Before the row was routed through this helper it read a
* `StateFlow` directly and was right immediately; this keeps that property.
*
* `false` for the two collapsed rows, whose fan-in flows have no cheap synchronous answer. That
* matches what those rows already did before this type existed.
*/
val initial: Boolean,
val flow: Flow<Boolean>,
)
fun rowHasUnread(
row: Note,
account: Account,
): Flow<Boolean>? {
): RowUnread? {
// Collapsed rows own a fan-in flow across their children — reuse the row's own signal verbatim.
if (row is RelayGroupServerRoomNote) return relayGroupServerHasUnreadFlow(account, row.relay)
if (row is ConcordServerRoomNote) return concordCommunityHasUnreadFlow(account, row.communityId)
if (row is RelayGroupServerRoomNote) return RowUnread(false, relayGroupServerHasUnreadFlow(account, row.relay))
if (row is ConcordServerRoomNote) return RowUnread(false, concordCommunityHasUnreadFlow(account, row.communityId))
val route = rowLastReadRoute(row, account) ?: return null
val createdAt = row.createdAt() ?: return null
return account.settings.getLastReadFlow(route).map { lastReadAt -> createdAt > lastReadAt }
val lastRead = account.settings.getLastReadFlow(route)
// Public chats can be silenced at runtime, so the mute set has to be part of the
// emitted signal rather than a snapshot taken when this flow was built — otherwise
// toggling mute would not move the dot until something else re-keyed the caller.
// Every other row type skips the mute flow entirely and stays a plain map.
val mutedChannelId = publicChatChannelIdOf(row.event)
if (mutedChannelId == null) {
return RowUnread(
initial = createdAt > lastRead.value,
flow = lastRead.map { createdAt > it }.distinctUntilChanged(),
)
}
val muted = account.settings.mutedPublicChats
// One expression feeds both the seed and the flow, so the first frame and every later
// frame cannot disagree — the drift this helper exists to prevent, in miniature.
val compute = { lastReadAt: Long, mutedSet: Set<String> ->
createdAt > lastReadAt && mutedChannelId !in mutedSet
}
return RowUnread(
initial = compute(lastRead.value, muted.value),
flow = combine(lastRead, muted) { read, mutedSet -> compute(read, mutedSet) }.distinctUntilChanged(),
)
}
/**
@@ -90,8 +143,12 @@ private fun rowLastReadRoute(
}
return when (val event = row.event) {
// Same route strings the row composables use — see ChatroomHeaderCompose.
is ChannelMessageEvent -> event.channelId()?.let { "Channel/$it" }
// Same route strings the row composables use — see ChatroomHeaderCompose. The channel
// id itself comes from [publicChatChannelIdOf], which is also what decides admin events
// (ChannelHideMessageEvent/ChannelMuteUserEvent) are not room activity — listing the
// three concrete types here keeps them falling through to `else`.
is ChannelMessageEvent, is ChannelMetadataEvent, is ChannelCreateEvent ->
publicChatChannelIdOf(event)?.let { "Channel/$it" }
is EphemeralChatEvent -> event.roomId()?.let { "Channel/${it.toKey()}" }
is GeohashChatEvent -> event.geohash()?.let { "Geohash/$it" }
// DMs keep their own rule: a room whose newest message is mine counts as read.
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.commons.util.replace
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.publicChatChannelIdOf
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.isConcordTimelineMessage
@@ -45,8 +46,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.groupId
@@ -632,15 +631,9 @@ class ChatroomListKnownFeedFilter(
// Maps a note that represents a public chat row to its channel id. The
// representative note for a channel may be the channel's create event
// (id == channelId), a metadata update, or a message — match all three so
// (id == channelId), a metadata update, or a message — all three resolve so
// an arriving ChannelMessageEvent replaces an existing placeholder
// metadata/create note for the same channel instead of duplicating it
// (which would yield the same LazyColumn key twice).
private fun publicChannelIdOf(note: Note): String? =
when (val event = note.event) {
is ChannelMessageEvent -> event.channelId()
is ChannelMetadataEvent -> event.channelId()
is ChannelCreateEvent -> event.id
else -> null
}
private fun publicChannelIdOf(note: Note): String? = publicChatChannelIdOf(note.event)
}
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal
import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType
import com.vitorpamplona.amethyst.commons.util.replace
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
@@ -73,7 +74,27 @@ class ChatroomListNewFeedFilter(
}
}
return (privateMessages + marmotGroups).sortedByDefaultFeedOrder()
// NIP-29 groups a relay says somebody added me to, but that I have not answered: not on my
// kind-10009, not dismissed, no later kind-44101 withdrawing it. Exactly the same standing as
// an unaccepted DM — a room waiting on a decision — so it belongs in this list and not pinned
// above it, sorted by when the invite landed like everything else here.
//
// The row is the kind-44100 itself, which carries the channel (`h`), the actor and the time.
// ChatroomHeaderCompose renders it as the group's row with the invitation as its newest line.
val relayGroupInvites =
if (!isEnabled(ChatFeedType.NIP29)) {
emptyList()
} else {
// Read the map the invalidation is driven by, not the sorted list derived from it:
// `AccountFeedContentStates` rebuilds this feed when `pendingByEventId` emits, and
// `flow` is a second StateFlow mapped off that one, so at the instant the rebuild runs
// it can still hold the previous answer — an accepted invite then keeps its row until
// something else refreshes the list. (Order is irrelevant here; the feed sorts below.)
account.channelInvites.pendingByEventId.value.keys
.mapNotNull { LocalCache.getNoteIfExists(it) }
}
return (privateMessages + marmotGroups + relayGroupInvites).sortedByDefaultFeedOrder()
}
override fun updateListWith(
@@ -87,13 +87,6 @@ fun ChatroomListFeedView(
scrollStateKey: String,
accountViewModel: AccountViewModel,
nav: INav,
/**
* Pinned above the rows. Rendered INSIDE the feed rather than beside it so it inherits
* [rememberFeedContentPadding] — the collapsing top bar draws over this area, and a header placed
* outside the list lands underneath it. Shown in every state, so a standing prompt is still
* reachable when the list itself is empty.
*/
headerContent: (@Composable () -> Unit)? = null,
) {
DisposableEffect(Unit) {
Log.d("DMPagination") { "rooms.list: OPEN" }
@@ -101,7 +94,7 @@ fun ChatroomListFeedView(
}
RefresheableBox(feedContentState, true) {
SaveableFeedContentState(feedContentState, scrollStateKey) { listState ->
CrossFadeState(feedContentState, listState, accountViewModel, nav, headerContent)
CrossFadeState(feedContentState, listState, accountViewModel, nav)
}
}
}
@@ -112,7 +105,6 @@ private fun CrossFadeState(
listState: LazyListState,
accountViewModel: AccountViewModel,
nav: INav,
headerContent: (@Composable () -> Unit)? = null,
) {
val feedState by feedContentState.feedContent.collectAsStateWithLifecycle()
@@ -142,7 +134,6 @@ private fun CrossFadeState(
when (state) {
is FeedState.Empty -> {
Column(Modifier.padding(rememberFeedContentPadding(FeedPadding))) {
headerContent?.invoke()
if (historyExhausted) {
FeedEmpty { feedContentState.invalidateData() }
} else {
@@ -156,7 +147,7 @@ private fun CrossFadeState(
}
is FeedState.Loaded -> {
FeedLoaded(state, listState, accountViewModel, nav, headerContent)
FeedLoaded(state, listState, accountViewModel, nav)
}
FeedState.Loading -> {
@@ -172,7 +163,6 @@ private fun FeedLoaded(
listState: LazyListState,
accountViewModel: AccountViewModel,
nav: INav,
headerContent: (@Composable () -> Unit)? = null,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
@@ -228,8 +218,6 @@ private fun FeedLoaded(
contentPadding = rememberFeedContentPadding(FeedPadding),
state = listState,
) {
headerContent?.let { item("chatroom-list-header") { it() } }
itemsIndexed(
items.list,
key = { _, item -> chatroomLazyKey(item, myPubKey) },
@@ -49,7 +49,6 @@ import com.vitorpamplona.amethyst.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.navs.zonedDrawerSwipeIfModal
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.ChannelInvitesSection
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size40dp
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
@@ -132,17 +131,6 @@ fun MessagesPager(
scrollStateKey = tabs[page].scrollStateKey,
accountViewModel = accountViewModel,
nav = nav,
// Channels somebody added you to are pending decisions, exactly like an unaccepted DM — so
// they belong on New Requests, pinned above the rows. Passed as the feed's header rather
// than stacked beside it: the collapsing top bar draws over this area, so a header outside
// the list renders underneath it. The Notifications tab shows the same prompts from the
// same state holder, so the two surfaces cannot disagree.
headerContent =
if (tabs[page].resource == R.string.new_requests) {
{ ChannelInvitesSection(accountViewModel, nav) }
} else {
null
},
)
}
}
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvite
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent
import com.vitorpamplona.amethyst.commons.ui.feeds.LoadedFeedState
@@ -39,9 +40,11 @@ import com.vitorpamplona.amethyst.service.BundledInsert
import com.vitorpamplona.amethyst.service.BundledUpdate
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrderCard
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedOrderCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
@@ -135,7 +138,7 @@ class CardFeedContentState(
val updatedCards =
(oldNotesState.feed.value.list + newCards)
.distinctBy { it.id() }
.sortedWith(DefaultFeedOrderCard)
.sortedWith(NotificationFeedOrderCard)
.take(localFilter.limit())
.toImmutableList()
@@ -149,7 +152,7 @@ class CardFeedContentState(
val cards =
convertToCard(notes)
.sortedWith(DefaultFeedOrderCard)
.sortedWith(NotificationFeedOrderCard)
.take(localFilter.limit())
.toImmutableList()
@@ -366,7 +369,10 @@ class CardFeedContentState(
// card — a duplicate of the grouped one.
it.event !is NutzapEvent
}.map {
if (it.event is PrivateDmEvent || it.event is NIP17Group || it.isInMarmotGroup()) {
val pendingInvite = if (it.event is MemberAddedNotificationEvent) pendingInvites[it.idHex] else null
if (pendingInvite != null) {
ChannelInviteCard(it, pendingInvite)
} else if (it.event is PrivateDmEvent || it.event is NIP17Group || it.isInMarmotGroup()) {
MessageSetCard(it)
} else if (it.event is BadgeAwardEvent) {
BadgeCard(it)
@@ -376,9 +382,25 @@ class CardFeedContentState(
}
return (multiCards + textNoteCards + userZaps + userNutzaps)
.sortedWith(compareByDescending<Card> { it.createdAt() }.thenBy { it.id() })
.sortedWith(NotificationFeedOrderCard)
}
/**
* The unanswered channel invites, keyed by their kind-44100.
*
* A StateFlow read, so each access is a volatile load of an already-built map — cheap enough to
* touch per note. `acceptableEvent` has already narrowed the feed to pending ones, so this only has
* to attach the resolved invite to its row; a 44100 that is no longer pending falls through to an
* ordinary card, which the filter should have excluded anyway.
*/
private val pendingInvites: Map<HexKey, BuzzChannelInvite>
get() =
(localFilter as? NotificationFeedFilter)
?.account
?.channelInvites
?.pendingByEventId
?.value ?: emptyMap()
private fun updateFeed(notes: ImmutableList<Card>) {
if (notes.size >= localFilter.limit()) {
val lastNoteTime =
@@ -458,7 +480,7 @@ class CardFeedContentState(
val updatedCards =
(oldNotesState.feed.value.list + newCards)
.distinctBy { it.id() }
.sortedWith(compareByDescending<Card> { it.createdAt() }.thenBy { it.id() })
.sortedWith(NotificationFeedOrderCard)
.take(localFilter.limit())
.toImmutableList()
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvite
import com.vitorpamplona.amethyst.commons.ui.notifications.Card
import com.vitorpamplona.amethyst.commons.util.firstFullCharOrEmoji
import com.vitorpamplona.amethyst.model.Note
@@ -129,6 +130,24 @@ class MessageSetCard(
override fun id() = note.idHex
}
/**
* "Somebody added you to a channel" — a relay-signed kind-44100 that is still unanswered, carrying the
* [invite] the projection resolved it to (who did it, which channel, on which relay).
*
* A question rather than a dated event, so [com.vitorpamplona.amethyst.ui.dal.NotificationFeedOrderCard]
* sorts these ahead of everything else instead of letting an old one sink into history. It still holds
* its [note], so it dedups, scrolls-to and pages exactly like every other card.
*/
@Immutable
class ChannelInviteCard(
val note: Note,
val invite: BuzzChannelInvite,
) : Card {
override fun createdAt(): Long = invite.createdAt
override fun id() = note.idHex
}
/**
* Checks if this card contains a specific event ID.
* Used for scrolling to a notification from a push notification intent.
@@ -147,6 +166,10 @@ fun Card.containsEventId(eventId: String): Boolean =
note.idHex == eventId
}
is ChannelInviteCard -> {
note.idHex == eventId
}
is ZapUserSetCard -> {
zapEvents.any { it.response.idHex == eventId || it.request.idHex == eventId }
}
@@ -115,11 +115,11 @@ fun RenderCardFeed(
// LazyColumns then share the same listState and only the top one scrolls.
when (val state = feedState) {
is CardFeedState.Empty -> {
NotificationFeedEmpty(feedContent::invalidateData)
HeaderAbove(headerContent) { NotificationFeedEmpty(feedContent::invalidateData) }
}
is CardFeedState.FeedError -> {
FeedError(state.errorMessage, feedContent::invalidateData)
HeaderAbove(headerContent) { FeedError(state.errorMessage, feedContent::invalidateData) }
}
is CardFeedState.Loaded -> {
@@ -137,7 +137,37 @@ fun RenderCardFeed(
}
CardFeedState.Loading -> {
LoadingFeed()
HeaderAbove(headerContent) { LoadingFeed() }
}
}
}
/**
* Draws [headerContent] above a non-loaded feed state.
*
* The header is not part of the feed's contents — it carries standing prompts (a pending channel
* invite, "you have no inbox relay") that are true regardless of whether any notification has loaded.
* Drawing it only in the `Loaded` branch made it blink out and back on every visit, because arriving on
* the screen re-runs `checkKeysInvalidateDataAndSendToTop` and any refresh that momentarily computes an
* empty list flips the state through `Empty`/`Loading` (see the `CardFeedState` comment above). Worse
* for the relay prompt specifically: a missing inbox relay is the most likely *reason* the feed is
* empty, so the one state that hid it was the one that needed it.
*
* The padding is applied here because only the `Loaded` branch has a `LazyColumn` to carry the
* scaffold's inset as content padding; without it the header would draw under the disappearing top bar.
* Same shape [com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed.ChatroomListFeedView] uses.
*/
@Composable
private fun HeaderAbove(
headerContent: (@Composable () -> Unit)?,
content: @Composable () -> Unit,
) {
if (headerContent == null) {
content()
} else {
Column(Modifier.fillMaxSize().padding(rememberFeedContentPadding(FeedPadding))) {
headerContent()
content()
}
}
}
@@ -369,6 +399,23 @@ private fun RenderCardItem(
)
}
is ChannelInviteCard -> {
// The same NoteCompose every other row uses. The invite-specific part is only the body,
// dispatched by kind in RenderNoteRow — so the author header, 3-dot menu, reactions row,
// last-read background and click-through are the shared ones rather than re-implemented.
NoteCompose(
baseNote = item.note,
modifier = Modifier.fillMaxWidth(),
routeForLastRead = routeForLastRead,
isBoostedNote = false,
isQuotedNote = false,
isHiddenFeed = showHidden,
quotesLeft = 3,
accountViewModel = accountViewModel,
nav = nav,
)
}
is MessageSetCard -> {
MessageSetCompose(
messageSetCard = item,
@@ -1,212 +0,0 @@
/*
* 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.notifications
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvite
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.layouts.NoteComposeLayout
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.DisplayBlankAuthor
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.UserNameRowHeight
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
/**
* "Somebody added you to a channel" prompts, rendered above the Notifications feed (the same header slot
* the missing-inbox-relay prompt uses) and inside Messages › New Requests.
*
* These are deliberately NOT auto-accepted. On a Buzz relay another member can add you to a channel
* server-side: the relay writes you into the kind-39002 roster and you can immediately read and post,
* without you ever agreeing to see it. Amethyst used to silently subscribe to those channels' messages
* while showing no row for them anywhere, so a channel could be joined, streaming, and invisible at once.
* Now the relay's decision is surfaced as a question instead of being acted on.
*/
@Composable
fun ChannelInvitesSection(
accountViewModel: AccountViewModel,
nav: INav,
modifier: Modifier = Modifier,
) {
val invites by accountViewModel.feedStates.channelInvites.flow
.collectAsStateWithLifecycle()
if (invites.isEmpty()) return
Column(modifier) {
invites.forEach { invite ->
// Keyed by channel: the list is sorted newest-first, so an arriving invite shifts every row
// below it. Without a key Compose matches children by position and each shifted row would
// recompose against a different invite — re-resolving the actor and reloading their avatar.
key(invite.channelId) {
ChannelInviteCard(invite, accountViewModel, nav)
HorizontalDivider(thickness = DividerThickness)
}
}
}
}
/**
* One pending add, drawn as a feed row instead of a floating Material card: the actor is the row's
* author — picture, name and time in the usual note header — and "added you to X" is the row's content,
* so the prompt reads like the reply/mention notifications it sits next to. The three choices take the
* reactions slot, which spans the full width and therefore fits "Add to Messages" without wrapping.
*/
@Composable
fun ChannelInviteCard(
invite: BuzzChannelInvite,
accountViewModel: AccountViewModel,
nav: INav,
) {
val baseChannel =
remember(invite.channelId, invite.relay) {
LocalCache.getOrCreateRelayGroupChannel(GroupId(invite.channelId, invite.relay))
}
// The channel's own metadata flow, collected directly rather than through `observeChannel`. That
// helper also registers a ChannelFinder query, and every assembler under it is gated on
// `is PublicChatChannel` / `is LiveActivitiesChannel` — a RelayGroupChannel yields no filter at all,
// so the registration buys nothing and only churns the app-wide key set on mount/unmount. The flow
// still fills the name in when the group's kind-39000 lands from the directory subscription, and
// nothing here opens the channel's *message* subscription — holding that back until the viewer
// answers is the whole point of the prompt.
val channelState by
remember(baseChannel) { baseChannel.flow().metadata.stateFlow }
.collectAsStateWithLifecycle()
val channel = channelState.channel as? RelayGroupChannel ?: baseChannel
val actorUser = remember(invite.actor) { invite.actor?.let { LocalCache.getOrCreateUser(it) } }
// A pending invite is by definition unanswered, so it always carries the new-item wash rather than
// fading with a last-read marker: it is a standing question, not a dated event.
val backgroundColor =
MaterialTheme.colorScheme.newItemBackgroundColor
.compositeOver(MaterialTheme.colorScheme.background)
NoteComposeLayout(
modifier =
remember(backgroundColor) {
Modifier.drawBehind { drawRect(backgroundColor) }.fillMaxWidth()
},
authorPicture = {
Box(Size55Modifier, contentAlignment = Alignment.BottomEnd) {
if (actorUser != null) {
UserPicture(actorUser, Size55dp, accountViewModel = accountViewModel, nav = nav)
} else {
DisplayBlankAuthor(Size55dp, accountViewModel = accountViewModel)
}
}
},
firstRow = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Size5dp),
modifier = UserNameRowHeight,
) {
// Who did it matters: the relay reports a self-join with the same event, so naming the
// actor is what tells "I joined this" apart from "a stranger put me here".
if (actorUser != null) {
UsernameDisplay(actorUser, Modifier.weight(1f), accountViewModel = accountViewModel)
} else {
Text(
text = stringRes(R.string.channel_invite_unknown_actor),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
// DottedTight, not Dotted: the row's `spacedBy` already supplies the gap, so the
// dotted variant's own leading space would double it. Same choice the note header makes.
TimeAgo(invite.createdAt, style = TimeAgoStyle.DottedTight)
}
},
secondRow = {},
noteContent = {
Text(text = stringRes(R.string.channel_invite_title, channel.toBestDisplayName()))
Text(
text = invite.relay.displayUrl(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
reactionsRow = {
Row(
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = Size10dp),
) {
// Leave is separate from Ignore on purpose: Ignore is a local display choice that leaves
// you in the roster, Leave is the kind-9022 that actually removes you from the channel.
TextButton(onClick = { accountViewModel.leaveChannelInvite(channel) }) {
Text(stringRes(R.string.channel_invite_leave), color = MaterialTheme.colorScheme.error)
}
TextButton(onClick = { accountViewModel.dismissChannelInvite(invite.channelId) }) {
Text(stringRes(R.string.channel_invite_ignore))
}
// Accepting *is* `addRelayGroupToMessages`, the same call behind the channel top bar's
// "Add to Messages", so it carries that label rather than a second word for one action.
TextButton(onClick = { accountViewModel.acceptChannelInvite(channel) }) {
Text(stringRes(R.string.add_to_messages), fontWeight = FontWeight.Bold)
}
}
},
)
}
@@ -1,64 +0,0 @@
/*
* 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.notifications
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvite
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvites
import com.vitorpamplona.amethyst.model.Account
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
/**
* The channels somebody else added the viewer to that are still awaiting a decision.
*
* An entry drops out the moment it stops being a question: accepting writes the group into kind-10009
* (so `joined` covers it and the ordinary Messages row takes over), dismissing records the channel in
* `dismissedChannelInvites`, and leaving makes the relay withdraw the membership. Nothing here asserts
* membership — the relay already granted that — it only tracks whose call it is to surface the channel.
*
* Modelled on [OpenPollsState]: a small always-on projection the Notifications screen and the Messages
* "New Requests" tab both render, so the two surfaces can never disagree about what is pending.
*/
@Stable
class ChannelInvitesState(
private val account: Account,
scope: CoroutineScope,
) {
val flow: StateFlow<List<BuzzChannelInvite>> =
combine(
BuzzChannelInvites.flow.map { it[account.userProfile().pubkeyHex] ?: emptyMap() },
account.settings.dismissedChannelInvites,
account.relayGroupList.liveRelayGroupList,
) { invites, dismissed, joined ->
val joinedIds = joined.mapTo(HashSet()) { it.groupId }
invites.values
.filter { it.channelId !in dismissed && it.channelId !in joinedIds }
.sortedByDescending { it.createdAt }
}.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, emptyList())
}
@@ -242,11 +242,11 @@ internal fun SingleNotificationsBody(
nav = nav,
routeForLastRead = NOTIFICATION_LAST_READ_KEY,
scrollToEventId = scrollToEventId,
// "X added you to #channel" is not a header prompt any more — each pending invite is a
// ChannelInviteCard in the feed itself, sorted ahead of the dated rows by
// NotificationFeedOrderCard.
headerContent = {
ObserveInboxRelayListAndDisplayIfNotFound(accountViewModel, nav)
// "X added you to #channel" prompts sit above the feed rather than inside it: they are a
// standing decision, not a dated event, so they must not scroll away into history.
ChannelInvitesSection(accountViewModel, nav)
},
)
}
@@ -28,12 +28,14 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.filterIntoSet
import com.vitorpamplona.amethyst.model.isMutedPublicChatMessage
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
import com.vitorpamplona.quartz.buzz.jobs.JobErrorEvent
import com.vitorpamplona.quartz.buzz.jobs.JobResultEvent
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot
@@ -190,8 +192,15 @@ class NotificationFeedFilter(
VoiceEvent.KIND,
VoiceReplyEvent.KIND,
// A Buzz workflow approval gate (46010) addressed to me — I need to grant/deny it.
// Also gates the push dispatcher, which uses NOTIFICATION_KINDS as its first filter.
// NOTE: this list does NOT gate push. NotificationDispatcher declares its own, separate
// NOTIFICATION_KINDS; adding a kind here changes only what renders on the tab.
WorkflowApprovalRequestedEvent.KIND,
// "Somebody added you to a channel" (44100). Like the approval gate above, this is a
// question addressed to me rather than a dated event — it renders as a ChannelInviteCard
// with Leave / Ignore / Add to Messages, and the DAL sorts pending ones to the top so an
// old invite can't sink into history. `acceptableEvent` narrows this to the ones still
// unanswered; a self-join, a dismissal or an accept drops it.
MemberAddedNotificationEvent.KIND,
) + ADDRESSABLE_KINDS
// How deep to walk a public chat reply chain looking for one of the
@@ -488,6 +497,27 @@ class NotificationFeedFilter(
val noteEvent = it.event
// Muted public chats contribute nothing to Notifications.
//
// NOTE: this filter is one-way. NotificationFeedFilter is an AdditiveFeedFilter, so
// applyFilter only ever runs over newly-arriving items — nothing re-scans LocalCache
// when the mute set changes. Entries suppressed while muted therefore do NOT come
// back on unmute until the tab is refreshed. Device-confirmed; see the design doc.
if (isMutedPublicChatMessage(noteEvent, account.settings.mutedPublicChats.value)) return false
// "Somebody added you to a channel" (kind 44100). The relay keypair authors it, so none of the
// follow/relevance heuristics below can say anything useful about it — its relevance is that it
// is addressed to me and still unanswered. Every rule that decides "unanswered" (self-join,
// dismissal, already on my kind-10009, DM vs named channel, superseded by a kind-44101) lives in
// the account's projection, so this is a map lookup, and answering the prompt drops the row on
// the next invalidation. This is a standing decision, not a chat message: it ignores the
// Messages toggle.
//
// Ordered after the mute check only for readability — a blanket "never show this" reads before a
// kind-specific accept. The two can't actually interact: the mute rule matches public-chat
// messages, which a kind-44100 is not.
if (noteEvent is MemberAddedNotificationEvent) return account.channelInvites.isPending(it.idHex)
// Buzz DM: a group chat message in a `t=dm` channel whose 39000 participants include me. A Buzz
// relay carries DM messages as either kind-9 (NIP-29 chat) or kind-40002 (stream message v2), and
// neither `p`-tags the recipient, so being a participant of the DM channel is the relevance signal
@@ -551,7 +581,12 @@ class NotificationFeedFilter(
noteEvent is RepostEvent || noteEvent is GenericRepostEvent
) {
val target = it.replyTo?.lastOrNull()
if (target != null && account.isThreadMuted(account.resolveThreadRoot(target))) {
if (target != null &&
(
account.isThreadMuted(account.resolveThreadRoot(target)) ||
isMutedPublicChatMessage(target.event, account.settings.mutedPublicChats.value)
)
) {
return false
}
}
@@ -260,7 +260,7 @@ fun RelayAuthSettingsScreen(
) {
SettingsSwitchTile(
icon = MaterialSymbols.Dns,
title = R.string.relay_auth_auto_my_relays,
title = R.string.relay_auth_auto_my_relays_and_venues,
checked = myRelays,
onCheckedChange = { account.settings.changeRelayAuthTrustMyRelaysAndVenues(it) },
)
@@ -32,7 +32,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
@@ -64,6 +66,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboard
@@ -74,6 +77,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.hashtags.Cashu
@@ -82,6 +86,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip60Cashu.CashuWalletHistoryEoseManager
import com.vitorpamplona.amethyst.ui.components.util.getText
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -96,6 +101,9 @@ import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
import com.vitorpamplona.quartz.nip60Cashu.history.SpendingDirection
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import java.text.DateFormat
import java.text.NumberFormat
@@ -382,7 +390,19 @@ private fun CashuWalletContent(
onMoveCoins: (String) -> Unit,
onResumePendingQuote: () -> Unit,
) {
val listState = rememberLazyListState()
// The kind:7376 rows below are only ever as complete as the relays were asked to be. The live wallet
// subscription asks for six kinds in one uncapped REQ, and history is the most numerous of them, so
// what lands there is a recent-N suffix chosen by each relay's cap. This pager walks older pages on
// demand — see CashuWalletHistoryEoseManager.
val history7376 = remember(accountViewModel) { accountViewModel.dataSources().account.cashuWalletHistory }
val loadingOlder by history7376.loadingMore.collectAsStateWithLifecycle()
val pagingStatus by history7376.status.collectAsStateWithLifecycle()
CashuHistoryPaging(historyCount = { history.size }, listState = listState, history = history7376)
LazyColumn(
state = listState,
modifier =
modifier
.fillMaxSize()
@@ -447,12 +467,90 @@ private fun CashuWalletContent(
items(history, key = { it.id }) { entry ->
HistoryRow(entry, accountViewModel, nav)
}
item {
CashuHistoryFooter(
loadingOlder = loadingOlder,
exhausted = pagingStatus.exhausted,
stalledCount = pagingStatus.stalledCount,
)
}
}
item { Spacer(modifier = Modifier.height(24.dp)) }
}
}
/** How many history rows to pull in before the user has scrolled at all. */
private const val CASHU_HISTORY_TARGET = 30
/** How close to the end of the list a page request fires. */
private const val CASHU_HISTORY_PREFETCH_AHEAD = 5
/**
* Drives the spending-history backward pager: pull a page on open so the list isn't whatever suffix the
* relay caps returned, then page older rows as the list nears its end. Same two-driver shape the NIP-29
* thread list and the notifications feed use — a bootstrap that fills the first screen, and a look-ahead
* that keeps going only while the user is actually scrolling toward the bottom.
*/
@Composable
private fun CashuHistoryPaging(
historyCount: () -> Int,
listState: LazyListState,
history: CashuWalletHistoryEoseManager,
) {
LaunchedEffect(history) {
combine(snapshotFlow { historyCount() }, history.loadingMore, history.status) { count, loading, s ->
count < CASHU_HISTORY_TARGET && !loading && !s.exhausted
}.distinctUntilChanged()
.filter { it }
.collect { history.advanceAll() }
}
LaunchedEffect(history, listState) {
snapshotFlow {
val last =
listState.layoutInfo.visibleItemsInfo
.lastOrNull()
?.index ?: 0
val total = historyCount()
total > 0 && last >= total - CASHU_HISTORY_PREFETCH_AHEAD
}.distinctUntilChanged()
.filter { it }
.collect {
if (!history.status.value.exhausted && !history.loadingMore.value) history.advanceAll()
}
}
}
/**
* A quiet footer under the transaction list: what the pager is doing, or nothing when idle.
*
* Splits on [stalledCount] because `exhausted` means "nothing more reachable right now", not "caught
* up" — a relay that answered an auth CLOSE, is unreachable, or went silent is stalled rather than done,
* and claiming the history is complete while some of it was never served would be a lie about the user's
* own money.
*/
@Composable
private fun CashuHistoryFooter(
loadingOlder: Boolean,
exhausted: Boolean,
stalledCount: Int,
) {
val text =
when {
loadingOlder -> stringRes(R.string.cashu_history_loading_older)
exhausted && stalledCount > 0 -> stringRes(R.string.cashu_history_some_relays_unreachable)
exhausted -> stringRes(R.string.cashu_history_all_loaded)
else -> return
}
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
)
}
/**
* Banner that surfaces unfinished mint quotes — tappable to resume the
* receive flow with the stored invoice. Driven by
@@ -216,16 +216,30 @@ class CashuWalletViewModel : ViewModel() {
}
/**
* Reconcile every mint we hold tokens at against its NUT-07 `/checkstate`
* — not just the mint a spend targets. Wired to the wallet screen opening
* so a balance auto-redeemed from a mint we never configured (e.g. a
* nutzap on a mint not in our kind:10019) still gets its stale proofs
* swept. Safe to call repeatedly; no-ops when nothing is stale or the
* wallet hasn't started yet.
* Bring the wallet's view of its own money up to date, in the two ways it
* can be behind.
*
* First re-page the proof set off the relays: the live subscription takes
* whatever one uncapped REQ returns, so proofs older than the relay's cap
* are simply absent, and the balance quietly reads low (see
* [CashuWalletState.resyncProofsFromRelays]). `force` because the user
* opening the wallet is a direct request for a current number, and the
* startup walk may have run before the relay list was known.
*
* Then reconcile every mint we hold tokens at against its NUT-07
* `/checkstate` — not just the mint a spend targets — so a balance
* auto-redeemed from a mint we never configured (e.g. a nutzap on a mint
* not in our kind:10019) still gets its stale proofs swept. Safe to call
* repeatedly; no-ops when nothing is stale or the wallet hasn't started.
*/
fun refresh() {
val vm = accountViewModel ?: return
vm.launchSigner {
try {
state.resyncProofsFromRelays(force = true)
} catch (e: Exception) {
Log.w("CashuWallet", "wallet proof re-page failed", e)
}
try {
state.syncAllMints()
} catch (e: Exception) {
@@ -1170,7 +1170,6 @@
keys are new rather than reused - a stale translation of the old standalone titles would
read as a non-sequitur under this header. -->
<string name="relay_auth_auto_login_when">Přihlásit se bez ptaní, když…</string>
<string name="relay_auth_auto_my_relays">…jde o mé relé nebo o místnost, do které jsem vstoupil</string>
<string name="relay_auth_auto_read_follows">…čtu někoho, koho sleduji</string>
<string name="relay_auth_auto_message_follows">…píšu někomu, koho sleduji</string>
<string name="relay_auth_auto_message_strangers">…píšu komukoli jinému</string>
@@ -1110,7 +1110,6 @@
keys are new rather than reused - a stale translation of the old standalone titles would
read as a non-sequitur under this header. -->
<string name="relay_auth_auto_login_when">Ohne Nachfrage anmelden, wenn…</string>
<string name="relay_auth_auto_my_relays">…es mein Relay ist oder ein Raum, dem ich beigetreten bin</string>
<string name="relay_auth_auto_read_follows">…ich jemanden lese, dem ich folge</string>
<string name="relay_auth_auto_message_follows">…ich jemandem schreibe, dem ich folge</string>
<string name="relay_auth_auto_message_strangers">…ich jemand anderem schreibe</string>
@@ -1110,7 +1110,6 @@
keys are new rather than reused - a stale translation of the old standalone titles would
read as a non-sequitur under this header. -->
<string name="relay_auth_auto_login_when">कब पूछे बिना प्रवेशांकन करें\u2026</string>
<string name="relay_auth_auto_my_relays">\u2026यह मेरा पुनःप्रसारक है। अथवा एक शाला जिससे मैं जुड चुका</string>
<string name="relay_auth_auto_read_follows">\u2026मैं पढ रहा हूँ किसी को जिसका मैं अनुगमन करता हूँ</string>
<string name="relay_auth_auto_message_follows">\u2026मैं सन्देश भेज रहा हूँ किसी को जिसका मैं अनुगमन करता हूँ</string>
<string name="relay_auth_auto_message_strangers">\u2026मैं किसी अन्य को सन्देश भेज रहा हूँ</string>
@@ -1111,7 +1111,6 @@
keys are new rather than reused - a stale translation of the old standalone titles would
read as a non-sequitur under this header. -->
<string name="relay_auth_auto_login_when">Jelentkezzen be kérdés nélkül, ha\u2026</string>
<string name="relay_auth_auto_my_relays">\u2026ez a saját átjátszóm, vagy egy szoba, amihez csatlakozott</string>
<string name="relay_auth_auto_read_follows">\u2026olyan valakit olvasok, akit követek</string>
<string name="relay_auth_auto_message_follows">\u2026olyan valakinek írok, akit követek</string>
<string name="relay_auth_auto_message_strangers">\u2026bárki másnak írok</string>
@@ -591,6 +591,9 @@
<string name="quick_action_block">Zablokuj</string>
<string name="quick_action_mute_thread">Zablokuj wątek</string>
<string name="quick_action_unmute_thread">Odblokuj wątek</string>
<string name="mute_notifications">Wycisz powiadomienia</string>
<string name="unmute_notifications">Wyłącz wyciszenie powiadomień</string>
<string name="muted_chat_content_description">Powiadomienia są wyciszone dla tego czatu</string>
<string name="quick_action_report">Zgłoś</string>
<string name="quick_action_dont_show_again_button">Nie pokazuj więcej</string>
<string name="report_dialog_spam">Spam lub oszustwa</string>
@@ -1171,7 +1174,6 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
keys are new rather than reused - a stale translation of the old standalone titles would
read as a non-sequitur under this header. -->
<string name="relay_auth_auto_login_when">Zaloguj się bez pytania, kiedy\u2026</string>
<string name="relay_auth_auto_my_relays">\u2026to mój transmiter lub pokój, do którego dołączyłem</string>
<string name="relay_auth_auto_read_follows">Czytam wpis osoby, którą obserwuję</string>
<string name="relay_auth_auto_message_follows">\u2026wysyłam wiadomość do osoby, którą obserwuję</string>
<string name="relay_auth_auto_message_strangers">\u2026piszę do wszystkich pozostałych</string>
@@ -3094,6 +3096,9 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="cashu_remove_mint">Usuń Minta</string>
<string name="cashu_add_mint">Dodaj mint</string>
<string name="cashu_history">Historia</string>
<string name="cashu_history_loading_older">Ładowanie starszych transakcji…</string>
<string name="cashu_history_all_loaded">Brak starszych transakcji</string>
<string name="cashu_history_some_relays_unreachable">Brak starszych transakcji z transmiterów, które odpowiedziały — z niektórymi nie udało się nawiązać połączenia</string>
<string name="cashu_wallet_autosaves">Twój portfel zapisuje dane automatycznie w miarę dodawania lub usuwania mintów. Klucz Nutzap jest generowany automatycznie przy pierwszym dodaniu minta.</string>
<string name="cashu_wallet_saving">Zapisywanie…</string>
<string name="cashu_p2pk_section">Klucz Nutzap (zaawansowany)</string>
@@ -1108,7 +1108,6 @@
keys are new rather than reused - a stale translation of the old standalone titles would
read as a non-sequitur under this header. -->
<string name="relay_auth_auto_login_when">Entrar sem perguntar quando…</string>
<string name="relay_auth_auto_my_relays">…for o meu relay, ou uma sala em que entrei</string>
<string name="relay_auth_auto_read_follows">…eu estiver lendo alguém que sigo</string>
<string name="relay_auth_auto_message_follows">…eu estiver enviando mensagem para alguém que sigo</string>
<string name="relay_auth_auto_message_strangers">…eu estiver enviando mensagem para qualquer outra pessoa</string>
@@ -1108,7 +1108,6 @@
keys are new rather than reused - a stale translation of the old standalone titles would
read as a non-sequitur under this header. -->
<string name="relay_auth_auto_login_when">Logga in utan att fråga när…</string>
<string name="relay_auth_auto_my_relays">…det är mitt relä, eller ett rum jag gått med i</string>
<string name="relay_auth_auto_read_follows">…jag läser någon jag följer</string>
<string name="relay_auth_auto_message_follows">…jag skriver till någon jag följer</string>
<string name="relay_auth_auto_message_strangers">…jag skriver till någon annan</string>
+10 -1
View File
@@ -602,6 +602,9 @@
<string name="quick_action_block">Block</string>
<string name="quick_action_mute_thread">Mute thread</string>
<string name="quick_action_unmute_thread">Unmute thread</string>
<string name="mute_notifications">Mute notifications</string>
<string name="unmute_notifications">Unmute notifications</string>
<string name="muted_chat_content_description">Notifications are muted for this chat</string>
<string name="quick_action_report">Report</string>
<string name="quick_action_dont_show_again_button">Don\'t show again</string>
<string name="report_dialog_spam">Spam or scams</string>
@@ -1149,7 +1152,7 @@
keys are new rather than reused - a stale translation of the old standalone titles would
read as a non-sequitur under this header. -->
<string name="relay_auth_auto_login_when">Log in without asking when\u2026</string>
<string name="relay_auth_auto_my_relays">\u2026it\'s my relay, or a room I joined</string>
<string name="relay_auth_auto_my_relays_and_venues">\u2026it\'s my relay, or a room I joined or follow</string>
<string name="relay_auth_auto_read_follows">\u2026I\'m reading someone I follow</string>
<string name="relay_auth_auto_message_follows">\u2026I\'m messaging someone I follow</string>
<string name="relay_auth_auto_message_strangers">\u2026I\'m messaging anyone else</string>
@@ -2889,6 +2892,9 @@
<string name="relay_group_no_messages_yet">No messages yet</string>
<string name="channel_invite_title">Added to %1$s</string>
<string name="channel_invite_unknown_actor">Someone</string>
<string name="channel_invite_added_you">added you to this channel</string>
<string name="channel_invite_row_added_you_by">%1$s added you to this channel</string>
<string name="channel_invite_row_added_you">You were added to this channel</string>
<string name="channel_invite_ignore">Ignore</string>
<string name="channel_invite_leave">Leave</string>
<string name="relay_group_join_to_post">Join this group to send messages.</string>
@@ -3259,6 +3265,9 @@
<string name="cashu_remove_mint">Remove mint</string>
<string name="cashu_add_mint">Add mint</string>
<string name="cashu_history">History</string>
<string name="cashu_history_loading_older">Loading older transactions…</string>
<string name="cashu_history_all_loaded">No older transactions</string>
<string name="cashu_history_some_relays_unreachable">No older transactions from the relays that answered — some could not be reached</string>
<string name="cashu_wallet_autosaves">Your wallet saves automatically as you add or remove mints. A nutzap key is created for you the first time you add a mint.</string>
<string name="cashu_wallet_saving">Saving…</string>
<string name="cashu_p2pk_section">Nutzap key (advanced)</string>
@@ -0,0 +1,108 @@
/*
* 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.model
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* Muted public chats ride inside the NIP-78 AppSpecificData blob so they reach the
* user's other devices.
*
* The nullable default is the load-bearing part. `updateFrom` overwrites local state
* whenever remote differs, and AppSpecificState replays the cached backup event on
* every app start — so if an older client rewrote the blob and dropped the key, a
* non-null `emptyList()` default would clear the local mute set on every single
* launch. `null` keeps "key absent" distinguishable from "explicitly empty".
*/
class MutedPublicChatsSyncTest {
private val channelA = "a".repeat(64)
private val channelB = "b".repeat(64)
@Test
fun blobWrittenWithoutTheFieldDecodesToNull() {
val json = """{"pinnedRooms":[]}"""
val decoded = JsonMapper.fromJson<AccountChatPreferencesInternal>(json)
assertNull(decoded.mutedPublicChats)
}
@Test
fun explicitlyEmptyListDecodesToEmptyNotNull() {
val json = """{"pinnedRooms":[],"mutedPublicChats":[]}"""
val decoded = JsonMapper.fromJson<AccountChatPreferencesInternal>(json)
assertEquals(emptyList<String>(), decoded.mutedPublicChats)
}
@Test
fun jsonRoundTripPreservesMutedChats() {
val internal = AccountChatPreferencesInternal(emptyList(), listOf(channelA, channelB))
val decoded = JsonMapper.fromJson<AccountChatPreferencesInternal>(JsonMapper.toJson(internal))
assertEquals(listOf(channelA, channelB), decoded.mutedPublicChats)
}
@Test
fun wireShapeIsSortedSoTheBlobIsStable() {
// Mirrors AccountSyncedSettings.toInternal(): mutedPublicChats.sorted().
// Two sets with the same members must serialize identically regardless of
// iteration order, or every settings save republishes a no-op event.
val oneOrder = setOf(channelB, channelA).sorted()
val otherOrder = setOf(channelA, channelB).sorted()
assertEquals(
JsonMapper.toJson(AccountChatPreferencesInternal(emptyList(), oneOrder)),
JsonMapper.toJson(AccountChatPreferencesInternal(emptyList(), otherOrder)),
)
}
// ---
// The merge decision itself, not just the decode.
//
// The decode tests above prove `null` and `[]` arrive distinguishable. These prove the
// merge ACTS on that distinction. Without them, collapsing the guard to
// `remote ?: emptyList()` — exactly the mistake the nullable default exists to prevent —
// leaves every other test in this file green.
// ---
@Test
fun absentKeyLeavesTheLocalMuteSetAlone() {
// An older client rewrote the blob and dropped the field. The local set must survive:
// AppSpecificState replays the cached backup on every launch, so a wipe here would
// repeat on every start.
assertEquals(setOf(channelA), mergeMutedPublicChats(setOf(channelA), null))
}
@Test
fun absentKeyOnAnEmptyLocalSetStaysEmpty() {
assertEquals(emptySet<String>(), mergeMutedPublicChats(emptySet(), null))
}
@Test
fun explicitEmptyListClearsTheLocalMuteSet() {
// A client that knows the field saying "unmute everything" must be obeyed.
assertEquals(emptySet<String>(), mergeMutedPublicChats(setOf(channelA), emptyList()))
}
@Test
fun remoteListReplacesTheLocalMuteSet() {
assertEquals(setOf(channelB), mergeMutedPublicChats(setOf(channelA), listOf(channelB)))
}
}
@@ -0,0 +1,145 @@
/*
* 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.model
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMuteUserEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The predicate behind every mute suppression point: the row dot, the bottom-bar
* badge, the push dispatcher and the Notifications feed all ask this one question,
* so they cannot drift apart.
*/
class MutedPublicChatsTest {
private val channelId: HexKey = "4".repeat(64)
private val otherChannel: HexKey = "5".repeat(64)
private val parentId: HexKey = "6".repeat(64)
private val author: HexKey = "b".repeat(64)
private val relay = "wss://relay.damus.io"
private val sig = "0".repeat(128)
private fun channelMessage(tags: Array<Array<String>>) = ChannelMessageEvent("3".repeat(64), author, 1778593701L, tags, "hi", sig)
private fun topLevel() = channelMessage(arrayOf(arrayOf("e", channelId, relay, "root")))
private fun reply() =
channelMessage(
arrayOf(
arrayOf("e", channelId, relay, "root"),
arrayOf("e", parentId, relay, "reply"),
),
)
@Test
fun topLevelMessageResolvesToItsChannel() {
assertEquals(channelId, publicChatChannelIdOf(topLevel()))
}
@Test
fun replyResolvesToTheChannelNotItsParent() {
// Every message in a NIP-28 channel shares one root, so mute is per-channel.
assertEquals(channelId, publicChatChannelIdOf(reply()))
}
@Test
fun nonPublicChatEventHasNoChannel() {
val dm = ChatMessageEvent("3".repeat(64), author, 1L, arrayOf(arrayOf("p", author)), "hi", sig)
assertNull(publicChatChannelIdOf(dm))
assertNull(publicChatChannelIdOf(null))
}
@Test
fun messageInMutedChannelIsMuted() {
assertTrue(isMutedPublicChatMessage(topLevel(), setOf(channelId)))
assertTrue(isMutedPublicChatMessage(reply(), setOf(channelId)))
}
@Test
fun messageInAnotherChannelIsNotMuted() {
assertFalse(isMutedPublicChatMessage(topLevel(), setOf(otherChannel)))
}
@Test
fun emptyMuteSetMutesNothing() {
assertFalse(isMutedPublicChatMessage(topLevel(), emptySet()))
}
@Test
fun nonPublicChatEventIsNeverMuted() {
val dm = ChatMessageEvent("3".repeat(64), author, 1L, arrayOf(arrayOf("p", author)), "hi", sig)
assertFalse(isMutedPublicChatMessage(dm, setOf(channelId)))
assertFalse(isMutedPublicChatMessage(null, setOf(channelId)))
}
// --- Regression coverage: a channel row's newest event is not always a ChannelMessageEvent.
// ChannelMetadataEvent (kind 41, e.g. a topic/picture edit) and ChannelCreateEvent (kind 40,
// the channel's own creation event) are the other two event types a public-chat row can
// dispatch to ChannelRoomCompose — see ChatroomHeaderCompose's `when`. Both
// ChatroomRowUnread.rowLastReadRoute and ChatroomListKnownFeedFilter resolve the id
// through publicChatChannelIdOf, so this test covers all three sites at once.
private fun channelMetadata(tags: Array<Array<String>>) = ChannelMetadataEvent("7".repeat(64), author, 1778593701L, tags, "{}", sig)
@Test
fun metadataEventResolvesToItsChannel() {
val metadata = channelMetadata(arrayOf(arrayOf("e", channelId, relay, "root")))
assertEquals(channelId, publicChatChannelIdOf(metadata))
}
@Test
fun createEventResolvesToItsOwnId() {
val createId = "8".repeat(64)
val create = ChannelCreateEvent(createId, author, 1778593701L, arrayOf(), "{}", sig)
assertEquals(createId, publicChatChannelIdOf(create))
}
@Test
fun metadataEventInMutedChannelIsMuted() {
val metadata = channelMetadata(arrayOf(arrayOf("e", channelId, relay, "root")))
assertTrue(isMutedPublicChatMessage(metadata, setOf(channelId)))
}
@Test
fun channelAdminEventIsNotRecognisedAsChannelActivity() {
// ChannelMuteUserEvent (and ChannelHideMessageEvent) are channel-admin actions, not
// activity that should resolve to a channel id — they must keep falling through.
val muteUser = ChannelMuteUserEvent("9".repeat(64), author, 1778593701L, arrayOf(arrayOf("e", channelId, relay, "root")), "", sig)
assertNull(publicChatChannelIdOf(muteUser))
assertFalse(isMutedPublicChatMessage(muteUser, setOf(channelId)))
}
@Test
fun channelMessageWithNoTagsHasNoChannel() {
// Right type, but channelId() is null because there is no e-tag at all.
val untagged = channelMessage(arrayOf())
assertNull(publicChatChannelIdOf(untagged))
assertFalse(isMutedPublicChatMessage(untagged, setOf(channelId)))
}
}
@@ -0,0 +1,110 @@
/*
* 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.model.buzz
import com.vitorpamplona.amethyst.commons.model.buzz.ChannelClassification
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* The channel-type map the invite projection classifies against.
*
* It exists because reading the type off the [com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel]
* is only correct *after* `LocalCache.consume(GroupMetadataEvent)` has copied the event into it — and
* consume wakes the cache observers one step earlier, so the recompute that the arriving directory
* triggers reads an empty channel, answers UNKNOWN, and never runs again. Deriving the type from the
* metadata event that caused the emission is what makes that answer stable.
*/
class BuzzChannelTypesTest {
private val relayKey = "b".repeat(64)
private fun metadata(
groupId: String,
channelType: String?,
): GroupMetadataEvent {
val tags = mutableListOf(arrayOf("d", groupId), arrayOf("name", groupId.uppercase()))
if (channelType != null) tags.add(arrayOf("t", channelType))
return GroupMetadataEvent(
id = groupId.padEnd(64, '0'),
pubKey = relayKey,
createdAt = 1_700_000_000L,
tags = tags.toTypedArray(),
content = "",
sig = "0".repeat(128),
)
}
private fun noteOf(event: Event): Note = AddressableNote((event as GroupMetadataEvent).address()).apply { this.event = event }
@Test
fun `a stream channel is a named channel and a dm channel is a dm`() {
val types =
buzzChannelTypes(
listOf(
noteOf(metadata("chan-eng", "stream")),
noteOf(metadata("chan-dm", "dm")),
),
)
assertEquals(ChannelClassification.NAMED, types["chan-eng"])
assertEquals(ChannelClassification.DM, types["chan-dm"])
}
@Test
fun `a channel with no buzz type at all is still a named channel`() {
// A vanilla NIP-29 relay has no `channel_type`, and a group there is a group — never a DM.
val types = buzzChannelTypes(listOf(noteOf(metadata("chan-plain", null))))
assertEquals(ChannelClassification.NAMED, types["chan-plain"])
}
@Test
fun `a note whose metadata has not arrived contributes nothing`() {
// The placeholder case the projection has to withhold on, rather than guess NAMED and flash a
// "somebody added you" card in front of every Buzz DM while its directory is in flight.
val placeholder = AddressableNote(metadata("chan-unloaded", "stream").address())
val types = buzzChannelTypes(listOf(placeholder))
assertNull(types["chan-unloaded"])
assertEquals(0, types.size)
}
@Test
fun `the newest note for a group wins`() {
// Two versions of the same addressable can be in flight; the last one applied is the one the
// cache kept, and the map must not go back to an older answer.
val types =
buzzChannelTypes(
listOf(
noteOf(metadata("chan-switch", "dm")),
noteOf(metadata("chan-switch", "stream")),
),
)
assertEquals(ChannelClassification.NAMED, types["chan-switch"])
}
}
@@ -0,0 +1,79 @@
/*
* 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.model.preferences
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* The migration precedence in [BuzzAttestationPreferences.restoreFrom]: which of the two on-disk
* shapes wins when the held attestation moved from one device-global list to a per-account key.
*
* The store itself needs a `Context`, so the decision is pulled out as a pure function — this is
* the part with the sharp edge, and it is the part the DataStore round-trip cannot express.
*/
class BuzzAttestationRestoreTest {
private val me = "a".repeat(64)
private val someoneElse = "b".repeat(64)
private val owner = "c".repeat(64)
private val sig = "d".repeat(128)
private fun saved(
owner: String = this.owner,
conditions: String = "kind=40002",
) = """{"owner":"$owner","conditions":"$conditions","sig":"$sig"}"""
private fun legacyList(vararg agents: String) = agents.joinToString(",", "[", "]") { """{"agent":"$it","owner":"$owner","conditions":"kind=40002","sig":"$sig"}""" }
@Test
fun nothingSavedAnywhereRestoresNothing() {
assertNull(BuzzAttestationPreferences.restoreFrom(null, null, me))
}
@Test
fun thisAccountsOwnKeyWins() {
val restored = BuzzAttestationPreferences.restoreFrom(saved(), legacyList(me), me)
assertEquals(owner, restored?.ownerPubKey)
}
@Test
fun aRemovedAttestationIsNotResurrectedFromTheLegacyList() {
// The regression this test exists for. Removing the held credential used to delete the
// per-account key, which is indistinguishable from "never migrated" — so the next launch
// seeded it straight back out of the legacy list, which nothing ever clears. An explicit
// tombstone is the only thing that can say "migrated, and holding nothing".
assertNull(BuzzAttestationPreferences.restoreFrom("", legacyList(me), me))
}
@Test
fun aNeverMigratedAccountTakesItsOwnEntryFromTheLegacyList() {
val restored = BuzzAttestationPreferences.restoreFrom(null, legacyList(someoneElse, me), me)
assertEquals(owner, restored?.ownerPubKey)
}
@Test
fun anotherAgentsLegacyEntryIsNeverPickedUp() {
// The legacy list was already agent-keyed, so the migration is exact rather than
// best-effort: there is no shared blob to accidentally inherit.
assertNull(BuzzAttestationPreferences.restoreFrom(null, legacyList(someoneElse), me))
}
}
@@ -0,0 +1,128 @@
/*
* 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.service.relayClient.authCommand.model
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurpose
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthContext
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* "…I'm reading someone I follow" has to actually cover the relays it is about.
*
* The whole point of the toggle is the outbox relay of somebody else — a relay we do not publish to,
* do not read our own inbox from, and do not list. That is exactly the shape `isFirstParty` reports
* false for, so requiring it emptied the category: with the toggle explicitly on, every one of the
* user's follows still produced a login prompt for its outbox relay.
*/
class RelayAuthReadFollowsTest {
private val followsRelay = "wss://outbox.someone-i-follow.example/"
private val followed = "a".repeat(64)
private val stranger = "b".repeat(64)
private class NoStore : RelayAuthPermissionStore {
override suspend fun loadDecision(relayUrl: String): RelayAuthDecision? = null
override suspend fun storeDecision(
relayUrl: String,
decision: RelayAuthDecision,
) = Unit
override suspend fun clearDecision(relayUrl: String) = Unit
override suspend fun allDecisions(): Map<String, RelayAuthDecision> = emptyMap()
}
private fun ledger(toggles: RelayAuthCustomToggles = RelayAuthCustomToggles()) =
RelayAuthPermissionLedger(
store = NoStore(),
globalPolicy = { RelayAuthPolicy.CUSTOM },
customToggles = { toggles },
isFollowed = { it == followed },
)
private fun readOutbox(vararg authors: String) = RelayAuthContext(followsRelay, listOf(AuthPurpose(AuthPurposeKind.READ_OUTBOX, authors.toSet())))
@Test
fun readingAFollowAutoAuthenticatesOnTheirOwnOutboxRelay() =
runTest {
// isFirstParty = false is not an edge case here, it is *the* case: the relay belongs to the
// author we are reading. Before the fix this returned ASK, so a user on "decide per relay"
// with this toggle on was prompted once per follow.
assertEquals(
RelayAuthVerdict.ALLOW,
ledger().decide(readOutbox(followed), isFirstParty = false),
)
}
@Test
fun readingAFollowStillAsksWhenTheToggleIsOff() =
runTest {
val off = RelayAuthCustomToggles(readFollows = false)
assertEquals(
RelayAuthVerdict.ASK,
ledger(off).decide(readOutbox(followed), isFirstParty = false),
)
}
@Test
fun readingAStrangerStillAsks() =
runTest {
// There is deliberately no "read strangers" category — browsing a profile we don't follow
// on a relay of theirs is still a question.
assertEquals(
RelayAuthVerdict.ASK,
ledger().decide(readOutbox(stranger), isFirstParty = false),
)
}
@Test
fun oneFollowInABatchedReadIsEnough() =
runTest {
// Outbox reads are batched per relay, so a single filter routinely names a mix. One
// followed author in it is the reason we are on this relay at all.
assertEquals(
RelayAuthVerdict.ALLOW,
ledger().decide(readOutbox(stranger, followed), isFirstParty = false),
)
}
@Test
fun messagingIsNotCoveredByTheReadExemption() =
runTest {
// Delivering to a followed user's *inbox* keeps the first-party gate: the pending event
// would be ours, and when it isn't, the traffic belongs to another logged-in account.
val ctx =
RelayAuthContext(
followsRelay,
listOf(AuthPurpose(AuthPurposeKind.SEND_DM, setOf(followed))),
)
assertEquals(RelayAuthVerdict.ASK, ledger().decide(ctx, isFirstParty = false))
assertEquals(RelayAuthVerdict.ALLOW, ledger().decide(ctx, isFirstParty = true))
}
}
@@ -0,0 +1,83 @@
/*
* 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.service.relayClient.reqCommand.account.buzz
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The always-on workspace-inbox filter: what a Buzz relay addresses to me personally. Pins the `#p`
* scope, the kind set, and — most importantly — that the filter carries NO `#h`, because this is the
* query that discovers which channels exist for me in the first place.
*/
class FilterWorkspaceInboxToPubkeyTest {
private val relay = RelayUrlNormalizer.normalizeOrNull("wss://buzz.example.team/")!!
private val me = "a".repeat(64)
@Test
fun `builds a single p-scoped filter over the workspace inbox kinds`() {
val filters = filterWorkspaceInboxToPubkey(relay, me, since = 500L)
val f = filters.single()
assertEquals(relay, f.relay)
assertEquals(listOf(me), f.filter.tags!!["p"])
assertEquals(500L, f.filter.since)
assertNull(f.filter.until)
assertNull(f.filter.authors)
}
@Test
fun `carries no channel scope`() {
// A `#h` here would be a contradiction: the channel ids are what this query is FOR. It also has
// to stay off any subscription that does carry one — buzz downgrades a mixed subscription to
// "global", which never receives channel-scoped events.
val f = filterWorkspaceInboxToPubkey(relay, me, since = null).single()
assertNull(f.filter.tags!!["h"])
assertEquals(setOf("p"), f.filter.tags!!.keys)
}
@Test
fun `asks for both membership verdicts and the hidden-DM snapshot`() {
val kinds = filterWorkspaceInboxToPubkey(relay, me, since = null).single().filter.kinds!!
assertTrue(kinds.contains(44100)) // MemberAddedNotificationEvent
assertTrue(kinds.contains(44101)) // MemberRemovedNotificationEvent — withdraws an add
assertTrue(kinds.contains(30622)) // DmVisibilityEvent — which DMs I hid
}
@Test
fun `the removal kind travels with the add kind`() {
// The invite projection resolves each channel to its NEWEST verdict, so asking for adds without
// removals would leave a prompt standing for a membership the relay already took away.
assertTrue(MembershipNotificationKinds.contains(44100))
assertTrue(MembershipNotificationKinds.contains(44101))
}
@Test
fun `no pubkey produces no filter`() {
assertTrue(filterWorkspaceInboxToPubkey(relay, null, since = null).isEmpty())
assertTrue(filterWorkspaceInboxToPubkey(relay, "", since = null).isEmpty())
}
}
@@ -0,0 +1,74 @@
/*
* 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.service.relayClient.reqCommand.account.nip60Cashu
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Pins the backward-paging Cashu history filter: it must ask for the N newest kind:7376 rows I authored
* strictly OLDER than a cursor (`until`+`limit`, no `since`), so the single per-relay cursor the
* [BackwardRelayPager][com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager] tracks
* can't skip a band and an empty page truly means "nothing older" (see RelayLoadingCursors).
*/
class FilterCashuHistoryTest {
private val relay = RelayUrlNormalizer.normalize("wss://outbox.example.com")
private val pubkey = "aa".repeat(32)
private val until = 1_700_000_000L
@Test
fun `history filter asks one until+limit page of my own rows, no since`() {
val filters = filterCashuHistoryToPubkey(relay, pubkey, until, 100)
assertEquals(1, filters.size)
val f = filters.first().filter
assertEquals(relay, filters.first().relay)
assertEquals(until, f.until)
assertEquals(100, f.limit)
assertNull("history pages by until, never since", f.since)
// Own events are read back by author, not by a #p tag — unlike notifications, these are mine.
assertEquals(listOf(pubkey), f.authors)
}
@Test
fun `history filter is scoped to kind 7376 alone`() {
val f = filterCashuHistoryToPubkey(relay, pubkey, until, 100).first().filter
assertEquals(listOf(CashuSpendingHistoryEvent.KIND), f.kinds)
// Proofs must never be paged on demand: a balance summed over a partial kind:7375 set is wrong,
// not merely incomplete, so those are walked to exhaustion by CashuWalletState instead.
assertTrue(
"kind:7375 must not ride along on a demand-paged query",
CashuTokenEvent.KIND !in f.kinds.orEmpty(),
)
}
@Test
fun `empty pubkey yields no filter`() {
assertTrue(filterCashuHistoryToPubkey(relay, null, until, 100).isEmpty())
assertTrue(filterCashuHistoryToPubkey(relay, "", until, 100).isEmpty())
}
}
@@ -0,0 +1,113 @@
/*
* 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.dal
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvite
import com.vitorpamplona.amethyst.commons.ui.notifications.Card
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.ChannelInviteCard
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* The Notifications tab's card order. An unanswered invite is a standing question, so it outranks every
* dated row no matter how old it is; everything else keeps the ordinary newest-first order.
*
* A plain `created_at` sort is what would let a week of reactions bury a decision the user still has to
* make — and, once the feed passes `limit()`, page it off the end entirely.
*/
class NotificationFeedOrderCardTest {
private val relay = RelayUrlNormalizer.normalizeOrNull("wss://buzz.example.team/")!!
/** A stand-in dated card. Only [Card.createdAt] and [Card.id] matter to the comparator. */
private class DatedCard(
private val createdAt: Long,
private val id: String,
) : Card {
override fun createdAt() = createdAt
override fun id() = id
}
private fun invite(
id: String,
createdAt: Long,
) = ChannelInviteCard(Note(id), BuzzChannelInvite(id, "chan-$id", relay, null, createdAt))
@Test
fun `a stale invite still outranks every dated row`() {
val cards =
listOf(
DatedCard(9_000L, "fresh-reaction"),
invite("old-invite", 1_000L),
DatedCard(8_000L, "older-reaction"),
)
assertEquals(
listOf("old-invite", "fresh-reaction", "older-reaction"),
cards.sortedWith(NotificationFeedOrderCard).map { it.id() },
)
}
@Test
fun `invites sort newest-first among themselves`() {
val cards =
listOf(
invite("older", 1_000L),
invite("newest", 3_000L),
invite("middle", 2_000L),
)
assertEquals(
listOf("newest", "middle", "older"),
cards.sortedWith(NotificationFeedOrderCard).map { it.id() },
)
}
@Test
fun `dated rows keep the default order behind the invites`() {
val cards = listOf(DatedCard(1_000L, "b"), DatedCard(3_000L, "a"), DatedCard(2_000L, "c"))
assertEquals(
cards.sortedWith(DefaultFeedOrderCard).map { it.id() },
cards.sortedWith(NotificationFeedOrderCard).map { it.id() },
)
}
@Test
fun `the comparator is a total order so sorting never throws`() {
// Cards tie on created_at routinely (a batch of reactions lands in the same second), and an
// inconsistent comparator makes TimSort throw "Comparison method violates its general contract".
val cards =
listOf(
DatedCard(1_000L, "b"),
DatedCard(1_000L, "a"),
invite("x", 1_000L),
invite("y", 1_000L),
)
assertEquals(
listOf("x", "y", "a", "b"),
cards.sortedWith(NotificationFeedOrderCard).map { it.id() },
)
}
}
+3 -2
View File
@@ -423,10 +423,11 @@ amy's on-relay events match the app's. NUT-13 counters persist in
| Command | What it does |
|---|---|
| `amy cashu wallet create [--mint URL] [--mints a,b] [--privkey HEX] [--relay r1,r2]` | Publish a kind:17375 wallet + kind:10019 nutzap info. Advertises your outbox relays for nutzaps unless `--relay` overrides. |
| `amy cashu wallet show` | P2PK pubkey, mints, balance, per-mint balances, proof/history/pending counts. |
| `amy cashu wallet show [--sync]` | P2PK pubkey, mints, balance, per-mint balances, proof/history/pending counts. `--sync` pulls from the relays first. |
| `amy cashu wallet export-key` | Decrypt and print the wallet's P2PK private key. |
| `amy cashu wallet destroy` | Withdraw the nutzap advertisement and NIP-09 delete the wallet (leaves token events — the ecash still lives at the mint). |
| `amy cashu balance [--mint URL]` | Spendable balance from the local store (optionally one mint). |
| `amy cashu sync` | Page every NIP-60/61 event off the relays into the local store, then report the balance and the proof/history counts. Every other `cashu` read projects the store and never touches the network, so this is what fills it — run it first on a machine that didn't create the wallet. |
| `amy cashu balance [--mint URL] [--sync]` | Spendable balance from the local store (optionally one mint). `--sync` pages from the relays first. |
| `amy cashu mint ping URL` / `info URL` | Stateless `/v1/info` probe (name/pubkey/version) / full DTO. |
| `amy cashu receive ln SATS [--mint URL]` | Request a mint quote; prints the bolt11 + kind:7374 quote. |
| `amy cashu receive complete QUOTE_ID` | Poll the quote; once the invoice is settled, mint proofs (kind:7375 + kind:7376). (`resume` is a deprecated alias.) |
+1 -1
View File
@@ -77,7 +77,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command ·
| Long-form (NIP-23) publish / read | 🆕 | |
| Live activities / chess (NIP-53 / NIP-64) | 🆕 | |
| Blossom blobs (NIP-B7) | ✅ | `BlossomCommands` — upload/download/list/delete/check/mirror on shared `commons` `BlossomClient`; live-server harness at `cli/tests/blossom/`. |
| NIP-60 / 61 Cashu wallet + nutzaps | ✅ | Full surface: `cashu wallet {create,show,export-key,destroy}`, `mint {ping,info}`, `balance`, `receive {ln,complete,resume,token,nutzap-sweep}`, `send {ln,token,nutzap}`, `maintenance {scrub,restore,migrate-keysets}`, `mint-rec {show,add,remove}` — all on shared `commons` `CashuWalletOps` + `CashuWalletReader` (the exact path the Android wallet runs). Interop harness pending. Plan: [`cli/plans/2026-05-28-cashu-cli.md`](./plans/2026-05-28-cashu-cli.md). |
| NIP-60 / 61 Cashu wallet + nutzaps | ✅ | Full surface: `cashu wallet {create,show,export-key,destroy}`, `mint {ping,info}`, `sync`, `balance`, `receive {ln,complete,resume,token,nutzap-sweep}`, `send {ln,token,nutzap}`, `maintenance {scrub,restore,migrate-keysets}`, `mint-rec {show,add,remove}` — all on shared `commons` `CashuWalletOps` + `CashuWalletReader` (the exact path the Android wallet runs). Reads project the local store; `cashu sync` (or `--sync`) is what fills it, paging every relay to exhaustion so a cap can't truncate the proof set. Interop harness pending. Plan: [`cli/plans/2026-05-28-cashu-cli.md`](./plans/2026-05-28-cashu-cli.md). |
| NIP-47 Wallet Connect | 🆕 | |
| NIP-46 bunker signer | ✅ | `BunkerCommand` + `NostrConnect` + `LoginCommand` — host (`amy bunker[ connect]`) and client (`amy login bunker://` / `--nostrconnect`) sides, `--perms`/`--interactive` gating, `auth_url` challenges. |
| Profile view (`amy profile show NPUB`) + edit | ✅ | `ProfileCommands`. Cache-first; `--refresh` forces a relay drain. |
@@ -24,6 +24,8 @@ import com.vitorpamplona.amethyst.cli.stores.FileCashuKeysetCounterStore
import com.vitorpamplona.amethyst.commons.cashu.CashuWalletReader
import com.vitorpamplona.amethyst.commons.cashu.ops.CashuWalletOps
import com.vitorpamplona.amethyst.commons.cashu.ops.RestoreOutcome
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.cashuInboundNutzapBackfillFilters
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.cashuOwnEventBackfillFilters
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
@@ -92,11 +94,60 @@ class CashuContext(
reserveCashuCounters = { keysetId, count -> counters.reserve(keysetId, count) },
)
/**
* Page this account's whole NIP-60/61/87 event set off the relays into the
* local store, so the next [snapshot] projects a complete wallet rather than
* whatever happened to be synced. Returns the number of events delivered
* (duplicates across relays already deduped by [Context.drainAllPages]).
*
* ### Why paging, and why this is opt-in
*
* [snapshot] reads the store and nothing else — that is amy's contract, and
* it is why `cashu balance` is instant and offline-capable. The cost is that
* the balance is only ever as complete as whatever last filled the store,
* and nothing in amy fetched the NIP-60 kinds at all: a wallet created on
* the phone read zero here. So this is a verb (`amy cashu sync`) and a flag
* (`--sync`), never an implicit round-trip inside a read.
*
* It pages rather than issuing one REQ because a relay answers an unbounded
* REQ with its own cap applied to the newest matching events, and kind:7376
* history outnumbers the kind:7375 proofs by an order of magnitude on a
* wallet with any history — so the events that fall off the bottom are the
* proofs at mints the user hasn't touched lately, and the balance reads low
* with nothing to indicate it. `drainAllPages` walks each relay on its own
* `until` cursor to exhaustion, which is the only way to be sure.
*
* Split across relay sets exactly like the Android subscription: own events
* from the outbox (where they were published), inbound nutzaps from the
* inbox (where senders deliver them).
*/
suspend fun sync(): Int {
val pk = ctx.identity.pubKeyHex
val outbox = ctx.outboxRelays()
val inbox = ctx.inboxRelays()
val own =
if (outbox.isEmpty()) {
emptyList()
} else {
ctx.drainAllPages(outbox.associateWith { cashuOwnEventBackfillFilters(pk) })
}
val nutzaps =
if (inbox.isEmpty()) {
emptyList()
} else {
ctx.drainAllPages(inbox.associateWith { cashuInboundNutzapBackfillFilters(pk) })
}
return own.size + nutzaps.size
}
/**
* Project this account's locally-stored NIP-60/61/87 events into a wallet
* snapshot via the shared [CashuWalletReader] — the same decrypt +
* del-rollover + pending-quote logic the Android holder runs. Reads the
* cache only; commands that need fresh state should [Context.drain] first.
* cache only; commands that need fresh state should [sync] (or
* [Context.drain]) first.
*/
suspend fun snapshot(): CashuWalletReader.WalletSnapshot {
val pk = ctx.identity.pubKeyHex
@@ -26,8 +26,13 @@ import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
/**
* `amy cashu balance [--mint URL]` — spendable balance from the local store,
* via the shared CashuWalletReader projection. Optionally filtered to one mint.
* `amy cashu balance [--mint URL] [--sync]` — spendable balance from the local
* store, via the shared CashuWalletReader projection. Optionally filtered to one
* mint.
*
* `--sync` pages the wallet off the relays first (see [CashuContext.sync]).
* Without it this is a pure local read, and reports only what the store already
* holds — which for a wallet created elsewhere may be nothing at all.
*/
object CashuBalanceCommand {
suspend fun run(
@@ -36,8 +41,10 @@ object CashuBalanceCommand {
): Int {
val args = Args(rest)
val mintFilter = args.flag("mint")?.trimEnd('/')
val sync = args.bool("sync")
args.rejectUnknown()
Context.open(dataDir).use { ctx ->
if (sync) ctx.cashu.sync()
val snap = ctx.cashuSnapshot()
val byMint =
snap.balancesByMint.let { all ->
@@ -37,11 +37,13 @@ object CashuCommands {
|Cashu wallet (NIP-60 / NIP-61):
| cashu wallet create [--mint URL] [--mints a,b] publish a kind:17375 wallet + kind:10019
| [--privkey HEX] [--relay r1,r2] nutzap info
| cashu wallet show P2PK pubkey, mints, balances, counts
| cashu wallet show [--sync] P2PK pubkey, mints, balances, counts
| cashu wallet export-key decrypt + print the wallet's P2PK key
| cashu wallet destroy withdraw nutzap ad + NIP-09 delete wallet
| cashu mint ping URL / info URL stateless /v1/info probe (no account)
| cashu balance [--mint URL] spendable balance from the local store
| cashu sync page every NIP-60/61 event off the relays
| into the local store, then report balance
| cashu balance [--mint URL] [--sync] spendable balance from the local store
| cashu receive ln SATS [--mint URL] request a mint quote (bolt11 + kind:7374)
| cashu receive complete QUOTE_ID poll the quote; mint proofs once settled
| cashu receive token TOKEN redeem a cashuB… token into the wallet
@@ -65,12 +67,13 @@ object CashuCommands {
route(
name = "cashu",
tail = tail,
usage = "cashu <wallet|mint|balance|receive|send|maintenance|mint-rec>",
usage = "cashu <wallet|mint|sync|balance|receive|send|maintenance|mint-rec>",
help = USAGE,
routes =
mapOf(
"wallet" to { rest -> CashuWalletCommands.dispatch(dataDir, rest) },
"mint" to { rest -> CashuMintCommands.dispatch(rest) },
"sync" to { rest -> CashuSyncCommand.run(dataDir, rest) },
"balance" to { rest -> CashuBalanceCommand.run(dataDir, rest) },
"receive" to { rest -> CashuReceiveCommands.dispatch(dataDir, rest) },
"send" to { rest -> CashuSendCommands.dispatch(dataDir, rest) },
@@ -0,0 +1,63 @@
/*
* 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.cli.commands.cashu
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
/**
* `amy cashu sync` — page the whole NIP-60/61 event set off the relays into the
* local store, then report the resulting balance.
*
* Every other `cashu` read command projects the local store and never touches
* the network, which is what makes them instant and offline-capable — but it
* also means they only ever saw whatever else had filled the store, and nothing
* in amy fetched the NIP-60 kinds at all. This is the verb that fills it.
*
* Reports both the balance and the proof/history counts so a caller can tell a
* genuinely empty wallet from an unsynced one.
*/
object CashuSyncCommand {
suspend fun run(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
args.rejectUnknown()
Context.open(dataDir).use { ctx ->
val downloaded = ctx.cashu.sync()
val snap = ctx.cashuSnapshot()
Output.emit(
mapOf(
"events_downloaded" to downloaded,
"balance_sats" to snap.balanceSats,
"balances_by_mint" to snap.balancesByMint,
"proofs_count" to snap.tokenEntries.sumOf { it.content.proofs.size },
"token_events" to snap.tokenEntries.size,
"history_events" to snap.history.size,
),
)
}
return 0
}
}
@@ -115,9 +115,22 @@ object CashuWalletCommands {
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val sync = args.bool("sync")
args.rejectUnknown()
Context.open(dataDir).use { ctx ->
if (sync) ctx.cashu.sync()
val snap = ctx.cashuSnapshot()
if (snap.walletEvent == null) return Output.error("no_wallet", "no kind:17375 wallet in the local store — run `cashu wallet create`")
// "Not in the store" is not the same as "does not exist": a wallet created on another
// client is on the relays and simply hasn't been pulled down here yet, and telling the
// user to `create` one in that state would publish a fresh kind:17375 over a replaceable
// slot that already holds theirs. Point at `sync` first.
if (snap.walletEvent == null) {
return Output.error(
"no_wallet",
"no kind:17375 wallet in the local store — run `cashu sync` to pull an existing one, or `cashu wallet create`",
)
}
Output.emit(
mapOf(
"p2pk_pubkey" to snap.nutzapInfoEvent?.p2pkPubkey(),
@@ -560,11 +560,27 @@ object ConcordActions {
fun guestbookMembers(
wraps: List<Event>,
guestbook: GroupKey,
): Set<HexKey> {
): Set<HexKey> = projectGuestbook(wraps.mapNotNull { guestbookEntry(it, guestbook) })
/**
* Opens a single guestbook [wrap] into its entry, or null when it doesn't belong to
* [guestbook] or isn't a guestbook rumor.
*
* Split out of [guestbookMembers] so a caller holding a growing wrap buffer can memoize the
* open per wrap id: opening is the expensive half (two NIP-44 decrypts plus the wrap and seal
* signature verifies), while [projectGuestbook] over the already-opened entries is trivial.
* Re-projecting a buffer of n wraps on every arrival without that memo is quadratic in
* decryptions — see [ConcordCommunitySession]'s guestbook cache.
*/
fun guestbookEntry(
wrap: Event,
guestbook: GroupKey,
): GuestbookEntry? = ConcordStreamEnvelope.openOrNull(wrap, guestbook)?.rumor?.let { Guestbook.parse(it) }
/** Last-writer-wins projection of already-opened [entries] down to the JOINed member set. */
fun projectGuestbook(entries: Collection<GuestbookEntry>): Set<HexKey> {
val latest = HashMap<HexKey, GuestbookEntry>()
for (wrap in wraps) {
val rumor = ConcordStreamEnvelope.openOrNull(wrap, guestbook)?.rumor ?: continue
val entry = Guestbook.parse(rumor) ?: continue
for (entry in entries) {
val prev = latest[entry.member.lowercase()]
if (prev == null || entry.createdAt > prev.createdAt) latest[entry.member.lowercase()] = entry
}
@@ -21,16 +21,18 @@
package com.vitorpamplona.amethyst.commons.model.buzz
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/** Somebody added [viewer] to a channel: who did it, where, and when. */
/**
* Somebody added me to a channel: who did it, where, and when.
*
* [eventId] is the kind-44100 this was projected from — the identity the Notifications feed keys its
* card on, so an invite is one row per relay verdict exactly like every other notification.
*/
@Immutable
class BuzzChannelInvite(
val eventId: HexKey,
val channelId: String,
val relay: NormalizedRelayUrl,
val actor: HexKey?,
@@ -38,74 +40,126 @@ class BuzzChannelInvite(
)
/**
* App-wide, per-viewer set of channels somebody **else** added the viewer to, awaiting the viewer's
* decision about whether they appear on Messages.
* One relay-signed membership verdict addressed to the viewer — a kind-44100 (`removed = false`) or a
* kind-44101 (`removed = true`), flattened out of the event so the projection below can be a pure
* function over a list and tested without a cache.
*/
@Immutable
class MembershipNotice(
val eventId: HexKey,
val channelId: String,
val relay: NormalizedRelayUrl,
val actor: HexKey?,
val createdAt: Long,
val removed: Boolean,
)
/** What a channel's kind-39000 says it is, once it has loaded. */
enum class ChannelClassification {
/** `t = dm` — a real Buzz DM. Never an invite; the DM inbox owns it. */
DM,
/** Any other `t` — a named channel somebody put me in. */
NAMED,
/** The kind-39000 hasn't arrived yet, so we cannot tell the two apart. */
UNKNOWN,
}
/**
* Projections over the Buzz relay's membership notifications (`#p` = me), which are the *only*
* enumeration of the channels a viewer belongs to on a Buzz relay.
*
* On a Buzz relay, membership is server-side: another member issues the add, the relay writes you into
* the channel's kind-39002 roster, and you can immediately read and post. The relay then addresses you a
* kind-44100 with `{"actor": …}` naming who did it — and it emits the *same* kind for a self-join, with
* `actor == you`, which is the only thing separating the two cases.
* Membership there is server-side: another member issues the add, the relay writes the viewer into the
* channel's kind-39002 roster, and the viewer can immediately read and post. The relay then addresses
* them a kind-44100 whose body names the actor — and it emits the *same* kind for a self-join, with
* `actor == me`, which is the only thing separating the two cases. A kind-44101 withdraws the
* membership again.
*
* Amethyst used to funnel every 44100 into [BuzzDmChannels], which silently subscribed the viewer to the
* channel's messages while the Messages list — which reads the self-published kind-10009 — showed no row
* for it. So a channel could be simultaneously joined (relay roster, no Join button, composer enabled),
* streaming messages, and invisible. Only `t = dm` channels belong in [BuzzDmChannels]; everything else
* lands here until the viewer accepts.
* ### Why this is a projection and not a registry
*
* Accepting adds the group to kind-10009 (`Account.follow`), after which the normal joined-group path
* owns it and the entry is dropped. Dismissing is a *display* choice recorded in
* `AccountSettings.dismissedChannelInvites`; genuinely leaving is a kind-9022 `LeaveRequestEvent`, which
* is a different action because the viewer really is a member until the relay says otherwise.
* This used to be a process-wide mutable registry that discovery `record`ed into and classification
* `remove`d from. Both halves of the state were derived from events the cache already held, so the
* registry was a second source of truth that could — and did — drift from it: the classification's
* removal was remembered nowhere, so any re-delivery of the same kind-44100 re-added an invite that had
* already been withdrawn, and the prompt flickered in and out. Deriving instead means the answer is a
* pure function of (notices, dismissals, joined list, channel types) and cannot disagree with the cache
* that produced it.
*/
object BuzzChannelInvites {
private val lock = KmpLock()
private val byViewer = HashMap<HexKey, MutableMap<String, BuzzChannelInvite>>()
private val mutableFlow = MutableStateFlow<Map<HexKey, Map<String, BuzzChannelInvite>>>(emptyMap())
/** Per-viewer pending invites (`channelId` -> who/where/when). */
val flow: StateFlow<Map<HexKey, Map<String, BuzzChannelInvite>>> = mutableFlow
/**
* The newest verdict per channel. Newest wins because membership is a running state, not a log: an
* add followed by a remove is *not* a member, and a re-add after that is. A tie in `created_at`
* resolves to the removal — the conservative side, since offering "Accept" on a membership the relay
* has taken away is an action that cannot succeed.
*/
fun latestPerChannel(notices: List<MembershipNotice>): Map<String, MembershipNotice> {
val newest = HashMap<String, MembershipNotice>(notices.size)
notices.forEach { notice ->
val current = newest[notice.channelId]
val wins =
current == null ||
notice.createdAt > current.createdAt ||
(notice.createdAt == current.createdAt && notice.removed)
if (wins) newest[notice.channelId] = notice
}
return newest
}
/**
* Records that somebody added [viewer] to [channelId]. Returns true when this is newly seen, so
* callers can invalidate a feed; a repeat of the same (viewer, channel) returns false rather than
* churning the flow — the relay re-sends the notification on every reconnect.
* Every channel the viewer is currently in (`channelId` -> the relay that vouched for it),
* irrespective of who added them or what type the channel turns out to be.
*
* This is what the directory fetch iterates: a channel's type is only knowable once its kind-39000
* has been fetched *by id*, so the fetch has to cover channels that will later be classified out.
*/
fun record(
viewer: HexKey,
invite: BuzzChannelInvite,
): Boolean =
lock.withLock {
val invites = byViewer.getOrPut(viewer) { mutableMapOf() }
if (invites.containsKey(invite.channelId)) return@withLock false
invites[invite.channelId] = invite
mutableFlow.value = snapshot()
true
}
fun currentMemberships(notices: List<MembershipNotice>): Map<String, NormalizedRelayUrl> =
latestPerChannel(notices)
.values
.filterNot { it.removed }
.associate { it.channelId to it.relay }
/**
* Drops an invite once it is no longer pending — the viewer accepted it (now in kind-10009), left the
* channel, or the relay reported a kind-44101 removal.
* The channels somebody **else** put the viewer in that are still awaiting a decision, newest first.
*
* An entry is withheld when it is not a question:
* - the newest verdict is a removal — there is no membership left to accept;
* - the actor is the viewer, so this is a self-join, not somebody else's doing;
* - the channel is on the viewer's kind-10009 list ([joined]) — accepted already, and the ordinary
* Messages row owns it;
* - the viewer dismissed it ([dismissed]) — a local, reversible display choice;
* - [classify] does not (yet) say it is a named channel.
*
* That last rule is deliberately positive: an [ChannelClassification.UNKNOWN] channel is withheld
* rather than shown. A Buzz DM arrives as the same kind-44100 as a channel add and is only told
* apart once its kind-39000 lands, so surfacing on unknown means every new DM flashes up a "somebody
* added you to a channel" card for as long as the directory fetch takes, and then withdraws it.
* Waiting costs a beat on a genuine invite; not waiting is a wrong prompt on every DM.
*/
fun remove(
fun pendingInvites(
viewer: HexKey,
channelId: String,
): Boolean =
lock.withLock {
val invites = byViewer[viewer] ?: return@withLock false
if (invites.remove(channelId) == null) return@withLock false
mutableFlow.value = snapshot()
true
}
notices: List<MembershipNotice>,
dismissed: Set<String>,
joined: Set<String>,
classify: (channelId: String, relay: NormalizedRelayUrl) -> ChannelClassification,
): List<BuzzChannelInvite> =
latestPerChannel(notices)
.values
.asSequence()
.filterNot { it.removed }
.filterNot { it.actor != null && it.actor.equals(viewer, ignoreCase = true) }
.filterNot { it.channelId in dismissed || it.channelId in joined }
.filter { classify(it.channelId, it.relay) == ChannelClassification.NAMED }
.map { BuzzChannelInvite(it.eventId, it.channelId, it.relay, it.actor, it.createdAt) }
.sortedByDescending { it.createdAt }
.toList()
/** Invites pending for [viewer], possibly empty. */
fun invitesFor(viewer: HexKey): Map<String, BuzzChannelInvite> = mutableFlow.value[viewer] ?: emptyMap()
private fun snapshot(): Map<HexKey, Map<String, BuzzChannelInvite>> = byViewer.mapValues { it.value.toMap() }
/** Test-only: clears all state so unit tests don't leak into each other. */
fun clearForTesting() =
lock.withLock {
byViewer.clear()
mutableFlow.value = emptyMap()
}
/** [pendingInvites] keyed by the kind-44100 that produced each one, for per-note lookups. */
fun pendingInvitesByEventId(
viewer: HexKey,
notices: List<MembershipNotice>,
dismissed: Set<String>,
joined: Set<String>,
classify: (channelId: String, relay: NormalizedRelayUrl) -> ChannelClassification,
): Map<HexKey, BuzzChannelInvite> = pendingInvites(viewer, notices, dismissed, joined, classify).associateBy { it.eventId }
}
@@ -28,10 +28,15 @@ import kotlinx.coroutines.flow.StateFlow
* NIP-29 `h`/UUID, globally unique on Buzz). Starred channels float to the top of the community view.
*
* There is no Nostr event for a personal star — it's the client's own bookkeeping — so, like
* [BuzzWorkspaces], this is a process-wide singleton mirrored to a device-global store by the
* platform ([com.vitorpamplona.amethyst] `BuzzChannelStarPreferences`) and restored at startup.
* [BuzzWorkspaces], it is mirrored to disk by the platform
* ([com.vitorpamplona.amethyst] `BuzzChannelStarPreferences`) and restored at startup.
*
* **One instance per account** (`Account.buzzChannelStars`). A star is by definition personal: it
* says which channels *this user* wants pinned to the top of the community view. While it was a
* process-wide singleton, one account's favorites reordered and badged every other logged-in
* account's channel list — and switching accounts silently rewrote the set they shared.
*/
object BuzzChannelStars {
class BuzzChannelStars {
private val starred = MutableStateFlow<Set<String>>(emptySet())
/** The starred channel ids; the community view collects this to pin + badge them. */
@@ -52,9 +57,4 @@ object BuzzChannelStars {
fun restore(ids: Set<String>) {
starred.value = ids
}
/** Test-only: clears the set so unit tests don't leak state into each other. */
fun clearForTesting() {
starred.value = emptySet()
}
}
@@ -85,6 +85,35 @@ object BuzzDmChannels {
true
}
/**
* Sets [viewer]'s whole DM set at once, replacing whatever was there.
*
* This is what discovery uses, because its input is a *recomputation* from the cache rather than a
* stream of deltas: every pass sees the complete membership picture, so declaring the result is both
* simpler and idempotent. Incremental [record]/[remove] against a recomputed set would ping-pong —
* classification removes a named channel, the next pass re-derives it from the same kind-44100 and
* re-adds it, and the flow churns forever with nothing having changed.
*
* Returns true when the set actually moved, so callers can skip work on a no-op pass.
*/
fun replace(
viewer: HexKey,
channels: Map<String, NormalizedRelayUrl>,
): Boolean =
lock.withLock {
val current = byViewer[viewer]
if (current == null && channels.isEmpty()) return@withLock false
if (current != null && current == channels) return@withLock false
if (channels.isEmpty()) {
byViewer.remove(viewer)
} else {
byViewer[viewer] = channels.toMutableMap()
}
mutableFlow.value = snapshot()
true
}
/** The DM channels [viewer] is in (`channelId` -> relay), possibly empty. */
fun channelsFor(viewer: HexKey): Map<String, NormalizedRelayUrl> = mutableFlow.value[viewer] ?: emptyMap()
@@ -26,75 +26,51 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Holds the NIP-OA [OwnerAttestation]s this device has *received* — one owner-signed
* authorization per agent key that lets that key publish in the owner's Buzz workspace
* without being enrolled as a relay member.
* The NIP-OA [OwnerAttestation] this account holds — an owner-signed authorization letting its key
* publish in the owner's Buzz workspace without being enrolled as a relay member.
*
* The counterpart of issuance ([OwnerAttestation] is signed by an owner and handed to an
* agent operator out-of-band): when the account whose pubkey equals a stored key
* authenticates (NIP-42) to a Buzz-dialect relay, the auth coordinator attaches the
* matching [OwnerAttestation.toTag] to the AUTH event, and the relay grants virtual
* membership while the owner stays a member.
* The counterpart of issuance ([OwnerAttestation] is signed by an owner and handed to an agent
* operator out-of-band): when this account authenticates (NIP-42) to a Buzz-dialect relay, the auth
* coordinator attaches [authTag] to the AUTH event, and the relay grants virtual membership while
* the owner stays a member.
*
* Keyed by the **agent** pubkey (the key the attestation authorizes). Only a
* [OwnerAttestation.verify]-passing attestation for that key should be stored, so the
* store never carries a credential the relay would reject.
*
* Like [BuzzRelayDialect] this is a process-wide singleton, and — for now — in-memory
* only: a held attestation is re-pasted after a process restart. Persisting it across
* launches (per-account, encrypted) is a follow-up.
* **One instance per account** (`Account.buzzAttestation`), holding at most one attestation — the
* one issued to [agentPubKey]. It was a process-wide `Map<agentPubKey, OwnerAttestation>`, but every
* caller only ever read or wrote the entry for the account doing the AUTH, so the map was a
* single-entry map with a lookup that could not miss. Owning the agent key here also lets [put]
* enforce the verification its callers used to be told to perform, which is the property that
* matters: the store never carries a credential the relay would reject.
*/
object BuzzHeldAttestations {
private val heldByAgent = MutableStateFlow<Map<HexKey, OwnerAttestation>>(emptyMap())
class BuzzHeldAttestations(
private val agentPubKey: HexKey,
) {
private val held = MutableStateFlow<OwnerAttestation?>(null)
/** All held attestations, keyed by agent pubkey; UI can collect this. */
val flow: StateFlow<Map<HexKey, OwnerAttestation>> = heldByAgent
/** The attestation held for [agentPubKey], or null. */
fun attestationFor(agentPubKey: HexKey): OwnerAttestation? = heldByAgent.value[agentPubKey]
/** The attestation held for this account, or null. UI collects this. */
val flow: StateFlow<OwnerAttestation?> = held
/**
* The `auth` tag to attach to [agentPubKey]'s NIP-42 AUTH event, or null when no
* verified attestation is held for that key.
* The `auth` tag to attach to this account's NIP-42 AUTH event, or null when no verified
* attestation is held.
*/
fun authTagFor(agentPubKey: HexKey): Array<String>? = attestationFor(agentPubKey)?.toTag()
fun authTag(): Array<String>? = held.value?.toTag()
/**
* Stores [attestation] as authorizing [agentPubKey]. The caller must have already
* confirmed `attestation.verify(agentPubKey)`; this is a CAS-loop put so concurrent
* writers don't clobber each other.
* Stores [attestation] as authorizing this account, if it verifies for [agentPubKey]. Returns
* false — storing nothing — when it does not.
*
* The check lives here rather than in the caller so it cannot be skipped: this is the single
* door into the store, used by the paste flow and by the on-disk restore alike, so a tampered
* saved credential is dropped by the same gate that rejects a mistyped one.
*/
fun put(
agentPubKey: HexKey,
attestation: OwnerAttestation,
) {
while (true) {
val current = heldByAgent.value
if (current[agentPubKey] == attestation) return
if (heldByAgent.compareAndSet(current, current + (agentPubKey to attestation))) return
}
fun put(attestation: OwnerAttestation): Boolean {
if (!attestation.verify(agentPubKey)) return false
held.value = attestation
return true
}
/** Removes any attestation held for [agentPubKey]. */
fun remove(agentPubKey: HexKey) {
while (true) {
val current = heldByAgent.value
if (agentPubKey !in current) return
if (heldByAgent.compareAndSet(current, current - agentPubKey)) return
}
}
/**
* Replaces the whole store with [entries] — used to restore from disk at startup. The
* caller must have re-verified each attestation against its agent key (the same gate
* [put] documents), so a tampered on-disk credential can't be reinstated.
*/
fun restore(entries: Map<HexKey, OwnerAttestation>) {
heldByAgent.value = entries
}
/** Test-only: clears all held attestations so unit tests don't leak state. */
fun clearForTesting() {
heldByAgent.value = emptyMap()
/** Drops the held attestation. */
fun clear() {
held.value = null
}
}
@@ -35,11 +35,19 @@ import kotlinx.coroutines.flow.StateFlow
* join event to key off — so this joined set is the client's own bookkeeping of which relays
* to sync as workspaces.
*
* Persisted across launches by the platform (`BuzzWorkspacePreferences` on Android mirrors it
* to a device-global store and restores it at startup). Like [BuzzRelayDialect] it is a
* process-wide singleton; joining also marks the relay as a Buzz dialect.
* **One instance per account** (`Account.buzzWorkspaces`) — deliberately NOT a process-wide
* singleton like [BuzzRelayDialect]. Joining is a per-user act: the invite was redeemed by one
* key and the relay grants membership to that key alone. While this was device-global it also
* fed `AuthCoordinator.isFirstParty`, so one account joining a workspace made *every* logged-in
* account first-party there — the bystander-account AUTH leak the per-account gate exists to
* prevent. The dialect mark stays global: which protocol a relay speaks is a property of the
* relay, not of who is asking.
*
* Persisted across launches by the platform (`BuzzWorkspacePreferences` on Android mirrors each
* account's set to that account's own store and restores it at startup). Joining also marks the
* relay as a Buzz dialect.
*/
object BuzzWorkspaces {
class BuzzWorkspaces {
private val joined = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
/** The joined workspace relays; discovery subscriptions and the workspaces hub collect this. */
@@ -74,9 +82,4 @@ object BuzzWorkspaces {
relays.forEach { BuzzRelayDialect.mark(it) }
joined.value = relays
}
/** Test-only: clears the joined set so unit tests don't leak state into each other. */
fun clearForTesting() {
joined.value = emptySet()
}
}
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord02Community.GuestbookEntry
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold
@@ -193,6 +194,26 @@ class ConcordCommunitySession(
*/
private val editionByWrapId = HashMap<HexKey, ControlEdition?>()
/**
* Guestbook wraps opened into entries, memoized by wrap id — the guestbook analogue of
* [editionByWrapId]. [refoldGuestbook] runs on *every* arriving guestbook wrap and re-projects
* the whole buffer, so without this the nth arrival re-opens all n wraps and a boot costs
* O(n^2) envelope opens (each = two NIP-44 decrypts + two signature verifies). Measured on a
* cold start: 6,229 opens over 448 distinct wraps, ~all of the app's NIP-44 traffic.
*
* Safe to key on wrap id alone: [guestbookKey] is derived once at construction from the
* session's epoch and never rotates in place (a rekey builds a new session).
*/
private val guestbookEntryByWrapId = HashMap<HexKey, GuestbookEntry?>()
/**
* Envelope opens [refoldGuestbook] actually performed (cache misses). Exposed so a test can
* assert the fold stays linear in arrivals; a regression to re-opening the buffer shows up here
* as O(n^2) long before it shows up as a slow boot.
*/
internal var guestbookOpens = 0
private set
// Prior-epoch Control Plane address -> (wrapId -> wrap). Kept apart from [controlWraps]: these
// never join the live fold, they only produce the anti-rollback floor.
private val historicalControlWraps = HashMap<HexKey, LinkedHashMap<HexKey, Event>>()
@@ -607,8 +628,16 @@ class ConcordCommunitySession(
private fun refoldGuestbook() {
lock.withLock {
val wraps = guestbookWraps.values.toList()
_members.value = ConcordActions.guestbookMembers(wraps, guestbookKey)
val entries =
guestbookWraps.values.mapNotNull { wrap ->
if (guestbookEntryByWrapId.containsKey(wrap.id)) {
guestbookEntryByWrapId[wrap.id]
} else {
guestbookOpens++
ConcordActions.guestbookEntry(wrap, guestbookKey).also { guestbookEntryByWrapId[wrap.id] = it }
}
}
_members.value = ConcordActions.projectGuestbook(entries)
}
}
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent
@@ -134,3 +135,79 @@ fun cashuWalletFilters(
return ownedSubs + inboundSubs
}
/**
* The filter used to **page** the account's whole proof set back from a relay,
* as opposed to [cashuWalletFilters], which opens a live subscription.
*
* The live subscription sends one REQ with no `limit` and takes whatever the
* relay decides to give back. Relays cap an unbounded REQ (NIP-11
* `limitation.max_limit`, or a hard-coded default) and serve the **newest**
* events within that cap, so a wallet whose kind:7375 events are outnumbered
* by its kind:7376 history — which is every wallet after a few hundred
* transactions — silently receives only a suffix of its proofs. Everything
* downstream (balance, per-mint balances, coin selection) is a pure function
* of that suffix, which is why two devices on the same account can show two
* different balances and neither is right.
*
* There is no way to detect the truncation from the REQ itself: a capped
* response and a complete one both just EOSE. The only fix is to not rely on
* one REQ — hand this to `fetchAllPages` / `fetchAllPagesFromPool`, which
* walks `until` cursors until a page comes back empty and thereby reaches
* events of any age regardless of the cap.
*
* Scoped to kind:7375 alone. Those are the events that carry money; history,
* quotes and recommendations are display-only, and paging them too would
* multiply the download for a wallet with a long history without changing a
* single balance. A caller that wants the rest — a headless client with no
* scrolling list to page for it — asks for [cashuOwnEventBackfillFilters].
*/
fun cashuProofBackfillFilters(pubkey: HexKey): List<Filter> = cashuOwnEventBackfillFilters(pubkey, listOf(CashuTokenEvent.KIND))
/**
* Every NIP-60/87 kind this account authors, for a paged walk over the relays it
* publishes to. The read-side twin of the `authors=` half of [cashuWalletFilters],
* minus the live subscription's cap exposure.
*/
val OWN_CASHU_KINDS =
listOf(
CashuWalletEvent.KIND,
CashuTokenEvent.KIND,
CashuSpendingHistoryEvent.KIND,
CashuMintQuoteEvent.KIND,
NutzapInfoEvent.KIND,
MintRecommendationEvent.KIND,
)
/**
* A paged backfill of the account's **own** NIP-60/87 events, over the relays it
* publishes to. Defaults to every kind it authors; pass a narrower [kinds] to
* page only part of it (see [cashuProofBackfillFilters]).
*
* Hand this to `fetchAllPages` / `fetchAllPagesFromPool`, never to a plain REQ:
* the whole point is walking `until` cursors past the relay's cap, which is what
* silently truncates the single uncapped REQ [cashuWalletFilters] opens.
*/
fun cashuOwnEventBackfillFilters(
pubkey: HexKey,
kinds: List<Int> = OWN_CASHU_KINDS,
): List<Filter> =
listOf(
Filter(
kinds = kinds,
authors = listOf(pubkey),
),
)
/**
* A paged backfill of inbound NIP-61 nutzaps (kind:9321) addressed to this
* account, matched by the recipient `#p` tag because someone else authored them.
* Read from the account's inbox set, mirroring the split in [cashuWalletFilters].
*/
fun cashuInboundNutzapBackfillFilters(pubkey: HexKey): List<Filter> =
listOf(
Filter(
kinds = listOf(NutzapEvent.KIND),
tags = mapOf("p" to listOf(pubkey)),
),
)
@@ -64,7 +64,8 @@ data class RelayAuthCustomToggles(
* there, a subscription there reads its own inbox/outbox, or the relay is in its own relay list.
* False means the only reason we are here belongs to somebody else (another logged-in account's
* traffic, or a followed author whose outbox happens to live here). Gates the *automatic* grants
* only: a non-first-party challenge is never auto-allowed, but it still reaches the user as a
* only, and only for the categories it can gate without emptying them (see [RelayAuthResolver]):
* a non-first-party challenge is never auto-allowed there, but it still reaches the user as a
* prompt rather than a silent denial.
*/
data class RelayAuthInputs(
@@ -98,12 +99,17 @@ data class RelayAuthInputs(
* else fall through
* 5. Fall-through → [RelayAuthVerdict.ASK] when the purpose is known, otherwise DENY.
*
* The [RelayAuthPolicy.CUSTOM] grant additionally requires [RelayAuthInputs.isFirstParty]: under
* Most [RelayAuthPolicy.CUSTOM] grants additionally require [RelayAuthInputs.isFirstParty]: under
* "decide per relay" an account never reveals its identity *without being asked* on a relay it has no
* reason of its own to be on, which is what keeps a bystander account off a relay only another account
* uses. It deliberately does not suppress the question — a non-first-party challenge we can explain
* falls through to ASK, so the user decides rather than getting a silent denial they never see.
*
* [RelayAuthCustomToggles.readFollows] is the one category exempt from that gate, because the gate is
* unsatisfiable there rather than merely strict: reading a followed author means talking to *their*
* outbox relay, which is by definition not one we publish to, subscribe to for our own inbox, or list.
* See [customAllows].
*
* [RelayAuthPolicy.ALWAYS] is NOT gated this way: it means what it says, every relay that asks. Users
* who want the narrower "only the relays I actually use" behaviour choose CUSTOM.
*/
@@ -131,14 +137,37 @@ object RelayAuthResolver {
// a large follow list, produced a prompt for each of the 250+ third-party outbox relays.
RelayAuthPolicy.ALWAYS -> RelayAuthVerdict.ALLOW
RelayAuthPolicy.CUSTOM ->
if (inputs.isFirstParty && customAllows(inputs)) RelayAuthVerdict.ALLOW else fallThrough(inputs)
if (customAllows(inputs)) RelayAuthVerdict.ALLOW else fallThrough(inputs)
}
}
/**
* Whether an enabled [RelayAuthCustomToggles] category covers this relay.
*
* [RelayAuthCustomToggles.readFollows] is checked *before* the [RelayAuthInputs.isFirstParty]
* gate because that gate is unsatisfiable for it, not merely strict. "I'm reading someone I
* follow" describes their outbox relay: not one we publish to, not one serving our own
* inbox/outbox, not one on our list — so `isFirstParty` is false by construction and gating the
* category made it unreachable. Every follow's outbox relay prompted even with the toggle on, and
* the only challenges it ever granted were ones `myRelaysAndVenues` already covered.
*
* Exempting it is safe in the way the gate is meant to be: the follow graph consulted is *this*
* account's, so no other account's traffic can conjure a match. What it can match is another
* logged-in account reading an author we follow too — and the cost of that is an AUTH on a relay
* we would be reading that same author from anyway, which is what the toggle asks for.
*
* Every other category keeps the gate, where it costs them nothing: our own relay list and our
* joined rooms' hosts are first-party by definition, and a pending event of ours makes its
* destination first-party too. That is precisely what stops a bystander account being
* auto-authenticated — and billed — on a paid inbox relay because *another* account's outgoing
* DM happens to name someone we follow.
*/
private fun customAllows(inputs: RelayAuthInputs): Boolean {
val t = inputs.toggles
if (t.readFollows && inputs.servesFollowedReadCounterparty) return true
if (!inputs.isFirstParty) return false
return (t.myRelaysAndVenues && (inputs.isInMyRelayList || inputs.servesTrustedVenue)) ||
(t.readFollows && inputs.servesFollowedReadCounterparty) ||
(t.messageFollows && inputs.servesFollowedWriteCounterparty) ||
(t.messageStrangers && inputs.servesStrangerWriteCounterparty)
}
@@ -0,0 +1,248 @@
/*
* 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.commons.model.buzz
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class BuzzChannelInvitesTest {
private val me = "a".repeat(64)
private val stranger = "b".repeat(64)
private val relay = RelayUrlNormalizer.normalizeOrNull("wss://buzz.example.team/")!!
private fun added(
channelId: String,
actor: String? = stranger,
createdAt: Long = 1_000L,
) = MembershipNotice("add-" + channelId + "-" + createdAt, channelId, relay, actor, createdAt, removed = false)
private fun removed(
channelId: String,
actor: String? = stranger,
createdAt: Long = 2_000L,
) = MembershipNotice("del-" + channelId + "-" + createdAt, channelId, relay, actor, createdAt, removed = true)
/** Every channel is a plain named channel unless a test says otherwise. */
private val allNamed = { _: String, _: NormalizedRelayUrl -> ChannelClassification.NAMED }
private fun invites(
notices: List<MembershipNotice>,
dismissed: Set<String> = emptySet(),
joined: Set<String> = emptySet(),
classify: (String, NormalizedRelayUrl) -> ChannelClassification = allNamed,
) = BuzzChannelInvites.pendingInvites(me, notices, dismissed, joined, classify)
@Test
fun anAddBySomebodyElseIsAnInvite() {
val result = invites(listOf(added("chan-1")))
assertEquals(1, result.size)
assertEquals("chan-1", result[0].channelId)
assertEquals(stranger, result[0].actor)
assertEquals(relay, result[0].relay)
assertEquals(1_000L, result[0].createdAt)
}
@Test
fun aSelfJoinIsNotAnInvite() {
assertTrue(invites(listOf(added("chan-1", actor = me))).isEmpty())
}
@Test
fun aSelfJoinIsMatchedCaseInsensitively() {
assertTrue(invites(listOf(added("chan-1", actor = me.uppercase()))).isEmpty())
}
@Test
fun anAddWithNoReadableActorIsStillAnInvite() {
// The relay body can be missing or malformed. "Somebody put me here and I can't tell who" is
// still a question for the viewer — the card renders an unknown-actor row for exactly this.
val result = invites(listOf(added("chan-1", actor = null)))
assertEquals(1, result.size)
assertEquals(null, result[0].actor)
}
@Test
fun aRemovalSupersedesAnEarlierAdd() {
assertTrue(invites(listOf(added("chan-1", createdAt = 1_000L), removed("chan-1", createdAt = 2_000L))).isEmpty())
}
@Test
fun anAddAfterARemovalIsAnInviteAgain() {
val result =
invites(
listOf(
added("chan-1", createdAt = 1_000L),
removed("chan-1", createdAt = 2_000L),
added("chan-1", createdAt = 3_000L),
),
)
assertEquals(1, result.size)
assertEquals(3_000L, result[0].createdAt)
}
@Test
fun orderOfArrivalDoesNotChangeTheAnswer() {
// A re-subscribe replays the relay's whole history, and nothing guarantees the order it comes
// back in. The projection resolves by created_at, so both orderings agree.
val chronological = listOf(added("chan-1", createdAt = 1_000L), removed("chan-1", createdAt = 2_000L))
assertEquals(
invites(chronological).map { it.channelId },
invites(chronological.reversed()).map { it.channelId },
)
}
@Test
fun aTieResolvesToTheRemoval() {
assertTrue(invites(listOf(added("chan-1", createdAt = 5L), removed("chan-1", createdAt = 5L))).isEmpty())
assertTrue(invites(listOf(removed("chan-1", createdAt = 5L), added("chan-1", createdAt = 5L))).isEmpty())
}
@Test
fun redeliveringTheSameNoticeIsIdempotent() {
// The regression this projection exists for: the old registry re-recorded an invite that
// classification had already withdrawn, so the prompt flickered on every re-delivery.
val once = invites(listOf(added("chan-1")))
val twice = invites(listOf(added("chan-1"), added("chan-1")))
assertEquals(once.map { it.channelId }, twice.map { it.channelId })
assertEquals(1, twice.size)
}
@Test
fun aDismissedChannelIsWithheld() {
assertTrue(invites(listOf(added("chan-1")), dismissed = setOf("chan-1")).isEmpty())
}
@Test
fun aJoinedChannelIsWithheld() {
assertTrue(invites(listOf(added("chan-1")), joined = setOf("chan-1")).isEmpty())
}
@Test
fun aDmIsNeverAnInvite() {
assertTrue(invites(listOf(added("chan-1"))) { _, _ -> ChannelClassification.DM }.isEmpty())
}
@Test
fun anUnclassifiedChannelIsWithheldRatherThanGuessed() {
// A DM arrives as the same kind-44100 as a channel add. Surfacing before the kind-39000 lands
// would flash a "somebody added you to a channel" card for every new DM and then withdraw it.
assertTrue(invites(listOf(added("chan-1"))) { _, _ -> ChannelClassification.UNKNOWN }.isEmpty())
}
@Test
fun invitesComeBackNewestFirst() {
val result =
invites(
listOf(
added("older", createdAt = 1_000L),
added("newest", createdAt = 3_000L),
added("middle", createdAt = 2_000L),
),
)
assertEquals(listOf("newest", "middle", "older"), result.map { it.channelId })
}
@Test
fun anInviteCarriesTheEventItCameFrom() {
// The Notifications feed keys its card on this — it is the identity that lets an invite dedup,
// scroll-to and page like every other notification row.
val invite = invites(listOf(added("chan-1", createdAt = 7L))).single()
assertEquals("add-chan-1-7", invite.eventId)
}
@Test
fun pendingByEventIdIsKeyedForThePerNoteLookup() {
// `NotificationFeedFilter.acceptableEvent` runs per note, so it needs this as a map rather than
// a scan: a cached 44100 is a live question exactly when its id is a key here.
val byId =
BuzzChannelInvites.pendingInvitesByEventId(
viewer = me,
notices = listOf(added("chan-1", createdAt = 7L), added("chan-2", createdAt = 8L)),
dismissed = setOf("chan-2"),
joined = emptySet(),
classify = allNamed,
)
assertEquals(setOf("add-chan-1-7"), byId.keys)
assertEquals("chan-1", byId["add-chan-1-7"]?.channelId)
}
@Test
fun aSupersededAddDropsOutOfThePerNoteLookup() {
// The 44100 stays in the cache forever, so the accept gate has to answer "no" for one the relay
// has since withdrawn — otherwise the card would outlive the membership.
val byId =
BuzzChannelInvites.pendingInvitesByEventId(
viewer = me,
notices = listOf(added("chan-1", createdAt = 1L), removed("chan-1", createdAt = 2L)),
dismissed = emptySet(),
joined = emptySet(),
classify = allNamed,
)
assertTrue(byId.isEmpty())
}
@Test
fun currentMembershipsCoverEveryChannelRegardlessOfTypeOrActor() {
// The directory fetch iterates this, and a channel's type is only knowable once its kind-39000
// has been fetched BY ID — so the set it works from cannot already be filtered by type.
val memberships =
BuzzChannelInvites.currentMemberships(
listOf(
added("named"),
added("dm"),
added("self-joined", actor = me),
added("left", createdAt = 1_000L),
removed("left", createdAt = 2_000L),
),
)
assertEquals(setOf("named", "dm", "self-joined"), memberships.keys)
assertEquals(relay, memberships["named"])
}
@Test
fun latestPerChannelKeepsChannelsApart() {
val newest =
BuzzChannelInvites.latestPerChannel(
listOf(
added("chan-1", createdAt = 1_000L),
added("chan-2", createdAt = 500L),
removed("chan-1", createdAt = 3_000L),
),
)
assertEquals(setOf("chan-1", "chan-2"), newest.keys)
assertTrue(newest["chan-1"]!!.removed)
assertEquals(500L, newest["chan-2"]!!.createdAt)
}
}
@@ -0,0 +1,69 @@
/*
* 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.commons.model.buzz
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class BuzzChannelStarsTest {
// A fresh instance per test: stars are per-account state now, not a process-wide singleton.
private val stars = BuzzChannelStars()
private val a = "channel-a"
private val b = "channel-b"
@Test
fun toggleStarsAndUnstars() {
assertFalse(stars.isStarred(a))
assertTrue(stars.toggle(a))
assertTrue(stars.isStarred(a))
assertEquals(setOf(a), stars.flow.value)
assertFalse(stars.toggle(a))
assertFalse(stars.isStarred(a))
assertEquals(emptySet(), stars.flow.value)
}
@Test
fun restoreReplacesTheWholeSet() {
stars.toggle(a)
stars.restore(setOf(b))
assertEquals(setOf(b), stars.flow.value)
assertFalse(stars.isStarred(a))
}
@Test
fun oneAccountsStarsDoNotPinForAnother() {
// Why this is per account: a star reorders and badges the community channel list. While the
// set was a process-wide singleton, one account pinning a channel reordered every other
// logged-in account's list, and switching accounts rewrote the set they shared.
val mine = BuzzChannelStars()
val theirs = BuzzChannelStars()
mine.toggle(a)
assertTrue(mine.isStarred(a))
assertFalse(theirs.isStarred(a))
assertEquals(emptySet(), theirs.flow.value)
}
}
@@ -65,4 +65,42 @@ class BuzzDmChannelsTest {
assertEquals(mapOf("chan-1" to relayA), BuzzDmChannels.channelsFor(alice))
assertEquals(emptyMap(), BuzzDmChannels.channelsFor(bob))
}
@Test
fun replaceDeclaresTheWholeSet() {
BuzzDmChannels.record(alice, "gone", relayA)
assertTrue(BuzzDmChannels.replace(alice, mapOf("kept" to relayB)))
assertEquals(mapOf("kept" to relayB), BuzzDmChannels.channelsFor(alice))
}
@Test
fun replaceWithTheSameSetIsANoOpAndDoesNotChurn() {
// Discovery recomputes from the cache on every pass, so most passes declare a set that is
// already current. Those must not emit — a churning flow would re-trigger every collector,
// which is what the incremental record/remove version did on a loop.
BuzzDmChannels.replace(alice, mapOf("chan-1" to relayA))
val before = BuzzDmChannels.flow.value
assertFalse(BuzzDmChannels.replace(alice, mapOf("chan-1" to relayA)))
assertTrue(before === BuzzDmChannels.flow.value, "the flow instance is unchanged on a no-op")
}
@Test
fun replaceWithAnEmptySetClearsTheViewer() {
BuzzDmChannels.replace(alice, mapOf("chan-1" to relayA))
assertTrue(BuzzDmChannels.replace(alice, emptyMap()))
assertEquals(emptyMap(), BuzzDmChannels.channelsFor(alice))
assertFalse(BuzzDmChannels.replace(alice, emptyMap()), "clearing an already-empty viewer is a no-op")
}
@Test
fun replaceLeavesOtherViewersAlone() {
BuzzDmChannels.replace(bob, mapOf("bobs" to relayA))
BuzzDmChannels.replace(alice, mapOf("alices" to relayB))
assertEquals(mapOf("bobs" to relayA), BuzzDmChannels.channelsFor(bob))
assertEquals(mapOf("alices" to relayB), BuzzDmChannels.channelsFor(alice))
}
}
@@ -21,44 +21,72 @@
package com.vitorpamplona.amethyst.commons.model.buzz
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
import kotlin.test.AfterTest
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class BuzzHeldAttestationsTest {
private val agent = "a".repeat(64)
private val owner = "b".repeat(64)
private val attestation = OwnerAttestation(ownerPubKey = owner, conditions = "kind=40002", sig = "c".repeat(128))
private val agentKey = KeyPair()
private val otherKey = KeyPair()
private val ownerKey = KeyPair()
@AfterTest
fun tearDown() = BuzzHeldAttestations.clearForTesting()
private val agent = agentKey.pubKey.toHexKey()
private val other = otherKey.pubKey.toHexKey()
private val attestation = OwnerAttestation.sign(agent, CONDITIONS, ownerKey.privKey!!)
// One store per account, holding the attestation issued to that account's key.
private val held = BuzzHeldAttestations(agent)
@Test
fun emptyStoreYieldsNoTag() {
assertNull(BuzzHeldAttestations.attestationFor(agent))
assertNull(BuzzHeldAttestations.authTagFor(agent))
assertNull(held.flow.value)
assertNull(held.authTag())
}
@Test
fun heldAttestationSurfacesAsItsAuthTag() {
BuzzHeldAttestations.put(agent, attestation)
assertEquals(attestation, BuzzHeldAttestations.attestationFor(agent))
assertTrue(held.put(attestation))
assertEquals(attestation, held.flow.value)
// The tag attached to the agent's AUTH is exactly the attestation's ["auth", …] tag.
assertContentEquals(attestation.toTag(), BuzzHeldAttestations.authTagFor(agent))
assertContentEquals(attestation.toTag(), held.authTag())
}
@Test
fun removeClearsTheHeldAttestation() {
BuzzHeldAttestations.put(agent, attestation)
BuzzHeldAttestations.remove(agent)
assertNull(BuzzHeldAttestations.authTagFor(agent))
fun clearDropsTheHeldAttestation() {
held.put(attestation)
held.clear()
assertNull(held.authTag())
assertNull(held.flow.value)
}
@Test
fun oneAgentsAttestationDoesNotLeakToAnother() {
BuzzHeldAttestations.put(agent, attestation)
assertNull(BuzzHeldAttestations.authTagFor("d".repeat(64)))
fun anAttestationIssuedToAnotherKeyIsRejected() {
// The check that used to be the caller's job: this credential is real and verifies — for
// somebody else's key. Storing it would attach an `auth` tag the relay rejects, and the
// store's whole contract is that it never holds one.
val theirs = BuzzHeldAttestations(other)
assertFalse(theirs.put(attestation))
assertNull(theirs.authTag())
}
@Test
fun aTamperedAttestationIsRejectedAndLeavesTheHeldOneIntact() {
held.put(attestation)
val forged = attestation.copy(conditions = "kind=1")
assertFalse(held.put(forged))
assertEquals(attestation, held.flow.value)
}
companion object {
private const val CONDITIONS = "kind=40002"
}
}

Some files were not shown because too many files have changed in this diff Show More