diff --git a/amethyst/plans/2026-08-03-auth-permissions-redesign.md b/amethyst/plans/2026-08-03-auth-permissions-redesign.md index ae69a30b65..295de64b98 100644 --- a/amethyst/plans/2026-08-03-auth-permissions-redesign.md +++ b/amethyst/plans/2026-08-03-auth-permissions-redesign.md @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index 45c757c32b..bdfb4ddc93 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index ed83fcf45c..930b118476 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -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 = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index e321c6b259..31b2e4b815 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index b763402e07..5598c8303c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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, selectedZapType: LnZapEvent.ZapType, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 523d86f594..e18e0779dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -330,6 +330,14 @@ class AccountSettings( * still lists you, and Leave (kind 9022) is the separate action that actually removes you. */ val dismissedChannelInvites: MutableStateFlow> = 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> = MutableStateFlow(setOf()), val viewedPollResultNoteIds: MutableStateFlow> = MutableStateFlow(mapOf()), val pendingAttestations: MutableStateFlow> = 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 // --- diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt index 5fa6adc7ec..afedafbff2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt @@ -90,7 +90,7 @@ class AccountSyncedSettings( MutableStateFlow(DrawerItemVisibility.sanitize(navBarItemsFromNames(internalSettings.navigation.hiddenDrawerItems))), ) - fun toInternal(): AccountSyncedSettingsInternal = + fun toInternal(mutedPublicChats: Set): 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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt index 32b3900cb0..f9b8624662 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt @@ -242,4 +242,14 @@ class AccountChatPreferencesInternal( // pubkeys (hex) sorted ascending, so the serialized form is deterministic // regardless of set iteration order. var pinnedRooms: List> = 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? = null, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt index 1c9f6ae8bf..ea88acf512 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/EventBroadcaster.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/EventBroadcaster.kt index 33ed7a5369..11a7854c98 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/EventBroadcaster.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/EventBroadcaster.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 7254304b16..75fc1a1908 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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 { + 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, … diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/MutedPublicChats.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/MutedPublicChats.kt new file mode 100644 index 0000000000..aa860d1ba6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/MutedPublicChats.kt @@ -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, +): 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, + remote: List?, +): Set = remote?.toSet() ?: local diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index 49b9004e68..cc875f2b0d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -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>(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)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/buzz/BuzzMembershipNotices.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/buzz/BuzzMembershipNotices.kt new file mode 100644 index 0000000000..12ee161e14 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/buzz/BuzzMembershipNotices.kt @@ -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? { + 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): 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.toMembershipNotices(workspaces: Set): List = 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, +): List = + 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): Map { + val types = HashMap(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 = emptyMap(), +): ChannelClassification { + val metadata = + cache.getRelayGroupChannelIfExists(GroupId(channelId, relay))?.event + ?: return knownTypes[channelId] ?: ChannelClassification.UNKNOWN + return if (metadata.isBuzzDmChannel()) ChannelClassification.DM else ChannelClassification.NAMED +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/buzz/ChannelInvitesState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/buzz/ChannelInvitesState.kt new file mode 100644 index 0000000000..2fce4a5ca0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/buzz/ChannelInvitesState.kt @@ -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>, + 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> = + 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> = + 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] +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt index e18dfbde9c..b67a4a0759 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt @@ -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>, private val inboxRelaysFlow: StateFlow>, @@ -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() + 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() 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(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>>>() + withSecrets.forEach { pair -> + pair.second.forEach { secret -> + holdersOfSecret.getOrPut(secret) { mutableListOf() }.add(pair) + } + } + val redundant = mutableListOf() 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" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip78AppSpecific/AppSpecificState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip78AppSpecific/AppSpecificState.kt index f9b93ba58b..cc8522c877 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip78AppSpecific/AppSpecificState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip78AppSpecific/AppSpecificState.kt @@ -53,7 +53,7 @@ class AppSpecificState( fun getAppSpecificDataFlow(): StateFlow = 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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzAttestationPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzAttestationPreferences.kt index 54d604318f..618518e919 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzAttestationPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzAttestationPreferences.kt @@ -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>(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) { + 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(saved) + return OwnerAttestation(entry.owner, entry.conditions, entry.sig) + } + val list = legacy ?: return null + return json + .decodeFromString>(list) + .firstOrNull { it.agent == agentPubKey } + ?.let { OwnerAttestation(it.owner, it.conditions, it.sig) } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzChannelStarPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzChannelStarPreferences.kt index ba694304b6..96c62f23eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzChannelStarPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzChannelStarPreferences.kt @@ -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) { 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") } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzWorkspacePreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzWorkspacePreferences.kt index cdd9a7855d..10cc89faa1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzWorkspacePreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzWorkspacePreferences.kt @@ -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) { 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") } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index e01e7acdf3..ad7dee959c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -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)) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/priority/WorkerThreadPriorityGovernor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/priority/WorkerThreadPriorityGovernor.kt new file mode 100644 index 0000000000..193a788e10 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/priority/WorkerThreadPriorityGovernor.kt @@ -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().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 { + // 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(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 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt index 038013711e..cd005c4e41 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt @@ -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, - accountPubKey: HexKey, + account: Account, relayUrl: NormalizedRelayUrl, ): EventTemplate { 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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt index a5f530da31..d22ba7f7f3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt @@ -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), ) /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/buzz/BuzzMembershipEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/buzz/BuzzMembershipEoseManager.kt new file mode 100644 index 0000000000..947bf5cbe0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/buzz/BuzzMembershipEoseManager.kt @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.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 { + 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, +) : PerUserEoseManager(client, allKeys) { + override fun user(key: AccountQueryState) = key.account.userProfile() + + override fun updateFilter( + key: AccountQueryState, + since: SincePerRelayMap?, + ): List { + 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>() + + 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() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip60Cashu/CashuWalletHistoryEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip60Cashu/CashuWalletHistoryEoseManager.kt new file mode 100644 index 0000000000..b1ee4f5b9a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip60Cashu/CashuWalletHistoryEoseManager.kt @@ -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, +) : PerUserEoseManager(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 = pager.loadingMore + val status: StateFlow = pager.status + + /** The relays this account pages its own NIP-60 history back through: where it publishes. */ + private fun historyRelaySet(account: Account): Set = account.outboxRelays.flow.value + + override fun updateFilter( + key: AccountQueryState, + since: SincePerRelayMap?, + ): List { + 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>() + + @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?, + ) { + if (pager.isBoundTo(myCursors)) pager.onEvent(relay, event.createdAt) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + 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?, + ) { + if (pager.isBoundTo(myCursors)) pager.onClosed(relay, message) + } + + override fun onCannotConnect( + relay: NormalizedRelayUrl, + message: String, + forFilters: List?, + ) { + 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 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip60Cashu/FilterCashuHistoryToPubkey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip60Cashu/FilterCashuHistoryToPubkey.kt new file mode 100644 index 0000000000..94f0b68c43 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip60Cashu/FilterCashuHistoryToPubkey.kt @@ -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 { + 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, + ), + ), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/StringResourceCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/StringResourceCache.kt index f42a588296..9f2697d538 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/StringResourceCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/StringResourceCache.kt @@ -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(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(10).also { iconCache.put(resourceId, it) } + sizes.put(sizeReference, loaded) return loaded } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DefaultFeedOrder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DefaultFeedOrder.kt index ebab80962a..0460729d2d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DefaultFeedOrder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DefaultFeedOrder.kt @@ -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 = @@ -33,6 +34,20 @@ val DefaultFeedOrderEvent: Comparator = val DefaultFeedOrderCard: Comparator = compareByDescending { 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 = + compareBy { 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt index a5a320de33..49fa2282e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 795e7fe46c..f51d6adfbc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index 0363421c51..8870752d8e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -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() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt index ba13c44b22..cf4533cc18 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChannelInvite.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChannelInvite.kt new file mode 100644 index 0000000000..071e6c7935 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChannelInvite.kt @@ -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), + ), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index a3d6f25523..a0a73195f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -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. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 3825dd0f5e..3ac12e21da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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> = account.settings.mutedPublicChats + + fun toggleMutedPublicChat(channelId: String) = + launchSigner { + account.toggleMutedPublicChat(channelId) + } + fun updateZapAmounts( amountSet: List, selectedZapType: LnZapEvent.ZapType, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt index 55fe5a3a12..f926a47f72 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt @@ -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(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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleViewModel.kt index 2433459063..9cd906ebcb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleViewModel.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmDiscovery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmDiscovery.kt index f1fd9926d7..fa94aaa886 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmDiscovery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmDiscovery.kt @@ -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, -) = 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().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, + knownTypes: Map, ) { 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) - } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt index dccf0cb851..c6e1e7117f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt @@ -23,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 = scopeRelay?.let { setOf(it) } ?: (BuzzWorkspaces.flow.value + BuzzRelayDialect.flow.value) + private fun relays(): Set = + 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().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) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzInviteScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzInviteScreen.kt index be61d00fe7..d5b3e7a101 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzInviteScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzInviteScreen.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt index a3976d203f..2b4cf9a15f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt @@ -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]. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt index f6d672af51..ba77b576ec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/MuteChatButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/MuteChatButton.kt new file mode 100644 index 0000000000..a6220fe8ac --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/MuteChatButton.kt @@ -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, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/BuzzChannelMenuItems.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/BuzzChannelMenuItems.kt index 6240aca33a..e6f762607d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/BuzzChannelMenuItems.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/BuzzChannelMenuItems.kt @@ -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) }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index 6e0ae2815f..571e328c9a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt index 33354badb9..afca51af34 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt index 4d725b781b..0fdb50c1f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index a28bdff3c6..a1fe6265a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -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, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomRowUnread.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/RowUnread.kt similarity index 60% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomRowUnread.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/RowUnread.kt index f84fa24049..ffffe43248 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomRowUnread.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/RowUnread.kt @@ -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` (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` rather than a `(route, createdAt)` + * The two collapsed rows are why this carries a `Flow` 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, +) + +fun rowHasUnread( row: Note, account: Account, -): Flow? { +): 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 -> + 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. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt index af44f5e0ca..fa4eb59438 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt index c9be15794f..03eb5beb80 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt index c8fafc54ec..ae5590d4cd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt @@ -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) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt index 1555ab0bc6..157702ec24 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt @@ -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 - }, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt index 39caaba460..83733193ce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt @@ -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 { 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 + get() = + (localFilter as? NotificationFeedFilter) + ?.account + ?.channelInvites + ?.pendingByEventId + ?.value ?: emptyMap() + private fun updateFeed(notes: ImmutableList) { 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 { it.createdAt() }.thenBy { it.id() }) + .sortedWith(NotificationFeedOrderCard) .take(localFilter.limit()) .toImmutableList() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedState.kt index 99125a720f..16dfda866b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedState.kt @@ -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 } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt index f52eae40e6..995c85bb71 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/ChannelInvitesSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/ChannelInvitesSection.kt deleted file mode 100644 index c10fdc654f..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/ChannelInvitesSection.kt +++ /dev/null @@ -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) - } - } - }, - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/ChannelInvitesState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/ChannelInvitesState.kt deleted file mode 100644 index 7b8c39a911..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/ChannelInvitesState.kt +++ /dev/null @@ -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> = - 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()) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt index d2882fe543..b2d4962727 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt @@ -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) }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index 3c5083986a..d2928e5e70 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -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 } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt index 8d084160d8..31b333c2cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt @@ -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) }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletScreen.kt index 1d7941e222..fb85eaec3a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletScreen.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt index 680b41702f..3a389c0e16 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt @@ -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) { diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index 7f82c4ef79..3619e47f67 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -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. --> Přihlásit se bez ptaní, když… - …jde o mé relé nebo o místnost, do které jsem vstoupil …čtu někoho, koho sleduji …píšu někomu, koho sleduji …píšu komukoli jinému diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 9b6f63b595..0609204e38 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -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. --> Ohne Nachfrage anmelden, wenn… - …es mein Relay ist oder ein Raum, dem ich beigetreten bin …ich jemanden lese, dem ich folge …ich jemandem schreibe, dem ich folge …ich jemand anderem schreibe diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 396d7b7ac9..891c1738b8 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -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. --> कब पूछे बिना प्रवेशांकन करें\u2026 - \u2026यह मेरा पुनःप्रसारक है। अथवा एक शाला जिससे मैं जुड चुका \u2026मैं पढ रहा हूँ किसी को जिसका मैं अनुगमन करता हूँ \u2026मैं सन्देश भेज रहा हूँ किसी को जिसका मैं अनुगमन करता हूँ \u2026मैं किसी अन्य को सन्देश भेज रहा हूँ diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index 3260f909e9..26955e7ec9 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -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. --> Jelentkezzen be kérdés nélkül, ha\u2026 - \u2026ez a saját átjátszóm, vagy egy szoba, amihez csatlakozott \u2026olyan valakit olvasok, akit követek \u2026olyan valakinek írok, akit követek \u2026bárki másnak írok diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 999b439ab8..6cfcadf768 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -591,6 +591,9 @@ Zablokuj Zablokuj wątek Odblokuj wątek + Wycisz powiadomienia + Wyłącz wyciszenie powiadomień + Powiadomienia są wyciszone dla tego czatu Zgłoś Nie pokazuj więcej Spam lub oszustwa @@ -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. --> Zaloguj się bez pytania, kiedy\u2026 - \u2026to mój transmiter lub pokój, do którego dołączyłem Czytam wpis osoby, którą obserwuję \u2026wysyłam wiadomość do osoby, którą obserwuję \u2026piszę do wszystkich pozostałych @@ -3094,6 +3096,9 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Usuń Minta Dodaj mint Historia + Ładowanie starszych transakcji… + Brak starszych transakcji + Brak starszych transakcji z transmiterów, które odpowiedziały — z niektórymi nie udało się nawiązać połączenia Twój portfel zapisuje dane automatycznie w miarę dodawania lub usuwania mintów. Klucz Nutzap jest generowany automatycznie przy pierwszym dodaniu minta. Zapisywanie… Klucz Nutzap (zaawansowany) diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index c540be9ad5..8ad1958b4a 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -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. --> Entrar sem perguntar quando… - …for o meu relay, ou uma sala em que entrei …eu estiver lendo alguém que sigo …eu estiver enviando mensagem para alguém que sigo …eu estiver enviando mensagem para qualquer outra pessoa diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index a743b77c10..b08b32c9a6 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -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. --> Logga in utan att fråga när… - …det är mitt relä, eller ett rum jag gått med i …jag läser någon jag följer …jag skriver till någon jag följer …jag skriver till någon annan diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b2a94d068f..a8e4100d84 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -602,6 +602,9 @@ Block Mute thread Unmute thread + Mute notifications + Unmute notifications + Notifications are muted for this chat Report Don\'t show again Spam or scams @@ -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. --> Log in without asking when\u2026 - \u2026it\'s my relay, or a room I joined + \u2026it\'s my relay, or a room I joined or follow \u2026I\'m reading someone I follow \u2026I\'m messaging someone I follow \u2026I\'m messaging anyone else @@ -2889,6 +2892,9 @@ No messages yet Added to %1$s Someone + added you to this channel + %1$s added you to this channel + You were added to this channel Ignore Leave Join this group to send messages. @@ -3259,6 +3265,9 @@ Remove mint Add mint History + Loading older transactions… + No older transactions + No older transactions from the relays that answered — some could not be reached Your wallet saves automatically as you add or remove mints. A nutzap key is created for you the first time you add a mint. Saving… Nutzap key (advanced) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/MutedPublicChatsSyncTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/MutedPublicChatsSyncTest.kt new file mode 100644 index 0000000000..51a452c384 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/MutedPublicChatsSyncTest.kt @@ -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(json) + assertNull(decoded.mutedPublicChats) + } + + @Test + fun explicitlyEmptyListDecodesToEmptyNotNull() { + val json = """{"pinnedRooms":[],"mutedPublicChats":[]}""" + val decoded = JsonMapper.fromJson(json) + assertEquals(emptyList(), decoded.mutedPublicChats) + } + + @Test + fun jsonRoundTripPreservesMutedChats() { + val internal = AccountChatPreferencesInternal(emptyList(), listOf(channelA, channelB)) + val decoded = JsonMapper.fromJson(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(), mergeMutedPublicChats(emptySet(), null)) + } + + @Test + fun explicitEmptyListClearsTheLocalMuteSet() { + // A client that knows the field saying "unmute everything" must be obeyed. + assertEquals(emptySet(), mergeMutedPublicChats(setOf(channelA), emptyList())) + } + + @Test + fun remoteListReplacesTheLocalMuteSet() { + assertEquals(setOf(channelB), mergeMutedPublicChats(setOf(channelA), listOf(channelB))) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/MutedPublicChatsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/MutedPublicChatsTest.kt new file mode 100644 index 0000000000..3a8a48b1c6 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/MutedPublicChatsTest.kt @@ -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>) = 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>) = 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))) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/buzz/BuzzChannelTypesTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/buzz/BuzzChannelTypesTest.kt new file mode 100644 index 0000000000..212b832a30 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/buzz/BuzzChannelTypesTest.kt @@ -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"]) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/preferences/BuzzAttestationRestoreTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/preferences/BuzzAttestationRestoreTest.kt new file mode 100644 index 0000000000..a14c8cdac8 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/preferences/BuzzAttestationRestoreTest.kt @@ -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)) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthReadFollowsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthReadFollowsTest.kt new file mode 100644 index 0000000000..4d41a8b9f3 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthReadFollowsTest.kt @@ -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 = 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)) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/buzz/FilterWorkspaceInboxToPubkeyTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/buzz/FilterWorkspaceInboxToPubkeyTest.kt new file mode 100644 index 0000000000..1349e0ca9a --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/buzz/FilterWorkspaceInboxToPubkeyTest.kt @@ -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()) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip60Cashu/FilterCashuHistoryTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip60Cashu/FilterCashuHistoryTest.kt new file mode 100644 index 0000000000..e6c7c82fc2 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip60Cashu/FilterCashuHistoryTest.kt @@ -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()) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedOrderCardTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedOrderCardTest.kt new file mode 100644 index 0000000000..a10d2f5e03 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedOrderCardTest.kt @@ -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() }, + ) + } +} diff --git a/cli/README.md b/cli/README.md index 76ec8f2844..7266b7df65 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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.) | diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 590964f6d4..d0fab242bd 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -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. | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/CashuContext.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/CashuContext.kt index 28cf1d9a63..0ac9e65b23 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/CashuContext.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/CashuContext.kt @@ -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 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuBalanceCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuBalanceCommand.kt index 1d5b1ee728..7db9036e58 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuBalanceCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuBalanceCommand.kt @@ -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 -> diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuCommands.kt index 9114c438d1..4d8375daec 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuCommands.kt @@ -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 ", + usage = "cashu ", 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) }, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuSyncCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuSyncCommand.kt new file mode 100644 index 0000000000..e1680a11a6 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuSyncCommand.kt @@ -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, + ): 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 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuWalletCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuWalletCommands.kt index b6427b2b34..5671d05d9a 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuWalletCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuWalletCommands.kt @@ -115,9 +115,22 @@ object CashuWalletCommands { dataDir: DataDir, rest: Array, ): 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(), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index e11a4278fa..0a9699bd1c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -560,11 +560,27 @@ object ConcordActions { fun guestbookMembers( wraps: List, guestbook: GroupKey, - ): Set { + ): Set = 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): Set { val latest = HashMap() - 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 } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzChannelInvites.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzChannelInvites.kt index 46f704c4ee..8c877f8b8d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzChannelInvites.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzChannelInvites.kt @@ -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>() - private val mutableFlow = MutableStateFlow>>(emptyMap()) - - /** Per-viewer pending invites (`channelId` -> who/where/when). */ - val flow: StateFlow>> = 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): Map { + val newest = HashMap(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): Map = + 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, + dismissed: Set, + joined: Set, + classify: (channelId: String, relay: NormalizedRelayUrl) -> ChannelClassification, + ): List = + 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 = mutableFlow.value[viewer] ?: emptyMap() - - private fun snapshot(): Map> = 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, + dismissed: Set, + joined: Set, + classify: (channelId: String, relay: NormalizedRelayUrl) -> ChannelClassification, + ): Map = pendingInvites(viewer, notices, dismissed, joined, classify).associateBy { it.eventId } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzChannelStars.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzChannelStars.kt index c5237ed38c..c7241132a5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzChannelStars.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzChannelStars.kt @@ -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>(emptySet()) /** The starred channel ids; the community view collects this to pin + badge them. */ @@ -52,9 +57,4 @@ object BuzzChannelStars { fun restore(ids: Set) { starred.value = ids } - - /** Test-only: clears the set so unit tests don't leak state into each other. */ - fun clearForTesting() { - starred.value = emptySet() - } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzDmChannels.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzDmChannels.kt index 0f4274e989..d5e6dc98d7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzDmChannels.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzDmChannels.kt @@ -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, + ): 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 = mutableFlow.value[viewer] ?: emptyMap() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzHeldAttestations.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzHeldAttestations.kt index 2680dede13..5ec6667c26 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzHeldAttestations.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzHeldAttestations.kt @@ -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`, 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>(emptyMap()) +class BuzzHeldAttestations( + private val agentPubKey: HexKey, +) { + private val held = MutableStateFlow(null) - /** All held attestations, keyed by agent pubkey; UI can collect this. */ - val flow: StateFlow> = 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 = 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? = attestationFor(agentPubKey)?.toTag() + fun authTag(): Array? = 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) { - 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 } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzWorkspaces.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzWorkspaces.kt index f5a6e42574..2b599f7404 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzWorkspaces.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzWorkspaces.kt @@ -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>(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() - } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt index ba91cfb8e4..d21038c171 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt @@ -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() + /** + * 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() + + /** + * 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>() @@ -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) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt index 35ca044868..e54b67c629 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt @@ -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 = 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 = OWN_CASHU_KINDS, +): List = + 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 = + listOf( + Filter( + kinds = listOf(NutzapEvent.KIND), + tags = mapOf("p" to listOf(pubkey)), + ), + ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthResolver.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthResolver.kt index 26b61fe48b..aee001ad97 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthResolver.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthResolver.kt @@ -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) } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzChannelInvitesTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzChannelInvitesTest.kt new file mode 100644 index 0000000000..613d185ada --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzChannelInvitesTest.kt @@ -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, + dismissed: Set = emptySet(), + joined: Set = 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) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzChannelStarsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzChannelStarsTest.kt new file mode 100644 index 0000000000..feae2dcb2a --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzChannelStarsTest.kt @@ -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) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzDmChannelsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzDmChannelsTest.kt index 09d88a6668..d734c0fdec 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzDmChannelsTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzDmChannelsTest.kt @@ -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)) + } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzHeldAttestationsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzHeldAttestationsTest.kt index 0e9376b956..fde7288201 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzHeldAttestationsTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzHeldAttestationsTest.kt @@ -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" } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzWorkspacesTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzWorkspacesTest.kt index 9e72bbcd3b..8a8195eac6 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzWorkspacesTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzWorkspacesTest.kt @@ -29,44 +29,74 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class BuzzWorkspacesTest { + // A fresh instance per test: the joined set is per-account state now, not a process-wide + // singleton, so there is nothing to reset between tests. The dialect mark stays global. + private val workspaces = BuzzWorkspaces() + private val a = RelayUrlNormalizer.normalize("wss://a.buzz.example") private val b = RelayUrlNormalizer.normalize("wss://b.buzz.example") @BeforeTest fun setup() { - BuzzWorkspaces.clearForTesting() BuzzRelayDialect.clearForTesting() } @AfterTest fun teardown() { - BuzzWorkspaces.clearForTesting() BuzzRelayDialect.clearForTesting() } @Test fun joiningRecordsAndMarksDialect() { - assertTrue(BuzzWorkspaces.join(a)) - assertTrue(BuzzWorkspaces.isJoined(a)) - assertEquals(setOf(a), BuzzWorkspaces.flow.value) + assertTrue(workspaces.join(a)) + assertTrue(workspaces.isJoined(a)) + assertEquals(setOf(a), workspaces.flow.value) // Joining also marks the relay a Buzz dialect so its events render as workspace channels. assertTrue(BuzzRelayDialect.isBuzz(a)) // Re-joining is a no-op (returns false). - assertFalse(BuzzWorkspaces.join(a)) + assertFalse(workspaces.join(a)) } @Test fun leaveRemoves() { - BuzzWorkspaces.join(a) - BuzzWorkspaces.join(b) - BuzzWorkspaces.leave(a) - assertEquals(setOf(b), BuzzWorkspaces.flow.value) - assertFalse(BuzzWorkspaces.isJoined(a)) + workspaces.join(a) + workspaces.join(b) + workspaces.leave(a) + assertEquals(setOf(b), workspaces.flow.value) + assertFalse(workspaces.isJoined(a)) } @Test fun restoreReplacesAndMarksAll() { - BuzzWorkspaces.join(a) - BuzzWorkspaces.restore(setOf(b)) - assertEquals(setOf(b), BuzzWorkspaces.flow.value) + workspaces.join(a) + workspaces.restore(setOf(b)) + assertEquals(setOf(b), workspaces.flow.value) assertTrue(BuzzRelayDialect.isBuzz(b)) } + + @Test + fun oneAccountsJoinDoesNotJoinForAnother() { + // The bystander-AUTH leak this became per-account for: while the joined set was a + // process-wide singleton it fed AuthCoordinator.isFirstParty, so an account that never + // redeemed the invite was auto-authenticated (and identified) on someone else's workspace. + val mine = BuzzWorkspaces() + val theirs = BuzzWorkspaces() + + mine.join(a) + + assertTrue(mine.isJoined(a)) + assertFalse(theirs.isJoined(a)) + assertEquals(emptySet(), theirs.flow.value) + } + + @Test + fun leavingOnOneAccountLeavesTheOtherJoined() { + val mine = BuzzWorkspaces() + val theirs = BuzzWorkspaces() + mine.join(a) + theirs.join(a) + + mine.leave(a) + + assertFalse(mine.isJoined(a)) + assertTrue(theirs.isJoined(a)) + } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordGuestbookFoldTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordGuestbookFoldTest.kt new file mode 100644 index 0000000000..ec95baabbf --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordGuestbookFoldTest.kt @@ -0,0 +1,76 @@ +/* + * 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.concord + +import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The guestbook re-folds on every arriving wrap, so opening the buffer each time is quadratic in + * NIP-44 decrypts (plus two signature verifies apiece). A cold start measured 6,229 envelope opens + * over 448 distinct wraps — ~13x redundant, and effectively all of the app's NIP-44 traffic. + */ +class ConcordGuestbookFoldTest { + private val owner = NostrSignerInternal(KeyPair()) + + @Test + fun opensEachGuestbookWrapOnceAcrossSequentialArrivals() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val entry = + ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + controlPk = community.controlPkHex, + controlRoot = community.controlRoot.toHexKey(), + relays = listOf("wss://r.example"), + name = "Nostrichs", + ) + val session = ConcordCommunitySession(entry, owner.pubKey) { _, _, _, _ -> } + community.genesisWraps.forEach { session.ingest(it) } + + val guestbook = ConcordActions.guestbookPlane(community.communityRoot, community.communityId, community.rootEpoch) + val members = List(12) { NostrSignerInternal(KeyPair()) } + val joins = members.mapIndexed { i, m -> ConcordActions.buildGuestbookJoin(m, guestbook, createdAt = 2L + i) } + + // Arrivals land one at a time, exactly as the relay delivers them. + joins.forEach { session.ingest(it) } + + // Linear, not 12*13/2 = 78. + assertEquals(joins.size, session.guestbookOpens, "guestbook wraps were re-decrypted on later folds") + assertEquals(members.mapTo(HashSet()) { it.pubKey.lowercase() }, session.members.value) + + // A duplicate delivery re-folds nothing and opens nothing. + session.ingest(joins.first()) + assertEquals(joins.size, session.guestbookOpens) + assertEquals(members.mapTo(HashSet()) { it.pubKey.lowercase() }, session.members.value) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthResolverTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthResolverTest.kt index 9ababd1fdd..1937b49ea2 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthResolverTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthResolverTest.kt @@ -134,6 +134,31 @@ class RelayAuthResolverTest { ) } + @Test + fun readFollowsGrantsOnTheFollowsOwnOutboxRelay() { + // The situation the toggle is *named for*: someone we follow publishes to a relay of theirs + // that we do not use. `isFirstParty` is false by construction there — the relay is theirs, we + // have no traffic of our own on it — so gating this category on it made "…I'm reading someone + // I follow" unreachable: every follow's outbox relay prompted, on an account with the toggle + // explicitly on. The only time it ever granted was when the relay was also on our own list, + // where `myRelaysAndVenues` already covered it. + assertEquals( + RelayAuthVerdict.ALLOW, + resolve(inputs(servesFollowedReadCounterparty = true, isFirstParty = false)), + ) + // Still off when the toggle is off. + assertEquals( + RelayAuthVerdict.ASK, + resolve( + inputs( + servesFollowedReadCounterparty = true, + isFirstParty = false, + toggles = RelayAuthCustomToggles(readFollows = false), + ), + ), + ) + } + @Test fun customMessageFollowsToggleGatesMessagingFollows() { assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(servesFollowedWriteCounterparty = true))) @@ -176,9 +201,22 @@ class RelayAuthResolverTest { val allOn = RelayAuthCustomToggles(myRelaysAndVenues = true, readFollows = true, messageFollows = true, messageStrangers = true) assertEquals(RelayAuthVerdict.ASK, resolve(inputs(toggles = allOn, isInMyRelayList = true, isFirstParty = false))) assertEquals(RelayAuthVerdict.ASK, resolve(inputs(toggles = allOn, servesTrustedVenue = true, isFirstParty = false))) - assertEquals(RelayAuthVerdict.ASK, resolve(inputs(toggles = allOn, servesFollowedReadCounterparty = true, isFirstParty = false))) assertEquals(RelayAuthVerdict.ASK, resolve(inputs(toggles = allOn, servesFollowedWriteCounterparty = true, isFirstParty = false))) assertEquals(RelayAuthVerdict.ASK, resolve(inputs(toggles = allOn, servesStrangerWriteCounterparty = true, isFirstParty = false))) + // readFollows is deliberately absent: see readFollowsGrantsOnTheFollowsOwnOutboxRelay. Its + // relay is the *follow's*, never ours, so the gate could only ever empty the category. + } + + @Test + fun readFollowsExemptionDoesNotLeakIntoTheOtherCategories() { + // Only the read category is exempt. With readFollows on but nothing being read from a follow, + // a non-first-party relay still asks for every other reason it might want us. + val allOn = RelayAuthCustomToggles(myRelaysAndVenues = true, readFollows = true, messageFollows = true, messageStrangers = true) + assertEquals(RelayAuthVerdict.ASK, resolve(inputs(toggles = allOn, isInMyRelayList = true, isFirstParty = false))) + assertEquals(RelayAuthVerdict.ASK, resolve(inputs(toggles = allOn, servesFollowedWriteCounterparty = true, isFirstParty = false))) + // The bystander case the gate exists for: another account's outgoing DM names someone we + // follow. Ours is not the traffic, so we do not sign for it without being asked. + assertEquals(RelayAuthVerdict.ASK, resolve(inputs(toggles = allOn, servesStrangerWriteCounterparty = true, isFirstParty = false))) } @Test @@ -194,7 +232,8 @@ class RelayAuthResolverTest { @Test fun customPolicyStillRequiresFirstParty() { // The first-party gate belongs to CUSTOM: a toggle that matches is not enough if the only reason - // we are on this relay belongs to somebody else. + // we are on this relay belongs to somebody else. (Except readFollows, whose relay always + // belongs to the follow — see readFollowsGrantsOnTheFollowsOwnOutboxRelay.) val allOn = RelayAuthCustomToggles(myRelaysAndVenues = true, readFollows = true, messageFollows = true, messageStrangers = true) assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(toggles = allOn, isInMyRelayList = true, isFirstParty = true))) assertEquals(RelayAuthVerdict.ASK, resolve(inputs(toggles = allOn, isInMyRelayList = true, isFirstParty = false))) diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt index 5b461d08d2..99def91987 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt @@ -61,6 +61,11 @@ import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.secp256k1.Secp256k1 +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -1111,13 +1116,35 @@ class CashuWalletOps( * funds into the wallet. * * Process: - * 1. Fetch the mint's active keyset. - * 2. Drive [CashuMintOperations.restore] — scans counters in batches - * until enough empty batches in a row signal "no more proofs". - * 3. Filter the returned proofs through /v1/checkstate to keep only + * 1. List **every** keyset the mint has published for this unit — see + * below on why the active one is not enough. + * 2. Drive [CashuMintOperations.restore] per keyset — scans counters in + * batches until enough empty batches in a row signal "no more proofs". + * 3. Filter the pooled proofs through /v1/checkstate to keep only * UNSPENT ones — NUT-09 alone returns even spent proofs. * 4. Drop secrets already present in [existingSecrets]. * + * ### Every keyset, not just the active one + * + * NUT-13 derives a separate counter chain per keyset + * (`m/129372'/0'/'/'`), so a proof minted under a + * keyset the mint has since rotated out is not reachable from the active + * keyset's derivation path at any counter. Scanning only the active keyset + * therefore reports "nothing to recover" for exactly the balance a restore + * exists to find: the older it is, the more likely its keyset is retired. + * That looked like an empty wallet rather than an incomplete scan, because + * a scan that never asks and a scan that finds nothing return the same + * thing. + * + * A keyset that errors out (mint won't serve its keys, restore rejected) + * is logged and skipped so one bad keyset can't sink the whole recovery. + * + * [RecoverableProofs.keysetId] and [RecoverableProofs.nextCounterAfterScan] + * continue to describe the **active** keyset specifically, because that is + * the only chain the wallet will derive new secrets on — an inactive + * keyset can never receive another mint, so advancing a counter for it + * would protect nothing. + * * Since it neither signs, swaps, nor publishes, this is safe to run * speculatively across many candidate wallets/seeds. */ @@ -1129,28 +1156,61 @@ class CashuWalletOps( ): RecoverableProofs { seedWarmer() val mintOps = ops(mintUrl) - val activeKeyset = mintOps.activeKeyset() - val result = - mintOps.restore( - seed = seed, - keysetId = activeKeyset.id, - startCounter = startCounter, - ) - if (result.proofs.isEmpty()) { - return RecoverableProofs(mintUrl, result.keysetId, emptyList(), result.nextCounterAfterScan) + val activeKeysetId = mintOps.activeKeyset().id + val keysetIds = mintOps.restorableKeysetIds() + + // Scan keysets concurrently, a few at a time. Each keyset's walk costs + // at least `emptyBatchesToStop` /v1/restore round-trips with batch + // bodies up to MAX_RESTORE_REQUEST_ITEMS outputs — so a mint that has + // rotated ten times turns a scan that used to be three requests into + // thirty, and run end to end that is a minute of staring at a spinner + // on mobile. The walks are independent (separate derivation chains, + // read-only at the mint), so the only reason to serialize them is + // politeness to the mint; [RESTORE_KEYSET_CONCURRENCY] keeps that + // while cutting the wall clock by roughly the same factor. + val semaphore = Semaphore(RESTORE_KEYSET_CONCURRENCY) + val results = + coroutineScope { + keysetIds + .map { keysetId -> + async { + semaphore.withPermit { + keysetId to + runCatching { + mintOps.restore(seed = seed, keysetId = keysetId, startCounter = startCounter) + }.onFailure { + // One retired keyset the mint won't serve keys for must not + // sink the recovery of every other keyset at this mint. + Log.w("CashuWalletOps") { + "NUT-09 restore of keyset $keysetId at $mintUrl failed: ${describeMintError(it)}" + } + }.getOrNull() + } + } + }.awaitAll() + } + + val recovered = mutableListOf() + var activeNextCounter = startCounter + for ((keysetId, result) in results) { + if (result == null) continue + if (keysetId == activeKeysetId) activeNextCounter = result.nextCounterAfterScan + recovered += result.proofs.map { it.proof } + } + + if (recovered.isEmpty()) { + return RecoverableProofs(mintUrl, activeKeysetId, emptyList(), activeNextCounter) } // /v1/checkstate filters out proofs that were minted but already // melted or sent. Without this, recovered "balance" would include // already-spent proofs that the mint would reject at next swap. - val stateMap = mintOps.checkStates(result.proofs.map { it.proof }) + val stateMap = mintOps.checkStates(recovered) val unspent = - result.proofs - .filter { recovered -> - stateMap[recovered.proof.secret] == ProofState.UNSPENT && - recovered.proof.secret !in existingSecrets - }.map { it.proof } - return RecoverableProofs(mintUrl, result.keysetId, unspent, result.nextCounterAfterScan) + recovered.filter { proof -> + stateMap[proof.secret] == ProofState.UNSPENT && proof.secret !in existingSecrets + } + return RecoverableProofs(mintUrl, activeKeysetId, unspent, activeNextCounter) } /** @@ -1262,6 +1322,14 @@ class CashuWalletOps( * cheap (one /v1/restore batch). */ private const val DEFAULT_RESTORE_SCAN_BACK: Long = 32L + + /** + * How many of a mint's keysets [scanRecoverableProofs] walks at once. + * Small on purpose: the walks are read-only but each one issues a + * series of large `/v1/restore` bodies, and a recovery is not worth + * tripping a mint's rate limiter over. + */ + private const val RESTORE_KEYSET_CONCURRENCY: Int = 3 } } diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuBalanceTruncationTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuBalanceTruncationTest.kt new file mode 100644 index 0000000000..48053dd60f --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuBalanceTruncationTest.kt @@ -0,0 +1,238 @@ +/* + * 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.cashu + +import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletQueryState +import com.vitorpamplona.amethyst.commons.relayClient.assemblers.cashuInboundNutzapBackfillFilters +import com.vitorpamplona.amethyst.commons.relayClient.assemblers.cashuOwnEventBackfillFilters +import com.vitorpamplona.amethyst.commons.relayClient.assemblers.cashuProofBackfillFilters +import com.vitorpamplona.amethyst.commons.relayClient.assemblers.cashuWalletFilters +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip60Cashu.token.CashuProof +import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent +import com.vitorpamplona.quartz.nip60Cashu.token.TokenContent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The NIP-60 balance is a pure function of the kind:7375 events the client + * holds, and those arrive over a subscription a relay is free to truncate. This + * pins both halves of that problem: + * + * - what a truncated delivery does to the number the user sees, and + * - that the backfill filter is shaped so `fetchAllPages` can walk past the + * truncation instead of inheriting it. + */ +class CashuBalanceTruncationTest { + private val owner: HexKey = "a".repeat(64) + + /** + * Build a kind:7375 whose decrypted content is [content]. The event's own + * `content` string is irrelevant here — [CashuWalletReader.computeUnspent] + * is handed the already-decrypted map, exactly as the wallet holder does + * after its NIP-44 pass. + */ + private fun tokenEvent( + idPrefix: String, + createdAt: Long, + ): CashuTokenEvent = + CashuTokenEvent( + id = idPrefix.padEnd(64, '0'), + pubKey = owner, + createdAt = createdAt, + tags = emptyArray(), + content = "", + sig = "0".repeat(128), + ) + + private fun proofs( + keysetId: String, + vararg amounts: Long, + ) = amounts.mapIndexed { i, amount -> + CashuProof(id = keysetId, amount = amount, secret = "$keysetId-$i-$amount", c = "02${"0".repeat(64)}") + } + + /** + * Three mints, funded at different times: the oldest one holds most of the + * money and hasn't been touched since. This is the shape of a real wallet — + * activity concentrates on the mint you last used. + */ + private fun wallet(): Pair, Map> { + val oldMint = tokenEvent("aa", createdAt = 1_000) + val midMint = tokenEvent("bb", createdAt = 2_000) + val hotMint = tokenEvent("cc", createdAt = 3_000) + + val contents = + mapOf( + oldMint.id to TokenContent(mint = "https://old.mint", proofs = proofs("ks1", 1024L, 459L)), + midMint.id to TokenContent(mint = "https://mid.mint", proofs = proofs("ks2", 1000L)), + hotMint.id to TokenContent(mint = "https://hot.mint", proofs = proofs("ks3", 32L, 7L)), + ) + return listOf(oldMint, midMint, hotMint) to contents + } + + private fun balanceOf( + events: List, + contents: Map, + ) = CashuWalletReader + .computeUnspent(events, contents) + .sumOf { it.content.totalAmount() } + + @Test + fun `complete delivery reports the whole balance`() { + val (events, contents) = wallet() + assertEquals(2522L, balanceOf(events, contents)) + } + + @Test + fun `a relay that serves only the newest events under-reports the balance`() { + val (events, contents) = wallet() + + // What a capped REQ returns: the newest N matching events. The proofs + // that fall off are the old, untouched mints — precisely the balance + // the user forgot they had, which is why the shortfall is large rather + // than marginal. + val newestOnly = events.sortedByDescending { it.createdAt }.take(1) + + assertEquals(39L, balanceOf(newestOnly, contents)) + assertNotEquals( + "a truncated delivery must not be mistaken for a complete one", + balanceOf(events, contents), + balanceOf(newestOnly, contents), + ) + } + + @Test + fun `each device sees a different number for the same wallet`() { + val (events, contents) = wallet() + val byAge = events.sortedByDescending { it.createdAt } + + // Same account, same relays, three delivery depths — three balances, + // none of which is an error the client can detect locally: every one of + // them is a correct sum over an incomplete set. + assertEquals(39L, balanceOf(byAge.take(1), contents)) + assertEquals(1039L, balanceOf(byAge.take(2), contents)) + assertEquals(2522L, balanceOf(byAge.take(3), contents)) + } + + @Test + fun `del rollover still retires spent tokens once everything is delivered`() { + val (events, contents) = wallet() + val spent = events.first { it.id.startsWith("aa") } + val rollover = tokenEvent("dd", createdAt = 4_000) + + val withRollover = events + rollover + val contentsWithRollover = + contents + + ( + rollover.id to + TokenContent( + mint = "https://old.mint", + proofs = proofs("ks1", 512L), + del = listOf(spent.id), + ) + ) + + // Backfilling every kind:7375 the account ever published cannot inflate + // the balance through superseded events: the `del` chain retires them. + assertEquals(1551L, balanceOf(withRollover, contentsWithRollover)) + } + + @Test + fun `backfill filter asks for proofs only, with no limit for fetchAllPages to inherit`() { + val filters = cashuProofBackfillFilters(owner) + + assertEquals(1, filters.size) + val filter = filters.single() + + assertEquals(listOf(CashuTokenEvent.KIND), filter.kinds) + assertEquals(listOf(owner), filter.authors) + + // fetchAllPages ends the walk on a fulfilled `limit` (End.LIMIT_REACHED) + // rather than on a drained page, so a limit here would reintroduce the + // very truncation the walk exists to defeat. + assertNull("the paged walk must run to exhaustion, not to a limit", filter.limit) + + // Cursors are what make paging work; a preset window would pin the walk + // to one slice of history. + assertNull(filter.since) + assertNull(filter.until) + } + + @Test + fun `own-event backfill covers every kind the live subscription authors`() { + val relay = RelayUrlNormalizer.normalize("wss://relay.example.com") + val liveOwnKinds = + cashuWalletFilters( + CashuWalletQueryState(owner, setOf(relay), emptySet()), + since = null, + ).single { it.filter.authors == listOf(owner) } + .filter.kinds + .orEmpty() + + val backfillKinds = cashuOwnEventBackfillFilters(owner).single().kinds.orEmpty() + + // A headless client has no scrolling list to page for it, so its one-shot walk has to reach + // everything the capped live query would have asked for — otherwise the gap just moves. + liveOwnKinds.forEach { + assertTrue("backfill must cover live-authored kind $it", it in backfillKinds) + } + } + + @Test + fun `inbound nutzap backfill matches by recipient tag, not author`() { + val f = cashuInboundNutzapBackfillFilters(owner).single() + + // Someone else signs a nutzap addressed to me, so it can only be found by the #p tag — + // authors=[me] would return nothing and look like an empty inbox. + assertEquals(listOf(owner), f.tags?.get("p")) + assertNull(f.authors) + assertNull("paged walks must not carry a limit", f.limit) + } + + @Test + fun `the live subscription mixes proofs with history — which is what starves them`() { + val relay = RelayUrlNormalizer.normalize("wss://relay.example.com") + val filters = + cashuWalletFilters( + CashuWalletQueryState( + pubkey = owner, + ownEventRelays = setOf(relay), + inboxRelays = emptySet(), + ), + since = null, + ) + + val ownFilter = filters.single { it.filter.authors == listOf(owner) }.filter + val kinds = ownFilter.kinds.orEmpty() + + // One REQ carries the proofs and the (far more numerous) history rows. + // A cap applied to that combined stream is spent mostly on history, so + // this filter alone can never be trusted to deliver the whole proof set + // — hence the separate paged backfill. + assertTrue(CashuTokenEvent.KIND in kinds) + assertTrue(kinds.size > 1) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/invite/BuzzInviteLink.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/invite/BuzzInviteLink.kt index 044ca61fde..1f1b0fbc37 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/invite/BuzzInviteLink.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/invite/BuzzInviteLink.kt @@ -27,8 +27,12 @@ import kotlin.io.encoding.ExperimentalEncodingApi /** * A parsed Buzz workspace invite link: `https:///invite/`, where `` is a - * relay-signed token `.` (base64url, JWT-style but not a JWT). The - * payload names the community, the granted role, an expiry and a nonce. + * relay-signed token in one of two shapes: + * + * - `.` (base64url, JWT-style but not a JWT) — the payload names the + * community, the granted role, an expiry and a nonce. + * - `v2.` — a bare server-side handle. Nothing about the invite is readable here; + * the relay resolves it on claim. * * A Buzz invite is **not** a NIP-29 invite code (kind 9009) — it is redeemed over HTTP against * the relay's tenant host: `POST /api/invites/claim`, NIP-98-signed by the joining key, after @@ -43,11 +47,15 @@ data class BuzzInvite( val host: String, /** The full opaque token (`payload.sig`) to hand back to the relay's claim endpoint. */ val code: String, - /** The community (workspace/tenant) UUID the invite admits into — the payload's `c`. */ + /** + * The community (workspace/tenant) UUID the invite admits into — the payload's `c`. Empty for + * a `v2.` token, whose code is opaque: the relay resolves the community on claim and returns + * it, so nothing client-side needs it (the join hands off to the tenant host, not the id). + */ val communityId: String, - /** The role granted on claim (e.g. `member`) — the payload's `r`. */ + /** The role granted on claim (e.g. `member`) — the payload's `r`, or [DEFAULT_ROLE] for `v2.`. */ val role: String, - /** Unix-seconds expiry, or null when the payload omits it — the payload's `e`. */ + /** Unix-seconds expiry, or null when the payload omits it (always for `v2.`) — the payload's `e`. */ val expiresAt: Long?, ) { /** The tenant's relay websocket URL. */ @@ -63,6 +71,12 @@ data class BuzzInvite( object BuzzInviteLink { private const val MARKER = "/invite/" + /** The payload segment of an opaque, server-resolved token. */ + private const val V2_PREFIX = "v2" + + /** What the relay grants when the token doesn't say — and it never says for `v2.`. */ + private const val DEFAULT_ROLE = "member" + private val JSON = Json { ignoreUnknownKeys = true } @Serializable @@ -100,6 +114,16 @@ object BuzzInviteLink { val payloadB64 = code.substringBefore('.') if (payloadB64 == code || payloadB64.isEmpty()) return null + // `v2.` carries no client-readable payload — the community, role and expiry live + // only on the relay, which resolves the code on claim and returns them. There is nothing + // to decode and nothing to lose by admitting it: the join flow needs the host (for the + // relay url and the REST base) and the code, both of which the url itself carries, and the + // claim response supplies the rest. Matched on the literal prefix rather than by relaxing + // the decode below, so `…/invite/anything.else` still fails to parse. + if (payloadB64 == V2_PREFIX) { + return BuzzInvite(host = host, code = code, communityId = "", role = DEFAULT_ROLE, expiresAt = null) + } + val payload = try { val bytes = Base64.UrlSafe.decode(padBase64(payloadB64)) @@ -113,7 +137,7 @@ object BuzzInviteLink { host = host, code = code, communityId = community, - role = payload.r?.takeIf { it.isNotBlank() } ?: "member", + role = payload.r?.takeIf { it.isNotBlank() } ?: DEFAULT_ROLE, expiresAt = payload.e, ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/content/ContentHashTags.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/content/ContentHashTags.kt index 51a70d5331..748550ce50 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/content/ContentHashTags.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/content/ContentHashTags.kt @@ -22,19 +22,43 @@ package com.vitorpamplona.quartz.nip10Notes.content val hashtagSearch = Regex("(?:\\s|\\A)#([^\\s!@#\$%^&*()=+./,\\[{\\]};:'\"?><]+)") +/** + * Characters that end a hashtag: the punctuation class spelled out in [hashtagSearch], plus ASCII + * whitespace. Everything else continues the tag — including every non-ASCII character, since the + * regex's class is ASCII-only, so accented letters, CJK and emoji are all valid tag content. + */ +private val HASHTAG_TERMINATORS = + BooleanArray(128).apply { + for (c in 0x09..0x0D) this[c] = true + this[' '.code] = true + for (c in "!@#\u0024%^&*()=+./,[{]};:'\"?><") this[c.code] = true + } + +/** + * True while [c] can still be part of a hashtag. + * + * Non-ASCII always continues the tag: [hashtagSearch]'s excluded set is entirely ASCII and its + * `\s` is ASCII-only, so nothing above 0x7F was ever excluded. + */ +private fun isHashtagChar(c: Char): Boolean = c.code >= 128 || !HASHTAG_TERMINATORS[c.code] + /** * Collects the hashtags in [content]. * - * [hashtagSearch] requires `(?:\s|\A)` immediately before the `#`, so every match - * starts either at position 0 or at a whitespace. That lets the scan jump between - * `#` occurrences with `indexOf` — an intrinsified char search — and apply the - * regex **anchored** at each, instead of letting `findAll` drive the regex engine - * from every position in the string. + * Jumps between `#` occurrences with `indexOf` — an intrinsified char search — and then matches + * `#` directly, character by character, rather than anchoring a regex there. * - * Measured on the production content distribution (median 529 B, tail to 767 KB): - * ~68 MB/s -> ~1,240 MB/s on hashtag-dense text (18x) and ~19,000 MB/s when the - * content has no `#` at all (up to 300x). Equivalence with the previous `findAll` - * implementation is guarded by `RegexContentBenchmark` in `commons`. + * **Why not a regex.** On Android `java.util.regex` is ICU-backed, and `Matcher.region()` -> + * `reset()` -> `MatcherNative.setInput()` copies the *entire input* into native memory on every + * call. `Regex.matchAt` builds a fresh Matcher per call, so anchoring one at each candidate cost a + * full native UTF-16 copy of the note's content **per `#`** — the same defect that drove the app's + * native heap to ~1.9GB on a cold start via the NIP-19 scanner. This one is worse: measured over + * 2588 real notes it minted 43,626 Matchers copying 9.6GB in total, with a single 119KB note + * costing 279MB, because a whitespace-preceded `#` is far more common in prose than a NIP-19 + * prefix. The Java `Matcher` object is tiny, so Java-heap-driven GC had no reason to reclaim them + * promptly while each pinned native memory. + * + * [hashtagSearch] is kept as the specification the scan is tested against, not used here. */ fun findHashtags( content: String, @@ -44,19 +68,17 @@ fun findHashtags( var h = content.indexOf('#') while (h >= 0) { - if (h == 0 || content[h - 1].isWhitespace()) { - val match = - try { - hashtagSearch.matchAt(content, if (h == 0) 0 else h - 1) - } catch (e: Exception) { - null - } - if (match != null) { - val tag = match.groups[1]?.value - if (tag != null && tag.isNotBlank()) { - output.add(tag) - } - h = content.indexOf('#', match.range.last + 1) + // `(?:\s|\A)` — the `#` must open the string or follow one ASCII space character. + if (h == 0 || isAsciiRegexSpace(content[h - 1])) { + var end = h + 1 + while (end < content.length && isHashtagChar(content[end])) end++ + // The tag group is `+`, so it needs at least one character. + if (end > h + 1) { + val tag = content.substring(h + 1, end) + // Non-ASCII whitespace (U+00A0 and friends) is valid tag content to the regex but + // still blank to Kotlin, and the old code dropped those too. + if (tag.isNotBlank()) output.add(tag) + h = content.indexOf('#', end) continue } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/content/ContentScanChars.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/content/ContentScanChars.kt new file mode 100644 index 0000000000..dcf1138af0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/content/ContentScanChars.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip10Notes.content + +/** + * `\s` as `java.util.regex` applies it *without* `UNICODE_CHARACTER_CLASS`: space plus the five + * control characters `\t \n \x0B \f \r`, which are contiguous at 0x09..0x0D — and nothing else. + * + * The scanners in this package hand-roll grammars that used to be regexes, so they must use this + * rather than [Char.isWhitespace], which is Unicode-aware and would accept U+00A0, U+2003 and + * friends that the regexes rejected. + */ +internal fun isAsciiRegexSpace(c: Char): Boolean = c == ' ' || c.code in 0x09..0x0D diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/content/IndexedTags.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/content/IndexedTags.kt index cb6a2769a6..0ce6fed888 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/content/IndexedTags.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/content/IndexedTags.kt @@ -29,36 +29,36 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArray val tagSearch = Regex("(?:\\s|\\A)\\#\\[([0-9]+)\\]") /** - * Walks every `#[n]` reference in [content]. + * Walks every `#[n]` reference in [content], handing each callback the digits between the brackets. * - * [tagSearch] requires `(?:\s|\A)` immediately before the `#`, so every match - * starts at position 0 or at a whitespace. That lets the scan jump between `#` - * occurrences with `indexOf` — an intrinsified char search — and apply the regex - * **anchored** at each, instead of letting `findAll` drive the regex engine from - * every position in the string. + * Jumps between `#` occurrences with `indexOf` — an intrinsified char search — then matches + * `#[]` directly rather than anchoring a regex there. * - * Measured on the production content distribution (median 529 B, tail to 767 KB): - * ~63 MB/s -> multiple GB/s when the content has no `#`, and ~18x on reference-dense - * text. Equivalence with the previous `findAll` implementation (both callers) is - * guarded by `RegexContentBenchmark` in `commons`. + * **Why not a regex.** On Android `java.util.regex` is ICU-backed, and `Matcher.region()` -> + * `reset()` -> `MatcherNative.setInput()` copies the *entire input* into native memory per call, + * so anchoring a fresh Matcher at each candidate cost a full native UTF-16 copy of the content per + * `#`. See [findHashtags] for the measurements; this scanner shares the defect but never fires on + * real data, since `#[0]` is the legacy citation form that no current client emits. + * + * [tagSearch] is kept as the specification the scan is tested against, not used here. */ private inline fun forEachIndexTag( content: String, - action: (MatchResult) -> Unit, + action: (digits: String) -> Unit, ) { var h = content.indexOf('#') while (h >= 0) { - if (h == 0 || content[h - 1].isWhitespace()) { - val match = - try { - tagSearch.matchAt(content, if (h == 0) 0 else h - 1) - } catch (e: Exception) { - null + // `(?:\s|\A)` — the `#` must open the string or follow one ASCII space character. + if (h == 0 || isAsciiRegexSpace(content[h - 1])) { + if (h + 1 < content.length && content[h + 1] == '[') { + var d = h + 2 + while (d < content.length && content[d] in '0'..'9') d++ + // `([0-9]+)` needs a digit, and the `]` must actually be there. + if (d > h + 2 && d < content.length && content[d] == ']') { + action(content.substring(h + 2, d)) + h = content.indexOf('#', d + 1) + continue } - if (match != null) { - action(match) - h = content.indexOf('#', match.range.last + 1) - continue } } h = content.indexOf('#', h + 1) @@ -73,10 +73,11 @@ fun findIndexTagsWithPeople( tags: TagArray, output: MutableSet = mutableSetOf(), ): List { - forEachIndexTag(content) { index -> + forEachIndexTag(content) { digits -> try { - val tag = index.groups[1]?.value?.let { tags[it.toInt()] } - if (tag != null && tag.size > 1 && tag[0] == "p") { + // Out-of-range indexes and non-numeric digits land in the catch below. + val tag = tags[digits.toInt()] + if (tag.size > 1 && tag[0] == "p") { output.add(tag[1]) } } catch (e: Exception) { @@ -94,13 +95,14 @@ fun findIndexTagsWithEventsOrAddresses( tags: TagArray, output: MutableSet = mutableSetOf(), ): Set { - forEachIndexTag(content) { index -> + forEachIndexTag(content) { digits -> try { - val tag = index.groups[1]?.value?.let { tags[it.toInt()] } - if (tag != null && tag.size > 1 && tag[0] == "e") { + // Out-of-range indexes and non-numeric digits land in the catch below. + val tag = tags[digits.toInt()] + if (tag.size > 1 && tag[0] == "e") { output.add(tag[1]) } - if (tag != null && tag.size > 1 && tag[0] == "a") { + if (tag.size > 1 && tag[0] == "a") { output.add(tag[1]) } } catch (e: Exception) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt index 0d8fb3bb4d..c7e83091ac 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable 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.nip19Bech32.bech32.Bech32 import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes import com.vitorpamplona.quartz.nip19Bech32.entities.Entity import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress @@ -133,6 +134,67 @@ object Nip19Parser { fun hasAny(content: String): Boolean = nip19regex.matches(content) + // --------------------------------------------------------------------------------------- + // ICU-free scanner for the ingest hot path. + // + // `java.util.regex` on Android is backed by ICU, and `Matcher.region()` -> `reset()` -> + // `MatcherNative.setInput()` copies the ENTIRE input into native memory on every call. + // `Regex.matchAt` builds a fresh Matcher per call, and [forEachNip19Match] calls it once per + // candidate position — so scanning one note allocated a full native UTF-16 copy of its content + // *per candidate*, with content running to 767KB in the tail. Because the Java Matcher object + // is tiny, Java-heap-driven GC had no reason to reclaim them promptly, so the native heap grew + // unbounded: measured 45MB -> 1372MB over a cold start, ending in an lmkd kill at ~1.9GB RSS. + // Disabling this one scan made the native heap plateau at ~257MB instead. + // + // The grammar is small enough to match directly, so nothing here touches ICU. Case folding is + // done ASCII-only on purpose: `RegexOption.IGNORE_CASE` maps to `Pattern.CASE_INSENSITIVE`, + // which is ASCII-only unless `UNICODE_CASE` is also set. Using Kotlin's `ignoreCase = true` + // would be Unicode-aware and would accept inputs the regex rejected (U+212A KELVIN SIGN folding + // to `k`, say). + // --------------------------------------------------------------------------------------- + + /** Entities whose bech32 payload the regexes pin to exactly 58 chars. */ + private val FIXED_58_PREFIXES = arrayOf("nsec1", "npub1", "note1") + + /** Entities whose bech32 payload is `+` (one or more). */ + private val VARIABLE_PREFIXES = arrayOf("nevent1", "naddr1", "nprofile1", "nrelay1", "nembed1") + + /** [nip19regexEvents] has no fixed-58 branch — there `note1` takes a variable payload. */ + private val EVENT_FIXED_58_PREFIXES = emptyArray() + + private val EVENT_VARIABLE_PREFIXES = arrayOf("nevent1", "naddr1", "note1", "nrelay1", "nembed1") + + /** + * `\S` in the trailing group. Java's `\s` is the six ASCII whitespace chars unless + * `UNICODE_CHARACTER_CLASS` is set, so U+00A0 and friends count as NON-space here. + */ + private fun isRegexSpace(c: Char): Boolean = c == ' ' || c == '\t' || c == '\n' || c == '\u000B' || c == '\u000C' || c == '\r' + + /** ASCII-only case-insensitive prefix compare; [prefix] must be lowercase ASCII. */ + private fun matchesPrefixAt( + content: String, + offset: Int, + prefix: String, + ): Boolean { + if (offset + prefix.length > content.length) return false + for (k in prefix.indices) { + val c = content[offset + k] + val folded = if (c in 'A'..'Z') c + 32 else c + if (folded != prefix[k]) return false + } + return true + } + + /** End (exclusive) of the `[\S]*` run starting at [from]. */ + private fun endOfTrailing( + content: String, + from: Int, + ): Int { + var j = from + while (j < content.length && !isRegexSpace(content[j])) j++ + return j + } + /** * True when one of the NIP-19 entity prefixes starts at [i]. * @@ -165,26 +227,84 @@ object Nip19Parser { } /** - * Applies [regex] anchored at every NIP-19 candidate position in [content]. + * Matches `<[\S]*>` at every NIP-19 candidate position in [content], + * without ICU — see the block comment above [FIXED_58_PREFIXES] for why that matters. * - * [isCandidateAt] covers the union of the prefixes across the three NIP-19 - * regexes, so a narrower [regex] simply fails `matchAt` on a prefix it does - * not accept — still far cheaper than `findAll` restarting the engine at - * every position in the string. + * [isCandidateAt] covers the union of the prefixes across the three NIP-19 regexes, so a + * caller passing a narrower prefix set simply finds no match at a prefix it does not accept. + * + * The prefixes are mutually exclusive (none is a prefix of another), so the first one that + * matches decides the branch, exactly as the regex alternation did. A fixed-58 entity with + * fewer than 58 payload chars fails outright rather than falling through to the variable + * branch, again matching the regex: no variable prefix can equal a fixed-58 one. */ private inline fun forEachNip19Match( content: String, - regex: Regex, - action: (MatchResult) -> Unit, + fixed58Prefixes: Array, + variablePrefixes: Array, + action: (type: String, key: String, additionalChars: String) -> Unit, ) { var i = 0 val len = content.length + // Jump between 'n'/'N' with indexOf — an intrinsified, vectorised char search — instead of + // testing every character. Both cases are tracked separately so each is only re-searched + // once consumed, amortising to about one indexOf per candidate. Measured ~2x faster on + // content with no entity at all, which is 94% of real notes (2588-note production sample), + // and ~26% faster on the 767KB mention-heavy tail. Small mention-dense content (a ~700B + // note with 2 mentions) is a few microseconds slower; that trade is deliberate. + var nextLower = content.indexOf('n') + var nextUpper = content.indexOf('N') while (i < len) { + if (nextLower in 0.. return + nextLower < 0 -> nextUpper + nextUpper < 0 -> nextLower + else -> if (nextLower < nextUpper) nextLower else nextUpper + } + if (i >= len) return if (isCandidateAt(content, i)) { - val match = regex.matchAt(content, i) - if (match != null) { - action(match) - i = match.range.last + 1 + var end = -1 + + for (prefix in fixed58Prefixes) { + if (!matchesPrefixAt(content, i, prefix)) continue + val dataStart = i + prefix.length + val dataEnd = dataStart + 58 + if (dataEnd <= len && allBech32(content, dataStart, dataEnd)) { + end = endOfTrailing(content, dataEnd) + action( + content.substring(i, dataStart), + content.substring(dataStart, dataEnd), + content.substring(dataEnd, end), + ) + } + break + } + + if (end < 0) { + for (prefix in variablePrefixes) { + if (!matchesPrefixAt(content, i, prefix)) continue + val dataStart = i + prefix.length + var dataEnd = dataStart + while (dataEnd < len && Bech32.isDataChar(content[dataEnd])) dataEnd++ + // `+` needs at least one payload char. + if (dataEnd > dataStart) { + end = endOfTrailing(content, dataEnd) + action( + content.substring(i, dataStart), + content.substring(dataStart, dataEnd), + content.substring(dataEnd, end), + ) + } + break + } + } + + // `end` is exclusive and always past `i`, so the scan still advances. + if (end >= 0) { + i = end continue } } @@ -192,6 +312,17 @@ object Nip19Parser { } } + private fun allBech32( + content: String, + from: Int, + to: Int, + ): Boolean { + for (k in from until to) { + if (!Bech32.isDataChar(content[k])) return false + } + return true + } + /** * Scans [content] for NIP-19 entities. * @@ -208,14 +339,8 @@ object Nip19Parser { */ fun parseAll(content: String): List { val returningList = mutableListOf() - forEachNip19Match(content, nip19regex) { matcher -> - val type = matcher.groups[3]?.value ?: matcher.groups[5]?.value // npub1 - val key = matcher.groups[4]?.value ?: matcher.groups[6]?.value // bech32 - val additionalChars = matcher.groups[7]?.value // additional chars - - if (type != null) { - parseComponents(type, key, additionalChars)?.entity?.let { returningList.add(it) } - } + forEachNip19Match(content, FIXED_58_PREFIXES, VARIABLE_PREFIXES) { type, key, additionalChars -> + parseComponents(type, key, additionalChars)?.entity?.let { returningList.add(it) } } return returningList } @@ -223,14 +348,8 @@ object Nip19Parser { /** Same scan as [parseAll], restricted to the event-ish entities. */ fun parseAllEvents(content: String): List { val returningList = mutableListOf() - forEachNip19Match(content, nip19regexEvents) { matcher -> - val type = matcher.groups[2]?.value // nevent1 - val key = matcher.groups[3]?.value // bech32 - val additionalChars = matcher.groups[4]?.value // additional chars - - if (type != null) { - parseComponents(type, key, additionalChars)?.entity?.let { returningList.add(it) } - } + forEachNip19Match(content, EVENT_FIXED_58_PREFIXES, EVENT_VARIABLE_PREFIXES) { type, key, additionalChars -> + parseComponents(type, key, additionalChars)?.entity?.let { returningList.add(it) } } return returningList } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/bech32/Bech32Util.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/bech32/Bech32Util.kt index 2cc561e561..7d2a2b6541 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/bech32/Bech32Util.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/bech32/Bech32Util.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.quartz.nip19Bech32.bech32 +import kotlin.jvm.JvmField + /* * Copyright 2020 ACINQ SAS * @@ -81,15 +83,50 @@ object Bech32 { // char -> 5 bits value private val map = Array(255) { -1 } + @PublishedApi + internal const val DATA_CHARS_SIZE = 128 + + /** + * Membership table for [isDataChar], kept separate from [map] because [map] is an + * `Array` — a boxed `java.lang.Byte[]` — and [isDataChar] runs per character over whole + * note contents (up to ~767KB), where unboxing on every char would show up. + * + * `@JvmField` so callers of the inline [isDataChar] compile to a direct `getstatic` instead of + * a property-getter `invokevirtual` per character (the same reasoning as `PointTypes`). + * `@PublishedApi internal` because a public inline function cannot touch a private member. + */ + @PublishedApi + @JvmField + internal val DATA_CHARS = BooleanArray(DATA_CHARS_SIZE) + init { for (i in 0..ALPHABET.lastIndex) { map[ALPHABET[i].code] = i.toByte() + DATA_CHARS[ALPHABET[i].code] = true } for (i in 0..ALPHABET_UPPERCASE.lastIndex) { map[ALPHABET_UPPERCASE[i].code] = i.toByte() + DATA_CHARS[ALPHABET_UPPERCASE[i].code] = true } } + /** + * True when [c] is part of the bech32 data alphabet, in either case — i.e. everything except + * `1`, `b`, `i` and `o`, which BIP-173 excludes as visually ambiguous. + * + * Exposed so scanners can find where an encoded payload *ends* without decoding it. The NIP-19 + * content scan needs exactly that on every ingested event, and cannot use a regex to do it: + * Android's `java.util.regex` is ICU-backed and `Matcher.region()` copies the entire input into + * native memory per call, which drove the app's native heap to ~1.9GB on a cold start. + * + * `inline` because it is called per character over whole note contents (up to ~767KB). As a + * normal member it compiled to an `invokevirtual` per char, which measured ~1-2% slower across + * the scan benchmark than the equivalent private lookup it replaced; inlining puts the constant + * compare and the `baload` straight into the caller's loop and closes that gap. + */ + @Suppress("NOTHING_TO_INLINE") + inline fun isDataChar(c: Char): Boolean = c.code < DATA_CHARS_SIZE && DATA_CHARS[c.code] + fun expand(hrp: String): Array { val half = hrp.length + 1 val size = half + hrp.length diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt index 9e57317be8..4e5aed0948 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt @@ -36,6 +36,8 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.kinds.KindTag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip29RelayGroups.groupId +import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -120,6 +122,12 @@ class LnZapRequestEvent( if (zappedEvent is AddressableEvent) { tags = tags + listOf(ATag.assemble(zappedEvent.address(), null)) } + // Zapping NIP-29 group content is itself group content: carry the room's `h` tag so the + // receipt the provider publishes is scoped to the room, which is what lets the host relay + // serve it to the members (and to the recipient's `#h` notification query) instead of + // dropping an unscoped kind-9735 nobody in the group will ever see. Same rule reactions + // follow (ReactionAction copies the `h` tag onto the kind-7). + zappedEvent.groupId()?.let { tags = tags + listOf(GroupIdTag.assemble(it)) } if (pollOption != null && pollOption >= 0) { tags = tags + listOf(arrayOf(PollOptionTag.TAG_NAME, pollOption.toString())) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/invite/BuzzInviteLinkTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/invite/BuzzInviteLinkTest.kt index 2bbf4beb78..4583b09b99 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/invite/BuzzInviteLinkTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/invite/BuzzInviteLinkTest.kt @@ -59,6 +59,37 @@ class BuzzInviteLinkTest { assertEquals("c03abaa9-65e4-43b1-b9b3-f502a2812d0b", BuzzInviteLink.parse("$realUrl?ref=1")!!.communityId) } + // A real v2 token minted by amethyst.communities.buzz.xyz: `v2.` plus an opaque handle. Nothing + // about the invite is encoded in it — the relay resolves the code when the claim arrives. + private val v2Token = "v2.WWsMv33mYH8o04ZGdcoZKmIImGOEMW7auc5cZ0UdH24" + private val v2Url = "https://amethyst.communities.buzz.xyz/invite/$v2Token" + + @Test + fun parsesAnOpaqueV2Invite() { + val invite = BuzzInviteLink.parse(v2Url)!! + assertEquals("amethyst.communities.buzz.xyz", invite.host) + assertEquals(v2Token, invite.code) + // Unknowable client-side: the claim response carries the community and the granted role. + assertEquals("", invite.communityId) + assertEquals("member", invite.role) + assertNull(invite.expiresAt) + // What the join actually needs, both derived from the host. + assertEquals("wss://amethyst.communities.buzz.xyz", invite.relayUrl()) + assertEquals("https://amethyst.communities.buzz.xyz", invite.httpBase()) + } + + @Test + fun aV2InviteNeverExpiresClientSide() { + // No expiry to check, so the courtesy check must not block the claim — the relay decides. + assertTrue(!BuzzInviteLink.parse(v2Url)!!.isExpired(Long.MAX_VALUE)) + } + + @Test + fun toleratesTrailingFragmentAndQueryOnV2() { + assertEquals(v2Token, BuzzInviteLink.parse("$v2Url#x")!!.code) + assertEquals(v2Token, BuzzInviteLink.parse("$v2Url?ref=1")!!.code) + } + @Test fun rejectsNonInviteAndConcordShapes() { assertNull(BuzzInviteLink.parse("https://amethyst.communities.buzz.xyz/")) @@ -67,5 +98,11 @@ class BuzzInviteLinkTest { assertNull(BuzzInviteLink.parse("https://amethyst.social/invite/naddr1abcdef#deadbeef")) // Dotless token → not a Buzz invite. assertNull(BuzzInviteLink.parse("https://host.example/invite/justsometext")) + // The v2 exemption is the literal prefix, not "give up on decoding": an undecodable + // payload with any other prefix is still not an invite. + assertNull(BuzzInviteLink.parse("https://host.example/invite/v3.WWsMv33mYH8o04ZGdcoZKmI")) + assertNull(BuzzInviteLink.parse("https://host.example/invite/notbase64json.sig")) + // `v2` without the dot is a dotless token like any other. + assertNull(BuzzInviteLink.parse("https://host.example/invite/v2")) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/content/ContentScanRegexEquivalenceTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/content/ContentScanRegexEquivalenceTest.kt new file mode 100644 index 0000000000..4bbf310bf6 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/content/ContentScanRegexEquivalenceTest.kt @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip10Notes.content + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Pins the ICU-free content scanners to the regexes they replaced. + * + * [findHashtags] and the `#[n]` walker used to anchor a fresh `Regex.matchAt` at every candidate. + * On Android that goes through ICU, where `Matcher.region()` copies the whole input into native + * memory per call — over 2588 real notes that was 43,626 Matchers copying 9.6GB, worst single note + * 279MB. The scanners now match the grammars directly, so [hashtagSearch] and [tagSearch] survive + * only as the specification, and this asserts the two agree. + * + * The corpus targets where a hand-rolled matcher is most likely to drift: the exact punctuation + * set that ends a tag, ASCII-vs-Unicode whitespace before the `#`, non-ASCII inside the tag, and + * the `+`/`[0-9]+` minimum-one-character rules. + */ +class ContentScanRegexEquivalenceTest { + private fun referenceHashtags(content: String): List { + if (content.isBlank()) return emptyList() + val out = mutableSetOf() + hashtagSearch.findAll(content).forEach { m -> + val tag = m.groups[1]?.value + if (tag != null && tag.isNotBlank()) out.add(tag) + } + return out.toList() + } + + private fun referenceIndexTags( + content: String, + tags: Array>, + wanted: String, + ): Set { + val out = mutableSetOf() + tagSearch.findAll(content).forEach { m -> + try { + val tag = m.groups[1]?.value?.let { tags[it.toInt()] } + if (tag != null && tag.size > 1 && tag[0] == wanted) out.add(tag[1]) + } catch (e: Exception) { + } + } + return out + } + + private val tagArray = + arrayOf( + arrayOf("p", "pubkey0"), + arrayOf("e", "event1"), + arrayOf("a", "addr2"), + arrayOf("p", "pubkey3"), + arrayOf("t", "topic4"), + ) + + private fun corpus(): List = + buildList { + add("") + add(" ") + add("#") + add("#tag") + add("hello #tag world") + add("a#tag") + add("#tag#other") + add("#tag #other") + add("##tag") + add("#tag.") + add("#tag, and #more!") + add("#tag's") + add("#tag\"quoted\"") + add("#a") + add("#1") + add("#tag-with-dash") + add("#tag_with_underscore") + add("#tag~tilde|pipe\\back`tick") + // non-ASCII is valid tag content: the regex class and its \s are ASCII-only + add("#café") + add("#日本語") + add("#tagéè") + // ASCII vs Unicode whitespace BEFORE the # decides whether it matches at all + add("x\u00A0#tag") + add("x\u2003#tag") + add("x\t#tag") + add("x\n#tag") + add("x\r#tag") + // Unicode whitespace INSIDE the tag is valid to the regex but blank to Kotlin + add("#\u00A0") + add("#\u00A0x") + // every excluded char must terminate the tag + for (c in "!@#$%^&*()=+./,[{]};:'\"?><") add("#tag${c}more") + for (c in "!@#$%^&*()=+./,[{]};:'\"?><") add("#$c") + // index tags + add("#[0]") + add("#[1] and #[2]") + add("look #[3] here") + add("#[]") + add("#[abc]") + add("#[99]") + add("#[0") + add("#[0]]") + add("x#[0]") + add("x\u00A0#[0]") + add("#[0]#[1]") + add("#[00]") + // mixed + add("#tag #[0] #other #[1]") + add("lorem ipsum ".repeat(500) + "#tail") + add("#head" + " dolor sit ".repeat(500)) + add("no hashes at all here ".repeat(200)) + } + + @Test + fun hashtagsMatchRegex() { + corpus().forEach { c -> + assertEquals( + referenceHashtags(c).sorted(), + findHashtags(c).sorted(), + "findHashtags diverged on: ${c.take(80)}", + ) + } + } + + @Test + fun indexTagsMatchRegex() { + corpus().forEach { c -> + assertEquals( + referenceIndexTags(c, tagArray, "p").sorted(), + findIndexTagsWithPeople(c, tagArray).sorted(), + "findIndexTagsWithPeople diverged on: ${c.take(80)}", + ) + val refEv = referenceIndexTags(c, tagArray, "e") + referenceIndexTags(c, tagArray, "a") + assertEquals( + refEv.sorted(), + findIndexTagsWithEventsOrAddresses(c, tagArray).sorted(), + "findIndexTagsWithEventsOrAddresses diverged on: ${c.take(80)}", + ) + } + } + + @Test + fun hashtagsMatchRegexAtEveryOffset() { + val filler = "a b\tc\nd " + for (i in 0..filler.length) { + val c = filler.substring(0, i) + "#tag" + filler.substring(i) + assertEquals(referenceHashtags(c).sorted(), findHashtags(c).sorted(), "offset $i") + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/Nip19ScannerRegexEquivalenceTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/Nip19ScannerRegexEquivalenceTest.kt new file mode 100644 index 0000000000..2799195579 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/Nip19ScannerRegexEquivalenceTest.kt @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip19Bech32 + +import com.vitorpamplona.quartz.nip19Bech32.entities.Entity +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Pins the ICU-free scanner in [Nip19Parser] to the regexes it replaced. + * + * The scan used to run `Regex.matchAt` at every candidate position. On Android that goes through + * ICU, and `Matcher.region()` copies the whole input into native memory on each call — one full + * native copy of a note's content *per candidate* — which drove the native heap to ~1.9GB on a + * cold start and got the process lmkd-killed. The scanner matches the grammar directly instead. + * + * [Nip19Parser.nip19regex] and [Nip19Parser.nip19regexEvents] are still the specification, so this + * runs both over the same corpus and requires identical entity lists. The corpus deliberately + * targets the places a hand-rolled matcher is most likely to drift from the regex: the exact-58 + * payload boundary, the bech32 alphabet's excluded characters, ASCII-only case folding, and which + * characters `[\S]*` is willing to swallow. + */ +class Nip19ScannerRegexEquivalenceTest { + companion object { + const val NPUB = "npub1hv7k2s755n697sptva8vkh9jz40lzfzklnwj6ekewfmxp5crwdjs27007y" + const val NOTE = "note1stqea6wmwezg9x6yyr6qkukw95ewtdukyaztycws65l8wppjmtpscawevv" + const val NEVENT = "nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy" + + /** 58 valid bech32 chars, so `npub1` + this is exactly the fixed-length branch. */ + const val PAYLOAD58 = "qpzry9x8gf2tvdw0s3jn54khce6mua7lqpzry9x8gf2tvdw0s3jn54khce" + } + + /** What `parseAll` did before: drive the regex from every position with `findAll`. */ + private fun referenceParseAll(content: String): List { + val out = mutableListOf() + Nip19Parser.nip19regex.findAll(content).forEach { m -> + val type = m.groups[3]?.value ?: m.groups[5]?.value + val key = m.groups[4]?.value ?: m.groups[6]?.value + val additionalChars = m.groups[7]?.value + if (type != null) { + Nip19Parser.parseComponents(type, key, additionalChars)?.entity?.let { out.add(it) } + } + } + return out + } + + private fun referenceParseAllEvents(content: String): List { + val out = mutableListOf() + Nip19Parser.nip19regexEvents.findAll(content).forEach { m -> + val type = m.groups[2]?.value + val key = m.groups[3]?.value + val additionalChars = m.groups[4]?.value + if (type != null) { + Nip19Parser.parseComponents(type, key, additionalChars)?.entity?.let { out.add(it) } + } + } + return out + } + + private fun assertSameAsRegex(content: String) { + assertEquals( + referenceParseAll(content), + Nip19Parser.parseAll(content), + "parseAll diverged from nip19regex on: ${content.take(90)}", + ) + assertEquals( + referenceParseAllEvents(content), + Nip19Parser.parseAllEvents(content), + "parseAllEvents diverged from nip19regexEvents on: ${content.take(90)}", + ) + } + + private fun corpus(): List = + buildList { + // plain placement + add("") + add(NPUB) + add("hello $NPUB world") + add("nostr:$NPUB") + add("@$NPUB") + add("nostr:@$NPUB") + add("prefix-nostr:$NPUB-suffix") + add(NOTE) + add(NEVENT) + + // adjacency and repetition — where scan-resume position matters + add(NPUB + NEVENT) + add("$NPUB $NEVENT") + add("$NPUB\n$NEVENT") + add("$NPUB,$NEVENT") + add(listOf(NPUB, NOTE, NEVENT).joinToString(" ")) + add(NPUB.repeat(3)) + + // the exact-58 boundary for npub/nsec/note + add("npub1" + PAYLOAD58) + add("npub1" + PAYLOAD58.dropLast(1)) // 57 -> must not match + add("npub1" + PAYLOAD58 + "q") // 59 -> 58 key, trailing takes the rest + add("npub1" + PAYLOAD58 + " tail") + add("note1" + PAYLOAD58) + add("nsec1" + PAYLOAD58) + + // bech32 alphabet: 1, b, i, o are excluded and must terminate the payload + add("nevent1qqs1qqs") + add("nevent1qqsbqqs") + add("nevent1qqsiqqs") + add("nevent1qqsoqqs") + + // A *valid* variable-length entity butted straight against an excluded char. The + // payload has to stop there and still decode. These are the cases with teeth: a + // charset that wrongly accepted b/i/o/1 would swallow the extra char, fail the + // bech32 decode and silently drop the entity — whereas cases whose payload is + // invalid either way agree trivially and prove nothing. + for (excluded in listOf("b", "i", "o", "1")) { + add(NEVENT + excluded) + add(NEVENT + excluded + "xyz") + add("$NEVENT$excluded more text") + } + add("nevent1") // variable branch needs >= 1 payload char + add("nprofile1") + add("naddr1q") + + // ASCII-only case folding + add(NPUB.uppercase()) + add("NOSTR:" + NPUB.uppercase()) + add(NEVENT.uppercase()) + add("nPuB1" + PAYLOAD58) + // U+212A KELVIN SIGN folds to 'k' under Unicode rules but NOT under the regex's + // ASCII-only CASE_INSENSITIVE; both sides must reject it. + add("npub1" + PAYLOAD58.replaceFirst("k", "K")) + + // what [\S]* may swallow: Java's \s is the six ASCII whitespace chars only, + // so U+00A0 and U+2003 are NON-space and belong to the trailing group. + add("$NPUB\u00A0more") + add("$NPUB\u2003more") + add("${NPUB}more") + add("${NPUB}1more") + add("$NPUB\tmore") + add("$NPUB\rmore") + + // near-misses that must not be mistaken for entities + add("n") + add("nn") + add("np") + add("no") + add("nostr:") + add("nothing to see here") + add("a note about nothing") + add("nopqrstuvwxyz") + add("x$NPUB") + add("1$NPUB") + + // long content with the entity at the far end (the 767KB-tail shape, scaled down) + add("lorem ipsum ".repeat(2000) + NPUB) + add(NPUB + " " + "dolor sit amet ".repeat(2000)) + // many 'n' candidates but no entities — the scanner's rejection path + add("neither nor none never nothing ".repeat(500)) + } + + @Test + fun matchesRegexAcrossCorpus() { + corpus().forEach { assertSameAsRegex(it) } + } + + @Test + fun matchesRegexWithEntityAtEveryOffset() { + // Slides the entity through a filler string so every start offset, including + // immediately after another candidate 'n', is exercised. + val filler = "n no non nost nostr " + for (i in 0..filler.length) { + assertSameAsRegex(filler.substring(0, i) + NPUB + filler.substring(i)) + } + } + + @Test + fun matchesRegexOnTruncatedPayloads() { + // Every truncation of a real entity: catches off-by-one at the 58 boundary and in `+`. + for (entity in listOf(NPUB, NOTE, NEVENT)) { + for (len in 1..entity.length) { + assertSameAsRegex(entity.substring(0, len)) + assertSameAsRegex("text " + entity.substring(0, len) + " text") + } + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/bech32/Bech32DataCharTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/bech32/Bech32DataCharTest.kt new file mode 100644 index 0000000000..dd2cd46816 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/bech32/Bech32DataCharTest.kt @@ -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.quartz.nip19Bech32.bech32 + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * [Bech32.isDataChar] is the membership test the NIP-19 content scan uses to find where an encoded + * payload ends, so it has to agree with [Bech32.ALPHABET] exactly — a char wrongly accepted extends + * a payload past its real end and silently drops the entity when the decode then fails. + */ +class Bech32DataCharTest { + @Test + fun acceptsExactlyTheAlphabetInBothCases() { + for (c in Bech32.ALPHABET) assertTrue(Bech32.isDataChar(c), "expected '$c' to be a data char") + for (c in Bech32.ALPHABET_UPPERCASE) assertTrue(Bech32.isDataChar(c), "expected '$c' to be a data char") + } + + @Test + fun rejectsTheAmbiguousFour() { + // BIP-173 leaves these out of the alphabet precisely because they are easy to misread. + for (c in "1bio1BIO") assertFalse(Bech32.isDataChar(c), "expected '$c' to be rejected") + } + + @Test + fun agreesWithTheAlphabetAcrossEveryChar() { + // Sweeps the whole BMP so nothing outside the alphabet sneaks in — including the + // out-of-range guard for chars beyond the lookup table. + val expected = (Bech32.ALPHABET + Bech32.ALPHABET_UPPERCASE).toSet() + for (code in 0..0xFFFF) { + val c = code.toChar() + assertEquals(c in expected, Bech32.isDataChar(c), "disagreement at code $code") + } + } + + @Test + fun countsMatchTheSpec() { + assertEquals(32, Bech32.ALPHABET.length) + // 32 symbols, but only the 23 letters have a distinct uppercase form — the 9 digits + // are the same char in both alphabets, so the accepted set is 23*2 + 9, not 64. + val letters = Bech32.ALPHABET.count { it.isLetter() } + val digits = Bech32.ALPHABET.count { it.isDigit() } + assertEquals(32, letters + digits) + assertEquals(letters * 2 + digits, (0..0xFFFF).count { Bech32.isDataChar(it.toChar()) }) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestGroupTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestGroupTagTest.kt new file mode 100644 index 0000000000..2553b9e31d --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestGroupTagTest.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip57Zaps + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.utils.DeterministicSigner +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Zapping NIP-29 group content is itself group content. + * + * Without the room's `h` tag the receipt the provider publishes is an unscoped kind-9735: the host + * relay has no reason to serve it to the room, and the recipient's group notification query (`#h` = + * their rooms) never matches it — so a zap on a group message would be invisible exactly where it was + * meant to be seen. Reactions already copy the tag (`ReactionAction`); this pins the same rule for zaps. + */ +class LnZapRequestGroupTagTest { + private val signer = + DeterministicSigner( + "nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(), + ) + + private val nostrSigner = NostrSignerInternal(signer.key) + + private val relays = setOf(NormalizedRelayUrl("wss://groups.example.com/")) + + private fun event( + kind: Int, + tags: Array>, + ) = Event( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1_700_000_000L, + kind = kind, + tags = tags, + content = "", + sig = "c".repeat(128), + ) + + private suspend fun zapRequestFor(zapped: Event) = + LnZapRequestEvent.create( + zappedEvent = zapped, + relays = relays, + signer = nostrSigner, + pollOption = null, + message = "", + zapType = LnZapEvent.ZapType.PUBLIC, + toUserPubHex = null, + ) + + @Test + fun `zapping a group message carries the room's h tag`() = + runTest { + val groupMessage = event(9, arrayOf(arrayOf("h", "chan-engineering"))) + + val hTag = zapRequestFor(groupMessage).tags.firstOrNull { it[0] == "h" } + + assertTrue(hTag != null, "a zap on group content must stay scoped to the room") + assertEquals("chan-engineering", hTag[1]) + } + + @Test + fun `zapping a membership notification carries the room it announces`() = + runTest { + // The kind-44100 an invite card renders: relay-signed, `h`-scoped to the channel it added + // the viewer to. Zapping it is zapping something that happened inside that room. + val invite = + event( + 44100, + arrayOf( + arrayOf("p", "d".repeat(64)), + arrayOf("h", "chan-design"), + ), + ) + + val hTag = zapRequestFor(invite).tags.firstOrNull { it[0] == "h" } + + assertTrue(hTag != null, "a membership notification is group content too") + assertEquals("chan-design", hTag[1]) + } + + @Test + fun `zapping an ordinary note stays unscoped`() = + runTest { + val note = event(1, arrayOf(arrayOf("t", "nostr"))) + + assertNull( + zapRequestFor(note).tags.firstOrNull { it[0] == "h" }, + "a note that belongs to no room must not claim one", + ) + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt index bd43bfc9ca..bedae4d049 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt @@ -552,7 +552,27 @@ class CashuMintOperations( ) } + /** + * Resolve one keyset's amount→pubkey map by id, **including keysets the + * mint has rotated out of active service**. + * + * `/v1/keys` returns only the active keysets, so resolving through it + * makes every inactive keyset unresolvable — and since a NUT-13 counter + * chain is scoped to a keyset id, that silently made a NUT-09 restore of + * anything minted before the mint's last rotation impossible: the restore + * threw "Mint doesn't expose keyset X", or was never attempted at all. + * NUT-01's `/v1/keys/{keyset_id}` is the endpoint that answers for any + * keyset the mint has ever published, active or not, so ask that first and + * keep the active-list lookup as the fallback for mints that don't serve + * the per-id route. + */ private suspend fun fetchKeysetById(keysetId: String): KeysetDto { + runCatching { client.keysetById(keysetId) } + .getOrNull() + ?.keysets + ?.firstOrNull { it.id == keysetId } + ?.let { return it } + val response = client.activeKeysets() return response.keysets.firstOrNull { it.id == keysetId } ?: throw IllegalStateException("Mint doesn't expose keyset $keysetId") @@ -561,6 +581,27 @@ class CashuMintOperations( /** Public surface for callers that need the active keyset (NUT-09 restore driver). */ suspend fun activeKeyset(): KeysetDto = fetchKeyset() + /** + * Every keyset id at this mint a NUT-09 restore should walk, for [unit], + * active keysets first. + * + * A restore driver that only scans the *active* keyset finds nothing for a + * wallet whose proofs were minted before the mint's last keyset rotation — + * which, for a mint that rotates on any schedule at all, is most of an + * older wallet's balance. Each keyset carries its own NUT-13 counter chain + * (`m/129372'/0'/'/'`), so proofs under a retired + * keyset are simply not on the path the active-only scan derives. + * + * Active first so a caller that cares about counter bookkeeping (only the + * active keyset can receive new mints, so only its counter governs future + * derivations) sees it before spending its scan budget elsewhere. + */ + suspend fun restorableKeysetIds(unit: String = "sat"): List { + val summaries = client.keysets().keysets.filter { it.unit == unit } + if (summaries.isEmpty()) return listOfNotNull(runCatching { activeKeyset().id }.getOrNull()) + return summaries.sortedByDescending { it.active }.map { it.id }.distinct() + } + /** * NUT-12 §3 Carol-side DLEQ verification on a batch of proofs that * arrived from OUTSIDE the wallet's own mint round-trips (an @@ -623,20 +664,42 @@ class CashuMintOperations( * Used by NUT-09 restore to filter spent proofs out of the recovered * set before publishing — the mint will sign blind messages whether * the underlying secret has been spent or not. + * + * Chunked at [MAX_CHECKSTATE_REQUEST_ITEMS] `Y`s per request. The wallet + * sweep ([scrubStaleProofs]) checks every proof it holds at a mint in one + * call, and that set is unbounded — it grows with the wallet's history, and + * a client that pages its whole proof set back off the relays can reach + * four figures in a single sweep. Mints run the same Pydantic list caps on + * `/v1/checkstate` that they do on `/v1/restore`, so an unchunked call + * fails the whole sweep with a validation error at exactly the moment the + * wallet has the most to reconcile. + * + * A proof whose `Y` the mint doesn't echo back is simply absent from the + * result, as before — callers treat "not UNSPENT" and "not present" alike. */ suspend fun checkStates(proofs: List): Map { if (proofs.isEmpty()) return emptyMap() - // NUT-07 keys check requests by `Y` (hash-to-curve of the secret). - val ys = proofs.map { Bdhke.hashToCurveCompressed(it.secret.encodeToByteArray()).toHexKey() } - val response = client.checkState(CheckStateRequestDto(ys = ys)) - val secretByY = - proofs.associateBy { - Bdhke.hashToCurveCompressed(it.secret.encodeToByteArray()).toHexKey() + // NUT-07 checks by `Y` (hash-to-curve of the secret). That's an EC + // operation per proof, so derive it once and keep both directions from + // the same pass — computing it separately for the request list and for + // the response lookup doubled the curve work on every sweep. + val secretByY = HashMap(proofs.size) + val ys = ArrayList(proofs.size) + proofs.forEach { proof -> + val y = Bdhke.hashToCurveCompressed(proof.secret.encodeToByteArray()).toHexKey() + // putIfAbsent: two proofs can legitimately carry the same secret + // (a duplicated kind:7375 the dedup pass hasn't retired yet), and + // they map to the same state anyway. + if (secretByY.putIfAbsent(y, proof.secret) == null) ys.add(y) + } + + val out = HashMap(secretByY.size) + ys.chunked(MAX_CHECKSTATE_REQUEST_ITEMS).forEach { chunk -> + val response = client.checkState(CheckStateRequestDto(ys = chunk)) + for (row in response.states) { + val secret = secretByY[row.y] ?: continue + out[secret] = ProofState.fromWire(row.state) } - val out = mutableMapOf() - for (row in response.states) { - val proof = secretByY[row.y] ?: continue - out[proof.secret] = ProofState.fromWire(row.state) } return out } @@ -804,6 +867,14 @@ class CashuMintOperations( */ const val MAX_RESTORE_REQUEST_ITEMS: Int = 500 + /** + * Upper bound on `Y`s per `/v1/checkstate` request body. Same + * reasoning as [MAX_RESTORE_REQUEST_ITEMS], but the response carries + * one small state row per `Y` rather than a full blind signature, so + * there is no doubling to leave headroom for. + */ + const val MAX_CHECKSTATE_REQUEST_ITEMS: Int = 500 + /** Re-exported from [splitAmountIntoDenominations] for convenience. */ fun splitAmounts(amount: Long): List = splitAmountIntoDenominations(amount)