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()) + } +}