From 7121b89e7169fc36ccee95866f0fba09e3d47103 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 23:05:48 +0000 Subject: [PATCH 1/6] feat: show payment-target pills in the payment targets dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dialog behind the payment-target button listed each target as a titlecased type over the full wallet id on a second line. It now renders the same pill the profile page uses — type icon, tinted type label and the shortened authority, with long-press to copy the full value. Extracts that pill as PaymentTargetPill and rebuilds PaymentTargetChip on top of ProfilePaymentChip, so the profile rail and the dialog (both from the profile button and from ReactionsRow) share one implementation instead of duplicating the Surface/Row layout. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01We35qZJEhp8bSbPUHo6Koc --- .../profile/header/DisplayPaymentTargets.kt | 135 +++++++----------- .../loggedIn/profile/header/PaymentButton.kt | 20 +-- 2 files changed, 54 insertions(+), 101 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayPaymentTargets.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayPaymentTargets.kt index 8d0b0193f5..a2000553e3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayPaymentTargets.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayPaymentTargets.kt @@ -20,48 +20,24 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header -import android.widget.Toast -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalClipboard -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler -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 com.vitorpamplona.amethyst.R 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.model.User -import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.payment.ProfilePaymentMethod -import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.Size16Modifier import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.SegwitAddress -import kotlinx.coroutines.launch /** Lightning-family target types Amethyst can pay in-app through the Send Payment screen. */ private val LIGHTNING_TARGET_TYPES = setOf("lightning", "ln", "lnurl") @@ -111,72 +87,57 @@ fun PaymentTargetChip( ) { val style = remember(target.type) { paymentTargetStyleFor(target.type) } val uriHandler = LocalUriHandler.current - val context = LocalContext.current - val clipboard = LocalClipboard.current - val scope = rememberCoroutineScope() - val copyLabel = stringRes(R.string.copy_to_clipboard) - val copiedMessage = stringRes(R.string.copied_to_clipboard) - Surface( - shape = RoundedCornerShape(50), - color = style.color.copy(alpha = 0.10f), - border = BorderStroke(1.dp, style.color.copy(alpha = 0.35f)), - modifier = - Modifier.combinedClickable( - onClick = { - // Targets one of the user's in-app wallets can pay (lightning, - // bitcoin) go to the Send Payment screen, which collects the - // amount and pays this exact target; everything else hands off - // to an external wallet app via its payment URI. - val inAppRoute = inAppPaymentRouteFor(baseUser.pubkeyHex, target) - if (inAppRoute != null) { - nav.nav(inAppRoute) - } else { - runCatching { uriHandler.openUri(style.uriFor(target.authority)) } - .onFailure { - accountViewModel.toastManager.toast( - R.string.error_dialog_payment_error, - R.string.no_payment_app_found_for_type, - style.label, - ) - } + PaymentTargetPill( + target = target, + onClick = { + // Targets one of the user's in-app wallets can pay (lightning, + // bitcoin) go to the Send Payment screen, which collects the + // amount and pays this exact target; everything else hands off + // to an external wallet app via its payment URI. + val inAppRoute = inAppPaymentRouteFor(baseUser.pubkeyHex, target) + if (inAppRoute != null) { + nav.nav(inAppRoute) + } else { + runCatching { uriHandler.openUri(style.uriFor(target.authority)) } + .onFailure { + accountViewModel.toastManager.toast( + R.string.error_dialog_payment_error, + R.string.no_payment_app_found_for_type, + style.label, + ) } - }, - onLongClick = { - scope.launch { - clipboard.setText(target.authority) - Toast.makeText(context, copiedMessage, Toast.LENGTH_SHORT).show() - } - }, - onLongClickLabel = copyLabel, - ), + } + }, + ) +} + +/** + * The pill for a single NIP-A3 payment target: the type's icon and tinted + * label followed by the shortened authority, with a long-press copy of the + * full authority. Shared by the profile's payment rail and the payment-target + * dialog so a target looks the same wherever it shows up. + */ +@Composable +fun PaymentTargetPill( + target: PaymentTarget, + onClick: () -> Unit, +) { + val style = remember(target.type) { paymentTargetStyleFor(target.type) } + + ProfilePaymentChip( + color = style.color, + label = style.label, + detail = remember(target.authority) { shortAddress(target.authority) }, + copyValue = target.authority, + onClick = onClick, ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), - ) { - Icon( - symbol = style.symbol, - contentDescription = style.label, - tint = style.color, - modifier = Size16Modifier, - ) - Text( - text = style.label, - color = style.color, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = shortAddress(target.authority), - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 12.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.widthIn(max = 180.dp), - ) - } + Icon( + symbol = style.symbol, + contentDescription = null, + tint = style.color, + modifier = Size16Modifier, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentButton.kt index be8d33b0e4..ab4d3b5424 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentButton.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header import android.content.Intent import android.widget.Toast 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.Spacer @@ -48,7 +49,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.core.net.toUri @@ -232,19 +232,11 @@ private fun PaymentTargetRow( .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 10.dp), ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = target.type.replaceFirstChar(Char::titlecase), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = target.authority, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) + // Same pill the profile page renders for this target: type icon, + // tinted type label and the shortened authority, instead of the + // raw wallet id spelled out over two lines. + Box(modifier = Modifier.weight(1f)) { + PaymentTargetPill(target = target, onClick = onPay) } Spacer(modifier = Modifier.width(8.dp)) IconButton(onClick = onShowQr) { From bb32822ab991d5f38b8fc94bacc02731b836ee2d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 23:07:18 +0000 Subject: [PATCH 2/6] feat: add a Block Relay button to the relay NOTIFY dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A paid relay answers a rejected AUTH with a NOTIFY asking for payment, and until now the only thing the prompt offered was "OK" — which dismisses it and lets the same relay ask again on the next AUTH. The user's actual intent ("stop talking to this relay") had to be carried out by hand on the Blocked Relays screen. Adds a "Block Relay" action to the dialog that publishes the relay into the account's NIP-51 kind:10006 blocked list. Enforcement is the existing one: BlockedRelayFilteringClient strips blocked relays from every REQ, COUNT and publish, so the pool drops the socket once the subscriptions that wanted the relay are recomputed. - BlockedRelayListState.addRelay / Account.blockRelay add one relay without rebuilding the list from a caller-held snapshot — the kind-10006 list is shared across clients and may have grown since. - NotifyRequestsCache.dismissAllFrom drops every queued prompt from the blocked relay, not just the one on screen: a paid relay files one NOTIFY per rejected AUTH, so dismissing them singly would immediately re-open the dialog. - NotifyCoordinator drops NOTIFYs from an already-blocked relay, closing the window where frames still in flight could re-open the prompt. - The button is hidden for read-only accounts, which cannot sign the list. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01U2GsUAheZmAXv6vk4m7m9T --- .../vitorpamplona/amethyst/model/Account.kt | 9 +++ .../blockedRelays/BlockedRelayListState.kt | 14 ++++ .../compose/DisplayNotifyMessages.kt | 13 ++++ .../compose/NotifyRequestDialog.kt | 30 ++++++++ .../notifyCommand/model/NotifyCoordinator.kt | 11 ++- .../model/NotifyRequestsCache.kt | 15 ++++ .../ui/screen/loggedIn/AccountViewModel.kt | 2 + amethyst/src/main/res/values/strings.xml | 1 + .../model/NotifyRequestsCacheTest.kt | 71 +++++++++++++++++++ 9 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequestsCacheTest.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 3dfb0d82e7..14231b94d2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -3533,6 +3533,15 @@ class Account( suspend fun saveBlockedRelayList(blockedRelays: List) = sendMyPublicAndPrivateOutbox(blockedRelayList.saveRelayList(blockedRelays)) + /** + * Blocks a single relay, leaving the rest of the kind-10006 list alone. + * + * Once published, [com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient] + * strips the relay from every REQ, COUNT and publish, so the pool drops the socket as soon as + * the subscriptions that wanted it are recomputed. + */ + suspend fun blockRelay(relay: NormalizedRelayUrl) = sendMyPublicAndPrivateOutbox(blockedRelayList.addRelay(relay)) + /** * Returns all known signed replaceable events that configure this account * (profile, contact list, relay lists, mute list, bookmarks, etc.). Events diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockedRelays/BlockedRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockedRelays/BlockedRelayListState.kt index 8bc4cec59e..533d57a239 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockedRelays/BlockedRelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockedRelays/BlockedRelayListState.kt @@ -73,6 +73,20 @@ class BlockedRelayListState( emptySet(), ) + /** + * Adds [relay] to the kind-10006 list, keeping whatever is already there. + * + * Callers that only want to block one relay (the NOTIFY prompt's "Block Relay" button, for + * instance) must not rebuild the list from a snapshot they captured earlier: the list is + * shared across clients and may have grown since. Reading the current note here keeps the + * add additive. + */ + suspend fun addRelay(relay: NormalizedRelayUrl): BlockedRelayListEvent { + val current = normalizeBlockedRelayListWithBackup(blockedListNote).toMutableList() + if (relay !in current) current.add(relay) + return saveRelayList(current) + } + suspend fun saveRelayList(blockedRelays: List): BlockedRelayListEvent { if (!signer.isWriteable()) throw SignerExceptions.ReadOnlyException() val relayListForBlocked = getBlockedRelayList() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt index f442bc3ef0..5351632880 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt @@ -57,6 +57,19 @@ fun DisplayNotifyMessages( accountViewModel = accountViewModel, nav = nav, onDismiss = { requests.dismissPaymentRequest(request) }, + onBlockRelay = + if (accountViewModel.isWriteable()) { + { + accountViewModel.blockRelay(request.relayUrl) + // Every queued prompt from this relay goes with the block, not just the one + // on screen: a paid relay files one NOTIFY per rejected AUTH, so dismissing + // only [request] would immediately re-open the dialog for a relay the user + // just asked us to stop talking to. + requests.dismissAllFrom(request.relayUrl) + } + } else { + null + }, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/NotifyRequestDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/NotifyRequestDialog.kt index e655dfc2b6..b92cb6eeb2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/NotifyRequestDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/NotifyRequestDialog.kt @@ -30,6 +30,7 @@ import androidx.compose.material3.ButtonColors 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.mutableStateOf import androidx.compose.runtime.remember @@ -55,6 +56,11 @@ fun NotifyRequestDialog( accountViewModel: AccountViewModel, nav: INav, onDismiss: () -> Unit, + /** + * Adds the relay that sent this message to the NIP-51 kind:10006 blocked list. Null hides the + * button — there is nothing to block for a read-only account, which cannot sign the list. + */ + onBlockRelay: (() -> Unit)? = null, ) { AlertDialog( onDismissRequest = onDismiss, @@ -94,5 +100,29 @@ fun NotifyRequestDialog( } } }, + dismissButton = + onBlockRelay?.let { + { + TextButton( + onClick = it, + contentPadding = PaddingValues(horizontal = Size16dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + symbol = MaterialSymbols.Block, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + Spacer(StdHorzSpacer) + Text( + text = stringRes(R.string.notify_block_relay), + color = MaterialTheme.colorScheme.error, + ) + } + } + } + }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt index c7e146b5bf..1e170f6171 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt @@ -101,8 +101,15 @@ class NotifyCoordinator( // Consume the correlation so a later, unrelated NOTIFY can't reuse a stale attribution. // An unattributable NOTIFY (none of our auths were rejected here) is dropped rather than // risk surfacing it under the wrong account. - val account = billedPubkeyAt.remove(relay)?.let(accountForPubkey) - account?.relayNotifications?.addPaymentRequestIfNew(message, relay) + val account = billedPubkeyAt.remove(relay)?.let(accountForPubkey) ?: return + + // A relay the user has already blocked doesn't get to keep prompting. The pool drops the + // socket once the subscriptions that wanted this relay are recomputed, but frames already + // in flight can still arrive in that window — and the whole point of the dialog's "Block + // Relay" button is that the dialog stops coming back. + if (relay in account.blockedRelayList.flow.value) return + + account.relayNotifications.addPaymentRequestIfNew(message, relay) } init { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequestsCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequestsCache.kt index e42954b26c..720872c55f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequestsCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequestsCache.kt @@ -52,4 +52,19 @@ class NotifyRequestsCache { this.transientPaymentRequestDismissals.update { it + request } } } + + /** + * Drops every pending prompt from [relayUrl] at once. + * + * Used when the user blocks the relay: a relay that asks for payment usually queues one NOTIFY + * per rejected AUTH, so dismissing them one at a time would keep re-showing the dialog for a + * relay the user just told us never to talk to again. + */ + fun dismissAllFrom(relayUrl: NormalizedRelayUrl) { + val fromRelay = this.transientPaymentRequests.value.filterTo(mutableSetOf()) { it.relayUrl == relayUrl } + if (fromRelay.isEmpty()) return + + this.transientPaymentRequests.update { it - fromRelay } + this.transientPaymentRequestDismissals.update { it + fromRelay } + } } 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 e524ee8622..41df89050e 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 @@ -1919,6 +1919,8 @@ class AccountViewModel( fun unfollowRelayFeed(url: NormalizedRelayUrl) = launchSigner { account.unfollowRelayFeed(url) } + fun blockRelay(url: NormalizedRelayUrl) = launchSigner { account.blockRelay(url) } + fun showWord(word: String) = launchSigner { account.showWord(word) } fun hideWord(word: String) = launchSigner { account.hideWord(word) } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 1c2b7e3e82..6ac5f887dd 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3123,6 +3123,7 @@ Message from %1$s + Block Relay Thread Send the seller a message Hi %1$s, is this still available? diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequestsCacheTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequestsCacheTest.kt new file mode 100644 index 0000000000..acf610c3aa --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequestsCacheTest.kt @@ -0,0 +1,71 @@ +/* + * 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.notifyCommand.model + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class NotifyRequestsCacheTest { + private val paid = NormalizedRelayUrl("wss://paid.relay/") + private val other = NormalizedRelayUrl("wss://other.relay/") + + @Test + fun blockingARelayDropsEveryPendingPromptFromIt() { + val cache = NotifyRequestsCache() + cache.addPaymentRequestIfNew("Pay up", paid) + cache.addPaymentRequestIfNew("Still unpaid", paid) + cache.addPaymentRequestIfNew("Unrelated", other) + + cache.dismissAllFrom(paid) + + assertEquals( + setOf(NotifyRequest(other, "Unrelated")), + cache.transientPaymentRequests.value, + ) + } + + @Test + fun aDismissedPromptStaysDismissedWhenTheRelayRepeatsIt() { + val cache = NotifyRequestsCache() + cache.addPaymentRequestIfNew("Pay up", paid) + + cache.dismissAllFrom(paid) + cache.addPaymentRequestIfNew("Pay up", paid) + + assertTrue(cache.transientPaymentRequests.value.isEmpty()) + } + + @Test + fun dismissingARelayWithNoPromptsChangesNothing() { + val cache = NotifyRequestsCache() + cache.addPaymentRequestIfNew("Unrelated", other) + + cache.dismissAllFrom(paid) + + assertEquals( + setOf(NotifyRequest(other, "Unrelated")), + cache.transientPaymentRequests.value, + ) + assertTrue(cache.transientPaymentRequestDismissals.value.isEmpty()) + } +} From b8899ec7ff9f5902acd1b6098f1a8326557d027c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 23:42:43 +0000 Subject: [PATCH 3/6] fix: block the relay before dismissing its prompts, and make the cache atomic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the Block Relay button, from a review of that change. Dismiss-before-block. The button called blockRelay() fire-and-forget and then dismissed every prompt from the relay. reportSignerErrors swallows a refused or timed-out signature (ManuallyUnauthorizedException, TimedOutException, CouldNotPerformException) with a log line and no toast, so rejecting the signer prompt closed the dialog, left the relay unblocked, and gave the user nothing to tell them so — and the prompts were in the dismissal set for good. The dismissal now runs from a callback that only fires after account.blockRelay() returns; leaving the prompt up is the feedback when it doesn't. This also shrinks the race window, since sendMyPublicAndPrivateOutbox consumes the kind-10006 into LocalCache synchronously before publishing. Non-atomic cache mutations. NOTIFYs are filed from the relay's socket coroutine while dismissals run from the UI, so addPaymentRequestIfNew's `value +=` read-modify-write could drop one of two concurrent edits, and dismissAllFrom read the pending set before updating it — a prompt arriving in between was removed without ever being recorded as dismissed. Both now go through update/getAndUpdate. Also avoids a copy on a hot path in BlockedRelayFilteringClient: every REQ, COUNT and publish went through filterKeys/minus whenever the block list was non-empty, allocating a full copy of the targets just to reproduce them unchanged. A blocked relay is by definition one the app has stopped aiming at, so it now checks whether any target is actually blocked before copying. This matters more now that blocking is one tap from the dialog rather than a trip to the settings screen, so non-empty block lists become the norm. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01U2GsUAheZmAXv6vk4m7m9T --- .../compose/DisplayNotifyMessages.kt | 19 +++++++++----- .../model/NotifyRequestsCache.kt | 25 +++++++++++-------- .../ui/screen/loggedIn/AccountViewModel.kt | 17 ++++++++++++- .../model/NotifyRequestsCacheTest.kt | 17 +++++++++++++ .../BlockedRelayFilteringClient.kt | 17 +++++++++++-- .../BlockedRelayFilteringClientTest.kt | 19 ++++++++++++++ 6 files changed, 95 insertions(+), 19 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt index 5351632880..304937a73b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt @@ -60,12 +60,19 @@ fun DisplayNotifyMessages( onBlockRelay = if (accountViewModel.isWriteable()) { { - accountViewModel.blockRelay(request.relayUrl) - // Every queued prompt from this relay goes with the block, not just the one - // on screen: a paid relay files one NOTIFY per rejected AUTH, so dismissing - // only [request] would immediately re-open the dialog for a relay the user - // just asked us to stop talking to. - requests.dismissAllFrom(request.relayUrl) + accountViewModel.blockRelay(request.relayUrl) { + // Only after the block is signed and published, never before: a refused + // or timed-out signature is swallowed without a toast, so dismissing up + // front would close the dialog on a relay that is still unblocked and + // leave the user no sign that anything failed. Leaving the prompt up is + // the feedback. + // + // Every queued prompt from this relay goes at once, not just the one on + // screen: a paid relay files one NOTIFY per rejected AUTH, so dismissing + // only [request] would immediately re-open the dialog for a relay the + // user just asked us to stop talking to. + requests.dismissAllFrom(request.relayUrl) + } } } else { null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequestsCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequestsCache.kt index 720872c55f..1086d7a77a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequestsCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequestsCache.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.update @Stable @@ -38,12 +39,12 @@ class NotifyRequestsCache { } fun addPaymentRequestIfNew(paymentRequest: NotifyRequest) { - if ( - !this.transientPaymentRequests.value.contains(paymentRequest) && - !this.transientPaymentRequestDismissals.value.contains(paymentRequest) - ) { - this.transientPaymentRequests.value += paymentRequest - } + if (this.transientPaymentRequestDismissals.value.contains(paymentRequest)) return + + // `update` rather than `value +=`: NOTIFYs are filed from the relay's socket coroutine + // while dismissals run from the UI, and a plain read-modify-write silently drops one of + // two concurrent edits — either losing a prompt or resurrecting a dismissed one. + this.transientPaymentRequests.update { if (paymentRequest in it) it else it + paymentRequest } } fun dismissPaymentRequest(request: NotifyRequest) { @@ -61,10 +62,14 @@ class NotifyRequestsCache { * relay the user just told us never to talk to again. */ fun dismissAllFrom(relayUrl: NormalizedRelayUrl) { - val fromRelay = this.transientPaymentRequests.value.filterTo(mutableSetOf()) { it.relayUrl == relayUrl } - if (fromRelay.isEmpty()) return + // getAndUpdate so the drain and the snapshot of what was drained are one atomic step: a + // NOTIFY filed by the socket coroutine between a separate read and write would otherwise + // be dropped from the pending set without ever being recorded as dismissed. + val before = this.transientPaymentRequests.getAndUpdate { pending -> pending.filterNotTo(mutableSetOf()) { it.relayUrl == relayUrl } } - this.transientPaymentRequests.update { it - fromRelay } - this.transientPaymentRequestDismissals.update { it + fromRelay } + val dismissed = before.filterTo(mutableSetOf()) { it.relayUrl == relayUrl } + if (dismissed.isEmpty()) return + + this.transientPaymentRequestDismissals.update { it + dismissed } } } 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 41df89050e..3b9e74dcc9 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 @@ -1919,7 +1919,22 @@ class AccountViewModel( fun unfollowRelayFeed(url: NormalizedRelayUrl) = launchSigner { account.unfollowRelayFeed(url) } - fun blockRelay(url: NormalizedRelayUrl) = launchSigner { account.blockRelay(url) } + /** + * Blocks [url], running [onBlocked] only once the kind-10006 has actually been signed and + * published. + * + * The ordering matters: [reportSignerErrors] swallows a refused or timed-out signature + * (ManuallyUnauthorizedException, TimedOutException, CouldNotPerformException) with nothing but + * a log line, so a caller that cleaned up before the block landed would leave the user with an + * unblocked relay, no feedback, and whatever UI state it tore down already gone. + */ + fun blockRelay( + url: NormalizedRelayUrl, + onBlocked: () -> Unit = {}, + ) = launchSigner { + account.blockRelay(url) + onBlocked() + } fun showWord(word: String) = launchSigner { account.showWord(word) } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequestsCacheTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequestsCacheTest.kt index acf610c3aa..46dce6b5d6 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequestsCacheTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequestsCacheTest.kt @@ -55,6 +55,23 @@ class NotifyRequestsCacheTest { assertTrue(cache.transientPaymentRequests.value.isEmpty()) } + @Test + fun aConcurrentPromptFromTheSameRelayIsNotSilentlySwallowed() { + val cache = NotifyRequestsCache() + cache.addPaymentRequestIfNew("Pay up", paid) + + // Stands in for a NOTIFY landing on the socket coroutine while the UI drains the relay: + // whatever survives the drain must still be reachable, never removed-but-unrecorded. + cache.dismissAllFrom(paid) + cache.addPaymentRequestIfNew("A different demand", paid) + + val pending = cache.transientPaymentRequests.value + val dismissed = cache.transientPaymentRequestDismissals.value + + assertEquals(setOf(NotifyRequest(paid, "Pay up")), dismissed) + assertEquals(setOf(NotifyRequest(paid, "A different demand")), pending) + } + @Test fun dismissingARelayWithNoPromptsChangesNothing() { val cache = NotifyRequestsCache() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/BlockedRelayFilteringClient.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/BlockedRelayFilteringClient.kt index 80e446bc51..32b7db05e7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/BlockedRelayFilteringClient.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/BlockedRelayFilteringClient.kt @@ -73,12 +73,25 @@ class BlockedRelayFilteringClient( relayList: Set, ) { val blocked = blockedRelays() - delegate.publish(event, if (blocked.isEmpty()) relayList else relayList - blocked) + delegate.publish(event, if (blocked.hitsNoneOf(relayList)) relayList else relayList - blocked) } private fun Map>.withoutBlocked(): Map> { val blocked = blockedRelays() - if (blocked.isEmpty()) return this + if (blocked.hitsNoneOf(keys)) return this return filterKeys { it !in blocked } } + + /** + * Whether none of the blocked relays appear in [targets] — i.e. whether the caller can be + * handed its own collection back untouched. + * + * Worth the extra scan because this runs on every REQ, COUNT and publish (filter assemblers + * rebuild their per-relay targets constantly) while a blocked relay is, by definition, one the + * app has stopped aiming at — so "the block list is non-empty but irrelevant to this call" is + * the overwhelmingly common case. Without the check, `filterKeys` / `minus` allocate and copy + * the whole collection every time just to reproduce it unchanged. A hash lookup per target and + * no allocation beats an allocation plus a full copy. + */ + private fun Set.hitsNoneOf(targets: Collection): Boolean = isEmpty() || targets.none { it in this } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/BlockedRelayFilteringClientTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/BlockedRelayFilteringClientTest.kt index 4965c0cc40..0fe19bde4f 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/BlockedRelayFilteringClientTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/BlockedRelayFilteringClientTest.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertSame class BlockedRelayFilteringClientTest { private val good = NormalizedRelayUrl("wss://good.example/") @@ -133,6 +134,24 @@ class BlockedRelayFilteringClientTest { assertEquals(emptySet(), inner.publishedRelays) } + @Test + fun aBlockListThatTouchesNothingHereIsPassedThroughWithoutCopying() { + val inner = RecordingClient() + // Non-empty block list, but none of it is aimed at on this call — the common case once the + // user has blocked anything at all. + val client = BlockedRelayFilteringClient(inner) { setOf(blocked) } + + val filters = mapOf(good to listOf(Filter()), alsoGood to listOf(Filter())) + val relays = setOf(good, alsoGood) + client.subscribe("sub", filters, null) + client.publish(event(), relays) + + // Identity, not just equality: reproducing an unchanged map/set costs an allocation and a + // full copy on a path that runs on every REQ and publish. + assertSame(filters, inner.subscribedFilters) + assertSame(relays, inner.publishedRelays) + } + @Test fun blockSetIsReadPerCallSoLaterChangesApply() { val inner = RecordingClient() From ea86c10adcf3df9f277887e75c4bb2bdf6fc6746 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 23:48:46 +0000 Subject: [PATCH 4/6] refactor: wrap the Block Relay press in launchSigner at the call site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the onBlocked callback parameter added to AccountViewModel.blockRelay with the pattern the rest of the app already uses for "sign, then clean up the UI only if it worked" — accountViewModel.launchSigner { … } around both steps at the call site, as in AwardBadgeScreen's launchSigner { sendPost(); popBack() }. There are 187 such direct uses in ui/, so a bespoke callback parameter on the ViewModel was the odd one out. Behaviour is unchanged: blockRelay was itself defined as `= launchSigner { … }`, so the press already ran inside one and the dismissal already waited on a successful signature. This just drops a layer and the now-unused ViewModel method rather than leaving dead API behind. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01U2GsUAheZmAXv6vk4m7m9T --- .../compose/DisplayNotifyMessages.kt | 20 +++++++++---------- .../ui/screen/loggedIn/AccountViewModel.kt | 17 ---------------- 2 files changed, 10 insertions(+), 27 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt index 304937a73b..25f5203281 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt @@ -60,17 +60,17 @@ fun DisplayNotifyMessages( onBlockRelay = if (accountViewModel.isWriteable()) { { - accountViewModel.blockRelay(request.relayUrl) { - // Only after the block is signed and published, never before: a refused - // or timed-out signature is swallowed without a toast, so dismissing up - // front would close the dialog on a relay that is still unblocked and - // leave the user no sign that anything failed. Leaving the prompt up is - // the feedback. + accountViewModel.launchSigner { + accountViewModel.account.blockRelay(request.relayUrl) + + // Reached only once the block is signed and published, because + // reportSignerErrors swallows a refused or timed-out signature without + // a toast: dismissing up front would close the dialog on a relay that + // is still unblocked and leave the user no sign anything failed. The + // prompt staying up is the feedback. // - // Every queued prompt from this relay goes at once, not just the one on - // screen: a paid relay files one NOTIFY per rejected AUTH, so dismissing - // only [request] would immediately re-open the dialog for a relay the - // user just asked us to stop talking to. + // Every queued prompt from the relay goes at once, not just the one on + // screen — a paid relay files one NOTIFY per rejected AUTH. requests.dismissAllFrom(request.relayUrl) } } 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 3b9e74dcc9..e524ee8622 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 @@ -1919,23 +1919,6 @@ class AccountViewModel( fun unfollowRelayFeed(url: NormalizedRelayUrl) = launchSigner { account.unfollowRelayFeed(url) } - /** - * Blocks [url], running [onBlocked] only once the kind-10006 has actually been signed and - * published. - * - * The ordering matters: [reportSignerErrors] swallows a refused or timed-out signature - * (ManuallyUnauthorizedException, TimedOutException, CouldNotPerformException) with nothing but - * a log line, so a caller that cleaned up before the block landed would leave the user with an - * unblocked relay, no feedback, and whatever UI state it tore down already gone. - */ - fun blockRelay( - url: NormalizedRelayUrl, - onBlocked: () -> Unit = {}, - ) = launchSigner { - account.blockRelay(url) - onBlocked() - } - fun showWord(word: String) = launchSigner { account.showWord(word) } fun hideWord(word: String) = launchSigner { account.hideWord(word) } From 5ba42b3439a374b85453c97fb44e1527d798ae44 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 00:30:28 +0000 Subject: [PATCH 5/6] test: verify the block actually mutates the kind-10006 correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The button had no test behind its central claim. NotifyRequestsCacheTest covered the prompt bookkeeping and BlockedRelayFilteringClientTest the enforcement, but nothing exercised BlockedRelayListState.addRelay — the step that decides what the published block list contains. That step is worth pinning because BlockedRelayListEvent.updateRelayList replaces every relay tag with what it is handed, so addRelay must read the current list before writing. Get it wrong and the second tap silently wipes the first block — a data-loss bug the UI gives no sign of, since the dialog closes either way. Drives the real thing: a real keypair, real NIP-51 encryption, LocalCache, and the production decryption cache. AccountSettings is stubbed only because it reads Resources.getSystem() for spoken languages, which is null outside an Android runtime; Looper is mocked as the neighbouring LocalCache tests already do. Covers: the list is created on the first block; the second block keeps the first; re-blocking is idempotent; and the relays stay in encrypted private tags, never public ones — a leak there would publish which paid relays the user walked away from. Confirmed the wipe case fails when addRelay is reverted to writing only the new relay. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01U2GsUAheZmAXv6vk4m7m9T --- .../BlockedRelayListAddRelayTest.kt | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip51Lists/BlockedRelayListAddRelayTest.kt diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip51Lists/BlockedRelayListAddRelayTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip51Lists/BlockedRelayListAddRelayTest.kt new file mode 100644 index 0000000000..43838456fc --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip51Lists/BlockedRelayListAddRelayTest.kt @@ -0,0 +1,154 @@ +/* + * 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.nip51Lists + +import android.os.Looper +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListDecryptionCache +import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Drives the real kind-10006 mutation behind the NOTIFY dialog's "Block Relay" button. + * + * The button hands a single relay to [BlockedRelayListState.addRelay], which read-modify-writes the + * whole list — and [BlockedRelayListEvent.updateRelayList] *replaces* every relay tag with what it + * is given. So "does blocking work" is really two questions this pins: the new relay lands, and the + * relays already blocked survive. A regression here silently wipes the user's block list on their + * next tap, which no amount of UI testing would make obvious. + * + * Each case uses a fresh keypair so its kind-10006 lives at its own address in the process-wide + * [LocalCache]. + */ +class BlockedRelayListAddRelayTest { + private val paid = RelayUrlNormalizer.normalize("wss://paid.example.com") + private val alsoPaid = RelayUrlNormalizer.normalize("wss://other.example.com") + + @Before + fun setup() { + // LocalCache.consume refuses the main thread; plain JVM tests have no Looper, + // where null == null reads as "main". Distinct mocks make it a worker thread. + mockkStatic(Looper::class) + every { Looper.myLooper() } returns mockk() + every { Looper.getMainLooper() } returns mockk() + } + + @After + fun tearDown() { + unmockkStatic(Looper::class) + } + + private class Fixture { + val keyPair = KeyPair() + val signer = NostrSignerInternal(keyPair) + val decryptionCache = BlockedRelayListDecryptionCache(signer) + + // Stubbed rather than real: AccountSettings reaches for Resources.getSystem() to read the + // user's spoken languages, which is null outside an Android runtime. The state only asks it + // for the backup list (none here) and hands it updates to persist. + val settings = mockk(relaxed = true) { every { backupBlockedRelayList } returns null } + + val state = + BlockedRelayListState( + signer = signer, + cache = LocalCache, + decryptionCache = decryptionCache, + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + settings = settings, + ) + + /** Mirrors what the publish path does: the signed event goes into the cache it came from. */ + fun land(event: BlockedRelayListEvent) = LocalCache.justConsumeMyOwnEvent(event) + + suspend fun blockedIn(event: BlockedRelayListEvent) = BlockedRelayListDecryptionCache(signer).relays(event) + } + + @Test + fun blockingTheFirstRelayCreatesTheList() = + runTest { + val f = Fixture() + + val event = f.state.addRelay(paid) + + assertEquals(BlockedRelayListEvent.KIND, event.kind) + assertEquals(setOf(paid), f.blockedIn(event)) + } + + /** The one that matters: a second tap must not throw away the first block. */ + @Test + fun blockingASecondRelayKeepsTheFirst() = + runTest { + val f = Fixture() + f.land(f.state.addRelay(paid)) + + val event = f.state.addRelay(alsoPaid) + + assertEquals(setOf(paid, alsoPaid), f.blockedIn(event)) + } + + @Test + fun blockingAnAlreadyBlockedRelayDoesNotDuplicateIt() = + runTest { + val f = Fixture() + f.land(f.state.addRelay(paid)) + + val event = f.state.addRelay(paid) + + assertEquals(setOf(paid), f.blockedIn(event)) + assertEquals(1, BlockedRelayListDecryptionCache(f.signer).relays(event).size) + } + + /** + * Blocked relays are private tags. Leaking them to public tags would publish which paid relays + * the user walked away from to anyone who reads their kind-10006. + */ + @Test + fun blockedRelaysStayInEncryptedPrivateTags() = + runTest { + val f = Fixture() + f.land(f.state.addRelay(paid)) + + val event = f.state.addRelay(alsoPaid) + + assertTrue("blocked relays must not appear in public tags", event.publicRelays().isEmpty()) + assertTrue( + "no relay url may appear in the cleartext tag array", + event.tags.none { tag -> tag.any { it.contains("paid.example.com") || it.contains("other.example.com") } }, + ) + } +} From 6faa6556dde3c8549d8e9553000f72b948f3c1f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 00:33:35 +0000 Subject: [PATCH 6/6] fix: keep the payment-target address visible and unify the wallet handoff Audit follow-ups to the pill format in the payment-targets dialog: - The row's three icon buttons left the pill 112dp on a 320dp dialog, which is exactly the width of the icon + type label: the shortened address was measured at 0dp and never drawn. The pill already pays on tap and copies on long-press (same as the profile), so the redundant bolt button goes and the address gets 45dp on a 320dp dialog, 97dp on a 372dp one. - Cap the chip label at one line: a long type ("BITCOINCASH") wrapped the pill to two lines in narrow hosts. - The dialog handed off to "payto:///" for every type while the identical pill on the profile uses the type's own scheme, so the same pill reached a different app depending on where it was tapped. Both now go through paymentTargetUri(), which keeps payto:// as the unknown-type fallback. - Drop FLAG_ACTIVITY_NEW_TASK|CLEAR_TASK from that handoff: CLEAR_TASK wiped whatever the wallet app already had open, and the dialog runs from an activity context that needs neither flag. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01We35qZJEhp8bSbPUHo6Koc --- .../profile/header/DisplayPaymentTargets.kt | 9 +++++ .../loggedIn/profile/header/PaymentButton.kt | 35 +++++++------------ .../profile/header/ProfilePaymentRailChips.kt | 4 +++ 3 files changed, 26 insertions(+), 22 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayPaymentTargets.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayPaymentTargets.kt index a2000553e3..54719239ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayPaymentTargets.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayPaymentTargets.kt @@ -73,6 +73,15 @@ fun inAppPaymentRouteFor( } } +/** + * The URI an external wallet app should receive for [target]: the type's own + * scheme (`bitcoin:`, `lightning:`, `https://cash.app/…`) and RFC 8905 + * `payto://` for types Amethyst has no dedicated scheme for. Shared with the + * payment-target dialog so the same pill hands off to the same app wherever + * it is tapped. + */ +fun paymentTargetUri(target: PaymentTarget): String = paymentTargetStyleFor(target.type).uriFor(target.authority) + /** * Chip for a NIP-A3 payment target. Rendered inside [DisplayPaymentRailChips]'s * FlowRow alongside the wallet-rail chips so all payment chips share one diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentButton.kt index ab4d3b5424..800a01c466 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentButton.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header -import android.content.Intent import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -48,10 +47,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog -import androidx.core.net.toUri import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -72,7 +71,6 @@ import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.ZeroPadding import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch @Composable @@ -153,6 +151,7 @@ fun PaymentTargetsDialog( payInApp: ((PaymentTarget) -> Boolean)? = null, ) { val context = LocalContext.current + val uriHandler = LocalUriHandler.current val clipboardManager = LocalClipboard.current val scope = rememberCoroutineScope() var errorMessage by remember { mutableStateOf(null) } @@ -188,15 +187,13 @@ fun PaymentTargetsDialog( }, onPay = { if (payInApp?.invoke(target) != true) { - try { - val intent = Intent(Intent.ACTION_VIEW, "payto://${target.type}/${target.authority}".toUri()) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - context.startActivity(intent) - onDismiss() - } catch (e: Exception) { - if (e is CancellationException) throw e - errorMessage = stringRes(context, R.string.no_payment_app_found) - } + // Same handoff the profile chip does: the type's own + // scheme when it has one (payto:// only as the + // fallback), and no task flags — CLEAR_TASK used to + // wipe whatever the wallet app already had open. + runCatching { uriHandler.openUri(paymentTargetUri(target)) } + .onSuccess { onDismiss() } + .onFailure { errorMessage = stringRes(context, R.string.no_payment_app_found) } } }, ) @@ -233,8 +230,10 @@ private fun PaymentTargetRow( .padding(horizontal = 16.dp, vertical = 10.dp), ) { // Same pill the profile page renders for this target: type icon, - // tinted type label and the shortened authority, instead of the - // raw wallet id spelled out over two lines. + // tinted type label and the shortened authority, instead of the raw + // wallet id spelled out over two lines. Tapping it pays and long-press + // copies, exactly like on the profile, so the row carries no separate + // pay button — three icon buttons left the address 0dp of width. Box(modifier = Modifier.weight(1f)) { PaymentTargetPill(target = target, onClick = onPay) } @@ -255,14 +254,6 @@ private fun PaymentTargetRow( tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } - IconButton(onClick = onPay) { - Icon( - symbol = MaterialSymbols.Bolt, - contentDescription = stringRes(R.string.payment_targets), - modifier = Size20Modifier, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfilePaymentRailChips.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfilePaymentRailChips.kt index b64b7bc3f0..812f56a2d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfilePaymentRailChips.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfilePaymentRailChips.kt @@ -290,6 +290,10 @@ fun ProfilePaymentChip( color = color, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, + // Narrow hosts (the payment-target dialog's row) would otherwise + // wrap a long type label into a two-line pill. + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) if (detail != null) { Text(