mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
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:
+44
-3
@@ -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 {
|
||||
|
||||
+59
@@ -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
-2
@@ -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)),
|
||||
|
||||
@@ -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>
|
||||
|
||||
+6
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user