From 81f852ad5d1ccc3fc8aadeb3f9e9d02d3d360cb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 18:45:08 +0000 Subject: [PATCH 01/35] fix: keep the notifications header on screen in every feed state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Notifications header slot carries standing prompts — pending channel invites and the "you have no inbox relay" warning — but RenderCardFeed drew it only in the Loaded branch. Arriving on the screen re-runs checkKeysInvalidateDataAndSendToTop, and any refresh that momentarily computes an empty list flips the feed through Empty/Loading, so the prompts blinked out and back on every visit. Draw the slot in the Empty, Loading and FeedError branches too. The padding is applied at that point because only the Loaded branch has a LazyColumn to carry the scaffold's inset as content padding — same shape ChatroomListFeedView already uses for its own empty state. This also fixes the relay prompt being hidden in the one state that needed it: a missing inbox relay is the most likely reason the feed is empty. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KYibeoSVdEVotM3xU1heoW --- .../loggedIn/notifications/CardFeedView.kt | 36 +++++++++++++++++-- .../notifications/NotificationScreen.kt | 2 ++ 2 files changed, 35 insertions(+), 3 deletions(-) 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..acac715666 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() } } } 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..55b53e95fc 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 @@ -246,6 +246,8 @@ internal fun SingleNotificationsBody( 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. + // [RenderCardFeed] draws this slot in every feed state, which is what keeps them on + // screen while a refresh bounces the feed through Loading/Empty and back. ChannelInvitesSection(accountViewModel, nav) }, ) From 97218b43adff320030913000f75be80e14bccd3f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 18:45:27 +0000 Subject: [PATCH 02/35] refactor: derive Buzz channel invites from the cache, not a registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "somebody added you to a channel" prompts appeared and disappeared on a loop. They were fed by BuzzChannelInvites, a process-wide mutable registry that the DM discovery pass recorded into and its classification step deleted from. The deletion was remembered nowhere, so any re-delivery of the same kind-44100 re-added an invite that had already been withdrawn, and the two steps fought each other. Both halves were derived from events LocalCache already held, so the registry was only ever a second source of truth able to drift from it. Subscription. BuzzMembershipEoseManager joins the account loaders and owns one `#p=me` REQ per joined workspace relay for 44100/44101 plus the 30622 hidden-DM snapshot. It needs a subscription of its own — the filter is channel-less by nature (it is the query that discovers which channels exist), and buzz downgrades a subscription carrying a channel-less filter to "global", which is right for these kinds but wrong for anything channel-scoped sharing the subscription. It pre-approves NIP-42 on each workspace relay; the authenticator re-signs on the `auth-required:` refusal and syncFilters re-drives the REQ, so no warm-auth one-shot is needed. State. BuzzChannelInvites is now a pure projection: newest verdict per channel, minus self-joins, dismissals, joined groups, and anything not yet classified as a named channel. Withholding the unclassified case is what stops every new DM flashing a channel-invite card until its kind-39000 lands. The 44101 handling moves out of LocalCache ingest and into the projection, which makes out-of-order replay produce the same answer as ordered delivery. Subscriptions removed. BuzzDmDiscovery and BuzzDmListViewModel each opened their own identical `#p=me` 44100 REQ; both now observe LocalCache. Discovery also recomputes and declares its whole DM set (BuzzDmChannels.replace) instead of accumulating deltas it later has to undo. 21 new tests cover the projection and the filter shape. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KYibeoSVdEVotM3xU1heoW --- .../amethyst/model/LocalCache.kt | 18 +- .../account/AccountFilterAssembler.kt | 5 + .../account/buzz/BuzzMembershipEoseManager.kt | 166 ++++++++++++++ .../ui/screen/loggedIn/AccountViewModel.kt | 15 +- .../screen/loggedIn/buzz/BuzzDmDiscovery.kt | 189 +++++----------- .../loggedIn/buzz/BuzzDmListViewModel.kt | 51 +++-- .../loggedIn/buzz/BuzzMembershipNotices.kt | 100 +++++++++ .../notifications/ChannelInvitesState.kt | 64 ++++-- .../buzz/FilterWorkspaceInboxToPubkeyTest.kt | 83 +++++++ .../commons/model/buzz/BuzzChannelInvites.kt | 164 ++++++++------ .../commons/model/buzz/BuzzDmChannels.kt | 29 +++ .../model/buzz/BuzzChannelInvitesTest.kt | 206 ++++++++++++++++++ .../commons/model/buzz/BuzzDmChannelsTest.kt | 38 ++++ 13 files changed, 887 insertions(+), 241 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/buzz/BuzzMembershipEoseManager.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzMembershipNotices.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/buzz/FilterWorkspaceInboxToPubkeyTest.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzChannelInvitesTest.kt 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..e4527fc901 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 @@ -2327,20 +2326,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/service/relayClient/reqCommand/account/AccountFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt index a5f530da31..96354ea0a1 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 @@ -110,6 +111,10 @@ class AccountFilterAssembler( // inside CashuWalletState, so it starts and stops with every other account-level loader. CashuWalletEoseManager(client, ::preferredKeys), 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..1ad90d37cc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/buzz/BuzzMembershipEoseManager.kt @@ -0,0 +1,166 @@ +/* + * 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.model.buzz.BuzzWorkspaces +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 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) { + 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/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 3825dd0f5e..9480469727 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 @@ -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 and drops the prompt on its own. + * + * The local dismissal is recorded too, so the card goes the moment the button is tapped rather than + * a relay round-trip later — and so a relay that never emits the 44101 can't leave a prompt standing + * for a membership it has already taken away. Same choice [removeRelayGroupFromMessages] makes for + * its half of the pair. + */ fun leaveChannelInvite(channel: RelayGroupChannel) = launchSigner { + account.settings.dismissChannelInvite(channel.groupId.id) account.relayGroups.leaveRelayGroup(channel) - BuzzChannelInvites.remove(account.userProfile().pubkeyHex, channel.groupId.id) } /** 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..b59442c7fd 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,101 @@ 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.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.ui.screen.loggedIn.notifications.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))), - ) - // `#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) - } - } + 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 observable list of addressables only grows, so a size change is exactly + // "a group we hadn't seen before is now known". + LocalCache + .observeNotes(Filter(kinds = listOf(GroupMetadataEvent.KIND))) + .map { it.size } + .distinctUntilChanged(), + ) { notices, _ -> BuzzChannelInvites.currentMemberships(notices.toMembershipNotices()) } + .collectLatest { memberships -> + fetchMissingDirectories(account, memberships) + BuzzDmChannels.replace(me, memberships.filter { (id, relay) -> classifyBuzzChannel(id, relay) == ChannelClassification.DM }) } - } } -/** 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, ) { val byRelay = - BuzzDmChannels - .channelsFor(viewer) + memberships + .filterKeys { id -> memberships[id]?.let { classifyBuzzChannel(id, it) } == 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..7da8e5746d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.model.buzz.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 @@ -39,7 +40,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 @@ -284,8 +284,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,23 +300,30 @@ 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 { notes -> + // 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 memberships = + BuzzChannelInvites + .currentMemberships(notes.toMembershipNotices()) + .filterValues { it in scoped } + var changed = false + memberships.forEach { (channelId, relay) -> + if (memberChannels.put(channelId, relay) == null) changed = true + } + // A 44101 takes the membership away: drop the row rather than leaving a + // conversation the relay no longer lets us read. + val gone = memberChannels.keys.filter { it !in memberships } + 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. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzMembershipNotices.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzMembershipNotices.kt new file mode 100644 index 0000000000..0105d3aea6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzMembershipNotices.kt @@ -0,0 +1,100 @@ +/* + * 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.buzz + +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces +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.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 + +/* + * 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. Both consumers of the stream — the channel-invite prompts and Buzz DM discovery — observe the + * cache through here, so there is exactly one `#p=me` REQ per workspace relay for the two of them. + */ + +/** 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. + */ +private fun Note.membershipRelay(): NormalizedRelayUrl? { + val seen = relays + if (seen.isEmpty()) return null + val workspaces = BuzzWorkspaces.flow.value + 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(): MembershipNotice? { + val relay = membershipRelay() ?: return null + return when (val noteEvent = event) { + is MemberAddedNotificationEvent -> + noteEvent.channel()?.let { + MembershipNotice(it, relay, noteEvent.actor(), noteEvent.createdAt, removed = false) + } + + is MemberRemovedNotificationEvent -> + noteEvent.channel()?.let { + MembershipNotice(it, relay, noteEvent.actor(), noteEvent.createdAt, removed = true) + } + + else -> null + } +} + +fun List.toMembershipNotices(): List = mapNotNull { it.toMembershipNotice() } + +/** + * What the 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]). + */ +fun classifyBuzzChannel( + channelId: String, + relay: NormalizedRelayUrl, +): ChannelClassification { + val metadata = LocalCache.getRelayGroupChannelIfExists(GroupId(channelId, relay))?.event ?: return ChannelClassification.UNKNOWN + return if (metadata.isBuzzDmChannel()) ChannelClassification.DM else ChannelClassification.NAMED +} 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 index 7b8c39a911..9f5c9b75c5 100644 --- 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 @@ -24,11 +24,18 @@ 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 com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.classifyBuzzChannel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.membershipNoticeFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.toMembershipNotices +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 @@ -36,29 +43,60 @@ 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. + * 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. * - * 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. + * ### 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. + * + * Modelled on [OpenPollsState], which projects the same way — `observeNotes` plus a persisted dismissal + * set — so the Notifications screen and Messages' "New Requests" tab can never disagree about what is + * pending. */ @Stable class ChannelInvitesState( - private val account: Account, + account: Account, scope: CoroutineScope, ) { + private val me = account.userProfile().pubkeyHex + + /** + * Fires when a group's kind-39000 first lands, which is what turns an + * [com.vitorpamplona.amethyst.commons.model.buzz.ChannelClassification.UNKNOWN] channel into a + * decidable one. The classification is read imperatively out of [LocalCache] (per channel, by id), + * so without this the projection would never recompute when the directory arrives. + * + * Mapped to a count and de-duplicated: the observable list of addressable notes only ever grows, so + * a size change is exactly "a group we hadn't seen before is now known" — and it keeps a busy + * account's metadata traffic from re-running the projection on every unrelated group edit. + */ + private val knownChannelTypes = + LocalCache + .observeNotes(Filter(kinds = listOf(GroupMetadataEvent.KIND))) + .map { it.size } + .distinctUntilChanged() + val flow: StateFlow> = combine( - BuzzChannelInvites.flow.map { it[account.userProfile().pubkeyHex] ?: emptyMap() }, + LocalCache.observeNotes(membershipNoticeFilter(me)), + knownChannelTypes, 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 } + ) { notices, _, dismissed, joined -> + BuzzChannelInvites.pendingInvites( + viewer = me, + notices = notices.toMembershipNotices(), + dismissed = dismissed, + joined = joined.mapTo(HashSet()) { it.groupId }, + classify = ::classifyBuzzChannel, + ) }.flowOn(Dispatchers.IO) .stateIn(scope, SharingStarted.Eagerly, emptyList()) } 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/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..dd069960fa 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,14 +21,10 @@ 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 [BuzzChannelInvite.actor] me to a channel: who did it, where, and when. */ @Immutable class BuzzChannelInvite( val channelId: String, @@ -38,74 +34,116 @@ 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 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 - } - - /** 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() - } + 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.channelId, it.relay, it.actor, it.createdAt) } + .sortedByDescending { it.createdAt } + .toList() } 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/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..166a88a898 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzChannelInvitesTest.kt @@ -0,0 +1,206 @@ +/* + * 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(channelId, relay, actor, createdAt, removed = false) + + private fun removed( + channelId: String, + actor: String? = stranger, + createdAt: Long = 2_000L, + ) = MembershipNotice(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 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/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)) + } } From 3603ddabb1ae02dfc2a0df0c0690d3b3f5e73379 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 19:25:43 +0000 Subject: [PATCH 03/35] feat: render pending channel invites as notification Cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The invite prompts reused NoteComposeLayout, so they looked like feed rows, but they were not part of the feed: a bespoke composable in the card feed's header slot, off a state holder hanging on AccountFeedContentStates. They went through none of the Card pipeline, which is the structure the rest of the tab is built on. A pending invite is now a ChannelInviteCard, built by convertToCard and drawn by the same RenderCardItem dispatch as every other row, so it inherits dedup by id, last-read, backward paging, scroll-to-event from a push intent, and trimToSize for free. Ordering is a DAL concern: NotificationFeedOrderCard sorts unanswered invites ahead of the dated rows, then newest-first as before. A plain created_at sort would let a week of reactions bury a decision the user still has to make, and page it off the end past limit(). Answering one drops it from the projection, so nothing lingers at the top. The projection moves from AccountFeedContentStates to Account.channelInvites because the DAL reads it: acceptableEvent resolves a cached 44100 to "is this still a live question" with a map lookup keyed on the event id, and convertToCard attaches the resolved invite to the row. The 44100 kind joins NOTIFICATION_KINDS — the contract test's envelope and subscription-coverage rules both still hold, and push is unaffected since NotificationDispatcher keeps its own kind set. ChannelInvitesSection stays for Messages > New Requests, which is not a Card feed. Its Notifications header slot is gone; the cards cover it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KYibeoSVdEVotM3xU1heoW --- .../vitorpamplona/amethyst/model/Account.kt | 15 +++ .../buzz/BuzzMembershipNotices.kt | 19 +-- .../buzz}/ChannelInvitesState.kt | 61 ++++++---- .../amethyst/ui/dal/DefaultFeedOrder.kt | 15 +++ .../loggedIn/AccountFeedContentStates.kt | 18 ++- .../screen/loggedIn/buzz/BuzzDmDiscovery.kt | 9 +- .../loggedIn/buzz/BuzzDmListViewModel.kt | 2 + .../notifications/CardFeedContentState.kt | 33 ++++- .../loggedIn/notifications/CardFeedState.kt | 23 ++++ .../loggedIn/notifications/CardFeedView.kt | 8 ++ .../notifications/ChannelInvitesSection.kt | 15 ++- .../notifications/NotificationScreen.kt | 8 +- .../dal/NotificationFeedFilter.kt | 16 +++ .../ui/dal/NotificationFeedOrderCardTest.kt | 113 ++++++++++++++++++ .../commons/model/buzz/BuzzChannelInvites.kt | 20 +++- .../model/buzz/BuzzChannelInvitesTest.kt | 46 ++++++- 16 files changed, 365 insertions(+), 56 deletions(-) rename amethyst/src/main/java/com/vitorpamplona/amethyst/{ui/screen/loggedIn => model}/buzz/BuzzMembershipNotices.kt (83%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/{ui/screen/loggedIn/notifications => model/buzz}/ChannelInvitesState.kt (65%) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedOrderCardTest.kt 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 ee50121463..236f4bc5f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -74,6 +74,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 @@ -552,6 +553,20 @@ 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, + relayGroupList = relayGroupList, + dismissed = settings.dismissedChannelInvites, + scope = scope, + ) + val concordChannelList = ConcordChannelListState(signer, cache, scope, settings) /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzMembershipNotices.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/buzz/BuzzMembershipNotices.kt similarity index 83% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzMembershipNotices.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/buzz/BuzzMembershipNotices.kt index 0105d3aea6..ff69049f12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzMembershipNotices.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/buzz/BuzzMembershipNotices.kt @@ -18,7 +18,7 @@ * 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.buzz +package com.vitorpamplona.amethyst.model.buzz import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces import com.vitorpamplona.amethyst.commons.model.buzz.ChannelClassification @@ -38,8 +38,12 @@ import com.vitorpamplona.quartz.nip29RelayGroups.GroupId * LocalCache instead of asking a relay for its own copy. * * The relay subscription lives in BuzzMembershipEoseManager, mounted with the rest of the account - * loaders. Both consumers of the stream — the channel-invite prompts and Buzz DM discovery — observe the - * cache through here, so there is exactly one `#p=me` REQ per workspace relay for the two of them. + * 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. */ @@ -70,12 +74,12 @@ fun Note.toMembershipNotice(): MembershipNotice? { return when (val noteEvent = event) { is MemberAddedNotificationEvent -> noteEvent.channel()?.let { - MembershipNotice(it, relay, noteEvent.actor(), noteEvent.createdAt, removed = false) + MembershipNotice(noteEvent.id, it, relay, noteEvent.actor(), noteEvent.createdAt, removed = false) } is MemberRemovedNotificationEvent -> noteEvent.channel()?.let { - MembershipNotice(it, relay, noteEvent.actor(), noteEvent.createdAt, removed = true) + MembershipNotice(noteEvent.id, it, relay, noteEvent.actor(), noteEvent.createdAt, removed = true) } else -> null @@ -85,16 +89,17 @@ fun Note.toMembershipNotice(): MembershipNotice? { fun List.toMembershipNotices(): List = mapNotNull { it.toMembershipNotice() } /** - * What the cache currently knows about a channel's type, from its kind-39000. + * 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]). */ fun classifyBuzzChannel( + cache: LocalCache, channelId: String, relay: NormalizedRelayUrl, ): ChannelClassification { - val metadata = LocalCache.getRelayGroupChannelIfExists(GroupId(channelId, relay))?.event ?: return ChannelClassification.UNKNOWN + val metadata = cache.getRelayGroupChannelIfExists(GroupId(channelId, relay))?.event ?: return ChannelClassification.UNKNOWN return if (metadata.isBuzzDmChannel()) ChannelClassification.DM else ChannelClassification.NAMED } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/ChannelInvitesState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/buzz/ChannelInvitesState.kt similarity index 65% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/ChannelInvitesState.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/buzz/ChannelInvitesState.kt index 9f5c9b75c5..07251da69b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/ChannelInvitesState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/buzz/ChannelInvitesState.kt @@ -18,16 +18,14 @@ * 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 +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.model.Account +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListState import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.classifyBuzzChannel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.membershipNoticeFilter -import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.toMembershipNotices +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 @@ -48,6 +46,13 @@ import kotlinx.coroutines.flow.stateIn * 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 @@ -55,48 +60,58 @@ import kotlinx.coroutines.flow.stateIn * 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. - * - * Modelled on [OpenPollsState], which projects the same way — `observeNotes` plus a persisted dismissal - * set — so the Notifications screen and Messages' "New Requests" tab can never disagree about what is - * pending. */ @Stable class ChannelInvitesState( - account: Account, + private val me: HexKey, + private val cache: LocalCache, + relayGroupList: RelayGroupListState, + dismissed: StateFlow>, scope: CoroutineScope, ) { - private val me = account.userProfile().pubkeyHex - /** * Fires when a group's kind-39000 first lands, which is what turns an * [com.vitorpamplona.amethyst.commons.model.buzz.ChannelClassification.UNKNOWN] channel into a - * decidable one. The classification is read imperatively out of [LocalCache] (per channel, by id), - * so without this the projection would never recompute when the directory arrives. + * decidable one. The classification is read per channel, by id, straight out of the cache, so + * without this the projection would never recompute when the directory arrives. * * Mapped to a count and de-duplicated: the observable list of addressable notes only ever grows, so * a size change is exactly "a group we hadn't seen before is now known" — and it keeps a busy * account's metadata traffic from re-running the projection on every unrelated group edit. */ private val knownChannelTypes = - LocalCache + cache .observeNotes(Filter(kinds = listOf(GroupMetadataEvent.KIND))) .map { it.size } .distinctUntilChanged() - val flow: StateFlow> = + /** Pending invites keyed by the kind-44100 that produced them — what the notifications DAL reads. */ + val pendingByEventId: StateFlow> = combine( - LocalCache.observeNotes(membershipNoticeFilter(me)), + cache.observeNotes(membershipNoticeFilter(me)), knownChannelTypes, - account.settings.dismissedChannelInvites, - account.relayGroupList.liveRelayGroupList, - ) { notices, _, dismissed, joined -> - BuzzChannelInvites.pendingInvites( + dismissed, + relayGroupList.liveRelayGroupList, + ) { notices, _, dismissals, joined -> + BuzzChannelInvites.pendingInvitesByEventId( viewer = me, notices = notices.toMembershipNotices(), - dismissed = dismissed, + dismissed = dismissals, joined = joined.mapTo(HashSet()) { it.groupId }, - classify = ::classifyBuzzChannel, + classify = { channelId, relay -> classifyBuzzChannel(cache, channelId, relay) }, ) }.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/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/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index a3d6f25523..85c7dddd4c 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,21 @@ class AccountFeedContentStates( } } + // A pending channel invite renders as a card, 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 passes `acceptableEvent`, so rebuild when the projection moves — + // otherwise an answered invite would sit on the tab until an unrelated event refreshed it. + scope.launch(Dispatchers.IO) { + account.channelInvites.pendingByEventId + .drop(1) + .collect { + notifications.invalidateData() + notificationsFollowing.invalidateData() + notificationsEveryone.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/buzz/BuzzDmDiscovery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmDiscovery.kt index b59442c7fd..d28d8d1155 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 @@ -27,6 +27,9 @@ import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmChannels 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.classifyBuzzChannel +import com.vitorpamplona.amethyst.model.buzz.membershipNoticeFilter +import com.vitorpamplona.amethyst.model.buzz.toMembershipNotices 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.nip01Core.relay.client.accessories.fetchAllWithHooks @@ -54,7 +57,7 @@ import kotlinx.coroutines.flow.map * * 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.ui.screen.loggedIn.notifications.ChannelInvitesState], which projects the + * [com.vitorpamplona.amethyst.model.buzz.ChannelInvitesState], which projects the * same cached notices. * * ### Recompute, don't accumulate @@ -94,7 +97,7 @@ private suspend fun runBuzzDmDiscovery(account: Account) { ) { notices, _ -> BuzzChannelInvites.currentMemberships(notices.toMembershipNotices()) } .collectLatest { memberships -> fetchMissingDirectories(account, memberships) - BuzzDmChannels.replace(me, memberships.filter { (id, relay) -> classifyBuzzChannel(id, relay) == ChannelClassification.DM }) + BuzzDmChannels.replace(me, memberships.filter { (id, relay) -> classifyBuzzChannel(LocalCache, id, relay) == ChannelClassification.DM }) } } @@ -113,7 +116,7 @@ private suspend fun fetchMissingDirectories( ) { val byRelay = memberships - .filterKeys { id -> memberships[id]?.let { classifyBuzzChannel(id, it) } == ChannelClassification.UNKNOWN } + .filterKeys { id -> memberships[id]?.let { classifyBuzzChannel(LocalCache, id, it) } == ChannelClassification.UNKNOWN } .entries .groupBy({ it.value }, { it.key }) .mapValues { (_, ids) -> listOf(Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf("d" to ids))) } 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 7da8e5746d..e2a8f342db 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 @@ -31,6 +31,8 @@ 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.toMembershipNotices 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 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..b2bc7b670d 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,24 @@ 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. + * + * Read once per conversion rather than 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 +479,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 acac715666..3a2b04193c 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 @@ -399,6 +399,14 @@ private fun RenderCardItem( ) } + is ChannelInviteCard -> { + ChannelInviteCompose( + item.invite, + 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 index c10fdc654f..4926d16759 100644 --- 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 @@ -66,8 +66,13 @@ 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. + * "Somebody added you to a channel" prompts, rendered as a block inside Messages › New Requests. + * + * On the Notifications tab these are not a section at all: each pending invite is a + * [ChannelInviteCard] in the ordinary card feed, built by `convertToCard` and drawn by the same + * `RenderCardItem` dispatch as every other row — see + * [com.vitorpamplona.amethyst.ui.dal.NotificationFeedOrderCard] for why they sort to the top. This + * section exists because Messages has its own list that is not a `Card` feed. * * 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, @@ -81,7 +86,7 @@ fun ChannelInvitesSection( nav: INav, modifier: Modifier = Modifier, ) { - val invites by accountViewModel.feedStates.channelInvites.flow + val invites by accountViewModel.account.channelInvites.flow .collectAsStateWithLifecycle() if (invites.isEmpty()) return @@ -92,7 +97,7 @@ fun ChannelInvitesSection( // 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) + ChannelInviteCompose(invite, accountViewModel, nav) HorizontalDivider(thickness = DividerThickness) } } @@ -106,7 +111,7 @@ fun ChannelInvitesSection( * reactions slot, which spans the full width and therefore fits "Add to Messages" without wrapping. */ @Composable -fun ChannelInviteCard( +fun ChannelInviteCompose( invite: BuzzChannelInvite, accountViewModel: AccountViewModel, nav: INav, 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 55b53e95fc..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,13 +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. - // [RenderCardFeed] draws this slot in every feed state, which is what keeps them on - // screen while a refresh bounces the feed through Loading/Empty and back. - 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..454a03643b 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 @@ -34,6 +34,7 @@ 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 @@ -192,6 +193,12 @@ class NotificationFeedFilter( // 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. 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 +495,15 @@ class NotificationFeedFilter( val noteEvent = it.event + // "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. + 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 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/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 dd069960fa..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 @@ -24,9 +24,15 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -/** Somebody added [BuzzChannelInvite.actor] me 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?, @@ -40,6 +46,7 @@ class BuzzChannelInvite( */ @Immutable class MembershipNotice( + val eventId: HexKey, val channelId: String, val relay: NormalizedRelayUrl, val actor: HexKey?, @@ -143,7 +150,16 @@ object BuzzChannelInvites { .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.channelId, it.relay, it.actor, it.createdAt) } + .map { BuzzChannelInvite(it.eventId, it.channelId, it.relay, it.actor, it.createdAt) } .sortedByDescending { it.createdAt } .toList() + + /** [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/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzChannelInvitesTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzChannelInvitesTest.kt index 166a88a898..613d185ada 100644 --- 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 @@ -35,13 +35,13 @@ class BuzzChannelInvitesTest { channelId: String, actor: String? = stranger, createdAt: Long = 1_000L, - ) = MembershipNotice(channelId, relay, actor, createdAt, removed = false) + ) = MembershipNotice("add-" + channelId + "-" + createdAt, channelId, relay, actor, createdAt, removed = false) private fun removed( channelId: String, actor: String? = stranger, createdAt: Long = 2_000L, - ) = MembershipNotice(channelId, relay, actor, createdAt, removed = true) + ) = 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 } @@ -169,6 +169,48 @@ class BuzzChannelInvitesTest { 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 From c57108f46ce6fcf248e3926ae2384a139fd9aa1d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 19:46:56 +0000 Subject: [PATCH 04/35] fix: correct the invite projection's cache read and card removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit of the branch turned up one root-cause bug and three consequences. 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 observeNotes' initial snapshot for them is always empty. Live arrivals were 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 account and back builds a fresh one, and consumeRegularEvent never re-notifies a duplicate, so it stayed empty for the session and no invite could surface. The observer is now only the arrival signal; the notices come from LocalCache.membershipNotices(), the same shape NotificationFeedFilter.feed() already uses over the same map. That scan is kept out of the projection's combine so a dismissal or a kind-10009 edit doesn't re-walk the whole cache — only a new verdict or a workspace-set change does. Also fixed: - Answering an invite could not remove its card. invalidateData() takes the additive path, finds no new notes (the 44100 *left* the feed) and bails on `if (newCards.isNotEmpty())`, leaving the answered invite pinned at the top by the invites-first order. The projection collector now clear()s first so the refresh rebuilds the whole list, which is the only branch that can shrink. - BuzzDmListViewModel dropped every channel absent from a single observer pass, wiping rows the seed and the one-shot fetch had legitimately added. It now removes only channels with an actual kind-44101. - leaveChannelInvite no longer records a persisted dismissal. That cleared the card a round-trip sooner but, being keyed by channel id and kept forever, would have swallowed a later legitimate re-add to the same channel. Ignore is the "don't ask again" action; Leave waits for the relay's own withdrawal. Two comments corrected: NOTIFICATION_KINDS does not gate push (NotificationDispatcher has its own set), and pendingInvites is a per-note StateFlow read, not a once-per-conversion one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KYibeoSVdEVotM3xU1heoW --- .../model/buzz/BuzzMembershipNotices.kt | 29 +++++++++++++++++++ .../model/buzz/ChannelInvitesState.kt | 26 +++++++++++++++-- .../loggedIn/AccountFeedContentStates.kt | 13 +++++++-- .../ui/screen/loggedIn/AccountViewModel.kt | 12 ++++---- .../screen/loggedIn/buzz/BuzzDmDiscovery.kt | 4 +-- .../loggedIn/buzz/BuzzDmListViewModel.kt | 24 +++++++++++---- .../notifications/CardFeedContentState.kt | 7 +++-- .../dal/NotificationFeedFilter.kt | 3 +- 8 files changed, 95 insertions(+), 23 deletions(-) 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 index ff69049f12..2cf43bd8b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/buzz/BuzzMembershipNotices.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/buzz/BuzzMembershipNotices.kt @@ -25,6 +25,7 @@ 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 @@ -88,6 +89,34 @@ fun Note.toMembershipNotice(): MembershipNotice? { fun List.toMembershipNotices(): List = mapNotNull { it.toMembershipNotice() } +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): List = + notes + .filterIntoSet { _, note -> note.isMembershipNoticeFor(me) } + .toList() + .toMembershipNotices() + /** * What [cache] currently knows about a channel's type, from its kind-39000. * 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 index 07251da69b..aefa75461e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/buzz/ChannelInvitesState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/buzz/ChannelInvitesState.kt @@ -23,6 +23,7 @@ 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 @@ -85,17 +86,36 @@ class ChannelInvitesState( .map { it.size } .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, + ) { _, _ -> cache.membershipNotices(me) } + /** Pending invites keyed by the kind-44100 that produced them — what the notifications DAL reads. */ val pendingByEventId: StateFlow> = combine( - cache.observeNotes(membershipNoticeFilter(me)), + notices, knownChannelTypes, dismissed, relayGroupList.liveRelayGroupList, - ) { notices, _, dismissals, joined -> + ) { verdicts, _, dismissals, joined -> BuzzChannelInvites.pendingInvitesByEventId( viewer = me, - notices = notices.toMembershipNotices(), + notices = verdicts, dismissed = dismissals, joined = joined.mapTo(HashSet()) { it.groupId }, classify = { channelId, relay -> classifyBuzzChannel(cache, channelId, relay) }, 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 85c7dddd4c..74d467e57a 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 @@ -189,13 +189,20 @@ class AccountFeedContentStates( // classification lands a kind-39000 that is not itself a notification. Each of those changes // whether the 44100 still passes `acceptableEvent`, so rebuild when the projection moves — // otherwise an answered invite would sit on the tab until an unrelated event refreshed it. + // + // `clear()` before each invalidation, because answering an invite REMOVES a row and the plain + // additive refresh cannot express that: it diffs `feed()` against `lastNotes`, finds no *new* + // notes, and bails on `if (newCards.isNotEmpty())` without touching the list — leaving the + // answered invite pinned at the top by the invites-first order. Clearing drops the additive + // fast path so the refresh rebuilds the whole list, which is the only branch that can shrink. scope.launch(Dispatchers.IO) { account.channelInvites.pendingByEventId .drop(1) .collect { - notifications.invalidateData() - notificationsFollowing.invalidateData() - notificationsEveryone.invalidateData() + listOf(notifications, notificationsFollowing, notificationsEveryone).forEach { + it.clear() + it.invalidateData() + } } } 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 9480469727..166973d344 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 @@ -1765,16 +1765,16 @@ class AccountViewModel( /** * Actually leave: kind-9022 to the host relay, which answers with a kind-44101 that supersedes the - * add and drops the prompt on its own. + * add, so the projection drops the card on its own. * - * The local dismissal is recorded too, so the card goes the moment the button is tapped rather than - * a relay round-trip later — and so a relay that never emits the 44101 can't leave a prompt standing - * for a membership it has already taken away. Same choice [removeRelayGroupFromMessages] makes for - * its half of the pair. + * 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.settings.dismissChannelInvite(channel.groupId.id) account.relayGroups.leaveRelayGroup(channel) } 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 d28d8d1155..e78416bc8d 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 @@ -29,7 +29,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.buzz.classifyBuzzChannel import com.vitorpamplona.amethyst.model.buzz.membershipNoticeFilter -import com.vitorpamplona.amethyst.model.buzz.toMembershipNotices +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.nip01Core.relay.client.accessories.fetchAllWithHooks @@ -94,7 +94,7 @@ private suspend fun runBuzzDmDiscovery(account: Account) { .observeNotes(Filter(kinds = listOf(GroupMetadataEvent.KIND))) .map { it.size } .distinctUntilChanged(), - ) { notices, _ -> BuzzChannelInvites.currentMemberships(notices.toMembershipNotices()) } + ) { _, _ -> BuzzChannelInvites.currentMemberships(LocalCache.membershipNotices(me)) } .collectLatest { memberships -> fetchMissingDirectories(account, memberships) BuzzDmChannels.replace(me, memberships.filter { (id, relay) -> classifyBuzzChannel(LocalCache, id, relay) == ChannelClassification.DM }) 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 e2a8f342db..2d41ee0388 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 @@ -32,7 +32,7 @@ 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.toMembershipNotices +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 @@ -303,21 +303,35 @@ class BuzzDmListViewModel : ViewModel() { liveJob = viewModelScope.launch(Dispatchers.IO) { launch { - LocalCache.observeNotes(membershipNoticeFilter(myPubkey)).collect { notes -> + 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 memberships = BuzzChannelInvites - .currentMemberships(notes.toMembershipNotices()) + .currentMemberships(LocalCache.membershipNotices(myPubkey)) .filterValues { it in scoped } var changed = false memberships.forEach { (channelId, relay) -> if (memberChannels.put(channelId, relay) == null) changed = true } - // A 44101 takes the membership away: drop the row rather than leaving a + // A kind-44101 takes the membership away: drop the row rather than leaving a // conversation the relay no longer lets us read. - val gone = memberChannels.keys.filter { it !in memberships } + // + // 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) + .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 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 b2bc7b670d..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 @@ -388,9 +388,10 @@ class CardFeedContentState( /** * The unanswered channel invites, keyed by their kind-44100. * - * Read once per conversion rather than 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. + * 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() = 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 454a03643b..941103bb09 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 @@ -191,7 +191,8 @@ 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 From 3cb5b56ea24732af99fcac422981b106ce701eab Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 23:01:33 +0000 Subject: [PATCH 05/35] fix: recover the whole Cashu balance instead of whatever a relay served MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NIP-60 balance is a pure function of the kind:7375 events the client holds, and nothing ever checked that it held all of them. The live wallet subscription sends one REQ per outbox relay with no `limit`, asking for six kinds at once. Relays answer an unbounded REQ with their own cap applied to the newest matching events, and kind:7376 history outnumbers the proofs by an order of magnitude on any wallet with a few hundred transactions — so the proofs that lose that race are the ones at mints the user hasn't touched recently, which is exactly the balance they'd forgotten they had. Nothing recovers afterwards: a capped page and a complete page both just EOSE, and PerUserEoseManager records that EOSE as the `since` for every later REQ to the relay, so the events below the cap are never asked for again. The subset is stable across cold starts and differs per device, which is how one account reads three different balances on three phones with none of them right. Page the proof set instead of taking one REQ's word for it: a one-shot fetchAllPagesFromPool walk over kind:7375 on the outbox relays, run at startup once the relay list is known and again (forced) when the user opens the wallet. Only kind:7375 — history and quotes are display-only, and paging them would multiply the download without moving a balance. Because a relay that ignores NIP-09 will hand back proofs the mint already burned, a walk that recovers anything new finishes with the NUT-07 scrub so the mint, not the relay, decides what is still unspent; a walk that finds nothing new skips it and costs no mint traffic. The seed-based recovery that should have been the fallback was blind in the same direction. NUT-13 derives a counter chain per keyset, and scanRecoverableProofs only ever scanned the mint's *active* keyset, so proofs minted before the mint's last rotation sat on a derivation path nothing walked — the restore reported an empty wallet rather than an incomplete scan, since a scan that never asks looks like one that found nothing. It now walks every keyset the mint lists for the unit, active first, skipping (and logging) any that errors. fetchKeysetById resolved ids through /v1/keys, which lists active keysets only, so an inactive keyset was unresolvable even when asked for by id; it now tries NUT-01's /v1/keys/{id} first and keeps the active-list lookup as fallback. Counter bookkeeping still tracks the active keyset alone — an inactive keyset can never receive another mint, so advancing its counter would protect nothing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HaZ8RprmKC3sidsq6W8dKY --- .../vitorpamplona/amethyst/model/Account.kt | 1 + .../model/nip60Cashu/CashuWalletState.kt | 145 +++++++++++++ .../loggedIn/wallet/CashuWalletViewModel.kt | 26 ++- .../assemblers/CashuWalletFilterAssembler.kt | 34 +++ .../commons/cashu/ops/CashuWalletOps.kt | 70 ++++-- .../cashu/CashuBalanceTruncationTest.kt | 205 ++++++++++++++++++ .../nip60Cashu/mintApi/CashuMintOperations.kt | 41 ++++ 7 files changed, 496 insertions(+), 26 deletions(-) create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuBalanceTruncationTest.kt 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 ee50121463..882472ef9b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -709,6 +709,7 @@ class Account( pubKey = signer.pubKey, signer = signer, cache = cache, + client = client, scope = scope, outboxRelaysFlow = outboxRelays.flow, inboxRelaysFlow = notificationRelays.flow, 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..8ee7de2e07 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,129 @@ class CashuWalletState( if (ids.isNotEmpty()) removeEvents(ids) } } + + // Page the full proof set back, once, as soon as we know where to ask. + // The live subscription above cannot do this on its own — see + // [resyncProofsFromRelays]. + jobs += + scope.launch(Dispatchers.IO) { + val relays = + withTimeoutOrNull(BACKFILL_RELAY_WAIT_MS) { + outboxRelaysFlow.first { it.isNotEmpty() } + } + if (relays == null) { + Log.w("CashuWallet") { "No outbox relays after ${BACKFILL_RELAY_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) + } + + val fresh = collected.values.filter { it.id !in tokenEvents.keys } + Log.i("CashuWallet") { + "Proof backfill over ${relays.size} relay(s): ${collected.size} kind:7375 seen, ${fresh.size} new" + } + proofBackfillDone = true + + 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) } + } + fresh.size + } } fun destroy() { @@ -1567,6 +1696,22 @@ class CashuWalletState( */ const val DISCOVERY_TIMEOUT_MS = 8_000L + /** + * How long the startup proof backfill waits for a non-empty outbox + * relay set before giving up. The NIP-65 list is restored from + * AccountSettings almost immediately on a returning launch; this + * window only matters on a first sign-in, where the list has to come + * off the network before we know where the wallet's events live. + */ + private const val BACKFILL_RELAY_WAIT_MS = 30_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/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/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..9d56877cf9 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,36 @@ 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. + */ +fun cashuProofBackfillFilters(pubkey: HexKey): List = + listOf( + Filter( + kinds = listOf(CashuTokenEvent.KIND), + authors = listOf(pubkey), + ), + ) 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..96cb8370fd 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 @@ -1111,13 +1111,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 +1151,36 @@ 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() + + val recovered = mutableListOf() + var activeNextCounter = startCounter + for (keysetId in keysetIds) { + val result = + runCatching { mintOps.restore(seed = seed, keysetId = keysetId, startCounter = startCounter) } + .onFailure { + Log.w("CashuWalletOps") { + "NUT-09 restore of keyset $keysetId at $mintUrl failed: ${describeMintError(it)}" + } + }.getOrNull() ?: 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) } /** 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..f0596040de --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuBalanceTruncationTest.kt @@ -0,0 +1,205 @@ +/* + * 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.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 `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/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt index bd43bfc9ca..c8a04c6a6c 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 From 954560666a176f6017391290e63e2becbe784c2a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 00:09:40 +0000 Subject: [PATCH 06/35] perf: stop the wallet paying per-bundle signer round-trips and per-mint rescans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit of the paths the proof backfill makes hot, plus two bugs it makes reachable. /v1/checkstate went out unchunked. scrubStaleProofs checks every proof held 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 reaches four figures in one sweep. Mints run the same Pydantic list caps there that they do on /v1/restore, which this file already caps at 500 for exactly that reason, so the sweep failed with a validation error at the moment the wallet had the most to reconcile. Chunked, and the hash-to-curve derivation now happens once per proof instead of twice (it was computed separately for the request list and the response lookup — a discarded EC operation per proof, every sweep). The auto-redeem sweep paid two NIP-44 decrypts of kind:17375 before checking whether it had anything to redeem, and p2pkPubkeyHex re-decrypts the same event walletPrivkeyHex just read. That sweep fires from every relevant cache bundle, so a wallet whose nutzaps were all redeemed months ago still paid two out-of-process round-trips per bundle on a NIP-46 bunker or a NIP-55 external signer. The candidate filter needs no key, so it now runs first, and the pubkey is derived from the privkey in hand. A kind:7375 we cannot decrypt hides money exactly as effectively as one a relay never delivered, and looked identical to an empty wallet. recomputeUnspent caches only successes, so failures are retried — but only when something else marks tokens dirty, which in a quiet wallet may be never. Failures are now counted and logged, and a forced resync retries them even when the relay walk found nothing new. Two quadratic scans that were invisible while truncation kept the entry list tiny: peekNutzapFunding filtered the whole entry list once per shared mint, allocating a list each time, from inside a composable remember (so per rendered note); and cleanupDuplicateProofs compared all pairs before every Resync. Both are single-pass/indexed now — a superset of B must share all of B's secrets, so only entries indexed under B's first secret can cover it. Finally, scanning every keyset made Resync N times slower by construction: each keyset costs at least three /v1/restore round-trips with 500-item bodies, so a mint that has rotated ten times turned a three-request scan into thirty run end to end. The walks are independent and read-only, so they run three at a time — bounded to stay polite to the mint's rate limiter. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HaZ8RprmKC3sidsq6W8dKY --- .../model/nip60Cashu/CashuWalletState.kt | 131 +++++++++++++++--- .../commons/cashu/ops/CashuWalletOps.kt | 54 ++++++-- .../nip60Cashu/mintApi/CashuMintOperations.kt | 50 +++++-- 3 files changed, 194 insertions(+), 41 deletions(-) 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 8ee7de2e07..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 @@ -526,17 +526,23 @@ class CashuWalletState( } } - // Page the full proof set back, once, as soon as we know where to ask. - // The live subscription above cannot do this on its own — see - // [resyncProofsFromRelays]. + // 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 relays = - withTimeoutOrNull(BACKFILL_RELAY_WAIT_MS) { + val ready = + withTimeoutOrNull(BACKFILL_READY_WAIT_MS) { + _walletEvent.first { it != null } outboxRelaysFlow.first { it.isNotEmpty() } } - if (relays == null) { - Log.w("CashuWallet") { "No outbox relays after ${BACKFILL_RELAY_WAIT_MS}ms; skipping proof backfill" } + if (ready == null) { + Log.d("CashuWallet") { "No wallet + outbox relays within ${BACKFILL_READY_WAIT_MS}ms; skipping proof backfill" } } else { resyncProofsFromRelays() } @@ -630,13 +636,19 @@ class CashuWalletState( } }.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 { it.id !in tokenEvents.keys } + 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" } - proofBackfillDone = true if (fresh.isNotEmpty()) { applyEvents(fresh) @@ -645,6 +657,15 @@ class CashuWalletState( // 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 } @@ -833,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 = @@ -844,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" } } @@ -852,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) @@ -876,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) } @@ -888,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) @@ -1047,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 @@ -1061,7 +1131,7 @@ class CashuWalletState( return NutzapFunding( target = NutzapTarget(mintUrl = bestMint, recipientP2pkPubkeyHex = recipientPubkeyHex), bestSingleMintSats = bestMintSats, - totalWalletSats = entries.sumOf { it.content.totalAmount() }, + totalWalletSats = totalWalletSats, ) } @@ -1332,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) && ( @@ -1697,13 +1782,13 @@ class CashuWalletState( const val DISCOVERY_TIMEOUT_MS = 8_000L /** - * How long the startup proof backfill waits for a non-empty outbox - * relay set before giving up. The NIP-65 list is restored from - * AccountSettings almost immediately on a returning launch; this - * window only matters on a first sign-in, where the list has to come - * off the network before we know where the wallet's events live. + * 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_RELAY_WAIT_MS = 30_000L + private const val BACKFILL_READY_WAIT_MS = 60_000L /** * Per-page idle window for the paged proof walk — measured from the 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 96cb8370fd..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 @@ -1154,16 +1159,41 @@ class CashuWalletOps( 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 in keysetIds) { - val result = - runCatching { mintOps.restore(seed = seed, keysetId = keysetId, startCounter = startCounter) } - .onFailure { - Log.w("CashuWalletOps") { - "NUT-09 restore of keyset $keysetId at $mintUrl failed: ${describeMintError(it)}" - } - }.getOrNull() ?: continue + for ((keysetId, result) in results) { + if (result == null) continue if (keysetId == activeKeysetId) activeNextCounter = result.nextCounterAfterScan recovered += result.proofs.map { it.proof } } @@ -1292,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/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt index c8a04c6a6c..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 @@ -664,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 } @@ -845,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) From 7a5193bcec589a9b8e81d4eb6bbbbae583c793cd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 00:43:56 +0000 Subject: [PATCH 07/35] feat: page the Cashu transaction list backward instead of showing a relay's suffix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kind:7375 fix made the balance whole; the transaction list was still whatever one uncapped REQ returned. CashuWalletEoseManager asks six kinds of one relay in a single REQ with no limit, and kind:7376 history is the most numerous of them, so the list every device shows is a recent-N suffix chosen by that relay's cap — and no later REQ asks for the rest, because the EOSE moves `since` forward. Proofs and history want opposite fixes. A balance summed over a partial proof set is wrong rather than incomplete, so those are walked to exhaustion in one shot. History is display-only and unbounded, and the user reads it newest-first, so pulling all of it at launch would be a large download for something they may never scroll. That is exactly the shape until+limit paging is for. Built on the existing machinery rather than a new one: BackwardRelayPager with cursors on the Account (Account.cashuHistory, beside notificationHistory), modelled on AccountNotificationsHistoryEoseManager for the loader and on the NIP-29 thread list for the two UI drivers — a bootstrap that fills the first screen and a look-ahead that pulls another page only while the user is scrolling toward the bottom. Nothing is fetched while the wallet is off screen, and a relay that finished a page parks at its cursor so another relay advancing doesn't re-REQ it. One deliberate divergence from the DM and notification pagers: they floor at `now - liveTail` because a separate live loader provably covers everything newer. The wallet has no such guarantee — its live REQ carries neither `since` nor `limit`, so how far back it reaches is whatever the relay decided, which is the bug being fixed. Flooring at a fixed tail would leave a band between the relay's cap and the tail boundary that neither loader ever asks for. This pager floors at `now` and overlaps the live subscription completely; duplicates are free (both LocalCache and CashuWalletState.historyEvents are keyed by event id) and gaplessness is worth more than the overlap. The footer splits on stalledCount rather than reporting `exhausted` as "all loaded": exhausted means nothing more is reachable right now, and a relay that answered an auth CLOSE or went silent is stalled, not done. Telling someone their transaction history is complete when part of it was never served is a lie about their own money. Page limit is 100, not 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. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HaZ8RprmKC3sidsq6W8dKY --- .../vitorpamplona/amethyst/model/Account.kt | 6 + .../account/AccountFilterAssembler.kt | 7 + .../CashuWalletHistoryEoseManager.kt | 225 ++++++++++++++++++ .../nip60Cashu/FilterCashuHistoryToPubkey.kt | 65 +++++ .../loggedIn/wallet/CashuWalletScreen.kt | 98 ++++++++ amethyst/src/main/res/values/strings.xml | 3 + .../nip60Cashu/FilterCashuHistoryTest.kt | 74 ++++++ 7 files changed, 478 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip60Cashu/CashuWalletHistoryEoseManager.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip60Cashu/FilterCashuHistoryToPubkey.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip60Cashu/FilterCashuHistoryTest.kt 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 882472ef9b..e4d31643ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -704,6 +704,12 @@ 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, 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..c75563b85f 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 @@ -33,6 +33,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 +97,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,6 +115,7 @@ 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), ) 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/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/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 97e1ef306e..32ebe48c0c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3255,6 +3255,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/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()) + } +} From 1fc45e4bf6237b63c36f3cc0157f22eef78d9159 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 01:14:15 +0000 Subject: [PATCH 08/35] =?UTF-8?q?feat(cli):=20amy=20cashu=20sync=20?= =?UTF-8?q?=E2=80=94=20page=20the=20wallet=20off=20the=20relays=20into=20t?= =?UTF-8?q?he=20store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit amy's cashu reads project the local event store and never touch the network: that is the contract, and it is why `cashu balance` is instant and works offline. The gap was that nothing in amy ever put the NIP-60 kinds INTO the store. CashuContext.snapshot() queries kinds 17375/7375/ 7376/7374/10019/38000 out of ctx.store, and a grep for CashuTokenEvent.KIND across cli/ returns exactly that one call site — a store query. So a wallet created on the phone read as an empty wallet here, and `cashu wallet show` answered "no kind:17375 wallet — run `cashu wallet create`", which for a replaceable kind is advice that would have overwritten the user's real wallet. Adds `amy cashu sync`, plus `--sync` on `cashu balance` and `cashu wallet show` for the common case. Kept opt-in rather than folded into the reads: an implicit network round-trip inside a command documented as a local projection is a contract change, and the offline read is worth keeping. It pages rather than issuing one REQ, for the same reason the Android backfill does: 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 what falls off the bottom is the proofs at mints the user hasn't touched lately — the balance reads low with nothing to indicate it. drainAllPages walks each relay on its own until cursor to exhaustion. Relay sets are split exactly as the Android subscription splits them: own events from the outbox, inbound nutzaps from the inbox. The filter builders move to commons per the thin-assembly rule, and generalize what the Android backfill already had: cashuProofBackfillFilters is now the kind:7375 narrowing of cashuOwnEventBackfillFilters, which defaults to every kind the account authors. cashuInboundNutzapBackfillFilters covers the #p half. A test pins that the own-event backfill covers every kind the live subscription authors, so the gap can't reopen by someone adding a kind to one and not the other. Verified against a real binary, not just the compiler: `cashu sync` emits its six JSON keys and exits 0 with no relays configured, `--sync` parses on both readers, an unknown flag still exits 2, and the reworded no_wallet error goes to stderr with exit 1. New --json keys (additive): events_downloaded, token_events, history_events on `cashu sync`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HaZ8RprmKC3sidsq6W8dKY --- cli/README.md | 5 +- cli/ROADMAP.md | 2 +- .../amethyst/cli/CashuContext.kt | 53 +++++++++++++++- .../cli/commands/cashu/CashuBalanceCommand.kt | 11 +++- .../cli/commands/cashu/CashuCommands.kt | 9 ++- .../cli/commands/cashu/CashuSyncCommand.kt | 63 +++++++++++++++++++ .../cli/commands/cashu/CashuWalletCommands.kt | 15 ++++- .../assemblers/CashuWalletFilterAssembler.kt | 49 ++++++++++++++- .../cashu/CashuBalanceTruncationTest.kt | 33 ++++++++++ 9 files changed, 227 insertions(+), 13 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuSyncCommand.kt 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/relayClient/assemblers/CashuWalletFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt index 9d56877cf9..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 @@ -159,12 +159,55 @@ fun cashuWalletFilters( * 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. + * 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 = +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 = listOf(CashuTokenEvent.KIND), + 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/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuBalanceTruncationTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuBalanceTruncationTest.kt index f0596040de..48053dd60f 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuBalanceTruncationTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuBalanceTruncationTest.kt @@ -21,6 +21,8 @@ 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 @@ -179,6 +181,37 @@ class CashuBalanceTruncationTest { 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") From a23781de211ea76de3fb20ab2ec9543733f0c619 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 10 Aug 2026 11:51:25 +0200 Subject: [PATCH 09/35] Initial commit feat: suppress the unread dot for muted public chats feat: expose the public-chat mute toggle on Account and AccountViewModel feat: sync muted public chats via the NIP-78 settings blob feat: persist muted public chats as local device state feat: add the public-chat mute predicate fix: make muted public chats a reactive signal in rowHasUnreadFlow --- .../amethyst/LocalPreferences.kt | 4 + .../vitorpamplona/amethyst/model/Account.kt | 11 +++ .../amethyst/model/AccountSettings.kt | 27 ++++++ .../amethyst/model/AccountSyncedSettings.kt | 9 +- .../model/AccountSyncedSettingsInternal.kt | 10 ++ .../amethyst/model/MutedPublicChats.kt | 46 +++++++++ .../nip78AppSpecific/AppSpecificState.kt | 2 +- .../ui/screen/loggedIn/AccountViewModel.kt | 7 ++ .../chats/rooms/ChatroomHeaderCompose.kt | 8 +- .../loggedIn/chats/rooms/ChatroomRowUnread.kt | 21 +++- .../model/MutedPublicChatsSyncTest.kt | 75 ++++++++++++++ .../amethyst/model/MutedPublicChatsTest.kt | 97 +++++++++++++++++++ 12 files changed, 310 insertions(+), 7 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/MutedPublicChats.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/model/MutedPublicChatsSyncTest.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/model/MutedPublicChatsTest.kt 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 ee50121463..3f61ba04a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1034,6 +1034,17 @@ 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() + } + + fun isPublicChatMuted(channelId: String): Boolean = settings.isPublicChatMuted(channelId) + 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..b1fd1373bd 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,12 @@ 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". + newSyncedSettings.chats.mutedPublicChats?.let { remote -> + mutedPublicChats.tryEmit(remote.toSet()) + } + saveAccountSettings() } } @@ -1614,6 +1628,19 @@ class AccountSettings( saveAccountSettings() } + // --- + // muted public chats + // --- + + fun isPublicChatMuted(channelId: String) = mutedPublicChats.value.contains(channelId) + + 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/MutedPublicChats.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/MutedPublicChats.kt new file mode 100644 index 0000000000..f49fce3319 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/MutedPublicChats.kt @@ -0,0 +1,46 @@ +/* + * 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.message.ChannelMessageEvent + +/** + * The channel a mute decision applies to, or null when [event] is not a public-chat + * message. + * + * 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. + */ +fun mutedChannelIdOf(event: Event?): HexKey? = (event as? ChannelMessageEvent)?.channelId() + +/** 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 = mutedChannelIdOf(event) ?: return false + return channelId in mutedChannels +} 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/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 3825dd0f5e..59f9d54adc 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 @@ -2036,6 +2036,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/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index a28bdff3c6..2c8bc16797 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 @@ -119,6 +119,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.MutableStateFlow @Composable fun ChatroomHeaderCompose( @@ -292,7 +293,10 @@ 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 rowHasUnreadFlow. + val hasNewMessages by remember(lastMessage, channel.idHex) { + rowHasUnreadFlow(lastMessage, accountViewModel.account) ?: MutableStateFlow(false) + }.collectAsStateWithLifecycle(false) ChannelName( channelIdHex = channel.idHex, @@ -300,7 +304,7 @@ private fun ChannelRoomCompose( channelTitle = { modifier -> ChannelTitleWithLabelInfo(channelName, MaterialSymbols.Public, R.string.public_chat, modifier) }, channelLastTime = lastMessage.createdAt(), channelLastContent = "$authorName: $description", - hasNewMessages = (noteEvent?.createdAt ?: Long.MIN_VALUE) > lastReadTime, + hasNewMessages = hasNewMessages, loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), autoPlayGif = 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/ChatroomRowUnread.kt index f84fa24049..31d99cb51d 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/ChatroomRowUnread.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.mutedChannelIdOf 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 @@ -38,6 +39,7 @@ import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map /** @@ -51,7 +53,12 @@ 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 [mutedChannelIdOf] 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)` * pair: their dot is a fan-in over every child channel, not one timestamp against one marker, and @@ -67,7 +74,17 @@ fun rowHasUnreadFlow( val route = rowLastReadRoute(row, account) ?: return null val createdAt = row.createdAt() ?: return null - return account.settings.getLastReadFlow(route).map { lastReadAt -> createdAt > lastReadAt } + + val unread = account.settings.getLastReadFlow(route).map { lastReadAt -> createdAt > lastReadAt } + + // 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. + val mutedChannelId = mutedChannelIdOf(row.event) ?: return unread + + return combine(unread, account.settings.mutedPublicChats) { hasUnread, muted -> + hasUnread && mutedChannelId !in muted + } } /** 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..8609fe219c --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/MutedPublicChatsSyncTest.kt @@ -0,0 +1,75 @@ +/* + * 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)), + ) + } +} 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..ca3f4244fd --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/MutedPublicChatsTest.kt @@ -0,0 +1,97 @@ +/* + * 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.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, mutedChannelIdOf(topLevel())) + } + + @Test + fun replyResolvesToTheChannelNotItsParent() { + // Every message in a NIP-28 channel shares one root, so mute is per-channel. + assertEquals(channelId, mutedChannelIdOf(reply())) + } + + @Test + fun nonPublicChatEventHasNoChannel() { + val dm = ChatMessageEvent("3".repeat(64), author, 1L, arrayOf(arrayOf("p", author)), "hi", sig) + assertNull(mutedChannelIdOf(dm)) + assertNull(mutedChannelIdOf(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))) + } +} From 1200519fe8d20742373801ed63abb75576bdf8ac Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 10 Aug 2026 12:24:39 +0200 Subject: [PATCH 10/35] Mute button: feat: add a mute button to the public chat header feat: add mute notifications to the public chat row menu feat: drop muted public chats from the Notifications feed feat: stop push notifications from muted public chats --- .../EventNotificationConsumer.kt | 7 ++ .../header/LongPublicChatChannelHeader.kt | 3 + .../header/actions/MuteChatButton.kt | 67 +++++++++++++++++++ .../chats/rooms/ChatroomHeaderCompose.kt | 38 ++++++++++- .../dal/NotificationFeedFilter.kt | 6 ++ amethyst/src/main/res/values/strings.xml | 3 + 6 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/MuteChatButton.kt 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..7736d7b52d 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 @@ -315,6 +316,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/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/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 2c8bc16797..3337314729 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 @@ -298,10 +298,22 @@ private fun ChannelRoomCompose( rowHasUnreadFlow(lastMessage, accountViewModel.account) ?: MutableStateFlow(false) }.collectAsStateWithLifecycle(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 -> + ChannelTitleWithLabelInfo( + channelName, + if (channel.idHex in mutedChats.value) MaterialSymbols.NotificationsOff else MaterialSymbols.Public, + R.string.public_chat, + modifier, + ) + }, channelLastTime = lastMessage.createdAt(), channelLastContent = "$authorName: $description", hasNewMessages = hasNewMessages, @@ -312,7 +324,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 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..8525dc6a64 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,6 +28,7 @@ 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 @@ -488,6 +489,11 @@ class NotificationFeedFilter( val noteEvent = it.event + // Muted public chats contribute nothing to Notifications. This feed is recomputed + // live from LocalCache rather than being an append-only store, so unmuting brings + // these entries back on its own — no replay needed. + if (isMutedPublicChatMessage(noteEvent, account.settings.mutedPublicChats.value)) return false + // 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 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 97e1ef306e..6f59148df9 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 From eef95eeb6e7281a0cf737113131508a819637601 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 10 Aug 2026 13:05:42 +0200 Subject: [PATCH 11/35] Code review fixes: fix: seed the row unread dot so it is right on the first frame fix: widen public-chat unread/mute matching to metadata and create events fix: announce muted public-chat state to screen readers --- .../vitorpamplona/amethyst/model/Account.kt | 2 - .../amethyst/model/AccountSettings.kt | 2 - .../amethyst/model/MutedPublicChats.kt | 26 ++++++-- .../ui/screen/loggedIn/AccountViewModel.kt | 6 +- .../chats/rooms/ChatroomHeaderCompose.kt | 18 +++-- .../{ChatroomRowUnread.kt => RowUnread.kt} | 66 +++++++++++++++---- .../rooms/dal/ChatroomListKnownFeedFilter.kt | 13 +--- .../amethyst/model/MutedPublicChatsTest.kt | 56 ++++++++++++++-- 8 files changed, 145 insertions(+), 44 deletions(-) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/{ChatroomRowUnread.kt => RowUnread.kt} (69%) 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 3f61ba04a9..16f5df8cf6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1043,8 +1043,6 @@ class Account( sendNewAppSpecificData() } - fun isPublicChatMuted(channelId: String): Boolean = settings.isPublicChatMuted(channelId) - 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 b1fd1373bd..b4fd952d02 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -1632,8 +1632,6 @@ class AccountSettings( // muted public chats // --- - fun isPublicChatMuted(channelId: String) = mutedPublicChats.value.contains(channelId) - fun toggleMutedPublicChat(channelId: String) { mutedPublicChats.update { if (channelId in it) it - channelId else it + channelId diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/MutedPublicChats.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/MutedPublicChats.kt index f49fce3319..f6e7e57ba1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/MutedPublicChats.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/MutedPublicChats.kt @@ -22,18 +22,36 @@ 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 channel a mute decision applies to, or null when [event] is not a public-chat - * message. + * 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 mutedChannelIdOf(event: Event?): HexKey? = (event as? ChannelMessageEvent)?.channelId() +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( @@ -41,6 +59,6 @@ fun isMutedPublicChatMessage( mutedChannels: Set, ): Boolean { if (mutedChannels.isEmpty()) return false - val channelId = mutedChannelIdOf(event) ?: return false + val channelId = publicChatChannelIdOf(event) ?: return false return channelId in mutedChannels } 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 59f9d54adc..bbe56db033 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 @@ -105,7 +105,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 +444,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 +460,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 -> 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 3337314729..22b1764b1a 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 @@ -119,7 +119,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.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow @Composable fun ChatroomHeaderCompose( @@ -293,10 +293,12 @@ private fun ChannelRoomCompose( noteEvent?.content?.take(200) } - // One predicate for the row dot and the bottom-bar badge — see rowHasUnreadFlow. - val hasNewMessages by remember(lastMessage, channel.idHex) { - rowHasUnreadFlow(lastMessage, accountViewModel.account) ?: MutableStateFlow(false) - }.collectAsStateWithLifecycle(false) + // 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 @@ -307,11 +309,13 @@ private fun ChannelRoomCompose( channelIdHex = channel.idHex, channelPicture = channelPicture, channelTitle = { modifier -> + val isMuted = channel.idHex in mutedChats.value ChannelTitleWithLabelInfo( channelName, - if (channel.idHex in mutedChats.value) MaterialSymbols.NotificationsOff else MaterialSymbols.Public, + 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(), @@ -793,6 +797,7 @@ private fun ChannelTitleWithLabelInfo( labelIcon: MaterialSymbol, label: Int, modifier: Modifier, + labelContentDescription: String? = null, ) { Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) { Text( @@ -808,6 +813,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 69% 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 31d99cb51d..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,7 +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.mutedChannelIdOf +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 @@ -37,9 +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 /** @@ -55,36 +58,69 @@ import kotlinx.coroutines.flow.map * 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. 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 [mutedChannelIdOf] combined + * 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 - val unread = 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. - val mutedChannelId = mutedChannelIdOf(row.event) ?: return unread + // Every other row type skips the mute flow entirely and stays a plain map. + val mutedChannelId = publicChatChannelIdOf(row.event) - return combine(unread, account.settings.mutedPublicChats) { hasUnread, muted -> - hasUnread && mutedChannelId !in muted + 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(), + ) } /** @@ -107,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/test/java/com/vitorpamplona/amethyst/model/MutedPublicChatsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/MutedPublicChatsTest.kt index ca3f4244fd..3a8a48b1c6 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/MutedPublicChatsTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/MutedPublicChatsTest.kt @@ -22,6 +22,9 @@ 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 @@ -56,20 +59,20 @@ class MutedPublicChatsTest { @Test fun topLevelMessageResolvesToItsChannel() { - assertEquals(channelId, mutedChannelIdOf(topLevel())) + assertEquals(channelId, publicChatChannelIdOf(topLevel())) } @Test fun replyResolvesToTheChannelNotItsParent() { // Every message in a NIP-28 channel shares one root, so mute is per-channel. - assertEquals(channelId, mutedChannelIdOf(reply())) + assertEquals(channelId, publicChatChannelIdOf(reply())) } @Test fun nonPublicChatEventHasNoChannel() { val dm = ChatMessageEvent("3".repeat(64), author, 1L, arrayOf(arrayOf("p", author)), "hi", sig) - assertNull(mutedChannelIdOf(dm)) - assertNull(mutedChannelIdOf(null)) + assertNull(publicChatChannelIdOf(dm)) + assertNull(publicChatChannelIdOf(null)) } @Test @@ -94,4 +97,49 @@ class MutedPublicChatsTest { 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))) + } } From e087ae8921b1cd5f7aee4b1e7ba5b6093d014a0b Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 10 Aug 2026 15:32:00 +0200 Subject: [PATCH 12/35] Manual testing and fixes: test: cover the null-vs-empty mute merge, not just its decode fix: make muting a public chat silence engagement too, and stop "Mute thread" from nuking a channel docs: correct the one-way-filter comment in NotificationFeedFilter --- .../amethyst/model/AccountSettings.kt | 8 +++-- .../amethyst/model/MutedPublicChats.kt | 17 ++++++++++ .../EventNotificationConsumer.kt | 15 ++++++-- .../amethyst/ui/note/NoteQuickActionMenu.kt | 34 ++++++++++++------- .../ui/note/elements/NoteActionSections.kt | 34 ++++++++++++------- .../dal/NotificationFeedFilter.kt | 16 ++++++--- .../model/MutedPublicChatsSyncTest.kt | 33 ++++++++++++++++++ 7 files changed, 121 insertions(+), 36 deletions(-) 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 b4fd952d02..e18e0779dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -1525,9 +1525,11 @@ class AccountSettings( // Null means an older client rewrote the blob without this key — leave the // local set alone rather than treating "absent" as "unmute everything". - newSyncedSettings.chats.mutedPublicChats?.let { remote -> - mutedPublicChats.tryEmit(remote.toSet()) - } + // 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() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/MutedPublicChats.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/MutedPublicChats.kt index f6e7e57ba1..aa860d1ba6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/MutedPublicChats.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/MutedPublicChats.kt @@ -62,3 +62,20 @@ fun isMutedPublicChatMessage( 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/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index 7736d7b52d..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 @@ -219,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) { 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/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index 8525dc6a64..02bc18c24c 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 @@ -489,9 +489,12 @@ class NotificationFeedFilter( val noteEvent = it.event - // Muted public chats contribute nothing to Notifications. This feed is recomputed - // live from LocalCache rather than being an append-only store, so unmuting brings - // these entries back on its own — no replay needed. + // 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 // Buzz DM: a group chat message in a `t=dm` channel whose 39000 participants include me. A Buzz @@ -557,7 +560,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/test/java/com/vitorpamplona/amethyst/model/MutedPublicChatsSyncTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/MutedPublicChatsSyncTest.kt index 8609fe219c..51a452c384 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/MutedPublicChatsSyncTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/MutedPublicChatsSyncTest.kt @@ -72,4 +72,37 @@ class MutedPublicChatsSyncTest { 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))) + } } From 06b49acf06674e3e7f25090e405720212570ba45 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 15:16:12 +0000 Subject: [PATCH 13/35] fix: make the "reading someone I follow" relay-auth toggle reachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under the CUSTOM ("decide per relay") policy, RelayAuthResolver AND-gated every toggle behind isFirstParty: if (inputs.isFirstParty && customAllows(inputs)) ALLOW else fallThrough isFirstParty (RelayAuthFirstParty.hasReason) is true only when we publish to the relay, the relay is on our own list, or it hosts a room we joined. A follow's outbox relay is none of those — it is theirs — so the readFollows branch of customAllows could never be reached. With "…I'm reading someone I follow" explicitly on, every follow's outbox relay still fell through to ASK, producing one login prompt per follow. The only challenges the category ever granted were ones myRelaysAndVenues already covered. customAllows now checks readFollows ahead of the gate. Exempting just that category keeps what the gate is for: the follow graph it consults is this account's, so another account's traffic cannot conjure a match, and the other three categories still require first-party — which is what stops a bystander account being auto-authenticated (and billed) on a paid inbox relay because another logged-in account's outgoing DM happened to name someone we follow. Those three lose nothing by keeping it: 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. RelayAuthResolverTest pinned the old behaviour as intended (nonFirstPartyAsksInsteadOfAutoAllowing), which is why this went unnoticed; that assertion is replaced by readFollowsGrantsOnTheFollowsOwnOutboxRelay plus readFollowsExemptionDoesNotLeakIntoTheOtherCategories, and a new RelayAuthReadFollowsTest covers the same case end-to-end through the ledger. Verified: 80 relay-auth tests green across :commons:jvmTest and :amethyst:testFdroidDebugUnitTest; spotlessApply clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014UmCeSWetuKmHdrWcZkWDR --- .../2026-08-03-auth-permissions-redesign.md | 6 + .../authCommand/model/AuthCoordinator.kt | 18 ++- .../model/RelayAuthReadFollowsTest.kt | 128 ++++++++++++++++++ .../commons/relayauth/RelayAuthResolver.kt | 37 ++++- .../relayauth/RelayAuthResolverTest.kt | 43 +++++- 5 files changed, 222 insertions(+), 10 deletions(-) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthReadFollowsTest.kt 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/service/relayClient/authCommand/model/AuthCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt index 7ff6533dc3..91c3e0a791 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 @@ -103,6 +103,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 +119,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 @@ -231,8 +236,13 @@ 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, 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/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthResolver.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthResolver.kt index 3d095ca38b..f3dd99dae5 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 @@ -62,7 +62,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( @@ -92,12 +93,17 @@ data class RelayAuthInputs( * else fall through * 4. 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. */ @@ -120,14 +126,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/relayauth/RelayAuthResolverTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthResolverTest.kt index 9b97ddeea4..485344e630 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 @@ -98,6 +98,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))) @@ -140,9 +165,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 @@ -158,7 +196,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))) From ff71e3c674f3acc4ce6cac8fd49397931368efbd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 16:05:01 +0000 Subject: [PATCH 14/35] fix: scope joined Buzz workspaces per account, correct the venue toggle label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from auditing the rest of the isFirstParty path against what the relay-auth screen options actually promise. **Joined Buzz workspaces are now per account.** BuzzWorkspaces was a process-wide singleton persisted to one device-global preference key. Everything else feeding AuthCoordinator.isFirstParty is read off the account, so one account redeeming a workspace invite made *every* other logged-in account first-party on that relay — the bystander-account AUTH leak the per-account gate exists to prevent, reintroduced on the line above the call to RelayAuthFirstParty.hasReason (which is why RelayAuthFirstPartyTest could not catch it: the Buzz clause sits outside the pure function it tests). Joining is a per-user act — the invite was redeemed by one key and the relay grants membership to that key alone. BuzzWorkspaces becomes a class held as Account.buzzWorkspaces; the dialect mark stays global, since which protocol a relay speaks is a property of the relay and not of who is asking. BuzzWorkspacePreferences namespaces its key by pubkey and is constructed per account, mirroring the same move the relay-auth overrides made from an app-wide file to a per-account one. AccountCacheState takes no Context, so it gets a startBuzzWorkspacePersistence lambda the way it already takes rootFilesDir and geolocationFlow. Restore falls back to the pre-namespacing key once per account so an upgrade doesn't empty the workspaces hub — that set is what every account already saw, and the first join after the upgrade writes to the account's own key and takes over. **The venue toggle's label was wrong, not its code.** "…it's my relay, or a room I joined" undersold isTrustedVenue, which also covers venues reached through the follow graph — the intent is joined, subscribed to, or favorited. Reworded to "…it's my relay, or a room I joined or follow". Renamed the key rather than reusing it (relay_auth_auto_my_relays → relay_auth_auto_my_relays_and_venues) so a stale Crowdin translation cannot bind to the changed copy, and dropped the 7 now-orphaned translations. Not addressed here: the write categories ("…I'm messaging …") are derived from pending events with the author discarded, so they read as "somebody is messaging" and lean on isFirstParty as an approximate stand-in. Fixing that needs purpose attribution by event.pubKey and is left for a separate change. Verified: 2797 tests green across :commons:jvmTest and :amethyst:testFdroidDebugUnitTest, incl. 2 new BuzzWorkspaces isolation tests; spotlessApply clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014UmCeSWetuKmHdrWcZkWDR --- .../com/vitorpamplona/amethyst/AppModules.kt | 12 ++-- .../vitorpamplona/amethyst/model/Account.kt | 8 +++ .../model/accountsCache/AccountCacheState.kt | 11 ++++ .../preferences/BuzzWorkspacePreferences.kt | 52 ++++++++++++----- .../authCommand/model/AuthCoordinator.kt | 10 ++-- .../loggedIn/buzz/AgentConsoleViewModel.kt | 3 +- .../screen/loggedIn/buzz/BuzzDmDiscovery.kt | 3 +- .../loggedIn/buzz/BuzzDmListViewModel.kt | 14 +++-- .../screen/loggedIn/buzz/BuzzInviteScreen.kt | 3 +- .../loggedIn/buzz/BuzzRelayImportViewModel.kt | 3 +- .../relayauth/RelayAuthSettingsScreen.kt | 2 +- amethyst/src/main/res/values-cs/strings.xml | 1 - .../src/main/res/values-de-rDE/strings.xml | 1 - .../src/main/res/values-hi-rIN/strings.xml | 1 - .../src/main/res/values-hu-rHU/strings.xml | 1 - .../src/main/res/values-pl-rPL/strings.xml | 1 - .../src/main/res/values-pt-rBR/strings.xml | 1 - .../src/main/res/values-sv-rSE/strings.xml | 1 - amethyst/src/main/res/values/strings.xml | 2 +- .../commons/model/buzz/BuzzWorkspaces.kt | 21 ++++--- .../commons/model/buzz/BuzzWorkspacesTest.kt | 58 ++++++++++++++----- 21 files changed, 141 insertions(+), 68 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index ed83fcf45c..44fd6526ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -291,11 +291,6 @@ class AppModules( // 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) @@ -905,6 +900,13 @@ class AppModules( meterSigner = { MeteringNostrSigner(it, resourceUsage) }, signerPermissionStore = signerPermissionStore, nip46ClientStore = nip46ClientStore, + // Restore + persist each account's joined Buzz workspace relays across restarts, 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). Per account: the set + // also makes a relay first-party for NIP-42. + startBuzzWorkspacePersistence = { pubKey, workspaces, accountScope -> + BuzzWorkspacePreferences(appContext, accountScope, pubKey, workspaces) + }, ) val sessionManager = 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 ee50121463..605bb05fdc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -35,6 +35,7 @@ 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.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 @@ -406,6 +407,13 @@ 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() + // Per-account NIP-42 policy evaluator (blocked → per-relay override → global policy → prompt), // reading THIS account's own toggles, relay lists and follow graph. Cached here so every AUTH // path (foreground screen + background notification consumer) shares one instance, and so an 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..2881c4f254 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 @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.commons.connectedApps.nip46.InMemoryNip46Clien import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore import com.vitorpamplona.amethyst.commons.connectedApps.signers.InMemoryNostrSignerPermissionStore import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings @@ -73,6 +74,12 @@ 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 joined Buzz workspaces (restore now, mirror later + * changes). A lambda because the store needs 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 startBuzzWorkspacePersistence: (HexKey, BuzzWorkspaces, CoroutineScope) -> Unit = { _, _, _ -> }, ) { val accounts = MutableStateFlow>(emptyMap()) @@ -286,6 +293,10 @@ class AccountCacheState( signerPermissionStore = signerPermissionStore, nip46ClientStore = nip46ClientStore, ).also { newAccount -> + // Per account, not per device: this 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. See BuzzWorkspacePreferences. + startBuzzWorkspacePersistence(signer.pubKey, newAccount.buzzWorkspaces, newAccount.scope) accounts.update { existingAccounts -> existingAccounts.plus(Pair(signer.pubKey, newAccount)) } 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..9b15b361b1 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}" } @@ -74,7 +95,7 @@ class BuzzWorkspacePreferences( private suspend fun persist(relays: Set) { try { 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 +104,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/relayClient/authCommand/model/AuthCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt index 91c3e0a791..cb5a0c3d37 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 @@ -23,7 +23,6 @@ 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 @@ -248,11 +247,12 @@ class AuthCoordinator( 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/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..b9b8e9a7e0 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 @@ -27,7 +27,6 @@ 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.model.Account import com.vitorpamplona.amethyst.model.LocalCache @@ -62,7 +61,7 @@ import kotlinx.coroutines.launch @Composable fun BuzzDmDiscoveryPreload(accountViewModel: AccountViewModel) { val account = accountViewModel.account - val joined by BuzzWorkspaces.flow.collectAsStateWithLifecycle() + val joined by account.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 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..e9316522a3 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 @@ -26,7 +26,6 @@ import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmChannels import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect -import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache @@ -111,7 +110,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 +134,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) @@ -315,7 +321,7 @@ class BuzzDmListViewModel : ViewModel() { } // 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/relayauth/RelayAuthSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt index 209329fa47..463c4d34a0 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 @@ -232,7 +232,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/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..9a431ac71e 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -1171,7 +1171,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 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 97e1ef306e..8c36a5e5b0 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1149,7 +1149,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 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/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)) + } } From 64cb6484473d43c1b4aa498c335d2d23b89098b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 16:25:42 +0000 Subject: [PATCH 15/35] fix: scope starred Buzz channels per account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same class of bug as the joined workspaces, spotted by reading the neighbour: BuzzChannelStars was a process-wide singleton on one device-global preference key. Its own KDoc calls a star "personal" and "the client's own bookkeeping", and the only justification offered for sharing it was "like BuzzWorkspaces, this is a process-wide singleton" — which stopped being true one commit ago. A star reorders and badges the community channel list, so while the set was shared, one account pinning a channel reordered every other logged-in account's list, and switching accounts silently rewrote the set they had in common. No AUTH exposure here — this one is cosmetic — but it is the same mistake and the plumbing was already in place. BuzzChannelStars becomes a class held as Account.buzzChannelStars, and BuzzChannelStarPreferences namespaces its key by pubkey with the same one-time fallback to the pre-namespacing key, so upgrading doesn't unpin everything. BuzzPinDropdownItem takes an AccountViewModel to read and toggle the right set. AccountCacheState's per-account Buzz hook collapses from startBuzzWorkspacePersistence(pubKey, workspaces, scope) to startBuzzPersistence(account): two features needing the same wiring is the point at which passing the account beats threading each piece of state through. Verified: 2800 tests green across :commons:jvmTest and :amethyst:testFdroidDebugUnitTest, incl. 3 new BuzzChannelStars tests (one pinning the cross-account isolation); spotlessApply clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014UmCeSWetuKmHdrWcZkWDR --- .../com/vitorpamplona/amethyst/AppModules.kt | 16 ++--- .../vitorpamplona/amethyst/model/Account.kt | 6 ++ .../model/accountsCache/AccountCacheState.kt | 14 ++-- .../preferences/BuzzChannelStarPreferences.kt | 33 ++++++--- .../relayGroup/BuzzChannelMenuItems.kt | 12 ++-- .../relayGroup/RelayGroupChannelListScreen.kt | 4 +- .../relayGroup/RelayGroupThreadsScreen.kt | 2 +- .../relayGroup/RelayGroupTopBar.kt | 2 +- .../commons/model/buzz/BuzzChannelStars.kt | 16 ++--- .../model/buzz/BuzzChannelStarsTest.kt | 69 +++++++++++++++++++ 10 files changed, 132 insertions(+), 42 deletions(-) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzChannelStarsTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 44fd6526ae..18c9289c08 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -291,9 +291,6 @@ class AppModules( // lazy) so it loads before the first Buzz-relay AUTH and mirrors later changes to disk. val buzzAttestationPrefs = BuzzAttestationPreferences(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). @@ -900,12 +897,13 @@ class AppModules( meterSigner = { MeteringNostrSigner(it, resourceUsage) }, signerPermissionStore = signerPermissionStore, nip46ClientStore = nip46ClientStore, - // Restore + persist each account's joined Buzz workspace relays across restarts, 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). Per account: the set - // also makes a relay first-party for NIP-42. - startBuzzWorkspacePersistence = { pubKey, workspaces, accountScope -> - BuzzWorkspacePreferences(appContext, accountScope, pubKey, workspaces) + // 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) }, ) 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 605bb05fdc..a7ada72abc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -34,6 +34,7 @@ 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.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel @@ -414,6 +415,11 @@ class Account( // 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() + // Per-account NIP-42 policy evaluator (blocked → per-relay override → global policy → prompt), // reading THIS account's own toggles, relay lists and follow graph. Cached here so every AUTH // path (foreground screen + background notification consumer) shares one instance, and so an 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 2881c4f254..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 @@ -26,7 +26,6 @@ import com.vitorpamplona.amethyst.commons.connectedApps.nip46.InMemoryNip46Clien import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore import com.vitorpamplona.amethyst.commons.connectedApps.signers.InMemoryNostrSignerPermissionStore import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore -import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings @@ -75,11 +74,12 @@ class AccountCacheState( /** App-global store of connected NIP-46 client display + relay info. */ val nip46ClientStore: Nip46ClientStore = InMemoryNip46ClientStore(), /** - * Starts per-account persistence of the joined Buzz workspaces (restore now, mirror later - * changes). A lambda because the store needs an Android `Context` and this class deliberately + * 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 startBuzzWorkspacePersistence: (HexKey, BuzzWorkspaces, CoroutineScope) -> Unit = { _, _, _ -> }, + val startBuzzPersistence: (Account) -> Unit = { }, ) { val accounts = MutableStateFlow>(emptyMap()) @@ -293,10 +293,10 @@ class AccountCacheState( signerPermissionStore = signerPermissionStore, nip46ClientStore = nip46ClientStore, ).also { newAccount -> - // Per account, not per device: this set makes a relay first-party for NIP-42, so a + // 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. See BuzzWorkspacePreferences. - startBuzzWorkspacePersistence(signer.pubKey, newAccount.buzzWorkspaces, newAccount.scope) + // 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/preferences/BuzzChannelStarPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzChannelStarPreferences.kt index ba694304b6..e0997047b5 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,7 @@ class BuzzChannelStarPreferences( private suspend fun persist(ids: Set) { try { - context.sharedPreferencesDataStore.edit { prefs -> prefs[KEY] = ids } + 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 +83,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/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/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/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) + } +} From 9d243b242074f8cbf945e284d94b3707c313df26 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 17 Aug 2026 12:54:43 -0400 Subject: [PATCH 16/35] feat: add opt-in worker-thread priority governor for cold-start diagnostics A cold start dials ~190 relays at once and grows the process to ~500 threads (OkHttp's TaskRunner pool, OkHttp dispatchers, the kotlinx scheduler, Arti's tokio workers), every one of them born at nice 0. This adds a governor that demotes them below the UI thread so the relay storm cannot starve main out of its frames. Disabled by default. It only runs when the `amethyst_worker_nice` global setting exists, so it is inert as shipped: adb shell settings put global amethyst_worker_nice 9 adb shell settings delete global amethyst_worker_nice It sweeps /proc/self/task rather than installing thread factories because the largest pool is OkHttp's TaskRunner backend, a process-wide singleton whose factory OkHttp does not expose per client. A nice value is per-OS-thread and survives renaming, so seeing a thread once is enough. A denylist protects the threads that must keep their scheduling: main, RenderThread, hwuiTask, the ART daemons (demoting HeapTaskDaemon would deepen the GC stalls this is meant to reduce) and binder threads. Measured on two rigs (4-round round-robin sweeps). The mechanism works everywhere -- main-thread starvation tracks the CFS weight monotonically and roughly halves (emulator 50.2% -> 24.8% at nice 9; SM-T220 15.2% -> 8.1%) -- but it does NOT reliably shorten time-to-first-paint on real hardware, which is why it ships off. On a 4-core emulator main is only 27% busy and genuinely starved; on the SM-T220 it is 70% busy and saturated with its own work, so scheduling was never the constraint there. Raising priority on device handed main more CPU (41.9s -> 47.6s on-cpu) and the stall did not move. Kept as a diagnostic knob for the scheduling half of the problem. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/vitorpamplona/amethyst/Amethyst.kt | 5 + .../priority/WorkerThreadPriorityGovernor.kt | 196 ++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/priority/WorkerThreadPriorityGovernor.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index 45c757c32b..c41e05057e 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,10 @@ class Amethyst : Application() { instance = AppModules(this) + // Keeps the ~500 relay/ingest worker threads a cold start spawns from starving the UI + // thread out of its frames. Off unless the `amethyst_worker_nice` global setting is set. + WorkerThreadPriorityGovernor.startIfConfigured(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/service/priority/WorkerThreadPriorityGovernor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/priority/WorkerThreadPriorityGovernor.kt new file mode 100644 index 0000000000..f1e35fd85e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/priority/WorkerThreadPriorityGovernor.kt @@ -0,0 +1,196 @@ +/* + * 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.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 ~500 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 ~30 simultaneously-runnable nice-0 threads on 4 cores still loses about + * half of its schedulable time to the runqueue — long enough that the first feed frame takes + * multiple seconds and the "Loading account" screen stays on-screen well after the account itself + * has loaded. + * + * **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. + * + * **Runtime control.** The target nice level is read from a `Settings.Global` key so the effect can + * be A/B-measured on one build: + * ``` + * adb shell settings put global amethyst_worker_nice 9 # demote workers (diagnostic only) + * adb shell settings delete global amethyst_worker_nice # control (no-op) + * ``` + * + * **Measured 2026-08-17 (4-round round-robin sweeps on two rigs) — this does NOT fix the stall on + * real hardware, which is why it ships disabled.** The mechanism works everywhere: starvation tracks + * the CFS weight monotonically and roughly halves (emulator 50.2% -> 24.8% at nice 9; SM-T220 15.2% + * -> 8.1%). But the user-visible time-to-first-paint does not reliably improve on device — the + * paired deltas were non-monotonic across nice levels, i.e. noise. + * + * The two rigs disagree because the bottleneck is not the same on both. On a 4-core emulator the + * main thread is only 27% busy and genuinely starved; on the SM-T220 it is **70% busy** and + * saturated with its own work (~42s of it), so scheduling was never the constraint. Raising priority + * there did hand main more CPU (41.9s -> 47.6s on-cpu) and the stall did not move. Treat an emulator + * as unable to answer scheduling questions: its shared cores manufacture contention real devices do + * not have. + * + * Kept as a diagnostic knob for the scheduling half of the problem. The larger, still-untested lever + * is bounding the ~190-relay connect fan-out. + * + * 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 there is no threshold at 10 to design around — pick the level off + * the weight/throughput curve above. + */ +object WorkerThreadPriorityGovernor { + /** `Settings.Global` key holding the target nice level. Absent/invalid = feature off. */ + const val SETTING_KEY = "amethyst_worker_nice" + + /** Sentinel for "not configured" — the governor stays off and costs nothing. */ + private const val DISABLED = Int.MIN_VALUE + + /** Sweep cadence while the cold-start storm is spawning threads. */ + private const val BURST_INTERVAL_MS = 250L + + /** How long to sweep aggressively before backing off to [IDLE_INTERVAL_MS]. */ + private const val BURST_DURATION_MS = 120_000L + + private const val IDLE_INTERVAL_MS = 2_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 startIfConfigured(context: Context) { + if (started) return + val targetNice = readTargetNice(context) + if (targetNice == DISABLED) { + Log.i("ThreadPriority") { "Worker thread governor off (no $SETTING_KEY setting)" } + return + } + started = true + Log.i("ThreadPriority") { "Worker thread governor ON, target nice=$targetNice" } + + Thread({ sweepLoop(targetNice) }, "worker-nice-governor") + .apply { + isDaemon = true + start() + } + } + + private fun readTargetNice(context: Context): Int = + runCatching { + Settings.Global.getInt(context.contentResolver, SETTING_KEY, DISABLED) + }.getOrDefault(DISABLED) + + 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 = System.currentTimeMillis() + val mainTid = Process.myPid() + // A thread's nice survives renaming, so once demoted it never needs revisiting. + // Seeding with our own tid keeps the sweep from demoting the governor itself. + val alreadyDemoted = HashSet().apply { add(Process.myTid()) } + + while (true) { + val demoted = sweepOnce(mainTid, targetNice, alreadyDemoted) + val elapsed = System.currentTimeMillis() - startedAt + if (demoted > 0) { + Log.d("ThreadPriority") { "Demoted $demoted thread(s) to nice $targetNice" } + } + runCatching { + Thread.sleep(if (elapsed < BURST_DURATION_MS) BURST_INTERVAL_MS else IDLE_INTERVAL_MS) + }.onFailure { return } + } + } + + private fun sweepOnce( + mainTid: Int, + targetNice: Int, + alreadyDemoted: MutableSet, + ): Int { + val tasks = File("/proc/self/task").listFiles() ?: return 0 + var demoted = 0 + for (task in tasks) { + val tid = task.name.toIntOrNull() ?: continue + if (tid == mainTid || tid in alreadyDemoted) continue + + // A thread can exit between listing and reading; treat any failure as "skip". + val name = runCatching { File(task, "comm").readText().trim() }.getOrNull() ?: continue + if (DENYLIST.any { name.startsWith(it) }) continue + + val ok = runCatching { Process.setThreadPriority(tid, targetNice) }.isSuccess + if (ok) { + alreadyDemoted.add(tid) + demoted++ + } + } + return demoted + } +} From c91478725728099dedd8a44ac27c64436c118822 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 17 Aug 2026 16:05:03 -0400 Subject: [PATCH 17/35] feat: enable the worker-thread priority governor by default at nice 10 Measuring on a release-codegen build (:amethyst:installPlayBenchmark -- R8 + baseline-profile AOT) reverses the earlier debug-build conclusion: demoting the relay/ingest workers is worth ~40% off time-to-first-paint there. SM-T220, 5-round round-robin, 22s window, every run valid: workers at | starvation | rq wait | first paint | spread nice 0 | 26.7% | 2300ms | 11.1s | 5.63s nice 5 | 22.0% | 1774ms | 8.0s | 4.05s nice 9 | 17.1% | 1280ms | 8.4s | 3.05s nice 10 | 14.6% | 1234ms | 6.2s | 1.55s nice 10 won 5/5 paired rounds (median 5.0s faster) and collapsed the run-to-run spread from 5.6s to 1.6s, so DEFAULT_NICE is 10 and the governor now starts without any setting. Re-validated end to end in the shipped configuration (default-on vs explicitly disabled): 4/4 paired wins, first paint 10.4s -> 6.4s, starvation 31.3% -> 20.5%. The effect exists only in release. In a debug build the same sweep changes nothing measurable, because there the main thread is ~70% busy saturated with ART interpretation and scheduling was never the constraint (starvation 15% debug vs 27% release). R8 collapses main's own work while leaving the relay storm untouched, which is what promotes starvation to the binding constraint. Recorded in the class doc so this is not re-validated on the wrong build type. Settings.Global is now an override rather than the gate: it replaces the default nice level, and any value <= 0 disables the governor entirely. Also halves the governor's own cost, 7.1% -> 3.6% of one core over a cold start (measured from the sweep thread's own utime+stime): - each thread is now touched once for its lifetime, not once per sweep -- denylisted threads are remembered instead of re-reading their comm every pass - the interval backs off when a sweep finds nothing new, and resets when it does - list() instead of listFiles() to avoid ~650 File allocations per sweep on an already GC-pressured heap - exited tids are pruned so a recycled tid is re-evaluated Co-Authored-By: Claude Opus 5 (1M context) --- .../com/vitorpamplona/amethyst/Amethyst.kt | 7 +- .../priority/WorkerThreadPriorityGovernor.kt | 173 +++++++++++------- 2 files changed, 113 insertions(+), 67 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index c41e05057e..bdfb4ddc93 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt @@ -120,9 +120,10 @@ class Amethyst : Application() { instance = AppModules(this) - // Keeps the ~500 relay/ingest worker threads a cold start spawns from starving the UI - // thread out of its frames. Off unless the `amethyst_worker_nice` global setting is set. - WorkerThreadPriorityGovernor.startIfConfigured(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/service/priority/WorkerThreadPriorityGovernor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/priority/WorkerThreadPriorityGovernor.kt index f1e35fd85e..193a788e10 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/priority/WorkerThreadPriorityGovernor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/priority/WorkerThreadPriorityGovernor.kt @@ -22,6 +22,7 @@ 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 @@ -31,12 +32,32 @@ import java.io.File * 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 ~500 threads (OkHttp's TaskRunner pool, OkHttp dispatchers, the kotlinx scheduler and + * 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 ~30 simultaneously-runnable nice-0 threads on 4 cores still loses about - * half of its schedulable time to the runqueue — long enough that the first feed frame takes - * multiple seconds and the "Loading account" screen stays on-screen well after the account itself - * has loaded. + * 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 @@ -53,49 +74,40 @@ import java.io.File * 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. * - * **Runtime control.** The target nice level is read from a `Settings.Global` key so the effect can - * be A/B-measured on one build: + * **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 9 # demote workers (diagnostic only) - * adb shell settings delete global amethyst_worker_nice # control (no-op) + * 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 * ``` - * - * **Measured 2026-08-17 (4-round round-robin sweeps on two rigs) — this does NOT fix the stall on - * real hardware, which is why it ships disabled.** The mechanism works everywhere: starvation tracks - * the CFS weight monotonically and roughly halves (emulator 50.2% -> 24.8% at nice 9; SM-T220 15.2% - * -> 8.1%). But the user-visible time-to-first-paint does not reliably improve on device — the - * paired deltas were non-monotonic across nice levels, i.e. noise. - * - * The two rigs disagree because the bottleneck is not the same on both. On a 4-core emulator the - * main thread is only 27% busy and genuinely starved; on the SM-T220 it is **70% busy** and - * saturated with its own work (~42s of it), so scheduling was never the constraint. Raising priority - * there did hand main more CPU (41.9s -> 47.6s on-cpu) and the stall did not move. Treat an emulator - * as unable to answer scheduling questions: its shared cores manufacture contention real devices do - * not have. - * - * Kept as a diagnostic knob for the scheduling half of the problem. The larger, still-untested lever - * is bounding the ~190-relay connect fan-out. - * * 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 there is no threshold at 10 to design around — pick the level off - * the weight/throughput curve above. + * only the nice value changed — so 10 carries no hidden cgroup penalty over 9. */ object WorkerThreadPriorityGovernor { - /** `Settings.Global` key holding the target nice level. Absent/invalid = feature off. */ + /** `Settings.Global` key overriding [DEFAULT_NICE]; any value <= 0 disables the governor. */ const val SETTING_KEY = "amethyst_worker_nice" - /** Sentinel for "not configured" — the governor stays off and costs nothing. */ - private const val DISABLED = Int.MIN_VALUE + /** Best measured value on a release-codegen build — see the table in the class doc. */ + const val DEFAULT_NICE = 10 - /** Sweep cadence while the cold-start storm is spawning threads. */ - private const val BURST_INTERVAL_MS = 250L + /** Sweep cadence while threads are still appearing. */ + private const val MIN_INTERVAL_MS = 250L - /** How long to sweep aggressively before backing off to [IDLE_INTERVAL_MS]. */ + /** 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 - private const val IDLE_INTERVAL_MS = 2_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` @@ -125,15 +137,15 @@ object WorkerThreadPriorityGovernor { @Volatile private var started = false - fun startIfConfigured(context: Context) { + fun start(context: Context) { if (started) return - val targetNice = readTargetNice(context) - if (targetNice == DISABLED) { - Log.i("ThreadPriority") { "Worker thread governor off (no $SETTING_KEY setting)" } + 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" } + Log.i("ThreadPriority") { "Worker thread governor on, target nice=$targetNice" } Thread({ sweepLoop(targetNice) }, "worker-nice-governor") .apply { @@ -142,55 +154,88 @@ object WorkerThreadPriorityGovernor { } } - private fun readTargetNice(context: Context): Int = - runCatching { - Settings.Global.getInt(context.contentResolver, SETTING_KEY, DISABLED) - }.getOrDefault(DISABLED) + /** 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 = System.currentTimeMillis() + val startedAt = SystemClock.elapsedRealtime() val mainTid = Process.myPid() - // A thread's nice survives renaming, so once demoted it never needs revisiting. - // Seeding with our own tid keeps the sweep from demoting the governor itself. - val alreadyDemoted = HashSet().apply { add(Process.myTid()) } + // 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, alreadyDemoted) - val elapsed = System.currentTimeMillis() - startedAt + val demoted = sweepOnce(mainTid, targetNice, handled) if (demoted > 0) { Log.d("ThreadPriority") { "Demoted $demoted thread(s) to nice $targetNice" } } - runCatching { - Thread.sleep(if (elapsed < BURST_DURATION_MS) BURST_INTERVAL_MS else IDLE_INTERVAL_MS) - }.onFailure { return } + + // 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, - alreadyDemoted: MutableSet, + handled: MutableSet, ): Int { - val tasks = File("/proc/self/task").listFiles() ?: return 0 + // 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 (task in tasks) { - val tid = task.name.toIntOrNull() ?: continue - if (tid == mainTid || tid in alreadyDemoted) continue - // A thread can exit between listing and reading; treat any failure as "skip". - val name = runCatching { File(task, "comm").readText().trim() }.getOrNull() ?: continue - if (DENYLIST.any { name.startsWith(it) }) continue + for (tidName in tidNames) { + val tid = tidName.toIntOrNull() ?: continue + live.add(tid) + if (tid == mainTid || tid in handled) continue - val ok = runCatching { Process.setThreadPriority(tid, targetNice) }.isSuccess - if (ok) { - alreadyDemoted.add(tid) + // 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 } } From d334139923aa11fffe399597431dc08bfb65e3b1 Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:16:58 +0000 Subject: [PATCH 18/35] chore: sync Crowdin translations and seed translator npub placeholders --- amethyst/src/main/res/values-pl-rPL/strings.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 999b439ab8..043ff6c9a5 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 @@ -3094,6 +3097,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) From d13a54d8320015fdbe47903c43ac6a3e1759da3d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 17 Aug 2026 17:30:43 -0400 Subject: [PATCH 19/35] perf: scan NIP-19 entities without ICU to stop the cold-start native-heap OOM On a 2.9GB SM-T220 the release build grew to ~1.9GB RSS during a cold start and was lmkd-killed ~32s in ("to free 1871628kB rss, 375492kB swap"). The growth was entirely in the NATIVE heap -- the Java heap plateaued at its 512MB largeHeap ceiling and GCed back down, while native ran 45MB -> 1372MB. Cause: `forEachNip19Match` called `Regex.matchAt` once per candidate position. On Android `java.util.regex` is ICU-backed, and `Regex.matchAt` builds a fresh Matcher whose `region()` -> `reset()` -> `MatcherNative.setInput()` copies the ENTIRE input into native memory. So scanning one note allocated a full native UTF-16 copy of its content *per candidate `n`*, and note content reaches 767KB in the tail. The Java Matcher object is tiny, so Java-heap-driven GC had no reason to reclaim them promptly and native memory grew unbounded. heapprofd's top malloc stack was exactly this path, under LocalCache.justConsume -> updateHintIndexes. The file's own KDoc had already recorded the symptom from an earlier pass -- "2,541 of 4,573 live Matchers were running this regex" -- but that pass optimized speed (the 9-23x anchoring win) and left the native retention in place. The grammar is prefix + bech32 payload + trailing non-space, so it is matched directly with char compares instead. Case folding is deliberately ASCII-only: RegexOption.IGNORE_CASE maps to Pattern.CASE_INSENSITIVE, which is ASCII-only unless UNICODE_CASE is set, so Kotlin's Unicode-aware `ignoreCase = true` would have accepted inputs the regex rejected (U+212A folding to 'k'). Reusing a single Matcher would NOT have fixed this: region() re-copies the input on every call. Only the ingest hot path changes. `uriToRoute`/`tryParseAndClean`/`hasAny` still use the regexes -- they run on short user input, not per ingested event. Measured on device (release codegen, 3 runs), native heap RSS: t~9s t~14s t~22s t~28s t~43s before 45M 497M 626M 1372M (killed at 31.9s) after 48M 127M 170M 175M 172M Native now plateaus at ~170MB, total RSS falls back to ~500MB instead of climbing to 1.88GB, and the process survives past 45s with zero lmkd kills. Equivalence is pinned by a new test that runs both original regexes over the same corpus and requires identical entity lists, targeting the exact-58 boundary, the excluded bech32 chars, ASCII-only case folding and what `[\S]*` swallows. Both mutations tried against it (58 -> 57, and admitting 'b' into the alphabet) fail the test. The pre-existing `Nip19ScanTest` (23 tests) and commons' `nip19MatchesReferenceScan` guard also still pass; full quartz suite 4288/0. Co-Authored-By: Claude Opus 5 (1M context) --- .../quartz/nip19Bech32/Nip19Parser.kt | 165 +++++++++++--- .../Nip19ScannerRegexEquivalenceTest.kt | 202 ++++++++++++++++++ 2 files changed, 340 insertions(+), 27 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/Nip19ScannerRegexEquivalenceTest.kt 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..b78ddfaf4b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt @@ -133,6 +133,78 @@ 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") + + /** bech32: digits except `1`, letters except `b`, `i`, `o`. ASCII-only, both cases. */ + private val BECH32_CHARS = + BooleanArray(128).apply { + for (c in "qpzry9x8gf2tvdw0s3jn54khce6mua7l") { + this[c.code] = true + if (c in 'a'..'z') this[c.code - 32] = true + } + } + + private fun isBech32(c: Char): Boolean = c.code < 128 && BECH32_CHARS[c.code] + + /** + * `\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 +237,66 @@ 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 while (i < len) { 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 && isBech32(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 +304,17 @@ object Nip19Parser { } } + private fun allBech32( + content: String, + from: Int, + to: Int, + ): Boolean { + for (k in from until to) { + if (!isBech32(content[k])) return false + } + return true + } + /** * Scans [content] for NIP-19 entities. * @@ -208,14 +331,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 +340,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/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") + } + } + } +} From c5a6aa2090e049363a47561d76d079900819596f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 17 Aug 2026 18:13:13 -0400 Subject: [PATCH 20/35] refactor: move the bech32 alphabet test into Bech32 as isDataChar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scanner added in the previous commit carried its own copy of the bech32 alphabet, duplicating `Bech32.ALPHABET`. "Is this a bech32 data character" is the codec's own question, so it belongs on `Bech32` next to the alphabet it derives from -- the same way `Hex` owns its parsing helpers. `Bech32.map` could not be reused for it: it is an `Array`, i.e. a boxed `java.lang.Byte[]`, and this runs per character over whole note contents (up to ~767KB), so it would unbox on every char. `isDataChar` gets its own primitive `BooleanArray` built from the same ALPHABET constants in the existing init block, so there is still one source of truth. Kept at parity with the private lookup it replaced, checked in the bytecode: - as a plain member it compiled to an `invokevirtual` per character and measured ~1-2% slower across the scan benchmark, consistently signed across 8 of 10 cases - `inline` removed that call, but property access to the table then compiled to a `getDATA_CHARS()` getter `invokevirtual` per character instead - `@JvmField` on the table makes the call site `getstatic; iload; baload` -- the same three instructions the private array produced Benchmark, medians of 3 runs of RegexContentBenchmark (nanoseconds, lower better): bytes before inlined 0 mentions 4104 4473 4518 0 mentions 68096 73398 74167 0 mentions 767144 836400 835772 m=120 767072 1029977 1030072 TOTAL 2102097 2104899 (+0.1%) The 767KB cases -- the tail that caused the OOM -- overlap run to run (before [855512, 828137, 836400] vs inlined [833456, 839112, 835772]). The two sub-microsecond cases swing ±80% between repeats of the *same* build, so they carry no signal. On-device native heap is unchanged from the previous commit: plateaus at ~169-182MB over two cold starts, zero lmkd kills. Adds Bech32DataCharTest, which sweeps the whole BMP and requires isDataChar to agree with ALPHABET exactly. Mutating the shared ALPHABET (adding 'b') now fails both it and the NIP-19 equivalence test. Co-Authored-By: Claude Opus 5 (1M context) --- .../quartz/nip19Bech32/Nip19Parser.kt | 16 +---- .../quartz/nip19Bech32/bech32/Bech32Util.kt | 37 ++++++++++ .../nip19Bech32/bech32/Bech32DataCharTest.kt | 67 +++++++++++++++++++ 3 files changed, 107 insertions(+), 13 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/bech32/Bech32DataCharTest.kt 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 b78ddfaf4b..25190ecc9d 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 @@ -163,17 +164,6 @@ object Nip19Parser { private val EVENT_VARIABLE_PREFIXES = arrayOf("nevent1", "naddr1", "note1", "nrelay1", "nembed1") - /** bech32: digits except `1`, letters except `b`, `i`, `o`. ASCII-only, both cases. */ - private val BECH32_CHARS = - BooleanArray(128).apply { - for (c in "qpzry9x8gf2tvdw0s3jn54khce6mua7l") { - this[c.code] = true - if (c in 'a'..'z') this[c.code - 32] = true - } - } - - private fun isBech32(c: Char): Boolean = c.code < 128 && BECH32_CHARS[c.code] - /** * `\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. @@ -280,7 +270,7 @@ object Nip19Parser { if (!matchesPrefixAt(content, i, prefix)) continue val dataStart = i + prefix.length var dataEnd = dataStart - while (dataEnd < len && isBech32(content[dataEnd])) dataEnd++ + while (dataEnd < len && Bech32.isDataChar(content[dataEnd])) dataEnd++ // `+` needs at least one payload char. if (dataEnd > dataStart) { end = endOfTrailing(content, dataEnd) @@ -310,7 +300,7 @@ object Nip19Parser { to: Int, ): Boolean { for (k in from until to) { - if (!isBech32(content[k])) return false + if (!Bech32.isDataChar(content[k])) return false } return true } 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/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()) }) + } +} From 6e8dd621fea56671ad4c7ea10ff865031cb9996b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 22:45:30 +0000 Subject: [PATCH 21/35] refactor: move the held NIP-OA attestation onto Account and collapse its map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not a leak — unlike the joined workspaces and the starred channels, this store was already keyed by the agent pubkey each attestation authorizes, so no account could ever read another's credential. It was a per-account store with extra steps, and the steps were hiding a real gap. Every caller only ever touched the entry for the account doing the AUTH: AgentAttestationScreen put/removed `myPubkey`, AuthCoordinator read `authTagFor(accountPubKey)`. So `Map` was a single-entry map behind a lookup that could not miss. BuzzHeldAttestations becomes a class holding one nullable attestation for the key it is constructed with, held as Account.buzzAttestation. The two CAS loops go with it — they guarded concurrent writers to a shared map, and a single slot is last-write-wins either way. Owning the agent key lets put() do the verification its KDoc used to delegate ("The caller must have already confirmed attestation.verify(agentPubKey)"). That obligation was discharged in two places and is now discharged in one, on the only door into the store, so the paste path and the on-disk restore are gated identically. BuzzAttestationPreferences drops its own re-verify loop as a result. That gap is worth naming: the old tests stored `sig = "c".repeat(128)` and asserted it came back out as an auth tag — an assertion that the store would hold a credential no relay would accept, which is exactly what the store promises not to do. They now sign real attestations with OwnerAttestation.sign and cover both rejection paths (issued to another key, tampered conditions), including that a rejected put leaves the held one intact. Persistence is per account with the key namespaced by pubkey. The migration is exact rather than best-effort: the legacy device-global list was already agent-keyed, so this account picks out its own entry and no other can match. Verified: 2801 tests green across :commons:jvmTest and :amethyst:testFdroidDebugUnitTest; spotlessApply clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014UmCeSWetuKmHdrWcZkWDR --- .../com/vitorpamplona/amethyst/AppModules.kt | 7 +- .../vitorpamplona/amethyst/model/Account.kt | 6 ++ .../preferences/BuzzAttestationPreferences.kt | 73 ++++++++++----- .../authCommand/model/AuthCoordinator.kt | 23 +++-- .../loggedIn/buzz/AgentAttestationScreen.kt | 22 +++-- .../model/buzz/BuzzHeldAttestations.kt | 92 +++++++------------ .../model/buzz/BuzzHeldAttestationsTest.kt | 64 +++++++++---- 7 files changed, 160 insertions(+), 127 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 18c9289c08..930b118476 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -287,10 +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 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). @@ -904,6 +900,9 @@ class AppModules( 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) }, ) 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 a7ada72abc..2f461497fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -35,6 +35,7 @@ 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 @@ -420,6 +421,11 @@ class Account( // 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) + // Per-account NIP-42 policy evaluator (blocked → per-relay override → global policy → prompt), // reading THIS account's own toggles, relay lists and follow graph. Cached here so every AUTH // path (foreground screen + background notification consumer) shares one instance, and so an 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..63e5fac2a9 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,37 @@ 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 +79,52 @@ 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. + val saved = prefs[key]?.let { json.decodeFromString(it) } + if (saved != null) { + attestation.put(OwnerAttestation(saved.owner, saved.conditions, saved.sig)) + return + } + // Nothing under this account's key: pick our entry out of the pre-namespacing list. That + // list was already agent-keyed, so this migration is exact — no other account's + // credential can match, and one that fails put()'s check is simply not reinstated. + val legacy = prefs[LEGACY_KEY] ?: return + json + .decodeFromString>(legacy) + .firstOrNull { it.agent == pubKeyHex } + ?.let { attestation.put(OwnerAttestation(it.owner, it.conditions, it.sig)) } } 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) + if (held == null) { + prefs.remove(key) + } else { + prefs[key] = 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") } } 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 cb5a0c3d37..ac9afc8d2e 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,7 +21,6 @@ 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.relayauth.RelayAuthContext import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision @@ -162,7 +161,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) } @@ -204,23 +203,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) } 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..de47959816 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,7 +212,7 @@ 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) + attestation.put(outcome.attestation) input = "" error = null } 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/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" } } From adca6e96661c5879d24d9c74d549f21902ad6590 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 23:24:22 +0000 Subject: [PATCH 22/35] fix: don't resurrect a removed NIP-OA attestation from the legacy store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit of this branch. The per-account migration added in the previous commit made "removed" indistinguishable from "never migrated", so removing a held attestation lasted exactly until the next launch. persist(null) removed the account's key. restoreFromDisk treats an absent key as "never migrated" and falls back to the pre-namespacing device-global list — which nothing ever clears — so the credential the user just deleted was put back. It survives every restart, because every restart repeats the same seeding. The joined-workspace and starred-channel stores are not affected, but only by luck of type: they persist a Set, and an empty Set reads back present, so their cleared state suppresses the fallback on its own. Verified both halves of that against a real PreferenceDataStore before fixing — a removed key reads back null, an empty set does not. Comments now say so at both persist() sites, since the correctness is entirely implicit and a later "cleanup" to remove() would be silent. The attestation store has no empty value to lean on, so it writes an explicit tombstone. The restore decision moves into a pure internal restoreFrom(saved, legacy, agent) — the store needs a Context and cannot be unit-tested on the JVM, and this is the part with the sharp edge. Five tests cover the precedence, including the regression (verified failing against the pre-fix logic). Also from the audit: AgentAttestationScreen ignored put()'s new boolean. It is unreachable today — parseHeldAttestation verifies against the same key the store does — but a rejected paste would have cleared the field and shown success while storing nothing. It now surfaces the shared failure message. Verified: 2806 tests green across :commons:jvmTest and :amethyst:testFdroidDebugUnitTest; spotlessApply clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014UmCeSWetuKmHdrWcZkWDR --- .../preferences/BuzzAttestationPreferences.kt | 61 +++++++++----- .../preferences/BuzzChannelStarPreferences.kt | 3 + .../preferences/BuzzWorkspacePreferences.kt | 3 + .../loggedIn/buzz/AgentAttestationScreen.kt | 17 +++- .../preferences/BuzzAttestationRestoreTest.kt | 79 +++++++++++++++++++ 5 files changed, 140 insertions(+), 23 deletions(-) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/model/preferences/BuzzAttestationRestoreTest.kt 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 63e5fac2a9..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 @@ -55,7 +55,6 @@ class BuzzAttestationPreferences( private val pubKeyHex: HexKey, private val attestation: BuzzHeldAttestations, ) { - private val json = Json { ignoreUnknownKeys = true } private val key = stringPreferencesKey("$KEY_PREFIX$pubKeyHex") @Serializable @@ -87,19 +86,7 @@ class BuzzAttestationPreferences( try { val prefs = context.sharedPreferencesDataStore.data.first() // put() verifies, so a credential that no longer checks out is dropped either way. - val saved = prefs[key]?.let { json.decodeFromString(it) } - if (saved != null) { - attestation.put(OwnerAttestation(saved.owner, saved.conditions, saved.sig)) - return - } - // Nothing under this account's key: pick our entry out of the pre-namespacing list. That - // list was already agent-keyed, so this migration is exact — no other account's - // credential can match, and one that fails put()'s check is simply not reinstated. - val legacy = prefs[LEGACY_KEY] ?: return - json - .decodeFromString>(legacy) - .firstOrNull { it.agent == pubKeyHex } - ?.let { attestation.put(OwnerAttestation(it.owner, it.conditions, it.sig)) } + 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 attestation: ${e.message}" } @@ -109,11 +96,10 @@ class BuzzAttestationPreferences( private suspend fun persist(held: OwnerAttestation?) { try { context.sharedPreferencesDataStore.edit { prefs -> - if (held == null) { - prefs.remove(key) - } else { - prefs[key] = json.encodeToString(Entry(held.ownerPubKey, held.conditions, held.sig)) - } + // 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 @@ -126,5 +112,42 @@ class BuzzAttestationPreferences( /** 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 e0997047b5..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 @@ -75,6 +75,9 @@ class BuzzChannelStarPreferences( private suspend fun persist(ids: Set) { try { + // 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 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 9b15b361b1..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 @@ -94,6 +94,9 @@ 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() } 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 de47959816..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 @@ -212,9 +212,15 @@ private fun HoldAttestationSection( when (val outcome = parseHeldAttestation(input, myPubkey)) { is HoldOutcome.Failure -> error = outcome.message is HoldOutcome.Success -> { - attestation.put(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 + } } } }, @@ -238,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 @@ -261,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/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)) + } +} From 86a9d3b7803d89af72f2e7e40e49006a78a956bc Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 17 Aug 2026 19:32:37 -0400 Subject: [PATCH 23/35] perf: jump NIP-19 candidates with indexOf instead of testing every char The scanner walked the content one character at a time looking for a candidate prefix. `findHashtags` already showed the better shape for this: jump between candidate positions with `indexOf`, which is an intrinsified, vectorised char search, and only do real work where one lands. 'n'/'N' are two separate searches, so both are tracked and each is only re-searched once consumed, amortising to about one indexOf per candidate. Medians of 6 RegexContentBenchmark runs per arm (ns/op, lower better): case bytes char-loop indexOf delta overlap 0 mentions 152 774.5 419.5 -45.8% no 0 mentions 608 1048.0 422.5 -59.7% no 0 mentions 4104 4704.5 2443.5 -48.1% no 0 mentions 68096 75678.0 38936.5 -48.5% no 0 mentions 767144 867870.5 434141.0 -50.0% no m=120 767072 1097200.5 808300.5 -26.3% no m=40 68072 150933.5 125125.0 -17.1% yes m=5 4050 12317.0 10835.5 -12.0% yes m=1 222 3185.5 3367.0 +5.7% yes m=2 698 4453.5 7211.0 +61.9% yes TOTAL 2218165.5 1431202.0 -35.5% Every no-match case is ~2x faster with non-overlapping ranges, and that is the path that matters: of 2588 real notes sampled off production relays, only 160 contain a NIP-19 prefix at all, so 94% never leave the scan loop. The 767KB mention-heavy tail -- the shape behind the OOM -- is 26% faster too. The one arguable regression is a ~700B note with 2 mentions, +2.7us in absolute terms with overlapping ranges across six runs. Accepted deliberately: it is microseconds on the rarest shape, against halving the case that runs on nearly every event. Still 0 mismatches against both original regexes over the 2588-note production corpus (7742 entities parsed), plus the synthetic equivalence corpus and Nip19ScanTest. Co-Authored-By: Claude Opus 5 (1M context) --- .../quartz/nip19Bech32/Nip19Parser.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 25190ecc9d..c7e83091ac 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt @@ -246,7 +246,25 @@ object Nip19Parser { ) { 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)) { var end = -1 From 6d57f30307bd43350fe36c6a1cc5fea6a41f01b0 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 17 Aug 2026 20:22:04 -0400 Subject: [PATCH 24/35] perf: scan hashtags and #[n] references without ICU MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `findHashtags` and `forEachIndexTag` jumped between `#` candidates with indexOf and then anchored `Regex.matchAt` at each. On Android `java.util.regex` is ICU-backed, and `Matcher.region()` -> `reset()` -> `MatcherNative.setInput()` copies the ENTIRE input into native memory per call, so every candidate cost a full native UTF-16 copy of the note's content. This is the same defect fixed for the NIP-19 scanner in the OOM work, and measured over 2588 notes pulled off production relays it is considerably worse, because a whitespace-preceded `#` is far more common in prose than a NIP-19 prefix: scanner Matchers native bytes copied worst single note nip19 7,871 1,752 MB 62.8 MB findHashtags 43,626 9,639 MB 279.6 MB (a 119KB note) findIndexTags 0 0 - Both grammars are small, so they are matched directly instead. `hashtagSearch` and `tagSearch` stay as the specification the scan is tested against. Case handling is deliberate: `(?:\s|\A)` is Java's `\s`, which without UNICODE_CHARACTER_CLASS is space plus 0x09..0x0D and nothing else, so the new `isAsciiRegexSpace` is used rather than Char.isWhitespace() — the latter is Unicode-aware and would accept U+00A0 before a `#`, which the regex rejected. The same asymmetry runs the other way inside a tag: the excluded punctuation class is entirely ASCII, so non-ASCII always continues a tag, and a tag made only of U+00A0 is non-empty to the regex but still dropped by `isNotBlank()`. Speed, medians of 5 RegexContentBenchmark runs (ns/op, lower better): case bytes regex ICU-free delta ranges overlap hashtags m=5 4050 3267 1035 -68.3% yes hashtags m=40 68072 56956 16033 -71.9% yes hashtags m=120 767072 656535 185675 -71.7% no TOTAL 717550 203400 -71.7% idxTags TOTAL 112932.5 99937.5 -11.5% Equivalence is pinned by a new test running both original regexes over a corpus covering the punctuation class, ASCII-vs-Unicode whitespace either side of the `#`, non-ASCII tag content and the minimum-one-character rules. Two mutations (dropping `.` from the terminators, and swapping in Char.isWhitespace) fail both it and the pre-existing ContentScanTest. Against 2588 real production notes the scanners agree with the regexes on every one, 32,075 hashtags parsed. `findIndexTags` shares the defect but never fires on real data — `#[0]` is the legacy citation form no current client emits — so it is fixed for consistency rather than impact. Co-Authored-By: Claude Opus 5 (1M context) --- .../nip10Notes/content/ContentHashTags.kt | 66 ++++--- .../nip10Notes/content/ContentScanChars.kt | 31 ++++ .../quartz/nip10Notes/content/IndexedTags.kt | 58 +++--- .../ContentScanRegexEquivalenceTest.kt | 167 ++++++++++++++++++ 4 files changed, 272 insertions(+), 50 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/content/ContentScanChars.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/content/ContentScanRegexEquivalenceTest.kt 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/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") + } + } +} From 3587a4c85813843d48a9652f8bf91697b59ecb56 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 17 Aug 2026 22:26:56 -0400 Subject: [PATCH 25/35] fix: cache the painter on its first miss in painterRes `painterRes` keeps an LruCache of per-size Painters per drawable, but on the first miss for a resource it installed the inner per-size cache and returned `loaded` without ever putting it in. A resource therefore had to be requested three times before it could hit: once to install the empty inner cache, once to populate it, once to read it -- so every drawable paid two extra `painterResource` loads. Found while profiling Home<->Notifications tab switching; it is a correctness fix, not a measurable win. The extra loads are two per (resource, size) pair for the life of the process, which does not show up next to the GC and lock-contention costs that actually dominate that switch. Not unit-tested: `painterRes` is @Composable and the module has neither Robolectric nor compose-ui-test, and `unitTests.isReturnDefaultValues = true` makes android.util.LruCache a no-op on the JVM, so a test would need either new test dependencies or a global android.util.LruCache stub affecting the other 154 test files. Verified by inspection plus the existing suite (1282 tests green). Co-Authored-By: Claude Opus 5 (1M context) --- .../amethyst/ui/StringResourceCache.kt | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) 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 } From dea37b3046063e9577074f0999d3591778570e2c Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 18 Aug 2026 12:39:02 -0400 Subject: [PATCH 26/35] perf: memoize guestbook envelope opens so the fold stops re-decrypting the buffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `refoldGuestbook()` re-projected the entire guestbook wrap buffer on every arriving guestbook wrap, and projecting means opening each envelope from scratch: two NIP-44 decrypts plus the wrap and seal signature verifies. The nth arrival therefore re-opened all n wraps, making a boot quadratic in decryptions. The Control Plane already avoids this via `editionByWrapId` ("a wrap is only ever decrypted once no matter how many folds it participates in"); the guestbook had no equivalent. This adds it, keyed on wrap id — safe because `guestbookKey` is derived once at construction and never rotates in place (a rekey builds a new session). Measured on an emulator cold start (Soapbox Community, 12 channels), counting at `Nip44v2.decrypt` — the choke point every NIP-44 caller funnels through: before 12,281 decrypts / 7,516 KB 6,229 opens over 448 unique (13x) after 724 decrypts / 705 KB 449 opens over 447 unique ( 1x) 94% fewer decrypts for the same set of envelopes, and the avoided opens skip two signature verifies apiece on top. This was effectively all of the app's NIP-44 traffic at boot: giftwrap/NIP-17 DMs measured 0 calls and the NIP-51 private-list "settings" 10 calls / 3 KB, so Concord refolding was the whole of it. `guestbookMembers(wraps, key)` keeps its signature for existing callers and is now the composition of the two halves it was split into, `guestbookEntry` (the expensive open) and `projectGuestbook` (the trivial last-writer-wins fold). Verified on device: the community still folds all 12 channels with unread counts, and the Members roster renders Owner/Admins/Moderators plus the plain guestbook members. Co-Authored-By: Claude Opus 5 --- .../commons/actions/ConcordActions.kt | 24 +++++- .../model/concord/ConcordCommunitySession.kt | 33 +++++++- .../model/concord/ConcordGuestbookFoldTest.kt | 76 +++++++++++++++++++ 3 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordGuestbookFoldTest.kt 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/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/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) + } +} From 8c1d75f354e46b45a84c2b129a5dcd154dc45594 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 19:29:19 +0000 Subject: [PATCH 27/35] refactor: render channel invites through NoteCompose like every other row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The invite row was a hand-assembled NoteComposeLayout with all five slots filled in by hand. That predates this branch — promoting invites into the Card pipeline renamed it and wired it into RenderCardItem without ever looking inside it, so the plumbing became standard while the row itself stayed bespoke, and it was missing everything NoteCompose provides: reply, boost, like, zap, share, the 3-dot menu, and click-through to open what it is about. NoteCompose already owns all of that chrome and RenderNoteRow is a `when` on the event kind supplying only the body — the same extension point RenderBadgeAward and ~30 others use. So kind 44100 gets a branch there, RenderChannelInvite supplies the body, and the card and the Messages section both just call NoteCompose. Nothing about the chrome is re-implemented. The body carries what the row is actually about: the channel's picture, name, member count, host relay and description, tappable through to the channel so the viewer can look before deciding (Route.RelayGroup opens a group that is not on kind-10009 yet, which is exactly this case), and the Leave / Ignore / Add to Messages actions with Accept promoted to a filled Button. It also names the actor inline. A kind-44100 is signed by the relay keypair — the relay is reporting a membership change it made — so NoteCompose's author header shows the relay, which is correct but does not say who added you. That moves into the body with their avatar and name; the hand-built row had cheated by putting the actor in the author slot. Note this now offers boost/zap/share on a relay-signed, `#p`-gated event: boost republishes a membership notification to your followers, zap pays the relay keypair rather than the actor, and share yields an nevent nobody else can fetch. Raised before implementing; kept deliberately for consistency with every other notification row. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KYibeoSVdEVotM3xU1heoW --- .../amethyst/ui/note/NoteCompose.kt | 6 + .../amethyst/ui/note/types/ChannelInvite.kt | 225 ++++++++++++++++++ .../loggedIn/notifications/CardFeedView.kt | 13 +- .../notifications/ChannelInvitesSection.kt | 176 ++------------ amethyst/src/main/res/values/strings.xml | 1 + 5 files changed, 266 insertions(+), 155 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChannelInvite.kt 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/types/ChannelInvite.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChannelInvite.kt new file mode 100644 index 0000000000..37e72468af --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChannelInvite.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.ui.note.types + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +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.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +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.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.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.ui.components.RobohashFallbackAsyncImage +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +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.Size25dp +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId + +/** + * 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, 3-dot menu, reactions row, last-read background and click-through all come from the + * same code every other notification uses. This used to be a hand-assembled `NoteComposeLayout` with + * every slot filled in by hand, which is why it had none of that. + * + * ### The author header names the relay, so the body names the actor + * + * A kind-44100 is signed by the **relay keypair** — the relay is reporting a membership change it made, + * so it really is the author. `NoteCompose` draws its header from `note.author` and will therefore show + * the relay. Who actually added you lives in the event's body, so it is rendered here, inline, with + * their avatar and name: "Alice added you". That distinction matters — the relay emits the identical + * kind for a self-join, and the actor is the only thing telling "I joined this" from "a stranger put me + * here" (the invite projection filters self-joins out before a card is ever built, so anything reaching + * this renderer was somebody else's doing). + */ +@Composable +fun RenderChannelInvite( + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val notice = remember(note) { note.toMembershipNotice() } ?: return + if (notice.removed) return + + val groupId = remember(notice) { GroupId(notice.channelId, notice.relay) } + val baseChannel = remember(groupId) { LocalCache.getOrCreateRelayGroupChannel(groupId) } + + // Recompose in place when the relay-signed metadata / roster changes, so the name, picture and + // member count fill in and stay current without the row being rebuilt. + val channelState by + remember(baseChannel) { baseChannel.flow().metadata.stateFlow } + .collectAsStateWithLifecycle() + val channel = channelState.channel as? RelayGroupChannel ?: baseChannel + + val actorUser = remember(notice.actor) { notice.actor?.let { LocalCache.getOrCreateUser(it) } } + val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle() + + Column(Modifier.fillMaxWidth()) { + // Who did this, since the header above is the relay rather than a person. + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth(), + ) { + if (actorUser != null) { + UserPicture(actorUser, Size25dp, 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, + ) + } + + // The channel itself, tappable so the viewer can look before deciding. Route.RelayGroup opens a + // group that is not on my kind-10009 yet, which is exactly this case. + RelayGroupSummary( + channel = channel, + autoPlayGif = autoPlayGif, + accountViewModel = accountViewModel, + onClick = { nav.nav(Route.RelayGroup(channel.groupId.id, channel.groupId.relayUrl.url)) }, + ) + + Row( + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(top = 4.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. + 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. + Button(onClick = { accountViewModel.acceptChannelInvite(channel) }) { + Text(stringRes(R.string.add_to_messages)) + } + } + } +} + +/** Compact channel identity — picture, name, member count and host relay — shared by the invite body. */ +@Composable +private fun RelayGroupSummary( + channel: RelayGroupChannel, + autoPlayGif: Boolean, + accountViewModel: AccountViewModel, + onClick: () -> Unit, +) { + val memberCount = channel.memberCount() + val description = channel.summary()?.takeIf { it.isNotBlank() } + + Column( + Modifier + .fillMaxWidth() + .padding(top = 6.dp) + .clip(MaterialTheme.shapes.medium), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 4.dp), + ) { + RobohashFallbackAsyncImage( + robot = channel.groupId.id, + model = channel.profilePicture(), + contentDescription = channel.toBestDisplayName(), + modifier = Modifier.size(44.dp).clip(CircleShape), + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = autoPlayGif, + ) + + Column(Modifier.weight(1f)) { + Text( + text = channel.toBestDisplayName(), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = + if (memberCount > 0) { + "$memberCount · " + channel.groupId.relayUrl.displayUrl() + } else { + channel.groupId.relayUrl.displayUrl() + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + if (description != null) { + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth().padding(top = 2.dp), + ) + } + } +} 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 3a2b04193c..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 @@ -400,8 +400,17 @@ private fun RenderCardItem( } is ChannelInviteCard -> { - ChannelInviteCompose( - item.invite, + // 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, ) 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 index 4926d16759..4177892aca 100644 --- 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 @@ -20,59 +20,32 @@ */ 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.note.NoteCompose 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 as a block inside Messages › New Requests. * * On the Notifications tab these are not a section at all: each pending invite is a - * [ChannelInviteCard] in the ordinary card feed, built by `convertToCard` and drawn by the same - * `RenderCardItem` dispatch as every other row — see - * [com.vitorpamplona.amethyst.ui.dal.NotificationFeedOrderCard] for why they sort to the top. This - * section exists because Messages has its own list that is not a `Card` feed. + * [ChannelInviteCard] in the ordinary card feed. Here Messages has its own list, which is not a `Card` + * feed, so the same rows are drawn directly. + * + * Either way the row itself is a plain [NoteCompose] over the relay's kind-44100 — same author header, + * 3-dot menu, reactions row and click-through as every other note — with only the body supplied per + * kind by `RenderNoteRow` → `RenderChannelInvite`. This used to be a hand-assembled `NoteComposeLayout` + * that reimplemented a fraction of that and therefore had none of the rest. * * 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, @@ -93,125 +66,22 @@ fun ChannelInvitesSection( 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) { - ChannelInviteCompose(invite, accountViewModel, nav) - HorizontalDivider(thickness = DividerThickness) + // Keyed by the kind-44100 it came from: 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 note. + key(invite.eventId) { + val note = remember(invite.eventId) { LocalCache.getNoteIfExists(invite.eventId) } + if (note != null) { + NoteCompose( + baseNote = note, + modifier = Modifier.fillMaxWidth(), + quotesLeft = 3, + accountViewModel = accountViewModel, + nav = 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 ChannelInviteCompose( - 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/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 62cb749f52..3a1fdcb8ee 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2888,6 +2888,7 @@ No messages yet Added to %1$s Someone + added you to this channel Ignore Leave Join this group to send messages. From 402bf412d53a12a31fdefd00f60b325154bb7309 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 20:32:28 +0000 Subject: [PATCH 28/35] feat: redesign the channel-invite body as a cover block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kind-44100 body was a plain text line plus three buttons. It sat inside a NoteCompose whose header, for this kind, is a bare npub — a kind-44100 is signed by the relay keypair, which has no kind-0 and no NIP-05, so line one falls through to npub1… and line two renders nothing. Everything that says what the row is about therefore has to live in the body. - Actor line: who added you, with their avatar, since the header names nobody. - Cover block: the channel picture edge to edge at 84dp with its name reversed out over a lower-half scrim and a visibility badge. The picture draws over a gradient hashed from the group id rather than falling back to one, so a channel with no picture and a channel whose picture fails to load land on the same stable colour with no placeholder branch. - Roster line: faces of people you already follow who are in the channel — a far better answer to "do I want to be here" than three strangers — plus the member count and host relay. - Description, dropped entirely when blank. - The whole block opens the channel, so it can be inspected before answering. Accept keeps the fill but drops to text-button padding at 34dp: the stock 40dp Button dominated the two choices beside it on a row that offers three. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KYibeoSVdEVotM3xU1heoW --- .../amethyst/ui/note/types/ChannelInvite.kt | 292 +++++++++++++----- 1 file changed, 214 insertions(+), 78 deletions(-) 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 index 37e72468af..549838203c 100644 --- 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 @@ -20,62 +20,76 @@ */ 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.layout.size -import androidx.compose.foundation.shape.CircleShape +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.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.ui.components.RobohashFallbackAsyncImage +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.Size25dp +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, 3-dot menu, reactions row, last-read background and click-through all come from the - * same code every other notification uses. This used to be a hand-assembled `NoteComposeLayout` with - * every slot filled in by hand, which is why it had none of that. + * author header, overflow menu, reaction bar, last-read background and click-through all come from the + * same code every other notification uses. * - * ### The author header names the relay, so the body names the actor + * ### 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. `NoteCompose` draws its header from `note.author` and will therefore show - * the relay. Who actually added you lives in the event's body, so it is rendered here, inline, with - * their avatar and name: "Alice added you". That distinction matters — the relay emits the identical - * kind for a self-join, and the actor is the only thing telling "I joined this" from "a stranger put me - * here" (the invite projection filters self-joins out before a card is ever built, so anything reaching - * this renderer was somebody else's doing). + * 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( @@ -84,30 +98,33 @@ fun RenderChannelInvite( nav: INav, ) { val notice = remember(note) { note.toMembershipNotice() } ?: 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 when the relay-signed metadata / roster changes, so the name, picture and - // member count fill in and stay current without the row being rebuilt. - val channelState by - remember(baseChannel) { baseChannel.flow().metadata.stateFlow } - .collectAsStateWithLifecycle() - val channel = channelState.channel as? RelayGroupChannel ?: baseChannel + // 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) } } - val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle() Column(Modifier.fillMaxWidth()) { - // Who did this, since the header above is the relay rather than a person. + // 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, Size25dp, accountViewModel = accountViewModel, nav = nav) + UserPicture(actorUser, Size22dp, accountViewModel = accountViewModel, nav = nav) UsernameDisplay(actorUser, Modifier.weight(1f, fill = false), accountViewModel = accountViewModel) } else { Text( @@ -125,22 +142,20 @@ fun RenderChannelInvite( ) } - // The channel itself, tappable so the viewer can look before deciding. Route.RelayGroup opens a - // group that is not on my kind-10009 yet, which is exactly this case. - RelayGroupSummary( - channel = channel, - autoPlayGif = autoPlayGif, - accountViewModel = accountViewModel, - onClick = { nav.nav(Route.RelayGroup(channel.groupId.id, channel.groupId.relayUrl.url)) }, - ) + 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.End, + horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.End), verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + 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. + // 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) } @@ -149,77 +164,198 @@ fun RenderChannelInvite( } // 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. - Button(onClick = { accountViewModel.acceptChannelInvite(channel) }) { - Text(stringRes(R.string.add_to_messages)) + // + // 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) } } } } -/** Compact channel identity — picture, name, member count and host relay — shared by the invite body. */ +/** + * 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 RelayGroupSummary( +private fun ChannelBanner( channel: RelayGroupChannel, - autoPlayGif: Boolean, accountViewModel: AccountViewModel, onClick: () -> Unit, ) { - val memberCount = channel.memberCount() + 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 = 6.dp) - .clip(MaterialTheme.shapes.medium), + .padding(top = 8.dp) + .clip(BlockShape) + .clickable(onClick = onClick), ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 4.dp), - ) { - RobohashFallbackAsyncImage( - robot = channel.groupId.id, - model = channel.profilePicture(), - contentDescription = channel.toBestDisplayName(), - modifier = Modifier.size(44.dp).clip(CircleShape), - loadProfilePicture = accountViewModel.settings.showProfilePictures(), - loadRobohash = accountViewModel.settings.isNotPerformanceMode(), - autoPlayGif = autoPlayGif, + 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), + ), + ), ) - Column(Modifier.weight(1f)) { + if (badge != null) { Text( - text = channel.toBestDisplayName(), - style = MaterialTheme.typography.titleMedium, + text = badge, + color = Color.White, + fontSize = 10.sp, fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + 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 = - if (memberCount > 0) { - "$memberCount · " + channel.groupId.relayUrl.displayUrl() - } else { - channel.groupId.relayUrl.displayUrl() - }, + text = description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, + maxLines = 2, overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 4.dp), ) } } - - if (description != null) { - Text( - text = description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth().padding(top = 2.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) + } + } + + Text( + text = + if (memberCount > 0) { + "$memberCount · " + channel.groupId.relayUrl.displayUrl() + } else { + channel.groupId.relayUrl.displayUrl() + }, + 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), + ), + ) +} From 5898dbde1c8d303c44371d7bec7ac3f61f802c03 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 21:12:21 +0000 Subject: [PATCH 29/35] =?UTF-8?q?feat:=20render=20pending=20invites=20as?= =?UTF-8?q?=20group=20rows=20in=20Messages=20=E2=80=BA=20New=20Requests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Requests is a list of rooms awaiting a decision, and a pending invite is exactly that — but it was drawn as a full note, a card the height of five rooms sitting on top of a list of rooms. It got that shape by inheritance, not by choice: the prompt was built for Notifications, where the unit of the feed genuinely is a note, and Messages reused the same composable. Extracts RelayGroupRow out of RelayGroupRoomCompose: one NIP-29 group as a Messages row, with the "last message" line and the long-press menu as the only two slots. Everything else — picture fallback to the host relay's NIP-11 icon, the unread rule, where a tap goes — is fixed there, because every list that shows a group has to agree on it. A pending invite then renders as that row with the invitation as its newest line: "Alice added you to this channel". The actor names it, not the signer — a kind-44100 is signed by the relay keypair reporting the change it made, so the author would be an npub. Deciding happens where it does for every other group: tap opens the channel so it can be read first, and its top bar already offers Add to Messages; long-press brings Add to Messages / Ignore / Leave to the row. Notifications keeps the note card — same state holder, so the two surfaces still cannot disagree about which invites are open. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KYibeoSVdEVotM3xU1heoW --- .../chats/rooms/ChatroomHeaderCompose.kt | 74 +++++++--- .../chats/rooms/feed/ChannelInvitesSection.kt | 138 ++++++++++++++++++ .../chats/rooms/feed/ChatroomListTabs.kt | 10 +- .../notifications/ChannelInvitesSection.kt | 87 ----------- amethyst/src/main/res/values/strings.xml | 2 + 5 files changed, 197 insertions(+), 114 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChannelInvitesSection.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/ChannelInvitesSection.kt 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 22b1764b1a..88b270cba1 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 @@ -485,9 +486,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 = @@ -513,6 +511,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) { @@ -529,9 +575,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 { @@ -554,9 +597,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 = @@ -568,20 +611,7 @@ 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 } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChannelInvitesSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChannelInvitesSection.kt new file mode 100644 index 0000000000..69bfe86121 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChannelInvitesSection.kt @@ -0,0 +1,138 @@ +/* + * 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.rooms.feed + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvite +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.RelayGroupRow +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId + +/** + * "Somebody added you to a channel" prompts, pinned above Messages › New Requests. + * + * Each one is an ordinary group row — the same [RelayGroupRow] the Known tab draws for a group you + * already joined — with the invitation itself as the row's newest line. New Requests is a list of + * rooms awaiting a decision, and a pending invite is exactly that; rendering it as a full note (which + * is what the Notifications tab does, where the unit of the feed genuinely is a note) put a card the + * height of five rooms on top of a list of rooms. + * + * Deciding happens where it does for every other group: tap opens the channel, so it can be read + * before answering, and its top bar offers "Add to Messages"; long-press brings the same three + * choices to the row. Nothing is auto-accepted — on a Buzz relay another member can add you to a + * channel server-side, so the relay writes you into the kind-39002 roster and you can read and post + * without ever having agreed to see it. Amethyst used to silently subscribe to those channels while + * showing no row for them anywhere; the relay's decision is now surfaced as a question instead. + */ +@Composable +fun ChannelInvitesSection( + accountViewModel: AccountViewModel, + nav: INav, + modifier: Modifier = Modifier, +) { + val invites by accountViewModel.account.channelInvites.flow + .collectAsStateWithLifecycle() + + if (invites.isEmpty()) return + + Column(modifier) { + invites.forEach { invite -> + // Keyed by the kind-44100 it came from: 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. + key(invite.eventId) { + ChannelInviteRow(invite, accountViewModel, nav) + HorizontalDivider(thickness = DividerThickness) + } + } + } +} + +@Composable +private fun ChannelInviteRow( + invite: BuzzChannelInvite, + accountViewModel: AccountViewModel, + nav: INav, +) { + val baseChannel = + remember(invite.channelId, invite.relay) { + LocalCache.getOrCreateRelayGroupChannel(GroupId(invite.channelId, invite.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(invite.actor, accountViewModel) + val lastContent = + if (invite.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 = invite.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 you in the roster; Leave is the kind-9022 that + // actually removes you 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) + }, + ) + } +} 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..c6e874df19 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 @@ -133,10 +132,11 @@ fun MessagesPager( 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. + // they belong on New Requests, pinned above the rows and shaped like them. 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 — as full notes there, since that feed's + // unit is a note — so the two surfaces cannot disagree about which invites are open. headerContent = if (tabs[page].resource == R.string.new_requests) { { ChannelInvitesSection(accountViewModel, nav) } 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 4177892aca..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/ChannelInvitesSection.kt +++ /dev/null @@ -1,87 +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.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.HorizontalDivider -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.key -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.note.NoteCompose -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.theme.DividerThickness - -/** - * "Somebody added you to a channel" prompts, rendered as a block inside Messages › New Requests. - * - * On the Notifications tab these are not a section at all: each pending invite is a - * [ChannelInviteCard] in the ordinary card feed. Here Messages has its own list, which is not a `Card` - * feed, so the same rows are drawn directly. - * - * Either way the row itself is a plain [NoteCompose] over the relay's kind-44100 — same author header, - * 3-dot menu, reactions row and click-through as every other note — with only the body supplied per - * kind by `RenderNoteRow` → `RenderChannelInvite`. This used to be a hand-assembled `NoteComposeLayout` - * that reimplemented a fraction of that and therefore had none of the rest. - * - * 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.account.channelInvites.flow - .collectAsStateWithLifecycle() - - if (invites.isEmpty()) return - - Column(modifier) { - invites.forEach { invite -> - // Keyed by the kind-44100 it came from: 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 note. - key(invite.eventId) { - val note = remember(invite.eventId) { LocalCache.getNoteIfExists(invite.eventId) } - if (note != null) { - NoteCompose( - baseNote = note, - modifier = Modifier.fillMaxWidth(), - quotesLeft = 3, - accountViewModel = accountViewModel, - nav = nav, - ) - HorizontalDivider(thickness = DividerThickness) - } - } - } - } -} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 3a1fdcb8ee..20eee5351d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2889,6 +2889,8 @@ 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. From 554685dbb144a94cf5686b21f9ef8ad0fcf7f065 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 21:24:59 +0000 Subject: [PATCH 30/35] feat: load channel invites through the New Requests DAL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The invites were a pinned header above the New Requests list. That put them outside the ordering: an invite from last week sat above a DM request from a minute ago, and the list had two sections that were the same kind of thing. ChatroomListNewFeedFilter now emits the kind-44100 itself as a row, so invites sort in among the unaccepted DMs by when they landed. ChatroomHeaderCompose matches the kind and draws it as the invited group's row — before the generic group-scoped fallback, which would otherwise render the relay keypair's npub with the raw JSON body as the preview and offer to leave a group never joined. The state stays on the account. LocalCache is a process-wide `object` shared by every logged-in account, and a kind-44100 is #p-gated — it is addressed to one viewer. Hanging "pending invite" off the shared channel would show account A's invite on account B's Messages; everything the channel does hold (39000 metadata, 39002 roster) is genuinely global, which is why membershipOf() takes the pubkey as an argument rather than knowing who is asking. "Pending" also is not a property of the notice alone: it means no later 44101 withdrew it, it is not on my kind-10009, and I have not dismissed it — two of those are account state. That is the same shape as joined groups, which the Known filter reads off account.relayGroupList and sorts into the feed exactly this way. Removes ChannelInvitesSection and the headerContent plumbing it needed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KYibeoSVdEVotM3xU1heoW --- .../loggedIn/AccountFeedContentStates.kt | 22 +-- .../chats/rooms/ChatroomHeaderCompose.kt | 76 ++++++++++ .../rooms/dal/ChatroomListNewFeedFilter.kt | 18 ++- .../chats/rooms/feed/ChannelInvitesSection.kt | 138 ------------------ .../chats/rooms/feed/ChatroomListFeedView.kt | 16 +- .../chats/rooms/feed/ChatroomListTabs.kt | 12 -- 6 files changed, 108 insertions(+), 174 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChannelInvitesSection.kt 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 74d467e57a..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 @@ -184,17 +184,20 @@ class AccountFeedContentStates( } } - // A pending channel invite renders as a card, 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 passes `acceptableEvent`, so rebuild when the projection moves — - // otherwise an answered invite would sit on the tab until an unrelated event refreshed it. + // 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. // - // `clear()` before each invalidation, because answering an invite REMOVES a row and the plain + // 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 on `if (newCards.isNotEmpty())` without touching the list — leaving the - // answered invite pinned at the top by the invites-first order. Clearing drops the additive - // fast path so the refresh rebuilds the whole list, which is the only branch that can shrink. + // 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) @@ -203,6 +206,7 @@ class AccountFeedContentStates( it.clear() it.invalidateData() } + dmNew.invalidateData() } } 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 88b270cba1..5918fa5f98 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 @@ -61,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 @@ -82,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 @@ -108,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 @@ -210,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. @@ -616,6 +629,69 @@ fun RelayGroupRow( } } +/** + * 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 notice = remember(inviteNote) { inviteNote.toMembershipNotice() } ?: 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, 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..9840145208 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,22 @@ 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 { + account.channelInvites.flow.value + .mapNotNull { LocalCache.getNoteIfExists(it.eventId) } + } + + return (privateMessages + marmotGroups + relayGroupInvites).sortedByDefaultFeedOrder() } override fun updateListWith( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChannelInvitesSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChannelInvitesSection.kt deleted file mode 100644 index 69bfe86121..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChannelInvitesSection.kt +++ /dev/null @@ -1,138 +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.chats.rooms.feed - -import androidx.compose.foundation.layout.Column -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.key -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvite -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.RelayGroupRow -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.quartz.nip29RelayGroups.GroupId - -/** - * "Somebody added you to a channel" prompts, pinned above Messages › New Requests. - * - * Each one is an ordinary group row — the same [RelayGroupRow] the Known tab draws for a group you - * already joined — with the invitation itself as the row's newest line. New Requests is a list of - * rooms awaiting a decision, and a pending invite is exactly that; rendering it as a full note (which - * is what the Notifications tab does, where the unit of the feed genuinely is a note) put a card the - * height of five rooms on top of a list of rooms. - * - * Deciding happens where it does for every other group: tap opens the channel, so it can be read - * before answering, and its top bar offers "Add to Messages"; long-press brings the same three - * choices to the row. Nothing is auto-accepted — on a Buzz relay another member can add you to a - * channel server-side, so the relay writes you into the kind-39002 roster and you can read and post - * without ever having agreed to see it. Amethyst used to silently subscribe to those channels while - * showing no row for them anywhere; the relay's decision is now surfaced as a question instead. - */ -@Composable -fun ChannelInvitesSection( - accountViewModel: AccountViewModel, - nav: INav, - modifier: Modifier = Modifier, -) { - val invites by accountViewModel.account.channelInvites.flow - .collectAsStateWithLifecycle() - - if (invites.isEmpty()) return - - Column(modifier) { - invites.forEach { invite -> - // Keyed by the kind-44100 it came from: 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. - key(invite.eventId) { - ChannelInviteRow(invite, accountViewModel, nav) - HorizontalDivider(thickness = DividerThickness) - } - } - } -} - -@Composable -private fun ChannelInviteRow( - invite: BuzzChannelInvite, - accountViewModel: AccountViewModel, - nav: INav, -) { - val baseChannel = - remember(invite.channelId, invite.relay) { - LocalCache.getOrCreateRelayGroupChannel(GroupId(invite.channelId, invite.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(invite.actor, accountViewModel) - val lastContent = - if (invite.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 = invite.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 you in the roster; Leave is the kind-9022 that - // actually removes you 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) - }, - ) - } -} 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 c6e874df19..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 @@ -131,18 +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 and shaped like them. 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 — as full notes there, since that feed's - // unit is a note — so the two surfaces cannot disagree about which invites are open. - headerContent = - if (tabs[page].resource == R.string.new_requests) { - { ChannelInvitesSection(accountViewModel, nav) } - } else { - null - }, ) } } From dac7bde2e179bfa435f8fab3941bd24a35be21a2 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 18 Aug 2026 17:33:31 -0400 Subject: [PATCH 31/35] fix: classify a buzz channel from the metadata event, not the channel it fills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An invite whose kind-39000 arrives after its kind-44100 never surfaced. The card stayed hidden on the Notifications tab and in Messages > New Requests until an unrelated membership notice happened to arrive, or the app was relaunched. LocalCache.consume(GroupMetadataEvent) loads the event onto its addressable note and wakes the cache observers FIRST, and only then copies it into the RelayGroupChannel. So the recompute that the arriving directory triggers reads a channel that is still empty, concludes UNKNOWN, and — with nothing left to emit — never runs again. (The channel is also skipped entirely when the event arrived without relay provenance.) The trigger now carries the classification instead of a note count: the flow maps the observed kind-39000 notes to their Buzz types, and classifyBuzzChannel falls back to that map when the channel has not been filled in yet. Reading the event that caused the emission cannot race with itself. Device-confirmed on an emulator against a local Buzz relay: a kind-44100 followed by its kind-39000 produced no card at all before this, and one unrelated kind-44101 made it appear instantly; after, the card appears on its own within seconds, on both surfaces. BuzzDmDiscovery classified off the same racing read and gets the same fix. Co-Authored-By: Claude Opus 5 (1M context) --- .../model/buzz/BuzzMembershipNotices.kt | 35 +++++- .../model/buzz/ChannelInvitesState.kt | 26 +++-- .../screen/loggedIn/buzz/BuzzDmDiscovery.kt | 25 ++-- .../model/buzz/BuzzChannelTypesTest.kt | 110 ++++++++++++++++++ 4 files changed, 177 insertions(+), 19 deletions(-) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/model/buzz/BuzzChannelTypesTest.kt 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 index 2cf43bd8b3..8150e3a6d0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/buzz/BuzzMembershipNotices.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/buzz/BuzzMembershipNotices.kt @@ -33,6 +33,7 @@ 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 @@ -117,18 +118,50 @@ fun LocalCache.membershipNotices(me: HexKey): List = .toList() .toMembershipNotices() +/** + * 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 ChannelClassification.UNKNOWN + 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 index aefa75461e..88e3dc16bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/buzz/ChannelInvitesState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/buzz/ChannelInvitesState.kt @@ -71,19 +71,25 @@ class ChannelInvitesState( scope: CoroutineScope, ) { /** - * Fires when a group's kind-39000 first lands, which is what turns an - * [com.vitorpamplona.amethyst.commons.model.buzz.ChannelClassification.UNKNOWN] channel into a - * decidable one. The classification is read per channel, by id, straight out of the cache, so - * without this the projection would never recompute when the directory arrives. + * 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. * - * Mapped to a count and de-duplicated: the observable list of addressable notes only ever grows, so - * a size change is exactly "a group we hadn't seen before is now known" — and it keeps a busy - * account's metadata traffic from re-running the projection on every unrelated group edit. + * 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 { it.size } + .map { buzzChannelTypes(it) } .distinctUntilChanged() /** @@ -112,13 +118,13 @@ class ChannelInvitesState( knownChannelTypes, dismissed, relayGroupList.liveRelayGroupList, - ) { verdicts, _, dismissals, joined -> + ) { 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) }, + classify = { channelId, relay -> classifyBuzzChannel(cache, channelId, relay, knownTypes) }, ) }.flowOn(Dispatchers.IO) .stateIn(scope, SharingStarted.Eagerly, emptyMap()) 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 e78416bc8d..8a0c20f734 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 @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmChannels 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 @@ -88,16 +89,23 @@ private suspend fun runBuzzDmDiscovery(account: Account) { 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 observable list of addressables only grows, so a size change is exactly - // "a group we hadn't seen before is now known". + // 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 { it.size } + .map { buzzChannelTypes(it) } .distinctUntilChanged(), - ) { _, _ -> BuzzChannelInvites.currentMemberships(LocalCache.membershipNotices(me)) } - .collectLatest { memberships -> - fetchMissingDirectories(account, memberships) - BuzzDmChannels.replace(me, memberships.filter { (id, relay) -> classifyBuzzChannel(LocalCache, id, relay) == ChannelClassification.DM }) + ) { _, knownTypes -> BuzzChannelInvites.currentMemberships(LocalCache.membershipNotices(me)) to knownTypes } + .collectLatest { (memberships, knownTypes) -> + fetchMissingDirectories(account, memberships, knownTypes) + BuzzDmChannels.replace( + me, + memberships.filter { (id, relay) -> + classifyBuzzChannel(LocalCache, id, relay, knownTypes) == ChannelClassification.DM + }, + ) } } @@ -113,10 +121,11 @@ private suspend fun runBuzzDmDiscovery(account: Account) { private suspend fun fetchMissingDirectories( account: Account, memberships: Map, + knownTypes: Map, ) { val byRelay = memberships - .filterKeys { id -> memberships[id]?.let { classifyBuzzChannel(LocalCache, id, it) } == ChannelClassification.UNKNOWN } + .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))) } 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"]) + } +} From 7c47a0495b4763e7cc369c20023fb7d3045a1539 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 18 Aug 2026 17:48:17 -0400 Subject: [PATCH 32/35] fix: read the invite map the New Requests rebuild is driven by MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accepting or ignoring an invite from the Messages row left the row on screen: the answer was applied (a manual pull-to-refresh dropped it, and the channel showed up under Known) but the automatic rebuild did not see it. `AccountFeedContentStates` invalidates dmNew when `pendingByEventId` emits, while the filter read `channelInvites.flow` — a second StateFlow mapped off that one. At the instant the rebuild runs, the derived flow can still hold the previous value, so the answered invite is rebuilt right back in, and nothing emits again. Read `pendingByEventId` directly. Ordering is irrelevant here — the feed sorts by its own comparator afterwards. Device-confirmed: Add to Messages and Ignore now clear the row immediately, and a live invite still appears on both surfaces within seconds. Co-Authored-By: Claude Opus 5 (1M context) --- .../chats/rooms/dal/ChatroomListNewFeedFilter.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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 9840145208..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 @@ -85,8 +85,13 @@ class ChatroomListNewFeedFilter( if (!isEnabled(ChatFeedType.NIP29)) { emptyList() } else { - account.channelInvites.flow.value - .mapNotNull { LocalCache.getNoteIfExists(it.eventId) } + // 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() From c4d68922bc2660d866271a1c06db1ab9b51e5f2a Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 18 Aug 2026 18:22:53 -0400 Subject: [PATCH 33/35] fix: give the invite roster line's member count its unit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The line read "5 · 10.0.2.2:7447". Sitting immediately after the faces of people you follow, a bare number reads as counting those faces — "5 people you follow" — which is the one thing it does not mean. Use the same `relay_group_member_count` plural every other group surface uses (the workspace channel list, discovery, the parent picker), so it reads "5 members · 10.0.2.2:7447" and matches the row the same channel gets elsewhere. Singular falls out of the plural: "1 member". Co-Authored-By: Claude Opus 5 (1M context) --- .../amethyst/ui/note/types/ChannelInvite.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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 index 549838203c..eda90a9ba3 100644 --- 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 @@ -46,6 +46,7 @@ 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 @@ -315,12 +316,17 @@ private fun ChannelRosterLine( } } + // "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) { - "$memberCount · " + channel.groupId.relayUrl.displayUrl() + pluralStringResource(R.plurals.relay_group_member_count, memberCount, memberCount) + " · " + host } else { - channel.groupId.relayUrl.displayUrl() + host }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText, From d93abf26fc318ecee8dab0fe01c8e244b9997f24 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 18 Aug 2026 19:40:49 -0400 Subject: [PATCH 34/35] feat: keep invite replies, reactions and zaps inside the relay that sent them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An invite card's actions behaved like actions on a public note: the reply routed to a generic kind-1111 whose broadcast reached the account's outbox, and the like and zap did the same. On a Buzz relay all three are room content — the room is the only place they mean anything, and for a private or closed group publishing them elsewhere advertises who is in which room. - Group-scoped events (anything carrying an `h` tag) now publish to the room's host relays and nowhere else. `EventBroadcaster` resolves the hosts from the cached channels for that group id and returns them outright instead of unioning them with the outbox and broadcast lists; a room this cache doesn't know still stays off the broadcast list. This is the rule the group reply composer already applied on its own path, applied to every group-scoped event. - Zap requests copy the room's `h` tag (reactions already did) and name the room's host as the relay for the receipt, so the kind-9735 lands where the message it pays for lives — and matches the recipient's `#h` notification query. - Tapping an invite row opens the reply page. The room block inside the card keeps its own click and opens the room, so the two destinations each have a target. The reply itself needed no change: it was already a kind-1111 carrying the room's `h` tag, rooted on the kind-44100 (`E`/`K`/`P`) — only its delivery was wrong. Device-verified against a local Buzz relay. Before: the comment reached nos.lol and nostr.mom. After: the comment and the reaction exist on the workspace relay only, absent from all three public relays checked, and the row-tap opens the composer while the room block still opens the room. Co-Authored-By: Claude Opus 5 (1M context) --- .../amethyst/model/AccountZapActions.kt | 10 +- .../amethyst/model/EventBroadcaster.kt | 16 +++ .../amethyst/model/LocalCache.kt | 15 +++ .../ui/navigation/routes/RouteMaker.kt | 10 ++ .../quartz/nip57Zaps/LnZapRequestEvent.kt | 8 ++ .../nip57Zaps/LnZapRequestGroupTagTest.kt | 117 ++++++++++++++++++ 6 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestGroupTagTest.kt 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 e4527fc901..75fc1a1908 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -749,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? = 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/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/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", + ) + } +} From 41fa4538c20eb959ba58c7774b6924e47ef87eed Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 18 Aug 2026 23:37:27 -0400 Subject: [PATCH 35/35] fix(buzz): open v2 invite links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The invite server now mints `v2.` tokens, and the client could not open them at all. BuzzInviteLink.parse requires `.` and reads the community out of the payload; for a v2 token the payload segment is the literal `v2`, which decodes to one byte and fails the JSON parse, so parse returned null. Every entry point is gated on that one call, so the failure was total and silent: the deep link fell through to the external browser (where nothing can sign the claim with the user's key — the reason the in-app flow exists), a pasted link in search did nothing at all, and a link inside a note rendered as a plain url instead of an invite. `amy buzz join` refused it too. Nothing is lost by admitting the shape. The join needs the host and the code, both carried by the url itself: relayUrl() is `wss://$host`, httpBase() is `https://$host`, and the claim response returns community_id and role — which is why the screen never reads communityId. Expiry is the relay's call for a token it alone can interpret. Matched on the literal `v2` prefix rather than by relaxing the decode, so `…/invite/anything.else` still fails to parse and a Concord naddr invite (no dot) is still rejected. Tests cover the real v2 token end to end plus both guards; all three fail against the unpatched parser. Verified on device against a live workspace: the link now opens the join screen, hands off to the window.nostr browser, and the claim enrolls the key. Co-Authored-By: Claude Opus 5 (1M context) --- .../quartz/buzz/invite/BuzzInviteLink.kt | 36 +++++++++++++++--- .../quartz/buzz/invite/BuzzInviteLinkTest.kt | 37 +++++++++++++++++++ 2 files changed, 67 insertions(+), 6 deletions(-) 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/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")) } }