feat: rework signup into a safer multi-step flow, add keep-mail setting
This commit is contained in:
parent
53c9c4b8ce
commit
2892ea3fa7
3 changed files with 649 additions and 132 deletions
380
src/app.rs
380
src/app.rs
|
|
@ -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 => {
|
||||||
|
if self.onboard.mode == OnboardMode::Created {
|
||||||
|
self.discard_identity();
|
||||||
|
} else {
|
||||||
self.onboard.mode = OnboardMode::Start;
|
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),
|
||||||
|
|
|
||||||
45
src/ops.rs
45
src/ops.rs
|
|
@ -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]
|
||||||
|
|
|
||||||
354
src/view.rs
354
src/view.rs
|
|
@ -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(¢er(&onboard_start), Some("onboard"));
|
stack.add_named(¢er(&onboard_start), Some("onboard"));
|
||||||
|
|
@ -746,46 +794,84 @@ impl CarrierWidgets {
|
||||||
onboard_restore.append(&restore_back);
|
onboard_restore.append(&restore_back);
|
||||||
stack.add_named(¢er(&onboard_restore), Some("onboard-restore"));
|
stack.add_named(¢er(&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(¢er(&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(®ister_button);
|
signup_account.append(®ister_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(¢er(&signup_account), Some("signup-account"));
|
||||||
stack.add_named(¢er(&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));
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue