diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectActivity.kt index bd3e035688..c48b742878 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectActivity.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt index 0da9d8e998..920d0dd38a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt @@ -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, inboxRelays: Set, connectedRelays: Set, + 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) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ConnectedAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ConnectedAppsScreen.kt index cbda6d1f2b..ad0f634374 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ConnectedAppsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ConnectedAppsScreen.kt @@ -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) { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index afa9ba923a..47e9bea0ca 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -932,6 +932,8 @@ used %1$s Connected Offline + Reconnect + Reconnecting to relays… Relays Signs over your inbox relays — this app brought none of its own, so it keeps no extra background connection. Amethyst holds a background connection to each of these while this app stays connected. Forget the app to drop them.