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 6747651bd5..dd450b3d1f 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 androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -31,6 +32,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -54,13 +56,17 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.browser.OmniboxInput +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientInfo import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrOpDecision @@ -83,6 +89,7 @@ import com.vitorpamplona.amethyst.napplet.resolveNappletMeta import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46ActivityCard import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -133,8 +140,31 @@ fun ConnectedAppDetailScreen( ) } + // NIP-46 remote clients are tailored: their self-declared metadata (name/url) heads the screen and + // their serviced-request history is shown, instead of the napplet manifest path this coordinate can't + // be resolved through. `null` for napplet/browser coordinates, which keep the generic rendering. + val nip46Client = remember(coordinate) { Nip46PermissionAuthorizer.clientPubKeyOf(coordinate) } + var nip46Info by remember(coordinate) { mutableStateOf(null) } + LaunchedEffect(coordinate) { + if (nip46Client != null) { + nip46Info = withContext(Dispatchers.Default) { Amethyst.instance.nip46ClientStore.load(coordinate) } + } + } + val allActivity by accountViewModel.account.nip46Signer.activityLog.entries + .collectAsStateWithLifecycle() + val nip46Activity = remember(allActivity, nip46Client) { allActivity.filter { nip46Client != null && it.clientPubKey == nip46Client } } + val nip46Title = nip46Info?.name?.ifBlank { null } ?: stringResource(R.string.nip46_signer_remote_app) + Scaffold( - topBar = { TopBarWithBackButton(state?.title ?: coordinate.substringAfter(':', "").ifBlank { coordinate.take(12) + "…" }, nav) }, + topBar = { + val title = + if (nip46Client != null) { + nip46Title + } else { + state?.title ?: coordinate.substringAfter(':', "").ifBlank { coordinate.take(12) + "…" } + } + TopBarWithBackButton(title, nav) + }, ) { padding -> val current = state if (current == null) { @@ -154,7 +184,11 @@ fun ConnectedAppDetailScreen( verticalArrangement = Arrangement.spacedBy(16.dp), ) { // App identity header - AppIdentityHeader(current) + if (nip46Client != null) { + Nip46AppHeader(title = nip46Title, url = nip46Info?.url, clientPubKey = nip46Client) + } else { + AppIdentityHeader(current) + } // Signing trust level section if (current.signerPolicy != null) { @@ -217,6 +251,12 @@ fun ConnectedAppDetailScreen( } } + // Recent activity (NIP-46 clients only) + if (nip46Client != null && nip46Activity.isNotEmpty()) { + SectionHeader(stringResource(R.string.nip46_signer_activity_title)) + Nip46ActivityCard(nip46Activity) + } + // Forget button Spacer(Modifier.size(8.dp)) Button( @@ -245,6 +285,57 @@ fun ConnectedAppDetailScreen( } } +@Composable +private fun Nip46AppHeader( + title: String, + url: String?, + clientPubKey: String, +) { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.large, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + modifier = + Modifier + .size(48.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Icon( + MaterialSymbols.Key, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(24.dp), + ) + } + Column(modifier = Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + url?.ifBlank { null } ?: (clientPubKey.take(16) + "…"), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + stringResource(R.string.nip46_signer_remote_app), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + @Composable private fun AppIdentityHeader(state: ConnectedAppDetailState) { Surface( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ActivityUi.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ActivityUi.kt new file mode 100644 index 0000000000..70319cbfa0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ActivityUi.kt @@ -0,0 +1,116 @@ +/* + * 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.ui.screen.loggedIn.settings.nip46 + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.nip46Signer.Nip46ActivityEntry +import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo + +private val LiveGreen = Color(0xFF3DDC84) + +/** A card listing the most recent [entries] a NIP-46 signer serviced (newest first). */ +@Composable +fun Nip46ActivityCard( + entries: List, + max: Int = 8, +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column(modifier = Modifier.padding(vertical = 4.dp)) { + entries.take(max).forEach { Nip46ActivityRow(it) } + } + } +} + +@Composable +private fun Nip46ActivityRow(entry: Nip46ActivityEntry) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box( + modifier = + Modifier + .size(8.dp) + .clip(CircleShape) + .background(if (entry.ok) LiveGreen else MaterialTheme.colorScheme.error), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + describeNip46Activity(entry), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + entry.clientPubKey.take(12) + "…", + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + TimeAgo(entry.atSeconds) + } +} + +/** A friendly, localized one-liner for a serviced request (e.g. "Signed an event (kind 1)"). */ +@Composable +fun describeNip46Activity(entry: Nip46ActivityEntry): String { + val base = + when (entry.method) { + "sign_event" -> stringResource(R.string.nip46_signer_act_signed_kind, entry.kind ?: 0) + "nip04_encrypt", "nip44_encrypt" -> stringResource(R.string.nip46_signer_act_encrypted) + "nip04_decrypt", "nip44_decrypt" -> stringResource(R.string.nip46_signer_act_decrypted) + "get_public_key" -> stringResource(R.string.nip46_signer_act_shared_pubkey) + "connect" -> stringResource(R.string.nip46_signer_act_connected) + "ping" -> stringResource(R.string.nip46_signer_act_ping) + "get_relays" -> stringResource(R.string.nip46_signer_act_listed_relays) + else -> stringResource(R.string.nip46_signer_act_other, entry.method) + } + return if (entry.ok) base else "$base · ${stringResource(R.string.nip46_signer_activity_denied)}" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt index d0a4e70b84..b6b4f4166d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt @@ -91,7 +91,6 @@ import com.vitorpamplona.amethyst.model.nip46Signer.Nip46SignerState import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton -import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner @@ -567,68 +566,10 @@ private fun ActivitySection(entries: List) { style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, ) - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), - ) { - Column(modifier = Modifier.padding(vertical = 4.dp)) { - entries.take(8).forEach { entry -> - ActivityRow(entry) - } - } - } + Nip46ActivityCard(entries) } } -@Composable -private fun ActivityRow(entry: Nip46ActivityEntry) { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Box( - modifier = - Modifier - .size(8.dp) - .clip(CircleShape) - .background(if (entry.ok) LiveGreen else MaterialTheme.colorScheme.error), - ) - Column(modifier = Modifier.weight(1f)) { - Text( - describeNip46Activity(entry), - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - entry.clientPubKey.take(12) + "…", - style = MaterialTheme.typography.labelSmall, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - TimeAgo(entry.atSeconds) - } -} - -@Composable -private fun describeNip46Activity(entry: Nip46ActivityEntry): String { - val base = - when (entry.method) { - "sign_event" -> stringResource(R.string.nip46_signer_act_signed_kind, entry.kind ?: 0) - "nip04_encrypt", "nip44_encrypt" -> stringResource(R.string.nip46_signer_act_encrypted) - "nip04_decrypt", "nip44_decrypt" -> stringResource(R.string.nip46_signer_act_decrypted) - "get_public_key" -> stringResource(R.string.nip46_signer_act_shared_pubkey) - "connect" -> stringResource(R.string.nip46_signer_act_connected) - "ping" -> stringResource(R.string.nip46_signer_act_ping) - "get_relays" -> stringResource(R.string.nip46_signer_act_listed_relays) - else -> stringResource(R.string.nip46_signer_act_other, entry.method) - } - return if (entry.ok) base else "$base · ${stringResource(R.string.nip46_signer_activity_denied)}" -} - @Composable private fun WarningCard(message: String) { Card(