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.
This commit is contained in:
nrobi144
2026-07-01 11:35:52 +03:00
parent 1c0141aba1
commit d216d22c3e
6 changed files with 310 additions and 111 deletions
@@ -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()
}
@@ -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)
}
}
@@ -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
},
)
}
}
@@ -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<String?>(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") }
},
)
}
@@ -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) {
@@ -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<String?>(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"