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

@ -19,7 +19,9 @@ use relm4::adw::prelude::*;
use relm4::ComponentSender;
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 relm4::RelmApp;
@ -193,6 +195,7 @@ pub struct CarrierWidgets {
mail_list: gtk::ListBox,
list_empty: adw::StatusPage,
list_empty_fetch: gtk::Button,
list_empty_hint: gtk::Label,
article_stack: gtk::Stack,
article_subject: gtk::Label,
article_meta: gtk::Label,
@ -223,16 +226,29 @@ pub struct CarrierWidgets {
offer_row: adw::ActionRow,
pins_group: adw::PreferencesGroup,
pin_rows: Vec<adw::ActionRow>,
keep_mail_row: adw::SwitchRow,
timeout_row: adw::SpinRow,
backup_label: gtk::Label,
// onboarding
onboard_seed_label: gtk::Label,
onboard_seed_entry: adw::EntryRow,
// signup
signup_back: gtk::Button,
signup_offer_list: gtk::ListBox,
signup_offer_row: adw::ActionRow,
signup_key_button: gtk::Button,
signup_key_card: gtk::Box,
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
spinner: gtk::Spinner,
@ -414,7 +430,18 @@ impl CarrierWidgets {
let list_empty = adw::StatusPage::new();
let list_empty_fetch = icon_label_button("mail-send-receive-symbolic", "Fetch", "pill");
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();
list_stack.set_hhomogeneous(false);
list_stack.set_vhomogeneous(false);
@ -625,6 +652,14 @@ impl CarrierWidgets {
.build();
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 timeout_row = adw::SpinRow::with_range(1.0, 600.0, 1.0);
timeout_row.set_title("Timeout");
@ -678,6 +713,19 @@ impl CarrierWidgets {
import_group.add(&import_backup);
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"));
// onboarding: three static modes, each a centred column
@ -700,8 +748,8 @@ impl CarrierWidgets {
tagline.set_halign(Align::Center);
tagline.set_margin_bottom(12);
onboard_start.append(&tagline);
let create_button = button("Create an identity", "pill");
let restore_button = button("Restore from a seed", "pill-quiet");
let create_button = button("Create a new identity", "pill");
let restore_button = button("Restore existing identity from seed", "pill-quiet");
onboard_start.append(&create_button);
onboard_start.append(&restore_button);
stack.add_named(&center(&onboard_start), Some("onboard"));
@ -746,46 +794,84 @@ impl CarrierWidgets {
onboard_restore.append(&restore_back);
stack.add_named(&center(&onboard_restore), Some("onboard-restore"));
// signup
let signup = gtk::Box::new(Orientation::Vertical, 12);
signup.append(&label("Set up the address", "subtitle"));
let signup_group = adw::PreferencesGroup::builder()
.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();
// signup: step 1 — the server this address lives on
let signup_server = gtk::Box::new(Orientation::Vertical, 12);
signup_server.append(&label("Set up the server", "subtitle"));
let signup_server_group = adw::PreferencesGroup::new();
let signup_host = adw::EntryRow::builder().title("Host").build();
let signup_invite = adw::EntryRow::builder().title("Invite token").build();
let signup_key = adw::EntryRow::builder().title("Server key").build();
for row in [&signup_user, &signup_host, &signup_invite, &signup_key] {
signup_group.add(row);
}
signup.append(&signup_group);
let signup_learn = button("Learn the server key and pin what it presents", "");
signup.append(&signup_learn);
let signup_offer_list = boxed_list();
signup_offer_list.set_visible(false);
let signup_offer_row = adw::ActionRow::builder()
.title("Server key offered")
signup_server_group.add(&signup_host);
signup_server.append(&signup_server_group);
// Shown once a key has been fetched: the key and its fingerprint,
// prominent, plus the reminder to check it against what the
// operator published. Hidden otherwise.
let signup_key_value = label("", "mono");
signup_key_value.set_selectable(true);
signup_key_value.set_wrap(true);
signup_key_value.set_margin_top(12);
signup_key_value.set_margin_start(12);
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();
signup_offer_row.set_subtitle_lines(2);
let signup_offer_pin = button("Pin", "accent");
signup_offer_pin.set_valign(Align::Center);
let signup_offer_discard = button("Discard", "");
signup_offer_discard.set_valign(Align::Center);
signup_offer_row.add_suffix(&signup_offer_discard);
signup_offer_row.add_suffix(&signup_offer_pin);
signup_offer_list.append(&signup_offer_row);
signup.append(&signup_offer_list);
let signup_mode_existing = gtk::ToggleButton::builder()
.label("I already have this address")
.hexpand(true)
.build();
signup_mode_existing.set_group(Some(&signup_mode_new));
let signup_mode_box = gtk::Box::new(Orientation::Horizontal, 0);
signup_mode_box.add_css_class("linked");
signup_mode_box.set_homogeneous(true);
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 recall_button = button("I already have this address", "pill-quiet");
signup.append(&register_button);
signup.append(&recall_button);
let signup_back = button("Back", "quiet");
signup_back.set_visible(false);
signup.append(&signup_back);
stack.add_named(&center(&signup), Some("signup"));
let recall_button = button("Restore access", "pill");
signup_account.append(&register_button);
signup_account.append(&recall_button);
let signup_step_back = button("Back", "quiet");
signup_account.append(&signup_step_back);
stack.add_named(&center(&signup_account), Some("signup-account"));
// --- status bar -------------------------------------------------
// One fixed line at the bottom: the spinner and the running
@ -939,17 +1025,20 @@ impl CarrierWidgets {
});
rotate_button.connect_clicked(input(Message::RotatePressed));
reset_button.connect_clicked(input(Message::ResetPressed));
learn_button.connect_clicked(input(Message::SettingsLearnKey));
offer_pin.connect_clicked(input(Message::PinAccept));
signup_offer_pin.connect_clicked(input(Message::PinAccept));
offer_discard.connect_clicked(input(Message::PinDiscard));
signup_offer_discard.connect_clicked(input(Message::PinDiscard));
export_button.connect_clicked(input(Message::ExportPressed));
import_backup.connect_clicked(input(Message::ImportPressed));
let timeout_sender = sender.clone();
timeout_row.connect_value_notify(move |row| {
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();
import_body.connect_changed(move |buffer| {
import_body_sender.input(Message::ImportBodyChanged(buffer_text(buffer)))
@ -982,14 +1071,23 @@ impl CarrierWidgets {
signup_invite.connect_changed(move |entry| {
invite_sender.input(Message::SignupInviteChanged(entry.text().to_string()))
});
let key_sender = sender.clone();
signup_key.connect_changed(move |entry| {
key_sender.input(Message::SignupKeyChanged(entry.text().to_string()))
});
signup_learn.connect_clicked(input(Message::SignupLearnKey));
signup_key_button.connect_clicked(input(Message::SignupKeyAction));
signup_start_over.connect_clicked(input(Message::SignupStartOver));
signup_step_back.connect_clicked(input(Message::SignupStepBack));
register_button.connect_clicked(input(Message::SignupRegister));
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 {
window: root.clone(),
@ -1005,6 +1103,7 @@ impl CarrierWidgets {
mail_list,
list_empty,
list_empty_fetch,
list_empty_hint,
article_stack,
article_subject,
article_meta,
@ -1027,12 +1126,25 @@ impl CarrierWidgets {
offer_row,
pins_group,
pin_rows: Vec::new(),
keep_mail_row,
timeout_row,
backup_label,
onboard_seed_label,
signup_back,
signup_offer_list,
signup_offer_row,
onboard_seed_entry,
signup_key_button,
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,
status_label,
cancel_button,
@ -1062,10 +1174,15 @@ impl Component for Carrier {
sender: ComponentSender<Self>,
) -> ComponentParts<Self> {
let (model, op) = Carrier::boot(init);
let widgets = CarrierWidgets::new(&root, &sender);
let mut widgets = CarrierWidgets::new(&root, &sender);
if let Some(op) = op {
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 }
}
@ -1098,21 +1215,15 @@ fn spawn_op(model: &Carrier, op: PendingOp, sender: &ComponentSender<Carrier>) {
let store = model.store.clone();
let master = model.master.unwrap_or([0u8; KEY_LEN]);
let timeout = model.timeout;
let keep_mail = model.keep_mail;
match op {
PendingOp::Snapshot { read } => sender.spawn_command(move |out| {
let _ = out.send(OpResult::SnapshotLoaded(ops::snapshot(
&store, &master, &read,
)));
}),
PendingOp::Register { addr, invite, key } => sender.spawn_command(move |out| {
let res = if key.is_empty() {
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)
})
};
PendingOp::Register { addr, invite } => sender.spawn_command(move |out| {
let res = ops::register(&store, &master, &addr, invite.as_deref(), timeout);
let _ = out.send(OpResult::Generic(res.map(|_| "registered".to_string())));
}),
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| {
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::Restore => "onboard-restore",
},
Page::Signup => "signup",
Page::Signup => match model.signup.step {
SignupStep::Server => "signup-server",
SignupStep::Account => "signup-account",
},
Page::Mail => "mail",
Page::Settings => "settings",
}
@ -1332,32 +1446,30 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
clear_list(&w.mail_list);
let rows = model.rows();
let (icon, title, description) = match model.folder {
Folder::Inbox => (
"mail-read-symbolic",
"Inbox is empty",
"Fetch to check the mailbox.",
),
Folder::Inbox => (None, "Inbox is empty", "Fetch to check the mailbox."),
Folder::Requests => (
"contact-new-symbolic",
Some("contact-new-symbolic"),
"No requests",
"Mail from senders you have not accepted yet arrives here.",
),
Folder::Sent => (
"mail-send-symbolic",
Some("mail-send-symbolic"),
"Nothing sent yet",
"Messages you send keep a sealed copy here.",
),
Folder::Drafts => (
"document-edit-symbolic",
Some("document-edit-symbolic"),
"No drafts",
"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_description(Some(description));
w.list_empty_fetch
.set_visible(model.folder == Folder::Inbox);
let inbox_empty = 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
.set_visible_child_name(if rows.is_empty() { "empty" } else { "list" });
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_subtitle(&escape(&b32(key)));
w.offer_group.set_visible(true);
w.signup_offer_row.set_subtitle(&escape(&fingerprint(key)));
w.signup_offer_list.set_visible(true);
} else {
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 {
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();
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.
if model.onboard.mode == OnboardMode::Created {
w.onboard_seed_label.set_text(&model.onboard.seed_shown);
}
// 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`
// 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) => {
let rotating = matches!(dialog, Dialog::Rotate);
let replying = matches!(dialog, Dialog::Reply { .. });
let resetting = matches!(dialog, Dialog::Reset);
let alert = match dialog {
Dialog::Delete { kept, .. } => {
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
}
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, .. } => {
let list = rows
.iter()
@ -1708,15 +1878,17 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
alert.set_default_response(Some(close_default));
let response_sender = sender.clone();
alert.connect_response(None, move |_, response| {
response_sender.input(match (rotating, replying, response) {
(true, _, "rotate") => Message::RotateConfirmed,
(true, _, _) => Message::RotateCancelled,
(_, true, "copy") => Message::ReplyConfirmed(true),
(_, true, "without") => Message::ReplyConfirmed(false),
(_, true, _) => Message::ReplyCancelled,
(false, _, "here") => Message::DeleteConfirmed(false),
(false, _, "everywhere") => Message::DeleteConfirmed(true),
(false, _, _) => Message::DeleteCancelled,
response_sender.input(match (rotating, replying, resetting, response) {
(true, _, _, "rotate") => Message::RotateConfirmed,
(true, _, _, _) => Message::RotateCancelled,
(_, true, _, "copy") => Message::ReplyConfirmed(true),
(_, true, _, "without") => Message::ReplyConfirmed(false),
(_, true, _, _) => Message::ReplyCancelled,
(_, _, true, "reset") => Message::ResetConfirmed,
(_, _, true, _) => Message::ResetCancelled,
(false, _, _, "here") => Message::DeleteConfirmed(false),
(false, _, _, "everywhere") => Message::DeleteConfirmed(true),
(false, _, _, _) => Message::DeleteCancelled,
});
});
alert.present(Some(&w.window));