feat: rework signup into a safer multi-step flow, add keep-mail setting

This commit is contained in:
randogoth 2026-10-08 19:58:50 +03:00
parent 53c9c4b8ce
commit 2892ea3fa7
3 changed files with 649 additions and 132 deletions

View file

@ -75,14 +75,28 @@ pub enum OnboardMode {
/// Signup: bind the identity to a username, or recall an existing address. /// Signup: bind the identity to a username, or recall an existing address.
pub struct Signup { pub struct Signup {
pub step: SignupStep,
pub mode: SignupMode,
pub user: String, pub user: String,
pub host: String, pub host: String,
pub invite: String, pub invite: String,
/// The server's base32 static key, pasted from what the operator /// True once this host's key is known to be pinned — either a fetch
/// published; pinned before the registration connects. /// was just confirmed (see `Carrier::pin_offer`) or it already was
pub key: String, /// from an earlier session — so the server step's button reads
/// True when onboarding just wrote the key file, so Back is offered. /// "Continue" instead of "Fetch the server's key".
pub fresh: bool, pub key_trusted: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SignupStep {
Server,
Account,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SignupMode {
New,
Existing,
} }
/// The compose view: `to` takes a short address or a smol:// URI. /// The compose view: `to` takes a short address or a smol:// URI.
@ -107,6 +121,8 @@ pub enum Dialog {
}, },
/// Rotation only moves the index forward, so the action asks first. /// Rotation only moves the index forward, so the action asks first.
Rotate, Rotate,
/// Factory reset erases every local file, so it asks first too.
Reset,
/// The message being answered carries the sender's own frontmatter /// The message being answered carries the sender's own frontmatter
/// fields; the reply asks whether to carry copies of them. /// fields; the reply asks whether to carry copies of them.
Reply { Reply {
@ -133,9 +149,13 @@ pub enum Message {
SignupUserChanged(String), SignupUserChanged(String),
SignupHostChanged(String), SignupHostChanged(String),
SignupInviteChanged(String), SignupInviteChanged(String),
SignupKeyChanged(String), /// The server step's one button: fetches the host's key, or — once
SignupBack, /// one is on screen (fetched this session or already pinned) — pins
SignupLearnKey, /// it and advances.
SignupKeyAction,
SignupStepBack,
SignupStartOver,
SignupModeChanged(SignupMode),
SignupRegister, SignupRegister,
SignupRecall, SignupRecall,
@ -186,6 +206,7 @@ pub enum Message {
// Settings // Settings
SettingsLearnKey, SettingsLearnKey,
KeepMailChanged(bool),
TimeoutChanged(String), TimeoutChanged(String),
RotatePressed, RotatePressed,
RotateConfirmed, RotateConfirmed,
@ -194,6 +215,9 @@ pub enum Message {
ExportPressed, ExportPressed,
ImportBodyChanged(String), ImportBodyChanged(String),
ImportPressed, ImportPressed,
ResetPressed,
ResetConfirmed,
ResetCancelled,
} }
/// What a background operation reports back. /// What a background operation reports back.
@ -219,8 +243,6 @@ pub enum PendingOp {
Register { Register {
addr: Address, addr: Address,
invite: Option<String>, invite: Option<String>,
/// A pasted operator key, pinned before the registration connects.
key: String,
}, },
Restore { Restore {
addr: Address, addr: Address,
@ -279,6 +301,9 @@ pub struct Carrier {
pub status_error: bool, pub status_error: bool,
pub busy: Option<String>, pub busy: Option<String>,
pub cancel: Arc<AtomicBool>, pub cancel: Arc<AtomicBool>,
/// Off by default: a fetch acknowledges what it takes, so mail is
/// removed from the server once it's stored here.
pub keep_mail: bool,
pub timeout: u64, pub timeout: u64,
pub timeout_text: String, pub timeout_text: String,
pub onboard: Onboard, pub onboard: Onboard,
@ -304,6 +329,11 @@ pub struct Carrier {
/// The view syncs entry text from the model once after a prefill /// The view syncs entry text from the model once after a prefill
/// (reply, write-to-contact), then leaves typing to the widgets. /// (reply, write-to-contact), then leaves typing to the widgets.
pub compose_fresh: Cell<bool>, pub compose_fresh: Cell<bool>,
/// Same pattern for the onboarding/signup entries: a programmatic
/// reset (factory reset, discarding a cancelled identity) clears the
/// model, and this flag makes the view push that blank state into
/// the entries once, instead of leaving stale typed text on screen.
pub form_fresh: Cell<bool>,
} }
impl Carrier { impl Carrier {
@ -334,6 +364,7 @@ impl Carrier {
status_error: false, status_error: false,
busy: None, busy: None,
cancel: Arc::new(AtomicBool::new(false)), cancel: Arc::new(AtomicBool::new(false)),
keep_mail: false,
timeout: 30, timeout: 30,
timeout_text: "30".to_string(), timeout_text: "30".to_string(),
onboard: Onboard { onboard: Onboard {
@ -342,11 +373,12 @@ impl Carrier {
seed_shown: String::new(), seed_shown: String::new(),
}, },
signup: Signup { signup: Signup {
step: SignupStep::Server,
mode: SignupMode::New,
user: String::new(), user: String::new(),
host: String::new(), host: String::new(),
invite: String::new(), invite: String::new(),
key: String::new(), key_trusted: false,
fresh: false,
}, },
folder: Folder::Inbox, folder: Folder::Inbox,
lists: vec![Vec::new(), Vec::new(), Vec::new()], lists: vec![Vec::new(), Vec::new(), Vec::new()],
@ -362,6 +394,7 @@ impl Carrier {
contact_import: String::new(), contact_import: String::new(),
dialog: None, dialog: None,
compose_fresh: Cell::new(false), compose_fresh: Cell::new(false),
form_fresh: Cell::new(true),
}; };
let op = registered.then(|| PendingOp::Snapshot { let op = registered.then(|| PendingOp::Snapshot {
read: app.marks.snapshot(), read: app.marks.snapshot(),
@ -427,6 +460,61 @@ impl Carrier {
self.compose_fresh.set(true); self.compose_fresh.set(true);
} }
/// Factory reset: close the store, erase the config directory, then
/// boot a fresh model in place. The connection is swapped out before
/// the files go because Windows refuses to remove a file a live
/// connection holds, and the busy guard has already seen to it that
/// no operation holds a clone of the store.
fn factory_reset(&mut self) {
let store = match Store::open_in_memory() {
Ok(store) => Arc::new(store),
Err(e) => {
self.fail(e.to_string());
return;
}
};
self.store = store;
if let Err(e) = ops::factory_reset(&self.dir) {
self.fail(e);
return;
}
let (app, _) = Carrier::boot(self.dir.clone());
*self = app;
self.note("factory reset done. Create a new identity or restore a seed.".into());
}
/// Cancelling out of a not-yet-registered identity removes the key
/// file too, so Back always leaves onboarding resumable from a clean
/// start — never behind a stale key a later Create could silently
/// overwrite and orphan the server registration against. The seed
/// already shown (or pasted) still regenerates the same master, so
/// nothing is actually lost.
fn discard_identity(&mut self) {
if self.store.account().ok().flatten().is_none() {
if let Err(e) = ops::delete_key(&self.key_path()) {
self.fail(e);
return;
}
self.master = None;
}
self.onboard = Onboard {
mode: OnboardMode::Start,
seed: String::new(),
seed_shown: String::new(),
};
self.signup = Signup {
step: SignupStep::Server,
mode: SignupMode::New,
user: String::new(),
host: String::new(),
invite: String::new(),
key_trusted: false,
};
self.pin_offer = None;
self.page = Page::Onboard;
self.form_fresh.set(true);
}
/// The current folder's rows, drafts included: drafts carry the /// The current folder's rows, drafts included: drafts carry the
/// recipient where mail carries the sender, newest first. /// recipient where mail carries the sender, newest first.
pub fn rows(&self) -> Vec<Row> { pub fn rows(&self) -> Vec<Row> {
@ -504,6 +592,17 @@ impl Carrier {
Address::parse(&format!("{user}@{host}")).ok() Address::parse(&format!("{user}@{host}")).ok()
} }
/// The host as typed on the signup page's server step, before a
/// username exists — `learn_key`/`connect` only read host/port/scheme,
/// so a throwaway username satisfies `Address::parse`'s grammar.
fn signup_host_address(&self) -> Option<Address> {
let host = self.signup.host.trim();
if host.is_empty() {
return None;
}
Address::parse(&format!("x@{host}")).ok()
}
/// Opens one message and marks it read inline: opening an envelope and /// Opens one message and marks it read inline: opening an envelope and
/// re-listing is local and fast, so it needs no background pass. /// re-listing is local and fast, so it needs no background pass.
fn select_message(&mut self, folder: Folder, id: String) { fn select_message(&mut self, folder: Folder, id: String) {
@ -596,16 +695,20 @@ impl Carrier {
} }
Message::SignupHostChanged(host) => { Message::SignupHostChanged(host) => {
self.signup.host = host; self.signup.host = host;
// A different host invalidates whatever key was on
// screen: there is only ever one key pending trust.
self.signup.key_trusted = false;
self.pin_offer = None;
} }
Message::SignupInviteChanged(invite) => { Message::SignupInviteChanged(invite) => {
self.signup.invite = invite; self.signup.invite = invite;
} }
Message::SignupKeyChanged(key) => {
self.signup.key = key;
}
Message::ContactImportChanged(text) => { Message::ContactImportChanged(text) => {
self.contact_import = text; self.contact_import = text;
} }
Message::KeepMailChanged(keep) => {
self.keep_mail = keep;
}
Message::TimeoutChanged(text) => { Message::TimeoutChanged(text) => {
if let Ok(timeout) = text.trim().parse::<u64>() { if let Ok(timeout) = text.trim().parse::<u64>() {
self.timeout = timeout; self.timeout = timeout;
@ -640,7 +743,13 @@ impl Carrier {
self.onboard.mode = OnboardMode::Restore; self.onboard.mode = OnboardMode::Restore;
} }
Message::OnboardBack => { Message::OnboardBack => {
self.onboard.mode = OnboardMode::Start; if self.onboard.mode == OnboardMode::Created {
self.discard_identity();
} else {
self.onboard.mode = OnboardMode::Start;
self.onboard.seed.clear();
self.form_fresh.set(true);
}
} }
Message::OnboardRestoreFromSeed => match ops::parse_seed(&self.onboard.seed) { Message::OnboardRestoreFromSeed => match ops::parse_seed(&self.onboard.seed) {
Ok(master) => { Ok(master) => {
@ -648,7 +757,6 @@ impl Carrier {
self.fail(e); self.fail(e);
} else { } else {
self.master = Some(master); self.master = Some(master);
self.signup.fresh = true;
self.page = Page::Signup; self.page = Page::Signup;
self.note("Seed restored. Bind a username or recall an address.".into()); self.note("Seed restored. Bind a username or recall an address.".into());
} }
@ -656,15 +764,28 @@ impl Carrier {
Err(e) => self.fail(e), Err(e) => self.fail(e),
}, },
Message::OnboardSeedSaved => { Message::OnboardSeedSaved => {
self.signup.fresh = true;
self.page = Page::Signup; self.page = Page::Signup;
} }
Message::SignupBack => { Message::SignupStepBack => {
self.page = Page::Onboard; self.signup.step = SignupStep::Server;
} }
Message::SignupLearnKey => { Message::SignupStartOver => {
let Some(addr) = self.signup_address() else { self.discard_identity();
self.fail("enter user and host first".into()); }
Message::SignupModeChanged(mode) => {
self.signup.mode = mode;
}
Message::SignupKeyAction => {
if let Some((addr, key)) = self.pin_offer.take() {
self.signup.step = SignupStep::Account;
return vec![PendingOp::Pin { addr, key }];
}
if self.signup.key_trusted {
self.signup.step = SignupStep::Account;
return Vec::new();
}
let Some(addr) = self.signup_host_address() else {
self.fail("enter the server host first".into());
return Vec::new(); return Vec::new();
}; };
return vec![PendingOp::LearnKey { addr }]; return vec![PendingOp::LearnKey { addr }];
@ -694,14 +815,19 @@ impl Carrier {
} else { } else {
Some(self.signup.invite.clone()) Some(self.signup.invite.clone())
}; };
let key = self.signup.key.trim().to_string(); if !self.start("registering") {
return vec![PendingOp::Register { addr, invite, key }]; return Vec::new();
}
return vec![PendingOp::Register { addr, invite }];
} }
Message::SignupRecall => { Message::SignupRecall => {
let Some(addr) = self.signup_address() else { let Some(addr) = self.signup_address() else {
self.fail("enter user and host".into()); self.fail("enter user and host".into());
return Vec::new(); return Vec::new();
}; };
if !self.start("restoring") {
return Vec::new();
}
return vec![PendingOp::Restore { addr }]; return vec![PendingOp::Restore { addr }];
} }
Message::GotoMail(folder) => { Message::GotoMail(folder) => {
@ -989,6 +1115,16 @@ impl Carrier {
Message::RotateCancelled => { Message::RotateCancelled => {
self.dialog = None; self.dialog = None;
} }
Message::ResetPressed => {
self.dialog = Some(Dialog::Reset);
}
Message::ResetCancelled => {
self.dialog = None;
}
Message::ResetConfirmed => {
self.dialog = None;
self.factory_reset();
}
Message::RotateConfirmed => { Message::RotateConfirmed => {
self.dialog = None; self.dialog = None;
return vec![PendingOp::Rotate]; return vec![PendingOp::Rotate];
@ -1030,6 +1166,13 @@ impl Carrier {
OpResult::Generic(Ok(note)) => { OpResult::Generic(Ok(note)) => {
self.done(); self.done();
self.note(note); self.note(note);
// The only `Generic` ops reachable from the signup page are
// Register and Restore — LearnKey/Pin report through their
// own variants — so a success here always means the
// address is bound and it's safe to leave the page.
if self.page == Page::Signup {
self.page = Page::Mail;
}
self.refresh() self.refresh()
} }
OpResult::Generic(Err(e)) => { OpResult::Generic(Err(e)) => {
@ -1049,6 +1192,7 @@ impl Carrier {
} }
OpResult::PinOffered(Ok(None)) => { OpResult::PinOffered(Ok(None)) => {
self.done(); self.done();
self.signup.key_trusted = true;
self.note("This server's key is already pinned.".into()); self.note("This server's key is already pinned.".into());
None None
} }
@ -1124,6 +1268,194 @@ mod tests {
assert_eq!(app.busy.as_deref(), Some("fetching")); assert_eq!(app.busy.as_deref(), Some("fetching"));
} }
/// Off by default — a fetch deletes what it takes — and the Settings
/// switch is a plain toggle, not busy-guarded like a network action.
#[test]
fn keep_mail_defaults_off_and_toggles() {
let mut app = app();
assert!(!app.keep_mail);
app.handle(Message::KeepMailChanged(true));
assert!(app.keep_mail);
app.handle(Message::KeepMailChanged(false));
assert!(!app.keep_mail);
}
/// A factory reset asks first, then erases every file in the config
/// directory and returns the model to onboarding, as if the app had
/// never run.
#[test]
fn factory_reset_erases_the_files_and_returns_to_onboarding() {
let dir = tempfile::tempdir().unwrap().keep();
let (mut app, _) = Carrier::boot(dir.clone());
let master = ops::new_master();
ops::save_key(&dir.join("carrier.key"), &master).unwrap();
app.master = Some(master);
std::fs::write(dir.join("read.txt"), "ab").unwrap();
std::fs::write(dir.join("drafts.txt"), "draft2 id 1 -\n\n\n\n").unwrap();
app.signup.host = "typed-before-the-reset.example".into();
app.signup.user = "alice".into();
// The button is busy-guarded like every other action.
app.busy = Some("fetching".into());
app.handle(Message::ResetPressed);
assert!(app.dialog.is_none());
app.busy = None;
app.handle(Message::ResetPressed);
assert!(matches!(app.dialog, Some(Dialog::Reset)));
app.handle(Message::ResetConfirmed);
assert!(app.dialog.is_none());
assert_eq!(app.page, Page::Onboard);
assert_eq!(app.onboard.mode, OnboardMode::Start);
assert!(app.master.is_none());
assert!(app.snapshot.is_none());
assert!(app.drafts.is_empty());
// The fields themselves, not just the model, must come back
// clean — form_fresh tells the view to push the blank state
// into the entries instead of leaving stale typed text on them.
assert!(app.signup.host.is_empty());
assert!(app.signup.user.is_empty());
assert!(app.form_fresh.get());
for name in ["carrier.key", "read.txt", "drafts.txt"] {
assert!(!dir.join(name).exists(), "{name} survived the reset");
}
// The store is reopened by boot, so a fresh empty db is the one
// file a reset legitimately leaves behind.
assert!(dir.join("carrier.db").exists());
assert!(app.store.account().unwrap().is_none());
}
/// Backing out of a freshly created, not-yet-registered identity must
/// discard it — otherwise a later Create can silently overwrite it out
/// from under an in-flight or already-committed registration.
#[test]
fn onboard_back_from_created_discards_the_key() {
let dir = tempfile::tempdir().unwrap().keep();
let (mut app, _) = Carrier::boot(dir.clone());
app.handle(Message::OnboardCreate);
assert!(app.master.is_some());
assert!(dir.join("carrier.key").exists());
app.handle(Message::OnboardBack);
assert!(app.master.is_none());
assert!(!dir.join("carrier.key").exists());
assert_eq!(app.page, Page::Onboard);
assert_eq!(app.onboard.mode, OnboardMode::Start);
}
/// Restore mode writes no key until the seed is actually submitted, so
/// backing out of it has nothing to discard — just the typed seed.
#[test]
fn onboard_back_from_restore_only_clears_the_typed_seed() {
let mut app = app();
app.handle(Message::OnboardRestore);
app.handle(Message::OnboardSeedChanged("not yet submitted".into()));
app.handle(Message::OnboardBack);
assert!(app.master.is_none());
assert!(app.onboard.seed.is_empty());
assert_eq!(app.onboard.mode, OnboardMode::Start);
}
/// Start Over on the signup page's server step is the same discard,
/// reachable after onboarding has already handed off to Page::Signup.
#[test]
fn signup_start_over_discards_the_key() {
let dir = tempfile::tempdir().unwrap().keep();
let (mut app, _) = Carrier::boot(dir.clone());
app.handle(Message::OnboardCreate);
app.handle(Message::OnboardSeedSaved);
assert_eq!(app.page, Page::Signup);
app.signup.host = "example.org".into();
app.handle(Message::SignupStartOver);
assert!(app.master.is_none());
assert!(!dir.join("carrier.key").exists());
assert_eq!(app.page, Page::Onboard);
assert!(app.signup.host.is_empty());
}
/// Register and Restore are long-running, so Back (and a second click)
/// must be locked out for their whole in-flight window, the same way
/// FetchPressed already locks out everything else.
#[test]
fn signup_register_and_recall_engage_the_busy_guard() {
let mut app = app();
app.master = Some([0u8; KEY_LEN]);
app.page = Page::Signup;
app.signup.user = "alice".into();
app.signup.host = "example.org".into();
let ops = app.handle(Message::SignupRegister);
assert_eq!(app.busy.as_deref(), Some("registering"));
assert!(matches!(ops.as_slice(), [PendingOp::Register { .. }]));
assert!(app.handle(Message::SignupStartOver).is_empty());
assert_eq!(app.page, Page::Signup);
app.busy = None;
let ops = app.handle(Message::SignupRecall);
assert_eq!(app.busy.as_deref(), Some("restoring"));
assert!(matches!(ops.as_slice(), [PendingOp::Restore { .. }]));
}
/// A successful Register/Restore must leave the signup page, or a
/// lingering Back there could still race a fresh identity into
/// existence against an address the store already considers bound.
#[test]
fn generic_success_on_the_signup_page_moves_on_to_mail() {
let mut app = app();
app.page = Page::Signup;
app.busy = Some("registering".into());
app.handle_result(OpResult::Generic(Ok("registered".into())));
assert_eq!(app.page, Page::Mail);
}
/// The server step's one button fetches a key when none is on
/// screen yet, then pins it and advances once one is.
#[test]
fn signup_key_action_fetches_then_pins_and_advances() {
let mut app = app();
app.signup.host = "example.org".into();
let ops = app.handle(Message::SignupKeyAction);
assert!(matches!(ops.as_slice(), [PendingOp::LearnKey { .. }]));
assert_eq!(app.signup.step, SignupStep::Server);
let addr = Address::parse("x@example.org").unwrap();
app.pin_offer = Some((addr, [0u8; KEY_LEN]));
let ops = app.handle(Message::SignupKeyAction);
assert!(matches!(ops.as_slice(), [PendingOp::Pin { .. }]));
assert!(app.pin_offer.is_none());
assert_eq!(app.signup.step, SignupStep::Account);
}
/// An already-trusted host (pinned in an earlier session) needs no
/// key to show — Continue just advances with no background op.
#[test]
fn signup_key_action_advances_without_a_pin_when_already_trusted() {
let mut app = app();
app.signup.host = "example.org".into();
app.signup.key_trusted = true;
let ops = app.handle(Message::SignupKeyAction);
assert!(ops.is_empty());
assert_eq!(app.signup.step, SignupStep::Account);
}
/// Editing the host after a key was fetched (or already trusted)
/// invalidates it: there is only ever one key pending trust.
#[test]
fn editing_the_host_resets_a_fetched_or_trusted_key() {
let mut app = app();
let addr = Address::parse("x@example.org").unwrap();
app.pin_offer = Some((addr, [0u8; KEY_LEN]));
app.signup.key_trusted = true;
app.handle(Message::SignupHostChanged("example.net".into()));
assert!(app.pin_offer.is_none());
assert!(!app.signup.key_trusted);
}
fn opened() -> OpenedMsg { fn opened() -> OpenedMsg {
OpenedMsg { OpenedMsg {
id: "ab".repeat(32), id: "ab".repeat(32),

View file

@ -292,6 +292,33 @@ pub fn save_key(path: &Path, master: &[u8; KEY_LEN]) -> Result<(), String> {
err err
} }
/// Removes a not-yet-registered master key when onboarding is cancelled.
/// Idempotent, like `factory_reset`: a missing file is fine.
pub fn delete_key(path: &Path) -> Result<(), String> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(format!("{}: {e}", path.display())),
}
}
/// Removes every file carrier owns: the store, the key and both sidecars.
/// A missing file is fine — reset is idempotent — but a file that cannot
/// be removed is reported, so a reset that did not take is never silent.
/// The caller must close the store first: Windows refuses to remove a
/// file a live connection holds.
pub fn factory_reset(dir: &Path) -> Result<(), String> {
for name in ["carrier.db", "carrier.key", "read.txt", "drafts.txt"] {
let path = dir.join(name);
if let Err(e) = std::fs::remove_file(&path) {
if e.kind() != std::io::ErrorKind::NotFound {
return Err(format!("{}: {e}", path.display()));
}
}
}
Ok(())
}
pub fn account(store: &Store, master: &[u8; KEY_LEN]) -> Result<account::Account, String> { pub fn account(store: &Store, master: &[u8; KEY_LEN]) -> Result<account::Account, String> {
let index = store.rotations().map_err(explain)?; let index = store.rotations().map_err(explain)?;
account::Account::new(*master, index).map_err(explain) account::Account::new(*master, index).map_err(explain)
@ -301,14 +328,6 @@ pub fn parse_address(text: &str) -> Result<Address, String> {
Address::parse(text).map_err(explain) Address::parse(text).map_err(explain)
} }
/// A base32 public key, the form the operator publishes and pins are shown in.
pub fn parse_key(text: &str) -> Result<[u8; KEY_LEN], String> {
unb32(text.trim())
.map_err(explain)?
.try_into()
.map_err(|_| "keys are 32 bytes of base32".to_string())
}
pub fn register( pub fn register(
store: &Store, store: &Store,
master: &[u8; KEY_LEN], master: &[u8; KEY_LEN],
@ -378,11 +397,12 @@ pub fn fetch(
store: &Store, store: &Store,
master: &[u8; KEY_LEN], master: &[u8; KEY_LEN],
cancel: Arc<AtomicBool>, cancel: Arc<AtomicBool>,
keep: bool,
timeout: u64, timeout: u64,
) -> Result<String, String> { ) -> Result<String, String> {
let me = account(store, master)?; let me = account(store, master)?;
let opts = FetchOptions { let opts = FetchOptions {
keep: false, keep,
reset: false, reset: false,
timeout, timeout,
dial: None, dial: None,
@ -913,13 +933,6 @@ mod tests {
assert!(parse_seed(&hex[..63]).is_err()); assert!(parse_seed(&hex[..63]).is_err());
} }
#[test]
fn key_parsing_round_trips() {
let m = master();
assert_eq!(parse_key(&b32(&m)).unwrap(), m);
assert!(parse_key("short").is_err());
}
/// A sealed self-copy in the store surfaces as a row, opens with its /// A sealed self-copy in the store surfaces as a row, opens with its
/// frontmatter split, and flips to read against the marks set. /// frontmatter split, and flips to read against the marks set.
#[test] #[test]

View file

@ -19,7 +19,9 @@ use relm4::adw::prelude::*;
use relm4::ComponentSender; use relm4::ComponentSender;
use relm4::{Component, ComponentParts}; use relm4::{Component, ComponentParts};
use crate::app::{Carrier, Dialog, Message, OnboardMode, OpResult, Page, PendingOp}; use crate::app::{
Carrier, Dialog, Message, OnboardMode, OpResult, Page, PendingOp, SignupMode, SignupStep,
};
use crate::ops::{self, Contact, Folder, Row}; use crate::ops::{self, Contact, Folder, Row};
use relm4::RelmApp; use relm4::RelmApp;
@ -193,6 +195,7 @@ pub struct CarrierWidgets {
mail_list: gtk::ListBox, mail_list: gtk::ListBox,
list_empty: adw::StatusPage, list_empty: adw::StatusPage,
list_empty_fetch: gtk::Button, list_empty_fetch: gtk::Button,
list_empty_hint: gtk::Label,
article_stack: gtk::Stack, article_stack: gtk::Stack,
article_subject: gtk::Label, article_subject: gtk::Label,
article_meta: gtk::Label, article_meta: gtk::Label,
@ -223,16 +226,29 @@ pub struct CarrierWidgets {
offer_row: adw::ActionRow, offer_row: adw::ActionRow,
pins_group: adw::PreferencesGroup, pins_group: adw::PreferencesGroup,
pin_rows: Vec<adw::ActionRow>, pin_rows: Vec<adw::ActionRow>,
keep_mail_row: adw::SwitchRow,
timeout_row: adw::SpinRow, timeout_row: adw::SpinRow,
backup_label: gtk::Label, backup_label: gtk::Label,
// onboarding // onboarding
onboard_seed_label: gtk::Label, onboard_seed_label: gtk::Label,
onboard_seed_entry: adw::EntryRow,
// signup // signup
signup_back: gtk::Button, signup_key_button: gtk::Button,
signup_offer_list: gtk::ListBox, signup_key_card: gtk::Box,
signup_offer_row: adw::ActionRow, signup_key_value: gtk::Label,
signup_key_fingerprint: gtk::Label,
signup_key_hint: gtk::Label,
signup_start_over: gtk::Button,
signup_step_back: gtk::Button,
signup_mode_new: gtk::ToggleButton,
signup_mode_existing: gtk::ToggleButton,
signup_host: adw::EntryRow,
signup_user: adw::EntryRow,
signup_invite: adw::EntryRow,
register_button: gtk::Button,
recall_button: gtk::Button,
// status bar and dialogs // status bar and dialogs
spinner: gtk::Spinner, spinner: gtk::Spinner,
@ -414,7 +430,18 @@ impl CarrierWidgets {
let list_empty = adw::StatusPage::new(); let list_empty = adw::StatusPage::new();
let list_empty_fetch = icon_label_button("mail-send-receive-symbolic", "Fetch", "pill"); let list_empty_fetch = icon_label_button("mail-send-receive-symbolic", "Fetch", "pill");
list_empty_fetch.set_halign(Align::Center); list_empty_fetch.set_halign(Align::Center);
list_empty.set_child(Some(&list_empty_fetch)); let list_empty_hint = label(
"Fetching removes mail from the server once it's stored here.\n\
Turn on \"Keep mail on the server\" in Settings to keep it there instead.",
"dim caption",
);
list_empty_hint.set_wrap(true);
list_empty_hint.set_justify(gtk::Justification::Center);
list_empty_hint.set_halign(Align::Center);
let list_empty_box = gtk::Box::new(Orientation::Vertical, 8);
list_empty_box.append(&list_empty_fetch);
list_empty_box.append(&list_empty_hint);
list_empty.set_child(Some(&list_empty_box));
let list_stack = gtk::Stack::new(); let list_stack = gtk::Stack::new();
list_stack.set_hhomogeneous(false); list_stack.set_hhomogeneous(false);
list_stack.set_vhomogeneous(false); list_stack.set_vhomogeneous(false);
@ -625,6 +652,14 @@ impl CarrierWidgets {
.build(); .build();
settings.add(&pins_group); settings.add(&pins_group);
let fetch_group = adw::PreferencesGroup::builder().title("Fetching").build();
let keep_mail_row = adw::SwitchRow::builder()
.title("Keep mail on the server")
.subtitle("Off: a fetch removes mail from the server once it's stored here.")
.build();
fetch_group.add(&keep_mail_row);
settings.add(&fetch_group);
let network_group = adw::PreferencesGroup::builder().title("Network").build(); let network_group = adw::PreferencesGroup::builder().title("Network").build();
let timeout_row = adw::SpinRow::with_range(1.0, 600.0, 1.0); let timeout_row = adw::SpinRow::with_range(1.0, 600.0, 1.0);
timeout_row.set_title("Timeout"); timeout_row.set_title("Timeout");
@ -678,6 +713,19 @@ impl CarrierWidgets {
import_group.add(&import_backup); import_group.add(&import_backup);
settings.add(&import_group); settings.add(&import_group);
// The reset closes the page list: nothing after it survives a
// wipe, so it sits last, marked destructive like the delete.
let reset_group = adw::PreferencesGroup::builder()
.title("Factory reset")
.description(
"Erase the seed key, the sealed store and every sidecar on this device, and return to onboarding. Mail already on the server is not touched.",
)
.build();
let reset_button = icon_label_button("user-trash-symbolic", "Factory reset", "danger");
reset_button.set_valign(Align::Center);
reset_group.set_header_suffix(Some(&reset_button));
settings.add(&reset_group);
stack.add_named(&settings, Some("settings")); stack.add_named(&settings, Some("settings"));
// onboarding: three static modes, each a centred column // onboarding: three static modes, each a centred column
@ -700,8 +748,8 @@ impl CarrierWidgets {
tagline.set_halign(Align::Center); tagline.set_halign(Align::Center);
tagline.set_margin_bottom(12); tagline.set_margin_bottom(12);
onboard_start.append(&tagline); onboard_start.append(&tagline);
let create_button = button("Create an identity", "pill"); let create_button = button("Create a new identity", "pill");
let restore_button = button("Restore from a seed", "pill-quiet"); let restore_button = button("Restore existing identity from seed", "pill-quiet");
onboard_start.append(&create_button); onboard_start.append(&create_button);
onboard_start.append(&restore_button); onboard_start.append(&restore_button);
stack.add_named(&center(&onboard_start), Some("onboard")); stack.add_named(&center(&onboard_start), Some("onboard"));
@ -746,46 +794,84 @@ impl CarrierWidgets {
onboard_restore.append(&restore_back); onboard_restore.append(&restore_back);
stack.add_named(&center(&onboard_restore), Some("onboard-restore")); stack.add_named(&center(&onboard_restore), Some("onboard-restore"));
// signup // signup: step 1 — the server this address lives on
let signup = gtk::Box::new(Orientation::Vertical, 12); let signup_server = gtk::Box::new(Orientation::Vertical, 12);
signup.append(&label("Set up the address", "subtitle")); signup_server.append(&label("Set up the server", "subtitle"));
let signup_group = adw::PreferencesGroup::builder() let signup_server_group = adw::PreferencesGroup::new();
.description(
"The invite token and server key are only needed when the operator published them.",
)
.build();
let signup_user = adw::EntryRow::builder().title("User").build();
let signup_host = adw::EntryRow::builder().title("Host").build(); let signup_host = adw::EntryRow::builder().title("Host").build();
let signup_invite = adw::EntryRow::builder().title("Invite token").build(); signup_server_group.add(&signup_host);
let signup_key = adw::EntryRow::builder().title("Server key").build(); signup_server.append(&signup_server_group);
for row in [&signup_user, &signup_host, &signup_invite, &signup_key] {
signup_group.add(row); // Shown once a key has been fetched: the key and its fingerprint,
} // prominent, plus the reminder to check it against what the
signup.append(&signup_group); // operator published. Hidden otherwise.
let signup_learn = button("Learn the server key and pin what it presents", ""); let signup_key_value = label("", "mono");
signup.append(&signup_learn); signup_key_value.set_selectable(true);
let signup_offer_list = boxed_list(); signup_key_value.set_wrap(true);
signup_offer_list.set_visible(false); signup_key_value.set_margin_top(12);
let signup_offer_row = adw::ActionRow::builder() signup_key_value.set_margin_start(12);
.title("Server key offered") signup_key_value.set_margin_end(12);
let signup_key_fingerprint = label("", "mono dim");
signup_key_fingerprint.set_selectable(true);
signup_key_fingerprint.set_wrap(true);
signup_key_fingerprint.set_margin_bottom(12);
signup_key_fingerprint.set_margin_start(12);
signup_key_fingerprint.set_margin_end(12);
let signup_key_card = gtk::Box::new(Orientation::Vertical, 4);
signup_key_card.add_css_class("card");
signup_key_card.set_visible(false);
signup_key_card.append(&signup_key_value);
signup_key_card.append(&signup_key_fingerprint);
signup_server.append(&signup_key_card);
let signup_key_hint = label(
"If the server operator published a key or fingerprint, make \
sure it matches before trusting it.",
"dim",
);
signup_key_hint.set_wrap(true);
signup_key_hint.set_visible(false);
signup_server.append(&signup_key_hint);
let signup_key_button = button("Fetch the server's key", "pill");
let signup_start_over = button("Start over", "pill-quiet");
signup_server.append(&signup_key_button);
signup_server.append(&signup_start_over);
stack.add_named(&center(&signup_server), Some("signup-server"));
// signup: step 2 — bind a username, or recall an existing address
let signup_account = gtk::Box::new(Orientation::Vertical, 12);
signup_account.append(&label("Choose your address", "subtitle"));
let signup_mode_new = gtk::ToggleButton::builder()
.label("Create a new address")
.active(true)
.hexpand(true)
.build(); .build();
signup_offer_row.set_subtitle_lines(2); let signup_mode_existing = gtk::ToggleButton::builder()
let signup_offer_pin = button("Pin", "accent"); .label("I already have this address")
signup_offer_pin.set_valign(Align::Center); .hexpand(true)
let signup_offer_discard = button("Discard", ""); .build();
signup_offer_discard.set_valign(Align::Center); signup_mode_existing.set_group(Some(&signup_mode_new));
signup_offer_row.add_suffix(&signup_offer_discard); let signup_mode_box = gtk::Box::new(Orientation::Horizontal, 0);
signup_offer_row.add_suffix(&signup_offer_pin); signup_mode_box.add_css_class("linked");
signup_offer_list.append(&signup_offer_row); signup_mode_box.set_homogeneous(true);
signup.append(&signup_offer_list); signup_mode_box.append(&signup_mode_new);
signup_mode_box.append(&signup_mode_existing);
signup_account.append(&signup_mode_box);
let signup_account_group = adw::PreferencesGroup::new();
let signup_user = adw::EntryRow::builder().title("User").build();
let signup_invite = adw::EntryRow::builder()
.title("Invite token (if required by the server)")
.build();
signup_account_group.add(&signup_user);
signup_account_group.add(&signup_invite);
signup_account.append(&signup_account_group);
let register_button = button("Register", "pill"); let register_button = button("Register", "pill");
let recall_button = button("I already have this address", "pill-quiet"); let recall_button = button("Restore access", "pill");
signup.append(&register_button); signup_account.append(&register_button);
signup.append(&recall_button); signup_account.append(&recall_button);
let signup_back = button("Back", "quiet"); let signup_step_back = button("Back", "quiet");
signup_back.set_visible(false); signup_account.append(&signup_step_back);
signup.append(&signup_back); stack.add_named(&center(&signup_account), Some("signup-account"));
stack.add_named(&center(&signup), Some("signup"));
// --- status bar ------------------------------------------------- // --- status bar -------------------------------------------------
// One fixed line at the bottom: the spinner and the running // One fixed line at the bottom: the spinner and the running
@ -939,17 +1025,20 @@ impl CarrierWidgets {
}); });
rotate_button.connect_clicked(input(Message::RotatePressed)); rotate_button.connect_clicked(input(Message::RotatePressed));
reset_button.connect_clicked(input(Message::ResetPressed));
learn_button.connect_clicked(input(Message::SettingsLearnKey)); learn_button.connect_clicked(input(Message::SettingsLearnKey));
offer_pin.connect_clicked(input(Message::PinAccept)); offer_pin.connect_clicked(input(Message::PinAccept));
signup_offer_pin.connect_clicked(input(Message::PinAccept));
offer_discard.connect_clicked(input(Message::PinDiscard)); offer_discard.connect_clicked(input(Message::PinDiscard));
signup_offer_discard.connect_clicked(input(Message::PinDiscard));
export_button.connect_clicked(input(Message::ExportPressed)); export_button.connect_clicked(input(Message::ExportPressed));
import_backup.connect_clicked(input(Message::ImportPressed)); import_backup.connect_clicked(input(Message::ImportPressed));
let timeout_sender = sender.clone(); let timeout_sender = sender.clone();
timeout_row.connect_value_notify(move |row| { timeout_row.connect_value_notify(move |row| {
timeout_sender.input(Message::TimeoutChanged((row.value() as u64).to_string())) timeout_sender.input(Message::TimeoutChanged((row.value() as u64).to_string()))
}); });
let keep_mail_sender = sender.clone();
keep_mail_row.connect_active_notify(move |row| {
keep_mail_sender.input(Message::KeepMailChanged(row.is_active()))
});
let import_body_sender = sender.clone(); let import_body_sender = sender.clone();
import_body.connect_changed(move |buffer| { import_body.connect_changed(move |buffer| {
import_body_sender.input(Message::ImportBodyChanged(buffer_text(buffer))) import_body_sender.input(Message::ImportBodyChanged(buffer_text(buffer)))
@ -982,14 +1071,23 @@ impl CarrierWidgets {
signup_invite.connect_changed(move |entry| { signup_invite.connect_changed(move |entry| {
invite_sender.input(Message::SignupInviteChanged(entry.text().to_string())) invite_sender.input(Message::SignupInviteChanged(entry.text().to_string()))
}); });
let key_sender = sender.clone(); signup_key_button.connect_clicked(input(Message::SignupKeyAction));
signup_key.connect_changed(move |entry| { signup_start_over.connect_clicked(input(Message::SignupStartOver));
key_sender.input(Message::SignupKeyChanged(entry.text().to_string())) signup_step_back.connect_clicked(input(Message::SignupStepBack));
});
signup_learn.connect_clicked(input(Message::SignupLearnKey));
register_button.connect_clicked(input(Message::SignupRegister)); register_button.connect_clicked(input(Message::SignupRegister));
recall_button.connect_clicked(input(Message::SignupRecall)); recall_button.connect_clicked(input(Message::SignupRecall));
signup_back.connect_clicked(input(Message::SignupBack)); let mode_new_sender = sender.clone();
signup_mode_new.connect_toggled(move |btn| {
if btn.is_active() {
mode_new_sender.input(Message::SignupModeChanged(SignupMode::New));
}
});
let mode_existing_sender = sender.clone();
signup_mode_existing.connect_toggled(move |btn| {
if btn.is_active() {
mode_existing_sender.input(Message::SignupModeChanged(SignupMode::Existing));
}
});
CarrierWidgets { CarrierWidgets {
window: root.clone(), window: root.clone(),
@ -1005,6 +1103,7 @@ impl CarrierWidgets {
mail_list, mail_list,
list_empty, list_empty,
list_empty_fetch, list_empty_fetch,
list_empty_hint,
article_stack, article_stack,
article_subject, article_subject,
article_meta, article_meta,
@ -1027,12 +1126,25 @@ impl CarrierWidgets {
offer_row, offer_row,
pins_group, pins_group,
pin_rows: Vec::new(), pin_rows: Vec::new(),
keep_mail_row,
timeout_row, timeout_row,
backup_label, backup_label,
onboard_seed_label, onboard_seed_label,
signup_back, onboard_seed_entry,
signup_offer_list, signup_key_button,
signup_offer_row, signup_key_card,
signup_key_value,
signup_key_fingerprint,
signup_key_hint,
signup_start_over,
signup_step_back,
signup_mode_new,
signup_mode_existing,
signup_host,
signup_user,
signup_invite,
register_button,
recall_button,
spinner, spinner,
status_label, status_label,
cancel_button, cancel_button,
@ -1062,10 +1174,15 @@ impl Component for Carrier {
sender: ComponentSender<Self>, sender: ComponentSender<Self>,
) -> ComponentParts<Self> { ) -> ComponentParts<Self> {
let (model, op) = Carrier::boot(init); let (model, op) = Carrier::boot(init);
let widgets = CarrierWidgets::new(&root, &sender); let mut widgets = CarrierWidgets::new(&root, &sender);
if let Some(op) = op { if let Some(op) = op {
spawn_op(&model, op, &sender); spawn_op(&model, op, &sender);
} }
// `update_view` runs only after a message, and a clean install
// boots with none queued, so the first paint happens here —
// otherwise the stack sits on its default page until the first
// click flips it to the page the model opened with.
render(&model, &mut widgets, &sender);
ComponentParts { model, widgets } ComponentParts { model, widgets }
} }
@ -1098,21 +1215,15 @@ fn spawn_op(model: &Carrier, op: PendingOp, sender: &ComponentSender<Carrier>) {
let store = model.store.clone(); let store = model.store.clone();
let master = model.master.unwrap_or([0u8; KEY_LEN]); let master = model.master.unwrap_or([0u8; KEY_LEN]);
let timeout = model.timeout; let timeout = model.timeout;
let keep_mail = model.keep_mail;
match op { match op {
PendingOp::Snapshot { read } => sender.spawn_command(move |out| { PendingOp::Snapshot { read } => sender.spawn_command(move |out| {
let _ = out.send(OpResult::SnapshotLoaded(ops::snapshot( let _ = out.send(OpResult::SnapshotLoaded(ops::snapshot(
&store, &master, &read, &store, &master, &read,
))); )));
}), }),
PendingOp::Register { addr, invite, key } => sender.spawn_command(move |out| { PendingOp::Register { addr, invite } => sender.spawn_command(move |out| {
let res = if key.is_empty() { let res = ops::register(&store, &master, &addr, invite.as_deref(), timeout);
ops::register(&store, &master, &addr, invite.as_deref(), timeout)
} else {
ops::parse_key(&key).and_then(|key| {
ops::pin(&store, &addr, &key)?;
ops::register(&store, &master, &addr, invite.as_deref(), timeout)
})
};
let _ = out.send(OpResult::Generic(res.map(|_| "registered".to_string()))); let _ = out.send(OpResult::Generic(res.map(|_| "registered".to_string())));
}), }),
PendingOp::Restore { addr } => sender.spawn_command(move |out| { PendingOp::Restore { addr } => sender.spawn_command(move |out| {
@ -1142,7 +1253,7 @@ fn spawn_op(model: &Carrier, op: PendingOp, sender: &ComponentSender<Carrier>) {
}); });
sender.spawn_command(move |out| { sender.spawn_command(move |out| {
let _ = out.send(OpResult::Generic(ops::fetch( let _ = out.send(OpResult::Generic(ops::fetch(
&store, &master, cancel, timeout, &store, &master, cancel, keep_mail, timeout,
))); )));
}); });
} }
@ -1321,7 +1432,10 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
OnboardMode::Created => "onboard-created", OnboardMode::Created => "onboard-created",
OnboardMode::Restore => "onboard-restore", OnboardMode::Restore => "onboard-restore",
}, },
Page::Signup => "signup", Page::Signup => match model.signup.step {
SignupStep::Server => "signup-server",
SignupStep::Account => "signup-account",
},
Page::Mail => "mail", Page::Mail => "mail",
Page::Settings => "settings", Page::Settings => "settings",
} }
@ -1332,32 +1446,30 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
clear_list(&w.mail_list); clear_list(&w.mail_list);
let rows = model.rows(); let rows = model.rows();
let (icon, title, description) = match model.folder { let (icon, title, description) = match model.folder {
Folder::Inbox => ( Folder::Inbox => (None, "Inbox is empty", "Fetch to check the mailbox."),
"mail-read-symbolic",
"Inbox is empty",
"Fetch to check the mailbox.",
),
Folder::Requests => ( Folder::Requests => (
"contact-new-symbolic", Some("contact-new-symbolic"),
"No requests", "No requests",
"Mail from senders you have not accepted yet arrives here.", "Mail from senders you have not accepted yet arrives here.",
), ),
Folder::Sent => ( Folder::Sent => (
"mail-send-symbolic", Some("mail-send-symbolic"),
"Nothing sent yet", "Nothing sent yet",
"Messages you send keep a sealed copy here.", "Messages you send keep a sealed copy here.",
), ),
Folder::Drafts => ( Folder::Drafts => (
"document-edit-symbolic", Some("document-edit-symbolic"),
"No drafts", "No drafts",
"A compose closed from the sidebar is kept here.", "A compose closed from the sidebar is kept here.",
), ),
}; };
w.list_empty.set_icon_name(Some(icon)); w.list_empty.set_icon_name(icon);
w.list_empty.set_title(title); w.list_empty.set_title(title);
w.list_empty.set_description(Some(description)); w.list_empty.set_description(Some(description));
w.list_empty_fetch let inbox_empty = model.folder == Folder::Inbox;
.set_visible(model.folder == Folder::Inbox); w.list_empty_fetch.set_visible(inbox_empty);
w.list_empty_hint
.set_visible(inbox_empty && !model.keep_mail);
w.list_stack w.list_stack
.set_visible_child_name(if rows.is_empty() { "empty" } else { "list" }); .set_visible_child_name(if rows.is_empty() { "empty" } else { "list" });
let mut selected_row = None; let mut selected_row = None;
@ -1594,11 +1706,11 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
w.offer_row.set_title(&escape(&fingerprint(key))); w.offer_row.set_title(&escape(&fingerprint(key)));
w.offer_row.set_subtitle(&escape(&b32(key))); w.offer_row.set_subtitle(&escape(&b32(key)));
w.offer_group.set_visible(true); w.offer_group.set_visible(true);
w.signup_offer_row.set_subtitle(&escape(&fingerprint(key)));
w.signup_offer_list.set_visible(true);
} else { } else {
w.offer_group.set_visible(false); w.offer_group.set_visible(false);
w.signup_offer_list.set_visible(false); }
if w.keep_mail_row.is_active() != model.keep_mail {
w.keep_mail_row.set_active(model.keep_mail);
} }
if w.timeout_row.value() as u64 != model.timeout { if w.timeout_row.value() as u64 != model.timeout {
w.timeout_row.set_value(model.timeout as f64); w.timeout_row.set_value(model.timeout as f64);
@ -1635,13 +1747,60 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
.unwrap_or_default(); .unwrap_or_default();
w.backup_label.set_text(&backup_text); w.backup_label.set_text(&backup_text);
// A programmatic reset (factory reset, discarding a cancelled
// identity) clears these in the model; push that once into the
// entries too, the same way a compose prefill does, so typing from
// a previous attempt cannot linger on screen.
if model.form_fresh.take() {
w.onboard_seed_entry.set_text(&model.onboard.seed);
w.signup_host.set_text(&model.signup.host);
w.signup_user.set_text(&model.signup.user);
w.signup_invite.set_text(&model.signup.invite);
}
// Onboarding. // Onboarding.
if model.onboard.mode == OnboardMode::Created { if model.onboard.mode == OnboardMode::Created {
w.onboard_seed_label.set_text(&model.onboard.seed_shown); w.onboard_seed_label.set_text(&model.onboard.seed_shown);
} }
// Signup. // Signup.
w.signup_back.set_visible(model.signup.fresh); let signup_busy = model.busy.is_none();
for button in [
&w.signup_key_button,
&w.signup_start_over,
&w.signup_step_back,
&w.register_button,
&w.recall_button,
] {
button.set_sensitive(signup_busy);
}
// The server step's one button: a fetched or already-trusted key
// reads "Continue" and shows the key prominently; otherwise "Fetch".
if let Some((_, key)) = &model.pin_offer {
w.signup_key_value.set_text(&b32(key));
w.signup_key_fingerprint.set_text(&fingerprint(key));
w.signup_key_card.set_visible(true);
w.signup_key_hint.set_visible(true);
w.signup_key_button.set_label("Continue");
} else if model.signup.key_trusted {
w.signup_key_card.set_visible(false);
w.signup_key_hint.set_visible(false);
w.signup_key_button.set_label("Continue");
} else {
w.signup_key_card.set_visible(false);
w.signup_key_hint.set_visible(false);
w.signup_key_button.set_label("Fetch the server's key");
}
let new_address = model.signup.mode == SignupMode::New;
w.signup_invite.set_visible(new_address);
w.register_button.set_visible(new_address);
w.recall_button.set_visible(!new_address);
if w.signup_mode_new.is_active() != new_address {
w.signup_mode_new.set_active(new_address);
}
if w.signup_mode_existing.is_active() == new_address {
w.signup_mode_existing.set_active(!new_address);
}
// Confirmations are presented once, on the None -> Some edge: `render` // Confirmations are presented once, on the None -> Some edge: `render`
// runs after every message and an alert dialog is modal, so presenting // runs after every message and an alert dialog is modal, so presenting
@ -1650,6 +1809,7 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
(Some(dialog), false) => { (Some(dialog), false) => {
let rotating = matches!(dialog, Dialog::Rotate); let rotating = matches!(dialog, Dialog::Rotate);
let replying = matches!(dialog, Dialog::Reply { .. }); let replying = matches!(dialog, Dialog::Reply { .. });
let resetting = matches!(dialog, Dialog::Reset);
let alert = match dialog { let alert = match dialog {
Dialog::Delete { kept, .. } => { Dialog::Delete { kept, .. } => {
let alert = adw::AlertDialog::new( let alert = adw::AlertDialog::new(
@ -1682,6 +1842,16 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
alert.set_response_appearance("rotate", adw::ResponseAppearance::Destructive); alert.set_response_appearance("rotate", adw::ResponseAppearance::Destructive);
alert alert
} }
Dialog::Reset => {
let alert = adw::AlertDialog::new(
Some("Factory reset this device?"),
Some("The seed key, the sealed store, contacts, server pins, drafts and read marks are erased here. Mail already on the server stays there — without the seed it cannot be read back."),
);
alert.add_response("cancel", "Cancel");
alert.add_response("reset", "Factory reset");
alert.set_response_appearance("reset", adw::ResponseAppearance::Destructive);
alert
}
Dialog::Reply { rows, .. } => { Dialog::Reply { rows, .. } => {
let list = rows let list = rows
.iter() .iter()
@ -1708,15 +1878,17 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
alert.set_default_response(Some(close_default)); alert.set_default_response(Some(close_default));
let response_sender = sender.clone(); let response_sender = sender.clone();
alert.connect_response(None, move |_, response| { alert.connect_response(None, move |_, response| {
response_sender.input(match (rotating, replying, response) { response_sender.input(match (rotating, replying, resetting, response) {
(true, _, "rotate") => Message::RotateConfirmed, (true, _, _, "rotate") => Message::RotateConfirmed,
(true, _, _) => Message::RotateCancelled, (true, _, _, _) => Message::RotateCancelled,
(_, true, "copy") => Message::ReplyConfirmed(true), (_, true, _, "copy") => Message::ReplyConfirmed(true),
(_, true, "without") => Message::ReplyConfirmed(false), (_, true, _, "without") => Message::ReplyConfirmed(false),
(_, true, _) => Message::ReplyCancelled, (_, true, _, _) => Message::ReplyCancelled,
(false, _, "here") => Message::DeleteConfirmed(false), (_, _, true, "reset") => Message::ResetConfirmed,
(false, _, "everywhere") => Message::DeleteConfirmed(true), (_, _, true, _) => Message::ResetCancelled,
(false, _, _) => Message::DeleteCancelled, (false, _, _, "here") => Message::DeleteConfirmed(false),
(false, _, _, "everywhere") => Message::DeleteConfirmed(true),
(false, _, _, _) => Message::DeleteCancelled,
}); });
}); });
alert.present(Some(&w.window)); alert.present(Some(&w.window));