From c29374f0890a33a07d34b6150dbb8cfd9cfc852a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 21:43:51 +0000 Subject: [PATCH] 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 ", 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 Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a --- .../DataStoreRelayAuthPermissionStore.kt | 47 ++++++++++++++- .../RelayPublishFailureToast.kt | 59 +++++++++++++++++++ .../ui/screen/loggedIn/LoggedInPage.kt | 4 ++ .../relayauth/RelayAuthSettingsScreen.kt | 42 ++++++++++++- amethyst/src/main/res/values/strings.xml | 4 ++ .../relayauth/RelayAuthPermissionStore.kt | 6 ++ 6 files changed, 157 insertions(+), 5 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/publishOutcome/RelayPublishFailureToast.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt index 9312de5417..63246883c4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt @@ -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 { + val prefs = store.data.first() + val result = mutableMapOf() + 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> { 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 { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/publishOutcome/RelayPublishFailureToast.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/publishOutcome/RelayPublishFailureToast.kt new file mode 100644 index 0000000000..ea1a2774f7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/publishOutcome/RelayPublishFailureToast.kt @@ -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) } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt index 49704a9575..8dd2678837 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt index 3e7f91fb98..4dffc1e792 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt @@ -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>(emptyMap()) } var rationales by remember { mutableStateOf>>>(emptyMap()) } + var lastUsed by remember { mutableStateOf>(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>, + 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)), diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 545a157e49..33456c0eec 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -841,6 +841,10 @@ Block this relay Per-relay overrides Why you\'re logged in to these relays + Forget + Last used %1$s ago + Couldn\'t deliver your event + The relay %1$s didn\'t accept it after several tries. No per-relay overrides — global policy applies everywhere Allow Deny diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthPermissionStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthPermissionStore.kt index fa4c3531c9..3b33a0666f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthPermissionStore.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthPermissionStore.kt @@ -57,4 +57,10 @@ interface RelayAuthPermissionStore { /** All per-relay rationales — for the relay auth settings screen. */ suspend fun allRationales(): Map>> = 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 = emptyMap() }