feat(nip46): security-icon trust picker + reconnect affordance

Two of the three refinements from the Primal comparison:

Trust picker as security icons: unify the connect dialog and the app-detail
picker on one open→shield→locked glyph set — LockOpen (Full trust, never asks),
Shield (Reasonable), Lock (Paranoid) — replacing the connect dialog's emoji and
the detail's heart so both surfaces read the same and the icon carries the
guard-level at a glance.

Reconnect affordance: when an app's relays show Offline (from the live status
added last commit), offer a one-tap Reconnect — on the connected-apps row and in
the detail's Relays section — that forces the relay pool to re-dial now, ignoring
backoff. Shared Nip46ReconnectPill.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
This commit is contained in:
Claude
2026-07-17 15:18:35 +00:00
parent 98c18a9fcf
commit a039f6adbb
4 changed files with 82 additions and 14 deletions
@@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
@@ -199,21 +200,21 @@ private fun SignerConnectScreen(
) {
PolicyOption(
selected = selected == AppSignerPolicy.FULL_TRUST,
icon = "❤",
symbol = MaterialSymbols.LockOpen,
label = stringResource(R.string.napplet_policy_full_trust),
description = stringResource(R.string.napplet_policy_full_trust_desc),
onClick = { selected = AppSignerPolicy.FULL_TRUST },
)
PolicyOption(
selected = selected == AppSignerPolicy.REASONABLE,
icon = "👍",
symbol = MaterialSymbols.Shield,
label = stringResource(R.string.napplet_policy_reasonable),
description = stringResource(R.string.napplet_policy_reasonable_desc),
onClick = { selected = AppSignerPolicy.REASONABLE },
)
PolicyOption(
selected = selected == AppSignerPolicy.PARANOID,
icon = "🕶",
symbol = MaterialSymbols.Lock,
label = stringResource(R.string.napplet_policy_paranoid),
description = stringResource(R.string.napplet_policy_paranoid_desc),
onClick = { selected = AppSignerPolicy.PARANOID },
@@ -254,7 +255,7 @@ private fun SignerConnectScreen(
@Composable
private fun PolicyOption(
selected: Boolean,
icon: String,
symbol: MaterialSymbol,
label: String,
description: String,
onClick: () -> Unit,
@@ -276,7 +277,12 @@ private fun PolicyOption(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(icon, style = MaterialTheme.typography.headlineSmall)
Icon(
symbol = symbol,
contentDescription = null,
tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(26.dp),
)
Column(modifier = Modifier.weight(1f)) {
Text(label, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface)
Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets
import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -54,6 +55,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.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
@@ -89,6 +91,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46ActivityCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46AppIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46LiveStatus
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46ReconnectPill
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46StatusDot
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.nip46AppOnline
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.nip46ClientSubtitle
@@ -202,7 +205,12 @@ fun ConnectedAppDetailScreen(
.collectAsStateWithLifecycle()
val inboxRelays by accountViewModel.account.nip46Signer.inboxRelays
.collectAsStateWithLifecycle()
Nip46RelaysSection(nip46Info?.relays.orEmpty(), inboxRelays, connectedRelays)
Nip46RelaysSection(
relays = nip46Info?.relays.orEmpty(),
inboxRelays = inboxRelays,
connectedRelays = connectedRelays,
onReconnect = { accountViewModel.account.client.reconnect(ignoreRetryDelays = true) },
)
}
// Signing trust level section
@@ -312,8 +320,26 @@ private fun Nip46RelaysSection(
relays: Set<String>,
inboxRelays: Set<NormalizedRelayUrl>,
connectedRelays: Set<NormalizedRelayUrl>,
onReconnect: () -> Unit,
) {
SectionHeader(stringResource(R.string.nip46_signer_app_relays_title))
val context = LocalContext.current
val anyOffline =
remember(relays, inboxRelays, connectedRelays) {
if (relays.isEmpty()) {
nip46AppOnline(emptySet(), inboxRelays, connectedRelays) == false
} else {
relays.any { RelayUrlNormalizer.normalizeOrNull(it)?.let { r -> r !in connectedRelays } ?: true }
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.weight(1f)) { SectionHeader(stringResource(R.string.nip46_signer_app_relays_title)) }
if (anyOffline) {
Nip46ReconnectPill {
onReconnect()
Toast.makeText(context, R.string.nip46_signer_reconnecting, Toast.LENGTH_SHORT).show()
}
}
}
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.medium,
@@ -472,7 +498,7 @@ private fun PolicyPicker(
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
PolicyCard(
selected = selected == AppSignerPolicy.FULL_TRUST,
symbol = MaterialSymbols.Favorite,
symbol = MaterialSymbols.LockOpen,
label = stringResource(R.string.napplet_policy_full_trust),
description = stringResource(R.string.napplet_policy_full_trust_desc),
onClick = { onSelect(AppSignerPolicy.FULL_TRUST) },
@@ -20,7 +20,9 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -34,6 +36,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
@@ -53,6 +56,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
@@ -108,6 +112,7 @@ fun Nip46ConnectedAppsScreen(
) {
val account = accountViewModel.account
val signerPubKey = remember { account.signer.pubKey }
val context = LocalContext.current
// Live relay state so each app shows whether the signer currently reaches it. An app that brought
// its own relays (nostrconnect) is judged on those; a bunker-flow app rides the inbox relays.
@@ -167,6 +172,10 @@ fun Nip46ConnectedAppsScreen(
Nip46AppCard(
entry = entry,
online = online,
onReconnect = {
account.client.reconnect(ignoreRetryDelays = true)
Toast.makeText(context, R.string.nip46_signer_reconnecting, Toast.LENGTH_SHORT).show()
},
onClick = { nav.nav(Route.ConnectedAppDetail(entry.coordinate)) },
)
}
@@ -179,6 +188,7 @@ fun Nip46ConnectedAppsScreen(
private fun Nip46AppCard(
entry: Nip46AppEntry,
online: Boolean?,
onReconnect: () -> Unit,
onClick: () -> Unit,
) {
// Same identity line the detail screen (Nip46AppHeader) uses: the app's self-declared website
@@ -241,12 +251,16 @@ private fun Nip46AppCard(
label = { Text(policy.shortLabel(), style = MaterialTheme.typography.labelSmall) },
)
}
Icon(
MaterialSymbols.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
if (online == false) {
Nip46ReconnectPill(onReconnect)
} else {
Icon(
MaterialSymbols.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
}
}
}
@@ -268,6 +282,26 @@ internal fun Nip46StatusDot(online: Boolean) {
Box(Modifier.size(8.dp).clip(CircleShape).background(if (online) LiveGreen else MaterialTheme.colorScheme.outline))
}
/**
* A small "Reconnect" pill shown when an app's relays are offline. Forces the whole relay pool to
* re-dial now (ignoring backoff), which re-establishes the offline relays. Consumes its own tap so a
* pill inside a clickable card doesn't also open the card.
*/
@Composable
internal fun Nip46ReconnectPill(onClick: () -> Unit) {
Text(
stringResource(R.string.nip46_signer_app_reconnect),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier =
Modifier
.clip(RoundedCornerShape(999.dp))
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(999.dp))
.clickable(onClick = onClick)
.padding(horizontal = 11.dp, vertical = 4.dp),
)
}
/** A [Nip46StatusDot] + Connected/Offline label showing whether the signer currently reaches an app's relays. */
@Composable
internal fun Nip46LiveStatus(online: Boolean) {
+2
View File
@@ -932,6 +932,8 @@
<string name="nip46_signer_app_last_used">used %1$s</string>
<string name="nip46_signer_app_online">Connected</string>
<string name="nip46_signer_app_offline">Offline</string>
<string name="nip46_signer_app_reconnect">Reconnect</string>
<string name="nip46_signer_reconnecting">Reconnecting to relays…</string>
<string name="nip46_signer_app_relays_title">Relays</string>
<string name="nip46_signer_app_relays_inbox">Signs over your inbox relays — this app brought none of its own, so it keeps no extra background connection.</string>
<string name="nip46_signer_app_relays_own_hint">Amethyst holds a background connection to each of these while this app stays connected. Forget the app to drop them.</string>