feat: add a Block Relay button to the relay NOTIFY dialog

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U2GsUAheZmAXv6vk4m7m9T
This commit is contained in:
Claude
2026-09-01 23:07:18 +00:00
parent 9fa60eae10
commit bb32822ab9
9 changed files with 164 additions and 2 deletions
@@ -3533,6 +3533,15 @@ class Account(
suspend fun saveBlockedRelayList(blockedRelays: List<NormalizedRelayUrl>) = 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
@@ -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<NormalizedRelayUrl>): BlockedRelayListEvent {
if (!signer.isWriteable()) throw SignerExceptions.ReadOnlyException()
val relayListForBlocked = getBlockedRelayList()
@@ -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
},
)
}
}
@@ -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,
)
}
}
}
},
)
}
@@ -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 {
@@ -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 }
}
}
@@ -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) }
+1
View File
@@ -3123,6 +3123,7 @@
</string>
<string name="payment_required_title">Message from %1$s</string>
<string name="notify_block_relay">Block Relay</string>
<string name="thread_title">Thread</string>
<string name="send_the_seller_a_message">Send the seller a message</string>
<string name="hi_seller_is_this_still_available">Hi %1$s, is this still available?</string>
@@ -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())
}
}