From d216d22c3e1e194b0e0b6c26fe20b8e799e0973d Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Wed, 1 Jul 2026 10:32:03 +0300 Subject: [PATCH] feat(privacylock): Messages first-run discovery banner (Desktop) Adds an inline banner at the top of the Desktop Messages deck column that nudges users to enable the privacy lock. Fires only when !lockEnabled && !firstRunCardSeen; dismissal is sticky across restarts + lock enable/disable cycles. - MessagesFirstRunBanner: AnimatedVisibility(expandVertically + fadeIn) wrapper around a Surface + Row with a padlock icon, title, body, and Enable / Not now buttons. Modeled on OfflineBanner.kt. - SetPasswordDialog extracted from PrivacyLockSettingsScreen.kt into a shared desktop/security/ file so the banner and the settings pane both point at the same composable. - MessagesLockState.onUnlockSuccess() relaxed to accept Disabled as a valid previous state, so enabling from the banner keeps the user Unlocked and doesn't flash the lock screen. New unit test covers this path; all 9 tests green. - DesktopMessagesScreen wraps its two-pane / compact layout in a Column with the banner on top and a Box(weight(1f)) around the panes so fillMaxSize propagates correctly. --- .../commons/privacylock/MessagesLockState.kt | 10 +- .../privacylock/MessagesLockStateTest.kt | 14 ++ .../security/MessagesFirstRunBanner.kt | 129 ++++++++++++++++++ .../desktop/security/SetPasswordDialog.kt | 125 +++++++++++++++++ .../desktop/ui/chats/DesktopMessagesScreen.kt | 59 ++++---- .../ui/settings/PrivacyLockSettingsScreen.kt | 84 +----------- 6 files changed, 310 insertions(+), 111 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/MessagesFirstRunBanner.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/SetPasswordDialog.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockState.kt index 35fcbc2c52..966f6c1872 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockState.kt @@ -86,9 +86,15 @@ class MessagesLockState( } } - /** Transition Locked → Unlocked. Idempotent. Starts the idle timer. */ + /** + * Mark the session as authenticated. Transitions from either + * [LockState.Locked] (normal unlock path) or [LockState.Disabled] + * (first-run banner path — enabling the lock while the user is + * actively in Messages should NOT flash the lock screen). + * No-op if already [LockState.Unlocked]. Starts the idle timer. + */ fun onUnlockSuccess() { - if (mutableState.value is LockState.Locked) { + if (mutableState.value !is LockState.Unlocked) { mutableState.value = LockState.Unlocked restartIdleTimer() } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockStateTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockStateTest.kt index 8e59ed7a67..699c82f05e 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockStateTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockStateTest.kt @@ -152,4 +152,18 @@ class MessagesLockStateTest { assertEquals(LockState.Disabled, state.state.value) assertEquals(false, settings.lockEnabled.value) } + + @Test + fun unlock_success_from_disabled_transitions_to_unlocked() = + runTest { + // First-run banner path: user enables lock + sets password while + // already viewing Messages. State is Disabled at that moment, and + // we want to stay Unlocked so the user isn't kicked to the lock + // screen right after enabling. + val settings = FakeSettings(lockEnabled = false) + val state = MessagesLockState(settings, backgroundScope) + assertEquals(LockState.Disabled, state.state.value) + state.onUnlockSuccess() + assertEquals(LockState.Unlocked, state.state.value) + } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/MessagesFirstRunBanner.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/MessagesFirstRunBanner.kt new file mode 100644 index 0000000000..05f388d037 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/MessagesFirstRunBanner.kt @@ -0,0 +1,129 @@ +/* + * 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.security + +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.layout.Arrangement +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.material3.Button +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.runtime.collectAsState +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.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.privacylock.LocalMessagesLockState + +/** + * One-time discovery banner at the top of the Desktop Messages column. + * Nudges users who haven't enabled the privacy lock yet. Modeled on + * `OfflineBanner.kt` (AnimatedVisibility + Surface + Row). + * + * Visibility: `!lockEnabled && !firstRunCardSeen`. Dismissal is sticky + * per the `firstRunCardSeen` flag — the banner does NOT reappear if + * the user later disables the lock. + * + * Renders nothing when the gate is Locked (implicit — the gate replaces + * content, so this composable never composes in that case). + */ +@Composable +fun MessagesFirstRunBanner() { + val settings = LocalPrivacyLockSettings.current + val lockState = LocalMessagesLockState.current + val enabled by settings.lockEnabled.collectAsState() + val seen by settings.firstRunCardSeen.collectAsState() + var showDialog by remember { mutableStateOf(false) } + + AnimatedVisibility( + visible = !enabled && !seen, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + symbol = MaterialSymbols.Lock, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Lock the Messages tab?", + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = "Require a password before Messages shows. Feed and profile stay open.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + TextButton(onClick = { settings.setFirstRunCardSeen(true) }) { + Text("Not now") + } + Button(onClick = { showDialog = true }) { + Text("Enable") + } + } + } + } + + if (showDialog) { + SetPasswordDialog( + existingHash = null, + onDismiss = { showDialog = false }, + onConfirm = { newHash -> + settings.setPasswordHashed(newHash) + settings.setLockEnabled(true) + settings.setFirstRunCardSeen(true) + // Keep the user Unlocked — don't kick them to the lock screen + // right after they just entered the password. + lockState.onUnlockSuccess() + showDialog = false + }, + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/SetPasswordDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/SetPasswordDialog.kt new file mode 100644 index 0000000000..28c2f64a9c --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/SetPasswordDialog.kt @@ -0,0 +1,125 @@ +/* + * 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.security + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +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.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp + +/** + * Set or change the privacy-lock password. + * + * Pass [existingHash] = null when the user hasn't set a password yet + * (first-run banner path, or a fresh Settings toggle). In that case the + * "current password" field is hidden. Pass a real hash to force + * verification of the current password before letting the user rotate. + * + * On successful validation, [onConfirm] is invoked with a fresh + * `salt$hash` string ready for [com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings.setPasswordHashed]. + */ +@Composable +fun SetPasswordDialog( + existingHash: String?, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, +) { + var current by remember { mutableStateOf("") } + var new1 by remember { mutableStateOf("") } + var new2 by remember { mutableStateOf("") } + var error by remember { mutableStateOf(null) } + + val submit: () -> Unit = { + val currentOk = + existingHash == null || + PasswordHasher.verify(current.toCharArray(), existingHash) + when { + !currentOk -> error = "Current password is wrong" + new1.length < 4 -> error = "New password must be at least 4 characters" + new1 != new2 -> error = "Passwords don't match" + else -> onConfirm(PasswordHasher.hash(new1.toCharArray())) + } + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(if (existingHash == null) "Set a password" else "Change password") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + if (existingHash != null) { + OutlinedTextField( + value = current, + onValueChange = { + current = it + error = null + }, + label = { Text("Current password") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + ) + } + OutlinedTextField( + value = new1, + onValueChange = { + new1 = it + error = null + }, + label = { Text("New password") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + ) + OutlinedTextField( + value = new2, + onValueChange = { + new2 = it + error = null + }, + label = { Text("Confirm new password") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + ) + error?.let { + Text( + text = it, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + } + }, + confirmButton = { + TextButton(onClick = submit) { Text("Save") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt index 768e7f1bff..a50244adfd 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.desktop.ui.chats import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize @@ -63,6 +64,7 @@ import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.security.MessagesFirstRunBanner import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import kotlinx.coroutines.CoroutineScope import java.awt.Cursor @@ -126,32 +128,37 @@ fun DesktopMessagesScreen( } } - if (compactMode) { - CompactMessagesContent( - selectedRoom = selectedRoom, - listState = listState, - account = account, - cacheProvider = cacheProvider, - scope = scope, - onNavigateToProfile = onNavigateToProfile, - listFocusRequester = listFocusRequester, - onShowNewDm = { showNewDmDialog = true }, - onShowRelayPicker = { showDmRelayPicker = true }, - keyHandler = keyHandler, - ) - } else { - SplitMessagesContent( - selectedRoom = selectedRoom, - listState = listState, - account = account, - cacheProvider = cacheProvider, - scope = scope, - onNavigateToProfile = onNavigateToProfile, - listFocusRequester = listFocusRequester, - onShowNewDm = { showNewDmDialog = true }, - onShowRelayPicker = { showDmRelayPicker = true }, - keyHandler = keyHandler, - ) + Column(modifier = Modifier.fillMaxSize()) { + MessagesFirstRunBanner() + Box(modifier = Modifier.weight(1f)) { + if (compactMode) { + CompactMessagesContent( + selectedRoom = selectedRoom, + listState = listState, + account = account, + cacheProvider = cacheProvider, + scope = scope, + onNavigateToProfile = onNavigateToProfile, + listFocusRequester = listFocusRequester, + onShowNewDm = { showNewDmDialog = true }, + onShowRelayPicker = { showDmRelayPicker = true }, + keyHandler = keyHandler, + ) + } else { + SplitMessagesContent( + selectedRoom = selectedRoom, + listState = listState, + account = account, + cacheProvider = cacheProvider, + scope = scope, + onNavigateToProfile = onNavigateToProfile, + listFocusRequester = listFocusRequester, + onShowNewDm = { showNewDmDialog = true }, + onShowRelayPicker = { showDmRelayPicker = true }, + keyHandler = keyHandler, + ) + } + } } if (showNewDmDialog) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/PrivacyLockSettingsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/PrivacyLockSettingsScreen.kt index 520fab9e9f..e97ce33670 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/PrivacyLockSettingsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/PrivacyLockSettingsScreen.kt @@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -34,10 +33,8 @@ import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -46,13 +43,12 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.privacylock.DmRedactionLevel import com.vitorpamplona.amethyst.commons.privacylock.InactivityTimer import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings import com.vitorpamplona.amethyst.desktop.security.LocalPrivacyLockSettings -import com.vitorpamplona.amethyst.desktop.security.PasswordHasher +import com.vitorpamplona.amethyst.desktop.security.SetPasswordDialog /** * Desktop privacy-lock settings pane. Column + Card layout (no Scaffold) — @@ -266,84 +262,6 @@ private fun SettingsCard( } } -@Composable -private fun SetPasswordDialog( - existingHash: String?, - onDismiss: () -> Unit, - onConfirm: (String) -> Unit, -) { - var current by remember { mutableStateOf("") } - var new1 by remember { mutableStateOf("") } - var new2 by remember { mutableStateOf("") } - var error by remember { mutableStateOf(null) } - - val submit: () -> Unit = { - val currentOk = - existingHash == null || - PasswordHasher.verify(current.toCharArray(), existingHash) - when { - !currentOk -> error = "Current password is wrong" - new1.length < 4 -> error = "New password must be at least 4 characters" - new1 != new2 -> error = "Passwords don't match" - else -> onConfirm(PasswordHasher.hash(new1.toCharArray())) - } - } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(if (existingHash == null) "Set a password" else "Change password") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - if (existingHash != null) { - OutlinedTextField( - value = current, - onValueChange = { - current = it - error = null - }, - label = { Text("Current password") }, - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - ) - } - OutlinedTextField( - value = new1, - onValueChange = { - new1 = it - error = null - }, - label = { Text("New password") }, - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - ) - OutlinedTextField( - value = new2, - onValueChange = { - new2 = it - error = null - }, - label = { Text("Confirm new password") }, - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - ) - error?.let { - Text( - text = it, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - ) - } - } - }, - confirmButton = { - TextButton(onClick = submit) { Text("Save") } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text("Cancel") } - }, - ) -} - private fun InactivityTimer.label(): String = when (this) { InactivityTimer.OneMin -> "1 min"