From bb32822ab991d5f38b8fc94bacc02731b836ee2d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 23:07:18 +0000 Subject: [PATCH] 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()) + } +}