From e6068c0916309c5eed64f521797abed098e33f7f Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Wed, 1 Apr 2026 08:54:18 +0300 Subject: [PATCH] =?UTF-8?q?feat(tor):=20desktop=20Tor=20settings=20UI=20?= =?UTF-8?q?=E2=80=94=20indicator,=20toggle,=20dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5: Desktop settings UI for Tor. TorStatusIndicator: - Shield icon in sidebar footer (gray/yellow/green/red) - Tooltip shows status (no port number for security) - Only visible when Tor is not Off TorSettingsSection: - Inline in settings screen between Media Server and Dev Settings - Mode selector (Off/Internal/External) with segmented buttons - External SOCKS port input with validation (1-65535) - "Advanced..." button opens full dialog TorSettingsDialog: - Full DialogWindow with preset selector (radio buttons) - 4 relay routing toggles + 7 content routing toggles - Preset auto-detection (whichPreset) - Save/Cancel with DesktopTorPreferences persistence Co-Authored-By: Claude Opus 4.6 (1M context) --- .../vitorpamplona/amethyst/desktop/Main.kt | 18 ++ .../amethyst/desktop/ui/deck/DeckSidebar.kt | 8 + .../desktop/ui/tor/TorSettingsDialog.kt | 238 ++++++++++++++++++ .../desktop/ui/tor/TorSettingsSection.kt | 139 ++++++++++ .../desktop/ui/tor/TorStatusIndicator.kt | 96 +++++++ 5 files changed, 499 insertions(+) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorSettingsDialog.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorSettingsSection.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorStatusIndicator.kt 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 12aeaee23..334ba1efc 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -573,6 +573,7 @@ fun App( nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, appScope = scope, + torStatus = torManager.status.collectAsState().value, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, onShowAddColumnDialog = onShowAddColumnDialog, @@ -624,6 +625,7 @@ fun MainContent( nwcConnection: Nip47WalletConnect.Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, appScope: CoroutineScope, + torStatus: com.vitorpamplona.amethyst.commons.tor.TorServiceStatus, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onShowAddColumnDialog: () -> Unit, @@ -796,6 +798,7 @@ fun MainContent( }, signerConnectionState = signerConnectionState, lastPingTimeSec = lastPingTimeSec, + torStatus = torStatus, ) VerticalDivider() @@ -879,6 +882,11 @@ fun RelaySettingsScreen( relayManager: DesktopRelayConnectionManager, account: AccountState.LoggedIn, accountManager: AccountManager, + torStatus: com.vitorpamplona.amethyst.commons.tor.TorServiceStatus = com.vitorpamplona.amethyst.commons.tor.TorServiceStatus.Off, + torSettings: com.vitorpamplona.amethyst.commons.tor.TorSettings = + com.vitorpamplona.amethyst.commons.tor + .TorSettings(torType = com.vitorpamplona.amethyst.commons.tor.TorType.OFF), + onTorSettingsChanged: (com.vitorpamplona.amethyst.commons.tor.TorSettings) -> Unit = {}, ) { val relayStatuses by relayManager.relayStatuses.collectAsState() val connectedRelays by relayManager.connectedRelays.collectAsState() @@ -994,6 +1002,16 @@ fun RelaySettingsScreen( HorizontalDivider() Spacer(Modifier.height(24.dp)) + // Tor Settings + com.vitorpamplona.amethyst.desktop.ui.tor.TorSettingsSection( + torStatus = torStatus, + currentSettings = torSettings, + onSettingsChanged = onTorSettingsChanged, + ) + Spacer(Modifier.height(24.dp)) + HorizontalDivider() + Spacer(Modifier.height(24.dp)) + // Developer Settings Section (only in debug mode) if (DebugConfig.isDebugMode) { com.vitorpamplona.amethyst.desktop.ui diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt index 5ddc99b21..1173b6912 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt @@ -42,7 +42,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState +import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus import com.vitorpamplona.amethyst.commons.ui.components.BunkerHeartbeatIndicator +import com.vitorpamplona.amethyst.desktop.ui.tor.TorStatusIndicator @Composable fun DeckSidebar( @@ -50,6 +52,7 @@ fun DeckSidebar( onOpenSettings: () -> Unit, signerConnectionState: SignerConnectionState, lastPingTimeSec: Long?, + torStatus: TorServiceStatus, modifier: Modifier = Modifier, ) { Column( @@ -87,6 +90,11 @@ fun DeckSidebar( lastPingTimeSec = lastPingTimeSec, ) + if (torStatus !is TorServiceStatus.Off) { + Spacer(Modifier.size(4.dp)) + TorStatusIndicator(status = torStatus) + } + Spacer(Modifier.size(8.dp)) IconButton(onClick = onOpenSettings) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorSettingsDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorSettingsDialog.kt new file mode 100644 index 000000000..f1faf37d8 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorSettingsDialog.kt @@ -0,0 +1,238 @@ +/* + * 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.desktop.ui.tor + +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +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.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogWindow +import androidx.compose.ui.window.rememberDialogState +import com.vitorpamplona.amethyst.commons.tor.TorPresetType +import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus +import com.vitorpamplona.amethyst.commons.tor.TorSettings +import com.vitorpamplona.amethyst.commons.tor.TorType +import com.vitorpamplona.amethyst.commons.tor.torDefaultPreset +import com.vitorpamplona.amethyst.commons.tor.torFullyPrivate +import com.vitorpamplona.amethyst.commons.tor.torOnlyWhenNeededPreset +import com.vitorpamplona.amethyst.commons.tor.torSmallPayloadsPreset +import com.vitorpamplona.amethyst.commons.tor.whichPreset + +@Composable +fun TorSettingsDialog( + currentSettings: TorSettings, + torStatus: TorServiceStatus, + onSettingsChanged: (TorSettings) -> Unit, + onDismiss: () -> Unit, +) { + var editSettings by remember { mutableStateOf(currentSettings) } + + DialogWindow( + onCloseRequest = onDismiss, + title = "Tor Settings", + state = rememberDialogState(size = DpSize(480.dp, 640.dp)), + ) { + Surface(color = MaterialTheme.colorScheme.background) { + Column( + modifier = + Modifier + .padding(24.dp) + .verticalScroll(rememberScrollState()), + ) { + // Status + Row(verticalAlignment = Alignment.CenterVertically) { + TorStatusIndicator(status = torStatus) + Spacer(Modifier.width(8.dp)) + Text( + when (torStatus) { + is TorServiceStatus.Off -> "Tor is off" + is TorServiceStatus.Connecting -> "Connecting to Tor..." + is TorServiceStatus.Active -> "Connected via Tor" + is TorServiceStatus.Error -> "Error: ${torStatus.message}" + }, + style = MaterialTheme.typography.bodyLarge, + ) + } + + Spacer(Modifier.height(16.dp)) + + // Mode selector + Text("Mode", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + TorType.entries.forEachIndexed { index, torType -> + SegmentedButton( + shape = SegmentedButtonDefaults.itemShape(index, TorType.entries.size), + onClick = { editSettings = editSettings.copy(torType = torType) }, + selected = editSettings.torType == torType, + ) { + Text(torType.name.lowercase().replaceFirstChar { it.uppercase() }) + } + } + } + + if (editSettings.torType == TorType.EXTERNAL) { + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = editSettings.externalSocksPort.toString(), + onValueChange = { text -> + text.toIntOrNull()?.let { port -> + if (port in 1..65535) { + editSettings = editSettings.copy(externalSocksPort = port) + } + } + }, + label = { Text("SOCKS Port") }, + singleLine = true, + modifier = Modifier.width(150.dp), + ) + } + + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(16.dp)) + + // Presets + Text("Preset", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + + val currentPreset = whichPreset(editSettings) + PresetRow("Only When Needed", TorPresetType.ONLY_WHEN_NEEDED, currentPreset) { + editSettings = torOnlyWhenNeededPreset.copy(torType = editSettings.torType, externalSocksPort = editSettings.externalSocksPort) + } + PresetRow("Default", TorPresetType.DEFAULT, currentPreset) { + editSettings = torDefaultPreset.copy(torType = editSettings.torType, externalSocksPort = editSettings.externalSocksPort) + } + PresetRow("Small Payloads", TorPresetType.SMALL_PAYLOADS, currentPreset) { + editSettings = torSmallPayloadsPreset.copy(torType = editSettings.torType, externalSocksPort = editSettings.externalSocksPort) + } + PresetRow("Full Privacy", TorPresetType.FULL_PRIVACY, currentPreset) { + editSettings = torFullyPrivate.copy(torType = editSettings.torType, externalSocksPort = editSettings.externalSocksPort) + } + PresetRow("Custom", TorPresetType.CUSTOM, currentPreset) {} + + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(16.dp)) + + // Relay Routing + Text("Relay Routing", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + ToggleRow(".onion relays via Tor", editSettings.onionRelaysViaTor) { editSettings = editSettings.copy(onionRelaysViaTor = it) } + ToggleRow("DM relays via Tor", editSettings.dmRelaysViaTor) { editSettings = editSettings.copy(dmRelaysViaTor = it) } + ToggleRow("Trusted relays via Tor", editSettings.trustedRelaysViaTor) { editSettings = editSettings.copy(trustedRelaysViaTor = it) } + ToggleRow("New/unknown relays via Tor", editSettings.newRelaysViaTor) { editSettings = editSettings.copy(newRelaysViaTor = it) } + + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(16.dp)) + + // Content Routing + Text("Content Routing", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + ToggleRow("URL previews via Tor", editSettings.urlPreviewsViaTor) { editSettings = editSettings.copy(urlPreviewsViaTor = it) } + ToggleRow("Profile pictures via Tor", editSettings.profilePicsViaTor) { editSettings = editSettings.copy(profilePicsViaTor = it) } + ToggleRow("Images via Tor", editSettings.imagesViaTor) { editSettings = editSettings.copy(imagesViaTor = it) } + ToggleRow("Videos via Tor", editSettings.videosViaTor) { editSettings = editSettings.copy(videosViaTor = it) } + ToggleRow("NIP-05 verifications via Tor", editSettings.nip05VerificationsViaTor) { editSettings = editSettings.copy(nip05VerificationsViaTor = it) } + ToggleRow("Money operations via Tor", editSettings.moneyOperationsViaTor) { editSettings = editSettings.copy(moneyOperationsViaTor = it) } + ToggleRow("Media uploads via Tor", editSettings.mediaUploadsViaTor) { editSettings = editSettings.copy(mediaUploadsViaTor = it) } + + Spacer(Modifier.height(24.dp)) + + // Buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onDismiss) { Text("Cancel") } + Spacer(Modifier.width(8.dp)) + TextButton(onClick = { + onSettingsChanged(editSettings) + onDismiss() + }) { Text("Save") } + } + } + } + } +} + +@Composable +private fun PresetRow( + label: String, + presetType: TorPresetType, + currentPreset: TorPresetType, + onClick: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + RadioButton( + selected = currentPreset == presetType, + onClick = onClick, + ) + Spacer(Modifier.width(4.dp)) + Text(label, style = MaterialTheme.typography.bodyMedium) + } +} + +@Composable +private fun ToggleRow( + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + ) { + Text(label, style = MaterialTheme.typography.bodyMedium) + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorSettingsSection.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorSettingsSection.kt new file mode 100644 index 000000000..e66e15f50 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorSettingsSection.kt @@ -0,0 +1,139 @@ +/* + * 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.desktop.ui.tor + +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.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus +import com.vitorpamplona.amethyst.commons.tor.TorSettings +import com.vitorpamplona.amethyst.commons.tor.TorType + +/** + * Inline Tor settings section for the desktop settings screen. + * Shows mode selector (Off/Internal/External) with status indicator + * and an "Advanced..." button to open the full dialog. + */ +@Composable +fun TorSettingsSection( + torStatus: TorServiceStatus, + currentSettings: TorSettings, + onSettingsChanged: (TorSettings) -> Unit, + modifier: Modifier = Modifier, +) { + var showDialog by remember { mutableStateOf(false) } + + Column(modifier = modifier) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "Tor", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Spacer(Modifier.width(12.dp)) + TorStatusIndicator(status = torStatus) + } + TextButton(onClick = { showDialog = true }) { + Text("Advanced...") + } + } + + Spacer(Modifier.height(8.dp)) + + Text( + "Route relay connections through Tor for privacy. Internal mode bundles a Tor daemon; External mode connects to your own SOCKS proxy.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(12.dp)) + + // Mode selector + SingleChoiceSegmentedButtonRow { + TorType.entries.forEachIndexed { index, torType -> + SegmentedButton( + shape = SegmentedButtonDefaults.itemShape(index = index, count = TorType.entries.size), + onClick = { onSettingsChanged(currentSettings.copy(torType = torType)) }, + selected = currentSettings.torType == torType, + ) { + Text(torType.name.lowercase().replaceFirstChar { it.uppercase() }) + } + } + } + + // External port input + if (currentSettings.torType == TorType.EXTERNAL) { + Spacer(Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "SOCKS Port:", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(8.dp)) + androidx.compose.material3.OutlinedTextField( + value = currentSettings.externalSocksPort.toString(), + onValueChange = { text -> + text.toIntOrNull()?.let { port -> + if (port in 1..65535) { + onSettingsChanged(currentSettings.copy(externalSocksPort = port)) + } + } + }, + modifier = Modifier.width(100.dp), + singleLine = true, + ) + } + } + } + + if (showDialog) { + TorSettingsDialog( + currentSettings = currentSettings, + torStatus = torStatus, + onSettingsChanged = onSettingsChanged, + onDismiss = { showDialog = false }, + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorStatusIndicator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorStatusIndicator.kt new file mode 100644 index 000000000..20530b1d7 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorStatusIndicator.kt @@ -0,0 +1,96 @@ +/* + * 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.desktop.ui.tor + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.TooltipArea +import androidx.compose.foundation.TooltipPlacement +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Shield +import androidx.compose.material.icons.outlined.Shield +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus + +/** + * Small shield icon showing Tor connection status in the sidebar footer. + * Tooltip shows status text (no port number for security). + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun TorStatusIndicator( + status: TorServiceStatus, + modifier: Modifier = Modifier, +) { + val (icon, tint, tooltip) = + when (status) { + is TorServiceStatus.Off -> { + Triple(Icons.Outlined.Shield, Color.Gray, "Tor: Off") + } + + is TorServiceStatus.Connecting -> { + Triple(Icons.Filled.Shield, Color(0xFFFFB300), "Tor: Connecting...") + } + + is TorServiceStatus.Active -> { + Triple(Icons.Filled.Shield, Color(0xFF4CAF50), "Tor: Connected") + } + + is TorServiceStatus.Error -> { + Triple(Icons.Outlined.Shield, Color(0xFFF44336), "Tor: ${status.message}") + } + } + + TooltipArea( + tooltip = { + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.inverseSurface, + ) { + Text( + text = tooltip, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + color = MaterialTheme.colorScheme.inverseOnSurface, + style = MaterialTheme.typography.bodySmall, + ) + } + }, + tooltipPlacement = TooltipPlacement.CursorPoint(alignment = Alignment.BottomEnd, offset = DpOffset(0.dp, 16.dp)), + ) { + Icon( + imageVector = icon, + contentDescription = tooltip, + tint = tint, + modifier = modifier.size(20.dp), + ) + } +}