feat(relayauth): surface failed sends + per-relay forget/last-used

Consume the new give-up signal: RelayPublishFailureToastSubscription
(hosted in LoggedInPage) listens for onEventGaveUp and toasts "couldn't
deliver to <relay>", so a dropped send is visible instead of silent.

Rationale polish in the auth settings screen: each relay card now shows
"Last used N ago" and a Forget button that clears both the ALLOW/DENY
override and the accumulated rationale for that relay. The store gains
clearRationale + allLastUsed (default-implemented on the interface) and
records a last-used timestamp on each grant; clearDecision/clearRationale
now prune the shared url key only when a relay has neither an override
nor rationale left, so a partial clear never orphans the reverse-lookup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
This commit is contained in:
Claude
2026-07-10 22:40:10 +00:00
parent 9179bd9d8d
commit c29374f089
6 changed files with 157 additions and 5 deletions
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.relayClient.authCommand.model
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
@@ -29,6 +30,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.flow.first
import java.io.File
import java.security.MessageDigest
@@ -65,9 +67,9 @@ class DataStoreRelayAuthPermissionStore(
}
override suspend fun clearDecision(relayUrl: String) {
store.edit {
it.remove(decisionKey(relayUrl))
it.remove(urlKey(relayUrl))
store.edit { prefs ->
prefs.remove(decisionKey(relayUrl))
pruneUrlIfEmpty(prefs, relayUrl)
}
}
@@ -92,6 +94,7 @@ class DataStoreRelayAuthPermissionStore(
if (additions.isEmpty()) return
store.edit { prefs ->
prefs[urlKey(relayUrl)] = relayUrl
prefs[lastUsedKey(relayUrl)] = TimeUtils.now().toString()
for ((kind, pubkeys) in additions) {
if (pubkeys.isEmpty()) continue
val key = rationaleKey(relayUrl, kind)
@@ -101,6 +104,41 @@ class DataStoreRelayAuthPermissionStore(
}
}
override suspend fun clearRationale(relayUrl: String) {
store.edit { prefs ->
AuthPurposeKind.entries.forEach { prefs.remove(rationaleKey(relayUrl, it)) }
pruneUrlIfEmpty(prefs, relayUrl)
}
}
override suspend fun allLastUsed(): Map<String, Long> {
val prefs = store.data.first()
val result = mutableMapOf<String, Long>()
for ((key, value) in prefs.asMap()) {
val name = key.name
if (!name.startsWith(LAST_USED_PREFIX)) continue
val hash = name.removePrefix(LAST_USED_PREFIX)
val url = prefs[stringPreferencesKey("$URL_PREFIX$hash")] ?: continue
val ts = (value as? String)?.toLongOrNull() ?: continue
result[url] = ts
}
return result
}
/** Removes the shared url + last-used keys once a relay has neither an override nor rationale,
* so a partial clear never orphans the reverse-lookup other queries depend on. */
private fun pruneUrlIfEmpty(
prefs: MutablePreferences,
relayUrl: String,
) {
val hasDecision = prefs[decisionKey(relayUrl)] != null
val hasRationale = AuthPurposeKind.entries.any { prefs[rationaleKey(relayUrl, it)] != null }
if (!hasDecision && !hasRationale) {
prefs.remove(urlKey(relayUrl))
prefs.remove(lastUsedKey(relayUrl))
}
}
override suspend fun loadRationale(relayUrl: String): Map<AuthPurposeKind, Set<String>> {
val prefs = store.data.first()
return buildMap {
@@ -140,10 +178,13 @@ class DataStoreRelayAuthPermissionStore(
kind: AuthPurposeKind,
) = stringPreferencesKey("$RATIONALE_PREFIX${hash(relayUrl)}:${kind.name}")
private fun lastUsedKey(relayUrl: String) = stringPreferencesKey("$LAST_USED_PREFIX${hash(relayUrl)}")
companion object {
private const val DECISION_PREFIX = "allow:"
private const val URL_PREFIX = "url:"
private const val RATIONALE_PREFIX = "rat:"
private const val LAST_USED_PREFIX = "used:"
private const val SEPARATOR = ","
private fun hash(relayUrl: String): String {
@@ -0,0 +1,59 @@
/*
* 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.publishOutcome
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
/**
* Surfaces a toast when the relay client gives up delivering one of our events to a relay after
* exhausting its retry budget, so a failed send is visible instead of silently lost. The toast
* channel keeps only the latest message, so a burst of per-relay failures won't stack up.
*/
@Composable
fun RelayPublishFailureToastSubscription(accountViewModel: AccountViewModel) {
val client = remember { Amethyst.instance.client }
DisposableEffect(accountViewModel) {
val listener =
object : RelayConnectionListener {
override fun onEventGaveUp(
relay: IRelayClient,
event: Event,
) {
accountViewModel.toastManager.toast(
R.string.relay_send_failed_title,
R.string.relay_send_failed_message,
relay.url.url,
)
}
}
client.addConnectionListener(listener)
onDispose { client.removeConnectionListener(listener) }
}
}
@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils
import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.RelayAuthPromptHost
import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.RelayAuthSubscription
import com.vitorpamplona.amethyst.service.relayClient.publishOutcome.RelayPublishFailureToastSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssemblerSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountForegroundFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.navigation.AppNavigation
@@ -86,6 +87,9 @@ fun LoggedInPage(
// Shows the "log in to this relay?" dialog when a NIP-42 challenge needs the user to decide.
RelayAuthPromptHost(accountViewModel)
// Toasts when the relay client gives up delivering one of our events to a relay.
RelayPublishFailureToastSubscription(accountViewModel)
// Loads account information + DMs and Notifications from Relays.
AccountFilterAssemblerSubscription(accountViewModel)
@@ -40,6 +40,7 @@ import androidx.compose.material3.SuggestionChipDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -51,6 +52,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -68,6 +70,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.PolicyCard
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -89,12 +92,14 @@ fun RelayAuthSettingsScreen(
var perRelayOverrides by remember { mutableStateOf<Map<String, RelayAuthDecision>>(emptyMap()) }
var rationales by remember { mutableStateOf<Map<String, Map<AuthPurposeKind, Set<HexKey>>>>(emptyMap()) }
var lastUsed by remember { mutableStateOf<Map<String, Long>>(emptyMap()) }
var reloadKey by remember { mutableIntStateOf(0) }
LaunchedEffect(reloadKey) {
withContext(Dispatchers.IO) {
perRelayOverrides = store.allDecisions()
rationales = store.allRationales()
lastUsed = store.allLastUsed()
}
}
@@ -242,7 +247,19 @@ fun RelayAuthSettingsScreen(
Spacer(Modifier.height(4.dp))
rationales.entries.sortedBy { it.key }.forEach { (url, rationale) ->
RelayRationaleCard(url, rationale, accountViewModel)
RelayRationaleCard(
url = url,
rationale = rationale,
lastUsedSecs = lastUsed[url],
accountViewModel = accountViewModel,
onForget = {
scope.launch {
ledger.clearDecision(url)
store.clearRationale(url)
reloadKey++
}
},
)
Spacer(Modifier.height(8.dp))
}
}
@@ -254,8 +271,11 @@ fun RelayAuthSettingsScreen(
private fun RelayRationaleCard(
url: String,
rationale: Map<AuthPurposeKind, Set<HexKey>>,
lastUsedSecs: Long?,
accountViewModel: AccountViewModel,
onForget: () -> Unit,
) {
val context = LocalContext.current
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.medium,
@@ -265,7 +285,25 @@ private fun RelayRationaleCard(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(text = url, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.MiddleEllipsis)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = url,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onForget) {
Text(stringResource(R.string.relay_auth_forget))
}
}
if (lastUsedSecs != null && lastUsedSecs > 0L) {
Text(
text = stringResource(R.string.relay_auth_last_used, timeAgo(lastUsedSecs, context, prefix = "")),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
rationale.forEach { (kind, pubkeys) ->
Text(
text = stringResource(reasonRes(kind)),
+4
View File
@@ -841,6 +841,10 @@
<string name="relay_auth_block">Block this relay</string>
<string name="relay_auth_per_relay_overrides">Per-relay overrides</string>
<string name="relay_auth_why_authenticated">Why you\'re logged in to these relays</string>
<string name="relay_auth_forget">Forget</string>
<string name="relay_auth_last_used">Last used %1$s ago</string>
<string name="relay_send_failed_title">Couldn\'t deliver your event</string>
<string name="relay_send_failed_message">The relay %1$s didn\'t accept it after several tries.</string>
<string name="relay_auth_no_overrides">No per-relay overrides — global policy applies everywhere</string>
<string name="relay_auth_decision_allow">Allow</string>
<string name="relay_auth_decision_deny">Deny</string>
@@ -57,4 +57,10 @@ interface RelayAuthPermissionStore {
/** All per-relay rationales — for the relay auth settings screen. */
suspend fun allRationales(): Map<String, Map<AuthPurposeKind, Set<String>>> = emptyMap()
/** Forgets the accumulated grant rationale for [relayUrl] (does not touch the ALLOW/DENY override). */
suspend fun clearRationale(relayUrl: String) {}
/** Epoch-second timestamp of the last time each relay was authenticated with (for display). */
suspend fun allLastUsed(): Map<String, Long> = emptyMap()
}