diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/AuthApprovalBanner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/AuthApprovalBanner.kt new file mode 100644 index 0000000000..6ba5814ef5 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/AuthApprovalBanner.kt @@ -0,0 +1,156 @@ +/* + * 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.commons.relayClient.auth + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl + +/** + * Inline AUTH approval banner. + * + * Renders one row per pending tier-2 NIP-42 AUTH challenge with three + * actions: `[Once]` `[Always]` `[Never]`. Each press calls [onResolve] + * with the user's choice, which the parent (typically a coordinator) + * uses to complete the underlying [PendingAuthApproval.decision] + * deferred and persist the scope. + * + * Stacks up to 3 entries inline; the rest collapse into a `+N more` row + * (a future iteration may expand them on click — keep simple for now). + * + * The component is platform-agnostic and lives in `commons` so Android + * and Desktop can render the same UX once the wire-up is built on each + * platform. + */ +@Composable +fun AuthApprovalBanner( + pending: List, + onResolve: (NormalizedRelayUrl, AuthApprovalScope) -> Unit, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + visible = pending.isNotEmpty(), + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + modifier = modifier, + ) { + Column(modifier = Modifier.fillMaxWidth()) { + val visible = pending.take(3) + val hidden = pending.size - visible.size + + visible.forEach { approval -> + AuthApprovalRow(approval = approval, onResolve = onResolve) + } + + if (hidden > 0) { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "+$hidden more relay${if (hidden == 1) "" else "s"} pending approval", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) + } + } + } + } +} + +@Composable +private fun AuthApprovalRow( + approval: PendingAuthApproval, + onResolve: (NormalizedRelayUrl, AuthApprovalScope) -> Unit, +) { + Surface( + color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.6f), + modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.4f)), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + symbol = MaterialSymbols.Lock, + contentDescription = null, + tint = MaterialTheme.colorScheme.onTertiaryContainer, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = approval.relayUrl.displayUrl(), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + Text( + text = + if (approval.pendingCount > 1) { + "requires authentication for ${approval.pendingCount} messages" + } else { + "requires authentication to deliver this message" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.8f), + ) + } + Spacer(Modifier.width(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + TextButton(onClick = { onResolve(approval.relayUrl, AuthApprovalScope.ONCE) }) { + Text("Once", style = MaterialTheme.typography.labelMedium) + } + TextButton(onClick = { onResolve(approval.relayUrl, AuthApprovalScope.ALWAYS) }) { + Text("Always", style = MaterialTheme.typography.labelMedium) + } + TextButton(onClick = { onResolve(approval.relayUrl, AuthApprovalScope.BLOCKED) }) { + Text("Never", style = MaterialTheme.typography.labelMedium) + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 7276f1e522..0f10968872 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -77,6 +77,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.ProvideMaterialSymbols import com.vitorpamplona.amethyst.commons.moderation.LocalHashtagSpamSettings import com.vitorpamplona.amethyst.commons.moderation.LocalSpamExemptKeys import com.vitorpamplona.amethyst.commons.moderation.PreferencesHashtagSpamSettings +import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalBanner import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull import com.vitorpamplona.amethyst.commons.wot.LocalWoTReady import com.vitorpamplona.amethyst.commons.wot.LocalWoTService @@ -1283,32 +1284,41 @@ private fun AppInner( LocalNamecoinService provides namecoinService, LocalSpamExemptKeys provides spamExemptKeys, ) { - MainContent( - layoutMode = layoutMode, - deckState = deckState, - workspaceManager = workspaceManager, - singlePaneState = singlePaneState, - pinnedNavBarState = pinnedNavBarState, - relayManager = relayManager, - localCache = localCache, - accountManager = accountManager, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - indexRelaysStore = indexRelaysStore, - nip11Fetcher = nip11Fetcher, - appScope = scope, - torStatus = currentTorStatus, - onShowComposeDialog = onShowComposeDialog, - onShowReplyDialog = onShowReplyDialog, - onShowAppDrawer = onShowAppDrawer, - onOpenFeedsDrawer = { - appDrawerInitialTab = - com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawerTab.FEEDS - onShowAppDrawer() - }, - onShowImportFollowListDialog = onShowImportFollowListDialog, - ) + val pendingAuthApprovals by authCoordinator.pendingApprovals.collectAsState() + Column(modifier = Modifier.fillMaxSize()) { + AuthApprovalBanner( + pending = pendingAuthApprovals.values.toList(), + onResolve = { url, scope -> authCoordinator.resolve(url, scope) }, + ) + Box(modifier = Modifier.weight(1f)) { + MainContent( + layoutMode = layoutMode, + deckState = deckState, + workspaceManager = workspaceManager, + singlePaneState = singlePaneState, + pinnedNavBarState = pinnedNavBarState, + relayManager = relayManager, + localCache = localCache, + accountManager = accountManager, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + indexRelaysStore = indexRelaysStore, + nip11Fetcher = nip11Fetcher, + appScope = scope, + torStatus = currentTorStatus, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onShowAppDrawer = onShowAppDrawer, + onOpenFeedsDrawer = { + appDrawerInitialTab = + com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawerTab.FEEDS + onShowAppDrawer() + }, + onShowImportFollowListDialog = onShowImportFollowListDialog, + ) + } + } // Import Follow List dialog (triggered from File menu / // Cmd+Shift+I). Rendered inside this CompositionLocalProvider diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/DesktopAuthCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/DesktopAuthCoordinator.kt index d0187060dd..3bf9ee8d7c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/DesktopAuthCoordinator.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/DesktopAuthCoordinator.kt @@ -32,7 +32,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent -import com.vitorpamplona.quartz.nip42RelayAuth.tags.RelayTag import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.PersistentMap import kotlinx.collections.immutable.persistentMapOf @@ -106,8 +105,8 @@ class DesktopAuthCoordinator( RelayAuthenticator( client = relayManager.client, scope = scope, - signWithAllLoggedInUsers = { template -> - val signed = signWithPolicy(account, template, policy) + signWithAllLoggedInUsers = { relayUrl, template -> + val signed = signWithPolicy(account, relayUrl, template, policy) signed?.let { listOf(it) } ?: emptyList() }, ) @@ -156,11 +155,11 @@ class DesktopAuthCoordinator( private suspend fun signWithPolicy( account: AccountState.LoggedIn, + relayUrl: NormalizedRelayUrl, template: EventTemplate, policy: AuthApprovalPolicy, - ): RelayAuthEvent? { - val relayUrl = template.tags.firstNotNullOfOrNull(RelayTag::parse) ?: return null - return when (val decision = policy.classify(relayUrl)) { + ): RelayAuthEvent? = + when (val decision = policy.classify(relayUrl)) { AuthApprovalDecision.Allow -> account.signer.sign(template) AuthApprovalDecision.Block -> null is AuthApprovalDecision.Pending -> { @@ -171,7 +170,6 @@ class DesktopAuthCoordinator( if (resolved == AuthApprovalScope.BLOCKED) null else account.signer.sign(template) } } - } private data class ActiveAuth( val pubKeyHex: String,