From 292f8a0c787afee822c9735bef46bbf2aefcc3c2 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Wed, 1 Jul 2026 11:09:06 +0300 Subject: [PATCH] feat(privacylock): redesign Set/Change password dialog + snackbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites SetPasswordDialog.kt with modern 2024-2026 UX. Adds "Privacy lock enabled" / "Password updated" confirmation snackbars. Dialog changes: - Dialog + Surface(shape=shapes.large, tonal=6.dp) shell instead of default AlertDialog. Fixed width 440dp. Matches NewDmDialog.kt. - Header row with Lock icon + title (titleLarge). Softer body copy under the header for the first-time-set path. - Set-a-password flow uses ONE password field with a reveal toggle (Visibility / VisibilityOff, per-field independent). The reveal toggle IS the confirmation — no more "confirm password" field. Matches WhatsApp Chat Lock + macOS Users & Groups. - Change-password flow uses two fields (current + new), each with its own reveal toggle. Current is verification, not redundancy. - Real-time checklist row under the New field: green CheckCircle + "Min 6 characters" when satisfied, outlined Circle + muted text otherwise. Copy-pattern from EditProfileScreen NIP-05 status. - Save button disabled until the checklist passes. - Bumps PRIVACY_LOCK_MIN_PASSWORD_LENGTH from 4 to 6. - Auto-focus first field on open (LaunchedEffect + FocusRequester). - Enter submits (via onPreviewKeyEvent + KeyboardActions.onDone). - Escape / click-outside-dismiss are disabled to prevent accidental loss of typed password (dismissOnClickOutside = false). - Wrong-current error shown inline under the Current field. - All reveal toggles reuse the KeyInputField.kt idiom verbatim. Snackbar plumbing (scope-local — no CompositionLocal): - PrivacyLockSettingsScreen owns a SnackbarHostState overlaid at Alignment.BottomCenter. LockToggleCard receives an onSaved callback, fires "Privacy lock enabled" on first-time set or "Password updated" on change. - DesktopMessagesScreen (banner path) owns its own SnackbarHostState overlaid at BottomCenter. MessagesFirstRunBanner takes an optional onSaved callback (default {}), fires "Privacy lock enabled" after the dialog saves. --- .../security/MessagesFirstRunBanner.kt | 3 +- .../desktop/security/SetPasswordDialog.kt | 271 ++++++++++++++---- .../desktop/ui/chats/DesktopMessagesScreen.kt | 73 +++-- .../ui/settings/PrivacyLockSettingsScreen.kt | 48 +++- 4 files changed, 302 insertions(+), 93 deletions(-) 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 index 05f388d037..6af1cd1efa 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/MessagesFirstRunBanner.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/MessagesFirstRunBanner.kt @@ -62,7 +62,7 @@ import com.vitorpamplona.amethyst.commons.privacylock.LocalMessagesLockState * content, so this composable never composes in that case). */ @Composable -fun MessagesFirstRunBanner() { +fun MessagesFirstRunBanner(onSaved: (String) -> Unit = {}) { val settings = LocalPrivacyLockSettings.current val lockState = LocalMessagesLockState.current val enabled by settings.lockEnabled.collectAsState() @@ -123,6 +123,7 @@ fun MessagesFirstRunBanner() { // right after they just entered the password. lockState.onUnlockSuccess() showDialog = false + onSaved("Privacy lock enabled") }, ) } 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 index 28c2f64a9c..c8ada14b42 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/SetPasswordDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/SetPasswordDialog.kt @@ -22,29 +22,65 @@ 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.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.layout.width +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect 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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols + +/** Enforced minimum length for a new/rotated password. */ +const val PRIVACY_LOCK_MIN_PASSWORD_LENGTH = 6 /** * 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. + * dialog shows a single New password field with a reveal toggle. Pass a + * real hash to force verification of the current password before letting + * the user rotate — the dialog then shows a Current password field + * followed by the New password field, each with its own reveal toggle. * * On successful validation, [onConfirm] is invoked with a fresh * `salt$hash` string ready for [com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings.setPasswordHashed]. + * + * UX: auto-focus the first field on open, Enter submits, Escape cancels. + * Real-time checklist under the New password field shows a green ✓ once + * the length threshold is reached. Reveal toggles are per-field + * independent so revealing Current does not reveal New. */ @Composable fun SetPasswordDialog( @@ -52,74 +88,207 @@ fun SetPasswordDialog( onDismiss: () -> Unit, onConfirm: (String) -> Unit, ) { + val isChange = existingHash != null var current by remember { mutableStateOf("") } - var new1 by remember { mutableStateOf("") } - var new2 by remember { mutableStateOf("") } - var error by remember { mutableStateOf(null) } + var new by remember { mutableStateOf("") } + var currentError by remember { mutableStateOf(null) } + var newError by remember { mutableStateOf(null) } + + val firstFieldFocus = remember { FocusRequester() } val submit: () -> Unit = { val currentOk = - existingHash == null || - PasswordHasher.verify(current.toCharArray(), existingHash) + !isChange || + 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())) + !currentOk -> { + currentError = "Wrong password" + newError = null + } + new.length < PRIVACY_LOCK_MIN_PASSWORD_LENGTH -> { + currentError = null + newError = "Must be at least $PRIVACY_LOCK_MIN_PASSWORD_LENGTH characters" + } + else -> { + onConfirm(PasswordHasher.hash(new.toCharArray())) + } } } - AlertDialog( + LaunchedEffect(Unit) { + firstFieldFocus.requestFocus() + } + + Dialog( onDismissRequest = onDismiss, - title = { Text(if (existingHash == null) "Set a password" else "Change password") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - if (existingHash != null) { - OutlinedTextField( + properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = false), + ) { + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surface, + tonalElevation = 6.dp, + modifier = Modifier.width(440.dp), + ) { + Column( + modifier = Modifier.padding(24.dp).fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + DialogHeader(isChange = isChange) + + if (!isChange) { + Text( + text = "Choose a password to lock the Messages tab. You'll enter it to unlock later.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (isChange) { + PasswordField( value = current, onValueChange = { current = it - error = null + currentError = null }, - label = { Text("Current password") }, - singleLine = true, - visualTransformation = PasswordVisualTransformation(), + label = "Current password", + errorMessage = currentError, + modifier = Modifier.focusRequester(firstFieldFocus), + imeAction = ImeAction.Next, + onImeAction = { /* Tab handled by focus system */ }, ) } - OutlinedTextField( - value = new1, + + PasswordField( + value = new, onValueChange = { - new1 = it - error = null + new = it + newError = null }, - label = { Text("New password") }, - singleLine = true, - visualTransformation = PasswordVisualTransformation(), + label = "New password", + errorMessage = newError, + modifier = + if (isChange) Modifier else Modifier.focusRequester(firstFieldFocus), + imeAction = ImeAction.Done, + onImeAction = { submit() }, ) - 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, - ) + + RequirementChecklistRow(satisfied = new.length >= PRIVACY_LOCK_MIN_PASSWORD_LENGTH) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onDismiss) { Text("Cancel") } + Button( + onClick = submit, + enabled = new.length >= PRIVACY_LOCK_MIN_PASSWORD_LENGTH, + ) { + Text("Save") + } } } + } + } +} + +@Composable +private fun DialogHeader(isChange: Boolean) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + symbol = MaterialSymbols.Lock, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = if (isChange) "Change password" else "Set a password", + style = MaterialTheme.typography.titleLarge, + ) + } +} + +@Composable +private fun PasswordField( + value: String, + onValueChange: (String) -> Unit, + label: String, + errorMessage: String?, + modifier: Modifier = Modifier, + imeAction: ImeAction = ImeAction.Done, + onImeAction: () -> Unit = {}, +) { + var revealed by remember { mutableStateOf(false) } + + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(label) }, + modifier = + modifier + .fillMaxWidth() + .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown && event.key == Key.Enter && imeAction == ImeAction.Done) { + onImeAction() + true + } else { + false + } + }, + singleLine = true, + visualTransformation = if (revealed) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = imeAction, + ), + keyboardActions = KeyboardActions(onDone = { onImeAction() }, onNext = { onImeAction() }), + trailingIcon = { + IconButton(onClick = { revealed = !revealed }) { + Icon( + symbol = if (revealed) MaterialSymbols.VisibilityOff else MaterialSymbols.Visibility, + contentDescription = if (revealed) "Hide password" else "Show password", + ) + } }, - confirmButton = { - TextButton(onClick = submit) { Text("Save") } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text("Cancel") } - }, + isError = errorMessage != null, + supportingText = + errorMessage?.let { + { + Text(it, color = MaterialTheme.colorScheme.error) + } + }, ) } + +@Composable +private fun RequirementChecklistRow(satisfied: Boolean) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + symbol = if (satisfied) MaterialSymbols.CheckCircle else MaterialSymbols.Circle, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = + if (satisfied) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + Text( + text = "Min $PRIVACY_LOCK_MIN_PASSWORD_LENGTH characters", + style = MaterialTheme.typography.bodySmall, + color = + if (satisfied) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } +} 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 a50244adfd..8b89c582db 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 @@ -29,9 +29,12 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +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.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -67,6 +70,7 @@ 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 kotlinx.coroutines.launch import java.awt.Cursor private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") @@ -128,37 +132,48 @@ fun DesktopMessagesScreen( } } - 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, - ) + val snackbarHostState = remember { SnackbarHostState() } + val snackbarScope = rememberCoroutineScope() + + Box(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { + MessagesFirstRunBanner(onSaved = { msg -> + snackbarScope.launch { snackbarHostState.showSnackbar(msg) } + }) + 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, + ) + } } } + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp), + ) } 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 e97ce33670..c01429a7d2 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 @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.desktop.ui.settings 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 @@ -33,6 +34,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.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -40,6 +43,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -49,6 +53,7 @@ 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.SetPasswordDialog +import kotlinx.coroutines.launch /** * Desktop privacy-lock settings pane. Column + Card layout (no Scaffold) — @@ -57,22 +62,39 @@ import com.vitorpamplona.amethyst.desktop.security.SetPasswordDialog @Composable fun PrivacyLockSettingsScreen() { val settings = LocalPrivacyLockSettings.current - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - LockToggleCard(settings) - InactivityCard(settings) - RedactionCard(settings) - LimitationsCard() + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + + Box(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + LockToggleCard( + settings = settings, + onSaved = { message -> + scope.launch { snackbarHostState.showSnackbar(message) } + }, + ) + InactivityCard(settings) + RedactionCard(settings) + LimitationsCard() + } + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp), + ) } } @Composable -private fun LockToggleCard(settings: PrivacyLockSettings) { +private fun LockToggleCard( + settings: PrivacyLockSettings, + onSaved: (String) -> Unit, +) { val enabled by settings.lockEnabled.collectAsState() val stored by settings.passwordHashed.collectAsState() var showSetPassword by remember { mutableStateOf(false) } @@ -117,6 +139,7 @@ private fun LockToggleCard(settings: PrivacyLockSettings) { } if (showSetPassword) { + val wasFirstSet = stored == null SetPasswordDialog( existingHash = stored, onDismiss = { @@ -128,6 +151,7 @@ private fun LockToggleCard(settings: PrivacyLockSettings) { if (pendingEnable) settings.setLockEnabled(true) showSetPassword = false pendingEnable = false + onSaved(if (wasFirstSet) "Privacy lock enabled" else "Password updated") }, ) }