carrier/src/view.rs

2495 lines
103 KiB
Rust

//! The Relm4 view: one component whose widgets mirror the model. The
//! `Component` impl is a thin adapter — `handle` and `handle_result` in
//! `app.rs` own the state machine; background work runs through
//! `spawn_command` on the blocking pool and reports back as `OpResult`.
//!
//! Every visual decision is delegated to libadwaita: the widgets below are
//! the stylesheet's own rows, groups, dialogs and status pages, and the
//! only styling is its documented class names. Nothing here picks a colour,
//! a font or a size, so carrier follows the user's theme and accent.
use std::sync::atomic::Ordering;
use std::time::Duration;
use fumi_core::address::trust_label;
use fumi_core::crypto::{b32, fingerprint, KEY_LEN};
use gtk::prelude::*;
use gtk::{Align, Orientation, WrapMode};
use relm4::adw;
use relm4::adw::prelude::*;
use relm4::ComponentSender;
use relm4::{Component, ComponentParts};
use crate::app::{
Carrier, Dialog, DialogKind, Message, OnboardMode, OpResult, Page, PendingOp, SignupMode,
SignupStep,
};
use crate::i18n;
use crate::ops::{self, Contact, Folder, Row};
use relm4::RelmApp;
/// A label, the unit every line is built from. The class argument is a
/// space-separated set of Adwaita's own typography classes, so weight and
/// size come from the stylesheet rather than from markup or a hardcoded
/// font: `heading` and `title` for hierarchy, `dim` and `caption` for
/// metadata, `mono` wherever a key or address must stay column-aligned.
fn label(text: &str, classes: &str) -> gtk::Label {
let label = gtk::Label::new(Some(text));
for class in classes.split_whitespace() {
match class {
"dim" => label.add_css_class("dim-label"),
"caption" => {
label.add_css_class("caption");
label.add_css_class("dim-label");
}
"heading" => label.add_css_class("heading"),
"title" => label.add_css_class("title-1"),
"subtitle" => label.add_css_class("title-4"),
"mono" => label.add_css_class("monospace"),
"numeric" => label.add_css_class("numeric"),
other => label.add_css_class(other),
}
}
label.set_xalign(0.0);
label
}
fn escape(text: &str) -> String {
gtk::glib::markup_escape_text(text).to_string()
}
/// Buttons map onto the stylesheet's built-in classes: "accent" is the
/// suggested action, "danger" the destructive one, "pill" the wide call to
/// action a welcome screen wants, everything else flat.
fn button(text: &str, class: &str) -> gtk::Button {
let button = gtk::Button::with_label(text);
match class {
"accent" => button.add_css_class("suggested-action"),
"danger" => button.add_css_class("destructive-action"),
"quiet" => button.add_css_class("flat"),
"pill" => {
button.add_css_class("pill");
button.add_css_class("suggested-action");
}
"pill-quiet" => button.add_css_class("pill"),
_ => {}
}
button
}
/// The same, with a symbolic icon beside the label. `ButtonContent` does
/// the icon-to-label spacing the stylesheet expects.
fn icon_label_button(icon: &str, text: &str, class: &str) -> gtk::Button {
let button = button("", class);
button.set_child(Some(
&adw::ButtonContent::builder()
.icon_name(icon)
.label(text)
.build(),
));
button
}
/// A flat round icon button: the Adwaita form for a secondary affordance
/// that sits inside a row rather than in a toolbar.
fn icon_button(icon: &str, tooltip: &str) -> gtk::Button {
let button = gtk::Button::from_icon_name(icon);
button.add_css_class("flat");
button.add_css_class("circular");
button.set_tooltip_text(Some(tooltip));
button.set_valign(Align::Center);
button
}
/// One sidebar destination: icon, name, and a count that stays hidden
/// until it is non-zero. The row carries no class of its own — the list's
/// `navigation-sidebar` paints hover and selection.
fn nav_row(icon: &str, text: &str) -> (gtk::ListBoxRow, gtk::Label) {
let row = gtk::ListBoxRow::new();
let line = gtk::Box::new(Orientation::Horizontal, 12);
line.set_margin_top(6);
line.set_margin_bottom(6);
line.set_margin_start(6);
line.set_margin_end(6);
line.append(&gtk::Image::from_icon_name(icon));
let name = gtk::Label::new(Some(text));
name.set_xalign(0.0);
name.set_hexpand(true);
line.append(&name);
let count = label("", "caption numeric");
count.set_visible(false);
line.append(&count);
row.set_child(Some(&line));
(row, count)
}
/// A list that paints itself as one rounded group: the Adwaita form for a
/// short, fixed set of rows, as opposed to a long scrolling one.
fn boxed_list() -> gtk::ListBox {
let list = gtk::ListBox::new();
list.add_css_class("boxed-list");
list.set_selection_mode(gtk::SelectionMode::None);
list
}
/// Removes every child of a box, the coarse re-render primitive.
fn clear(boxed: &gtk::Box) {
while let Some(child) = boxed.first_child() {
boxed.remove(&child);
}
}
/// The same for lists.
fn clear_list(list: &gtk::ListBox) {
while let Some(child) = list.first_child() {
list.remove(&child);
}
}
pub fn run(dir: std::path::PathBuf) {
let app: RelmApp<_> = RelmApp::new("com.randogoth.carrier");
app.run::<Carrier>(dir);
}
fn truncate(text: &str, max: usize) -> String {
if text.chars().count() <= max {
text.to_string()
} else {
let cut: String = text.chars().take(max - 1).collect();
format!("{cut}…")
}
}
fn buffer_text(buffer: &gtk::TextBuffer) -> String {
buffer
.text(&buffer.start_iter(), &buffer.end_iter(), false)
.to_string()
}
/// The contact's trust state as one word, the same vocabulary the sidebar,
/// the detail pane and the contacts page all label it with.
fn contact_state(contact: &Contact) -> String {
let id = match contact.accepted {
Some(true) => "contact-state-accepted",
Some(false) => "contact-state-blocked",
None if contact.verified => "contact-state-verified",
None => "contact-state-tofu",
};
i18n::t(id)
}
#[derive(Debug, Clone)]
pub struct CarrierWidgets {
/// `AlertDialog::present` needs a parent, and only the window is one.
window: gtk::Window,
window_title: adw::WindowTitle,
// sidebar top: actions and destinations
folders: gtk::ListBox,
folder_counts: Vec<gtk::Label>,
/// The Drafts row, hidden until the folder holds one.
drafts_nav: gtk::ListBoxRow,
settings_nav: gtk::ListBox,
fetch_button: gtk::Button,
write_button: gtk::Button,
stack: gtk::Stack,
// mail page
list_stack: gtk::Stack,
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,
article_headers: gtk::Label,
article_view: gtk::TextView,
article_body: gtk::TextBuffer,
actions: gtk::Box,
// compose page
compose_to: adw::EntryRow,
compose_headers: gtk::ListBox,
compose_subject: adw::EntryRow,
compose_body: gtk::TextBuffer,
compose_send: adw::ButtonContent,
compose_pin_card: gtk::Box,
compose_pin_title: gtk::Label,
compose_pin_body: gtk::Label,
compose_pin_key: gtk::Label,
compose_pin_fingerprint: gtk::Label,
compose_pin_hint: gtk::Label,
compose_pin_button: gtk::Button,
// sidebar bottom: own identity and the contact list
sidebar_own: gtk::Label,
sidebar_contacts: gtk::ListBox,
// the QR-only share popover on the detail view's header
detail_share: gtk::MenuButton,
detail_copy: gtk::Button,
detail_qr: gtk::Image,
// settings page
identity_rows: Vec<adw::ActionRow>,
secret_row: adw::ActionRow,
secret_reveal: gtk::Button,
host_row: adw::ActionRow,
public_key_row: adw::ActionRow,
rotations_value: gtk::Label,
keep_mail_check: gtk::CheckButton,
strict_check: gtk::CheckButton,
timeout_row: adw::SpinRow,
// onboarding
onboard_seed_label: gtk::Label,
onboard_seed_entry: adw::EntryRow,
// signup
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,
status_label: gtk::Label,
cancel_button: gtk::Button,
/// The dialog currently on screen, so `render` presents it once on the
/// None -> Some edge instead of stacking a copy on every message.
dialog: Option<adw::AlertDialog>,
}
impl CarrierWidgets {
fn new(root: &gtk::Window, sender: &ComponentSender<Carrier>) -> Self {
// One helper per widget parameter type: a single closure would
// have its signature unified across every call site.
let input = |msg: Message| {
let sender = sender.clone();
move |_: &gtk::Button| sender.input(msg.clone())
};
// An Adwaita headerbar carrying the app name over the account
// address, so the window reads like every other GNOME app and the
// identity in use is never more than a glance away.
let window_title = adw::WindowTitle::new("carrier", "");
let header = adw::HeaderBar::new();
header.set_title_widget(Some(&window_title));
root.set_titlebar(Some(&header));
// The sidebar column, left of the main box, split on the same
// line as the main box's list/article split. The top panel stacks
// the actions and the destinations; the bottom panel leads with
// our own identity, carded apart from the contact list.
let sidebar = gtk::Paned::new(Orientation::Vertical);
// Position 1 is below any child's minimum, so both columns open
// with the top pane at its smallest height.
sidebar.set_position(1);
// Refuse to shrink the top panel past its own minimum: otherwise
// dragging the divider up clips it from the top and the actions
// are the first thing to go. The filler below the folders has no
// minimum of its own, so that space collapses first instead.
sidebar.set_shrink_start_child(false);
let sidebar_top = gtk::Box::new(Orientation::Vertical, 12);
sidebar_top.set_margin_top(6);
sidebar_top.set_margin_start(6);
sidebar_top.set_margin_end(6);
let sidebar_bottom = gtk::Box::new(Orientation::Vertical, 6);
sidebar_bottom.set_margin_start(6);
sidebar_bottom.set_margin_end(6);
sidebar_bottom.set_margin_bottom(6);
sidebar.set_start_child(Some(&sidebar_top));
sidebar.set_end_child(Some(&sidebar_bottom));
// Fetch and Write lead the top panel, each an equal half. Only
// Fetch is suggested: two suggested actions side by side would
// leave neither of them reading as the primary one.
let sidebar_actions = gtk::Box::new(Orientation::Horizontal, 6);
sidebar_actions.set_homogeneous(true);
let fetch_button = icon_label_button(
"mail-send-receive-symbolic",
&i18n::t("mail-fetch-button"),
"accent",
);
let write_button = icon_label_button(
"mail-message-new-symbolic",
&i18n::t("mail-write-button"),
"",
);
sidebar_actions.append(&fetch_button);
sidebar_actions.append(&write_button);
sidebar_top.append(&sidebar_actions);
// The destinations follow, as the sidebar list every GNOME app
// uses: icon, name, and the unread count on the right.
let folders = gtk::ListBox::new();
folders.add_css_class("navigation-sidebar");
folders.set_selection_mode(gtk::SelectionMode::Single);
let folder_rows = [
nav_row("folder-download-symbolic", &i18n::t("folder-inbox")),
nav_row("contact-new-symbolic", &i18n::t("folder-requests")),
nav_row("mail-send-symbolic", &i18n::t("folder-sent")),
nav_row("document-edit-symbolic", &i18n::t("folder-drafts")),
];
let mut folder_counts = Vec::new();
let mut drafts_nav = None;
for (index, (row, count)) in folder_rows.into_iter().enumerate() {
// The drafts folder is carrier's own: it stays out of the
// sidebar until a compose has been set aside into it.
if index == 3 {
row.set_visible(false);
drafts_nav = Some(row.clone());
}
folders.append(&row);
folder_counts.push(count);
}
let drafts_nav = drafts_nav.expect("four folder rows");
sidebar_top.append(&folders);
// Settings closes the top panel, pinned to its bottom. It is its
// own list so that it highlights like a destination without
// joining the folders' selection, the way GNOME Settings splits
// its own sidebar.
let filler = gtk::Label::new(None);
filler.set_vexpand(true);
sidebar_top.append(&filler);
let settings_nav = gtk::ListBox::new();
settings_nav.add_css_class("navigation-sidebar");
settings_nav.set_selection_mode(gtk::SelectionMode::Single);
let (settings_row, _) = nav_row(
"preferences-system-symbolic",
&i18n::t("settings-nav-label"),
);
settings_nav.append(&settings_row);
sidebar_top.append(&settings_nav);
// Our own identity heads the bottom panel, carded so it reads
// apart from the contacts below. Tapping it opens the detail
// view the contacts get, with our own key lines in it.
let own_text = gtk::Box::new(Orientation::Vertical, 0);
own_text.set_hexpand(true);
own_text.set_margin_top(8);
own_text.set_margin_bottom(8);
own_text.set_margin_start(12);
let own_caption = label(&i18n::t("identity-you-caption"), "caption");
let sidebar_own = label("", "mono heading");
// Not selectable: a selectable label eats clicks on the text with
// its selection gesture, and the address is already copyable from
// the detail view's URI line.
sidebar_own.set_ellipsize(gtk::pango::EllipsizeMode::End);
own_text.append(&own_caption);
own_text.append(&sidebar_own);
let own_row = gtk::Button::new();
own_row.add_css_class("card");
own_row.set_halign(Align::Fill);
own_row.set_margin_top(6);
own_row.set_child(Some(&own_text));
sidebar_bottom.append(&own_row);
// The list's header carries its one action: importing a
// smol:// address, in a popover off a plain add button.
let contacts_head = gtk::Box::new(Orientation::Horizontal, 6);
let contacts_caption = label(&i18n::t("contacts-caption"), "caption");
contacts_caption.set_hexpand(true);
contacts_caption.set_valign(Align::Center);
contacts_caption.set_margin_start(12);
let contact_import = adw::EntryRow::builder()
.title(i18n::t("contacts-import-placeholder"))
.build();
let import_button = icon_label_button(
"document-open-symbolic",
&i18n::t("contacts-import-button"),
"accent",
);
import_button.set_valign(Align::Center);
contact_import.add_suffix(&import_button);
let import_list = boxed_list();
import_list.set_width_request(320);
import_list.set_margin_top(6);
import_list.set_margin_bottom(6);
import_list.set_margin_start(6);
import_list.set_margin_end(6);
import_list.append(&contact_import);
let import_popover = gtk::Popover::new();
import_popover.set_child(Some(&import_list));
let add_contact = gtk::MenuButton::new();
add_contact.set_icon_name("list-add-symbolic");
add_contact.add_css_class("flat");
add_contact.add_css_class("circular");
add_contact.set_tooltip_text(Some(&i18n::t("contacts-import-placeholder")));
add_contact.set_popover(Some(&import_popover));
contacts_head.append(&contacts_caption);
contacts_head.append(&add_contact);
sidebar_bottom.append(&contacts_head);
let contacts_scroll = gtk::ScrolledWindow::new();
contacts_scroll.set_vexpand(true);
let contacts_list = gtk::ListBox::new();
contacts_list.add_css_class("navigation-sidebar");
contacts_list.set_selection_mode(gtk::SelectionMode::Single);
contacts_scroll.set_child(Some(&contacts_list));
sidebar_bottom.append(&contacts_scroll);
// --- pages ------------------------------------------------------
let stack = gtk::Stack::new();
stack.set_vexpand(true);
// mail: the split — list above, article below
let paned = gtk::Paned::new(Orientation::Vertical);
paned.set_position(1);
let list_scroll = gtk::ScrolledWindow::new();
let mail_list = gtk::ListBox::new();
mail_list.set_selection_mode(gtk::SelectionMode::Single);
list_scroll.set_child(Some(&mail_list));
// An empty folder gets a status page rather than a stray line of
// dim text, so the pane says what to do about it.
let list_empty = adw::StatusPage::new();
let list_empty_fetch = icon_label_button(
"mail-send-receive-symbolic",
&i18n::t("mail-fetch-button"),
"pill",
);
list_empty_fetch.set_halign(Align::Center);
let list_empty_hint = label(&i18n::t("mail-empty-hint"), "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);
list_stack.add_named(&list_scroll, Some("list"));
list_stack.add_named(&list_empty, Some("empty"));
paned.set_start_child(Some(&list_stack));
paned.set_resize_start_child(true);
paned.set_shrink_start_child(false);
let article = gtk::Box::new(Orientation::Vertical, 0);
let article_head = gtk::Box::new(Orientation::Vertical, 10);
article_head.set_margin_top(12);
article_head.set_margin_bottom(12);
article_head.set_margin_start(12);
article_head.set_margin_end(12);
let article_subject = label("", "subtitle");
article_subject.set_wrap(true);
article_subject.set_hexpand(true);
// The detail view's header bar: the address on the left, the QR
// share on the right. The popover holds the code and nothing
// else — the URI sits in the body below.
let detail_qr = gtk::Image::new();
detail_qr.set_margin_top(12);
detail_qr.set_margin_bottom(12);
detail_qr.set_margin_start(12);
detail_qr.set_margin_end(12);
let detail_popover = gtk::Popover::new();
detail_popover.set_child(Some(&detail_qr));
let detail_share = gtk::MenuButton::new();
detail_share.set_icon_name("send-to-symbolic");
detail_share.add_css_class("flat");
detail_share.add_css_class("circular");
detail_share.set_tooltip_text(Some(&i18n::t("detail-qr-tooltip")));
detail_share.set_popover(Some(&detail_popover));
detail_share.set_valign(Align::Start);
detail_share.set_visible(false);
// Copies the address the header shows, so it serves the contact
// and the own-identity detail alike.
let detail_copy = icon_button("edit-copy-symbolic", &i18n::t("detail-copy-tooltip"));
detail_copy.set_valign(Align::Start);
detail_copy.set_visible(false);
let copy_source = article_subject.clone();
detail_copy.connect_clicked(move |_| {
let address = copy_source.text().to_string();
if let Some(display) = gtk::gdk::Display::default() {
display.clipboard().set_text(&address);
}
});
let subject_row = gtk::Box::new(Orientation::Horizontal, 6);
subject_row.append(&article_subject);
subject_row.append(&detail_copy);
subject_row.append(&detail_share);
let article_meta = label("", "caption");
article_meta.set_wrap(true);
article_head.append(&subject_row);
article_head.append(&article_meta);
// The sender's own frontmatter fields, when a mail carries any:
// envelope data, so it reads like the contact detail's key block
// rather than like body prose.
let article_headers = label("", "caption mono");
article_headers.set_wrap(true);
article_headers.set_wrap_mode(gtk::pango::WrapMode::WordChar);
article_headers.set_selectable(true);
article_headers.set_visible(false);
article_head.append(&article_headers);
article.append(&article_head);
article.append(&gtk::Separator::new(Orientation::Horizontal));
let article_view = gtk::TextView::new();
article_view.set_wrap_mode(WrapMode::WordChar);
article_view.set_editable(false);
article_view.set_cursor_visible(false);
article_view.set_top_margin(12);
article_view.set_bottom_margin(12);
article_view.set_left_margin(12);
article_view.set_right_margin(12);
let article_body = article_view.buffer();
let article_scroll = gtk::ScrolledWindow::new();
article_scroll.set_vexpand(true);
article_scroll.set_child(Some(&article_view));
article.append(&article_scroll);
// The action bar carries the stylesheet's `toolbar` class, which
// supplies its own padding and ground, so no separator is needed
// to tell it apart from the body above.
let actions = gtk::Box::new(Orientation::Horizontal, 6);
actions.add_css_class("toolbar");
article.append(&actions);
// No icon: the pane sits directly under the list's own empty page,
// and two envelopes stacked up the window say nothing twice.
let article_empty = adw::StatusPage::builder()
.title(i18n::t("mail-empty-title"))
.description(i18n::t("mail-empty-description"))
.build();
let article_stack = gtk::Stack::new();
article_stack.set_hhomogeneous(false);
article_stack.set_vhomogeneous(false);
article_stack.add_named(&article, Some("detail"));
article_stack.add_named(&article_empty, Some("empty"));
paned.set_end_child(Some(&article_stack));
// The sidebar's divider follows this one and vice versa, so both
// columns always split on the same line.
let sidebar_sync = sidebar.clone();
paned.connect_position_notify(move |p| {
if sidebar_sync.position() != p.position() {
sidebar_sync.set_position(p.position());
}
});
let main_sync = paned.clone();
sidebar.connect_position_notify(move |p| {
if main_sync.position() != p.position() {
main_sync.set_position(p.position());
}
});
stack.add_named(&paned, Some("mail"));
// compose
let compose = gtk::Box::new(Orientation::Vertical, 12);
compose.set_margin_top(12);
compose.set_margin_bottom(12);
compose.set_margin_start(12);
compose.set_margin_end(12);
let compose_fields = boxed_list();
let compose_to = adw::EntryRow::builder()
.title(i18n::t("compose-to-label"))
.build();
let compose_subject = adw::EntryRow::builder()
.title(i18n::t("compose-subject-label"))
.build();
compose_fields.append(&compose_to);
compose_fields.append(&compose_subject);
compose.append(&compose_fields);
// Shown when a send was refused because the recipient's key would
// have come from a server with no pin: the refusal and its fix in
// one place, since nowhere else in the app pins a correspondent's
// server.
let compose_pin_title = label("", "heading");
compose_pin_title.set_wrap(true);
let compose_pin_body = label("", "dim");
compose_pin_body.set_wrap(true);
let compose_pin_key = label("", "mono");
compose_pin_key.set_selectable(true);
compose_pin_key.set_wrap(true);
compose_pin_key.set_wrap_mode(gtk::pango::WrapMode::Char);
let compose_pin_fingerprint = label("", "mono dim");
compose_pin_fingerprint.set_selectable(true);
compose_pin_fingerprint.set_wrap(true);
let compose_pin_hint = label(&i18n::t("trust-fingerprint-hint"), "dim caption");
compose_pin_hint.set_wrap(true);
let compose_pin_button = button("", "accent");
compose_pin_button.set_halign(Align::Start);
compose_pin_button.set_margin_top(6);
let compose_pin_card = gtk::Box::new(Orientation::Vertical, 6);
compose_pin_card.add_css_class("card");
compose_pin_card.set_visible(false);
for child in [&compose_pin_title, &compose_pin_body] {
child.set_margin_start(12);
child.set_margin_end(12);
}
compose_pin_title.set_margin_top(12);
for child in [
&compose_pin_key,
&compose_pin_fingerprint,
&compose_pin_hint,
] {
child.set_margin_start(12);
child.set_margin_end(12);
}
compose_pin_button.set_margin_start(12);
compose_pin_button.set_margin_bottom(12);
compose_pin_card.append(&compose_pin_title);
compose_pin_card.append(&compose_pin_body);
compose_pin_card.append(&compose_pin_key);
compose_pin_card.append(&compose_pin_fingerprint);
compose_pin_card.append(&compose_pin_hint);
compose_pin_card.append(&compose_pin_button);
compose.append(&compose_pin_card);
// Frontmatter rows: one `Key: value` entry each, plus a flat
// add button below. Rows are rebuilt in render on structural
// changes only, so the entries never lose focus while typing.
let compose_headers = boxed_list();
compose.append(&compose_headers);
let add_header = button(&i18n::t("compose-add-header-button"), "quiet");
add_header.set_halign(Align::Start);
compose.append(&add_header);
let compose_view = gtk::TextView::new();
compose_view.set_wrap_mode(WrapMode::WordChar);
compose_view.set_top_margin(8);
compose_view.set_bottom_margin(8);
compose_view.set_left_margin(8);
compose_view.set_right_margin(8);
let compose_body = compose_view.buffer();
let compose_scroll = gtk::ScrolledWindow::new();
compose_scroll.add_css_class("card");
compose_scroll.set_vexpand(true);
compose_scroll.set_child(Some(&compose_view));
compose.append(&compose_scroll);
let compose_send_button = button("", "accent");
let compose_send = adw::ButtonContent::builder()
.icon_name("mail-send-symbolic")
.label(i18n::t("compose-send-button"))
.build();
compose_send_button.set_child(Some(&compose_send));
let compose_cancel = button(&i18n::t("action-cancel"), "quiet");
let compose_actions = gtk::Box::new(Orientation::Horizontal, 6);
compose_actions.set_halign(Align::End);
compose_actions.append(&compose_cancel);
compose_actions.append(&compose_send_button);
compose.append(&compose_actions);
stack.add_named(&compose, Some("compose"));
// settings: a tabbed preferences page — Identity, Server, Backup,
// Advanced — switched by an adw::ViewSwitcher over an
// adw::ViewStack, each tab its own PreferencesPage.
let settings_stack = adw::ViewStack::new();
// Read-only values use Adwaita's "property" row: the field name
// sits small above the value it labels, which leaves the value
// itself room to wrap instead of being ellipsized into a suffix.
// Markup is off so the copy button can hand back exactly what is
// on screen.
// `id` is a message id (e.g. "settings-field-host"), not literal
// text, so both the title and its "Copy the …" tooltip come from
// the catalogue rather than one being built from the other.
let property_row = |id: &str| -> adw::ActionRow {
let title = i18n::t(id);
let row = adw::ActionRow::builder()
.title(&title)
.use_markup(false)
.subtitle_lines(0)
.subtitle_selectable(true)
.build();
row.add_css_class("property");
row.add_css_class("monospace");
let copy = icon_button(
"edit-copy-symbolic",
&i18n::t1("property-row-copy-tooltip", "field", title.to_lowercase()),
);
let copy_source = row.clone();
copy.connect_clicked(move |_| {
if let Some(display) = gtk::gdk::Display::default() {
display
.clipboard()
.set_text(&copy_source.subtitle().unwrap_or_default());
}
});
row.add_suffix(&copy);
row
};
// -- Identity tab --------------------------------------------------
// The switcher already names each page, so the first group on a
// page carries no title of its own.
let identity_page = adw::PreferencesPage::new();
let identity_group = adw::PreferencesGroup::new();
let mut identity_rows = Vec::new();
for field in [
"settings-field-address",
"settings-field-key",
"settings-field-fingerprint",
"settings-field-uri",
] {
let row = property_row(field);
identity_group.add(&row);
identity_rows.push(row);
}
identity_page.add(&identity_group);
// The group header names this one, so the row carries only the
// secret — masked until the reveal button is pressed.
let secret_group = adw::PreferencesGroup::builder()
.title(i18n::t("settings-recovery-secret-title"))
.build();
let secret_row = adw::ActionRow::builder()
.use_markup(false)
.title_lines(0)
.title_selectable(true)
.build();
secret_row.add_css_class("monospace");
let secret_reveal = icon_button(
"view-reveal-symbolic",
&i18n::t("settings-secret-reveal-tooltip"),
);
let secret_copy = icon_button(
"edit-copy-symbolic",
&i18n::t("settings-secret-copy-tooltip"),
);
secret_row.add_suffix(&secret_reveal);
secret_row.add_suffix(&secret_copy);
secret_group.add(&secret_row);
identity_page.add(&secret_group);
settings_stack.add_titled_with_icon(
&identity_page,
Some("identity"),
&i18n::t("settings-tab-identity"),
"avatar-default-symbolic",
);
// -- Server tab ----------------------------------------------------
let server_page = adw::PreferencesPage::new();
let server_group = adw::PreferencesGroup::builder()
.title(i18n::t("server-connection-title"))
.build();
let host_row = property_row("settings-field-host");
let public_key_row = property_row("settings-field-public-key");
server_group.add(&host_row);
server_group.add(&public_key_row);
let timeout_row = adw::SpinRow::with_range(1.0, 600.0, 1.0);
timeout_row.set_title(&i18n::t("server-timeout-title"));
timeout_row.set_subtitle(&i18n::t("server-timeout-subtitle"));
server_group.add(&timeout_row);
server_page.add(&server_group);
let network_group = adw::PreferencesGroup::builder()
.title(i18n::t("server-fetching-title"))
.build();
let keep_mail_row = adw::ActionRow::builder()
.title(i18n::t("server-leave-mail-title"))
.subtitle(i18n::t("server-leave-mail-subtitle"))
.subtitle_lines(0)
.activatable(true)
.build();
let keep_mail_check = gtk::CheckButton::builder().valign(Align::Center).build();
keep_mail_row.add_suffix(&keep_mail_check);
keep_mail_row.set_activatable_widget(Some(&keep_mail_check));
network_group.add(&keep_mail_row);
server_page.add(&network_group);
settings_stack.add_titled_with_icon(
&server_page,
Some("server"),
&i18n::t("settings-tab-server"),
"network-server-symbolic",
);
// -- Backup tab ----------------------------------------------------
let backup_page = adw::PreferencesPage::new();
let backup_group = adw::PreferencesGroup::builder()
.description(i18n::t("backup-page-description"))
.build();
let export_row = adw::ActionRow::builder()
.title(i18n::t("backup-export-title"))
.subtitle(i18n::t("backup-export-subtitle"))
.activatable(true)
.build();
export_row.add_prefix(&gtk::Image::from_icon_name("document-save-symbolic"));
export_row.add_suffix(&gtk::Image::from_icon_name("go-next-symbolic"));
let import_row = adw::ActionRow::builder()
.title(i18n::t("backup-import-title"))
.subtitle(i18n::t("backup-import-subtitle"))
.activatable(true)
.build();
import_row.add_prefix(&gtk::Image::from_icon_name("document-open-symbolic"));
import_row.add_suffix(&gtk::Image::from_icon_name("go-next-symbolic"));
backup_group.add(&export_row);
backup_group.add(&import_row);
backup_page.add(&backup_group);
settings_stack.add_titled_with_icon(
&backup_page,
Some("backup"),
&i18n::t("settings-tab-backup"),
"document-save-symbolic",
);
// -- Advanced tab --------------------------------------------------
let advanced_page = adw::PreferencesPage::new();
let rotations_group = adw::PreferencesGroup::builder()
.title(i18n::t("advanced-rotations-title"))
.description(i18n::t("advanced-rotations-description"))
.build();
// Both actions sit in their group's body, centred under the
// explanation: a long description wraps under the group title and
// would run straight into a header-suffix button.
let rotations_row = adw::ActionRow::builder()
.title(i18n::t("advanced-rotations-completed"))
.build();
let rotations_value = label("", "dim numeric");
rotations_value.set_valign(Align::Center);
rotations_row.add_suffix(&rotations_value);
rotations_group.add(&rotations_row);
let rotate_button = icon_label_button(
"view-refresh-symbolic",
&i18n::t("advanced-rotate-button"),
"",
);
rotate_button.set_halign(Align::Center);
rotate_button.set_margin_top(12);
rotations_group.add(&rotate_button);
advanced_page.add(&rotations_group);
let trust_group = adw::PreferencesGroup::builder()
.title(i18n::t("advanced-first-contact-title"))
.description(i18n::t("advanced-first-contact-description"))
.build();
let strict_row = adw::ActionRow::builder()
.title(i18n::t("advanced-strict-pinning-title"))
.subtitle(i18n::t("advanced-strict-pinning-subtitle"))
.subtitle_lines(0)
.activatable(true)
.build();
let strict_check = gtk::CheckButton::builder().valign(Align::Center).build();
strict_row.add_suffix(&strict_check);
strict_row.set_activatable_widget(Some(&strict_check));
trust_group.add(&strict_row);
advanced_page.add(&trust_group);
// The log-out button is wide and destructive, so it sits in the
// group's body under the warning rather than squeezed beside the
// header where its label would crowd the title.
let logout_group = adw::PreferencesGroup::builder()
.title(i18n::t("advanced-logout-title"))
.description(i18n::t("advanced-logout-description"))
.build();
let reset_button = icon_label_button(
"system-log-out-symbolic",
&i18n::t("advanced-logout-button"),
"danger",
);
reset_button.set_halign(Align::Center);
reset_button.set_margin_top(12);
logout_group.add(&reset_button);
advanced_page.add(&logout_group);
settings_stack.add_titled_with_icon(
&advanced_page,
Some("advanced"),
&i18n::t("settings-tab-advanced"),
"emblem-system-symbolic",
);
// The switcher is a centred bar of its own above the pages, since
// the window's headerbar already carries the app name and address.
let settings_switcher = adw::ViewSwitcher::builder()
.stack(&settings_stack)
.policy(adw::ViewSwitcherPolicy::Wide)
.halign(Align::Center)
.build();
let settings_switcher_bar = gtk::Box::new(Orientation::Vertical, 0);
settings_switcher_bar.add_css_class("toolbar");
settings_switcher_bar.append(&settings_switcher);
let settings = gtk::Box::new(Orientation::Vertical, 0);
settings.append(&settings_switcher_bar);
settings.append(&settings_stack);
stack.add_named(&settings, Some("settings"));
// onboarding: three static modes, each a centred column
let center = |child: &gtk::Box| {
let outer = gtk::Box::new(Orientation::Vertical, 0);
outer.set_hexpand(true);
outer.set_vexpand(true);
child.set_halign(Align::Center);
child.set_valign(Align::Center);
child.set_width_request(520);
outer.append(child);
outer
};
let onboard_start = gtk::Box::new(Orientation::Vertical, 12);
let title = label("carrier", "title");
title.set_halign(Align::Center);
onboard_start.append(&title);
let tagline = label(&i18n::t("onboard-tagline"), "dim");
tagline.set_halign(Align::Center);
tagline.set_margin_bottom(12);
onboard_start.append(&tagline);
let create_button = button(&i18n::t("onboard-create-button"), "pill");
let restore_button = button(&i18n::t("onboard-restore-button"), "pill-quiet");
onboard_start.append(&create_button);
onboard_start.append(&restore_button);
stack.add_named(&center(&onboard_start), Some("onboard"));
let onboard_created = gtk::Box::new(Orientation::Vertical, 12);
let created_title = label(&i18n::t("onboard-created-title"), "subtitle");
onboard_created.append(&created_title);
onboard_created.append(&label(&i18n::t("onboard-created-description"), "dim"));
let onboard_seed_label = label("", "mono subtitle");
onboard_seed_label.set_selectable(true);
onboard_seed_label.set_wrap(true);
onboard_seed_label.set_wrap_mode(gtk::pango::WrapMode::Char);
onboard_seed_label.set_hexpand(true);
onboard_seed_label.set_margin_top(12);
onboard_seed_label.set_margin_bottom(12);
onboard_seed_label.set_margin_start(12);
onboard_seed_label.set_margin_end(12);
let seed_card = gtk::Box::new(Orientation::Horizontal, 0);
seed_card.add_css_class("card");
seed_card.append(&onboard_seed_label);
onboard_created.append(&seed_card);
let saved_button = button(&i18n::t("onboard-saved-button"), "pill");
let created_back = button(&i18n::t("action-back"), "pill-quiet");
onboard_created.append(&saved_button);
onboard_created.append(&created_back);
stack.add_named(&center(&onboard_created), Some("onboard-created"));
let onboard_restore = gtk::Box::new(Orientation::Vertical, 12);
onboard_restore.append(&label(&i18n::t("onboard-restore-title"), "subtitle"));
let restore_list = boxed_list();
let onboard_seed_entry = adw::EntryRow::builder()
.title(i18n::t("onboard-seed-entry-title"))
.build();
restore_list.append(&onboard_seed_entry);
onboard_restore.append(&restore_list);
let restore_go = button(&i18n::t("onboard-restore-go-button"), "pill");
let restore_back = button(&i18n::t("action-back"), "pill-quiet");
onboard_restore.append(&restore_go);
onboard_restore.append(&restore_back);
stack.add_named(&center(&onboard_restore), Some("onboard-restore"));
// signup: step 1 — the server this address lives on
let signup_server = gtk::Box::new(Orientation::Vertical, 12);
signup_server.append(&label(&i18n::t("signup-server-title"), "subtitle"));
let signup_server_group = adw::PreferencesGroup::new();
let signup_host = adw::EntryRow::builder()
.title(i18n::t("settings-field-host"))
.build();
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(&i18n::t("trust-fingerprint-hint"), "dim");
signup_key_hint.set_wrap(true);
signup_key_hint.set_visible(false);
signup_server.append(&signup_key_hint);
let signup_key_button = button(&i18n::t("signup-fetch-key-button"), "pill");
let signup_start_over = button(&i18n::t("signup-start-over-button"), "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(&i18n::t("signup-account-title"), "subtitle"));
let signup_mode_new = gtk::ToggleButton::builder()
.label(i18n::t("signup-mode-new-label"))
.active(true)
.hexpand(true)
.build();
let signup_mode_existing = gtk::ToggleButton::builder()
.label(i18n::t("signup-mode-existing-label"))
.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(i18n::t("signup-user-label"))
.build();
let signup_invite = adw::EntryRow::builder()
.title(i18n::t("signup-invite-label"))
.build();
signup_account_group.add(&signup_user);
signup_account_group.add(&signup_invite);
signup_account.append(&signup_account_group);
let register_button = button(&i18n::t("signup-register-button"), "pill");
let recall_button = button(&i18n::t("signup-recall-button"), "pill");
signup_account.append(&register_button);
signup_account.append(&recall_button);
let signup_step_back = button(&i18n::t("action-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
// operation while something is busy, the last finished note
// afterwards — no transient chrome, nothing moving.
let status_bar = gtk::Box::new(Orientation::Horizontal, 10);
status_bar.add_css_class("toolbar");
let spinner = gtk::Spinner::new();
spinner.set_visible(false);
let status_label = label("", "caption");
status_label.set_hexpand(true);
let cancel_button = button(&i18n::t("action-cancel"), "");
cancel_button.set_visible(false);
status_bar.append(&spinner);
status_bar.append(&status_label);
status_bar.append(&cancel_button);
let toolbar = adw::ToolbarView::new();
toolbar.set_content(Some(&stack));
toolbar.add_bottom_bar(&status_bar);
// The columns split is a paned too: the divider between sidebar
// and main box drags, extra space goes to the main box.
let columns = gtk::Paned::new(Orientation::Horizontal);
columns.set_start_child(Some(&sidebar));
columns.set_end_child(Some(&toolbar));
columns.set_resize_start_child(false);
columns.set_position(1);
// The sidebar opens as wide as the top-left pane is tall — a
// value only the first layout knows, so the divider moves once
// measurement lands.
let list_default = list_stack.clone();
let columns_default = columns.clone();
gtk::glib::timeout_add_local(Duration::from_millis(50), move || {
let height = list_default.height();
if height == 0 {
gtk::glib::ControlFlow::Continue
} else {
columns_default.set_position(height);
gtk::glib::ControlFlow::Break
}
});
// A window resize (fullscreen, maximize, tiling) makes the paned
// re-derive its dividers, and the position sync between the two
// columns then feeds those transient values to each other. Every
// height change re-asserts the default layout: the top panes back
// at their smallest, the sidebar back at the top pane's height.
let watch_root = root.clone();
let watch_paned = paned.clone();
let watch_sidebar = sidebar.clone();
let watch_list = list_stack.clone();
let watch_columns = columns.clone();
let last_height = std::cell::Cell::new(0);
gtk::glib::timeout_add_local(Duration::from_millis(250), move || {
let height = watch_root.height();
let was = last_height.replace(height);
if was != 0 && was != height {
watch_paned.set_position(1);
watch_sidebar.set_position(1);
let relist = watch_list.clone();
let recolumns = watch_columns.clone();
gtk::glib::timeout_add_local(Duration::from_millis(200), move || {
let measured = relist.height();
if measured > 0 {
recolumns.set_position(measured);
}
gtk::glib::ControlFlow::Break
});
}
gtk::glib::ControlFlow::Continue
});
root.set_child(Some(&columns));
// --- signal wiring ---------------------------------------------
// Both sidebar lists route through row-activated, which fires only
// on a click or Enter: the programmatic `select_row` in `render`
// would otherwise feed its own selection straight back as input.
let folder_sender = sender.clone();
folders.connect_row_activated(move |_, row| {
let folder = match row.index() {
0 => Folder::Inbox,
1 => Folder::Requests,
2 => Folder::Sent,
3 => Folder::Drafts,
_ => return,
};
folder_sender.input(Message::GotoMail(folder));
});
let settings_sender = sender.clone();
settings_nav.connect_row_activated(move |_, _| {
settings_sender.input(Message::Goto(Page::Settings));
});
fetch_button.connect_clicked(input(Message::FetchPressed));
list_empty_fetch.connect_clicked(input(Message::FetchPressed));
write_button.connect_clicked(input(Message::ComposePressed));
cancel_button.connect_clicked(input(Message::FetchCancelled));
// The mail and contact lists carry their target in the row's
// widget name, which row-activated reads back.
let mail_sender = sender.clone();
mail_list.connect_row_activated(move |_, row| {
if let Some((folder, id)) = row.widget_name().split_once('|') {
let folder = match folder {
"0" => Folder::Inbox,
"1" => Folder::Requests,
"2" => Folder::Sent,
"3" => Folder::Drafts,
_ => return,
};
mail_sender.input(Message::MessageSelected(folder, id.to_string()));
}
});
let contact_sender = sender.clone();
contacts_list.connect_row_activated(move |_, row| {
contact_sender.input(Message::ContactSelected(row.widget_name().to_string()));
});
let meta_link = sender.clone();
article_meta.connect_activate_link(move |_, uri| {
meta_link.input(Message::ContactSelected(uri.to_string()));
gtk::glib::Propagation::Stop
});
add_header.connect_clicked(input(Message::ComposeHeaderAdded));
compose_send_button.connect_clicked(input(Message::SendPressed));
compose_pin_button.connect_clicked(input(Message::PinServerAction));
compose_cancel.connect_clicked(input(Message::ComposeClosed));
let to_sender = sender.clone();
compose_to.connect_changed(move |entry| {
to_sender.input(Message::ComposeToChanged(entry.text().to_string()))
});
let subject_sender = sender.clone();
compose_subject.connect_changed(move |entry| {
subject_sender.input(Message::ComposeSubjectChanged(entry.text().to_string()))
});
let body_sender = sender.clone();
compose_body.connect_changed(move |buffer| {
body_sender.input(Message::ComposeBodyChanged(buffer_text(buffer)))
});
own_row.connect_clicked(input(Message::OwnSelected));
import_button.connect_clicked(input(Message::ContactImportPressed));
let import_sender = sender.clone();
contact_import.connect_changed(move |entry| {
import_sender.input(Message::ContactImportChanged(entry.text().to_string()))
});
let import_entry_sender = sender.clone();
contact_import.connect_entry_activated(move |_| {
import_entry_sender.input(Message::ContactImportPressed)
});
rotate_button.connect_clicked(input(Message::RotatePressed));
reset_button.connect_clicked(input(Message::ResetPressed));
secret_reveal.connect_clicked(input(Message::SecretRevealToggled));
secret_copy.connect_clicked(input(Message::CopySecret));
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_check.connect_toggled(move |check| {
keep_mail_sender.input(Message::KeepMailChanged(check.is_active()))
});
let strict_sender = sender.clone();
strict_check.connect_toggled(move |check| {
strict_sender.input(Message::StrictPinningChanged(check.is_active()))
});
// Export/Import go straight to a native file dialog rather than a
// Message — the chosen path only matters for this one call, and
// round-tripping it through the model would gain nothing.
let export_sender = sender.clone();
let export_window = root.clone();
export_row.connect_activated(move |_| {
let dialog = gtk::FileDialog::builder()
.title(i18n::t("backup-export-dialog-title"))
.initial_name("carrier-backup.gsmol")
.build();
let sender = export_sender.clone();
dialog.save(
Some(&export_window),
gtk::gio::Cancellable::NONE,
move |result| {
if let Ok(file) = result {
if let Some(path) = file.path() {
sender.input(Message::ExportBackupTo(path));
}
}
},
);
});
let backup_import_sender = sender.clone();
let backup_import_window = root.clone();
import_row.connect_activated(move |_| {
let dialog = gtk::FileDialog::builder()
.title(i18n::t("backup-import-dialog-title"))
.build();
let sender = backup_import_sender.clone();
dialog.open(
Some(&backup_import_window),
gtk::gio::Cancellable::NONE,
move |result| {
if let Ok(file) = result {
if let Some(path) = file.path() {
if let Ok(text) = std::fs::read_to_string(&path) {
sender.input(Message::ImportBackupFrom(text));
}
}
}
},
);
});
create_button.connect_clicked(input(Message::OnboardCreate));
restore_button.connect_clicked(input(Message::OnboardRestore));
restore_back.connect_clicked(input(Message::OnboardBack));
created_back.connect_clicked(input(Message::OnboardBack));
saved_button.connect_clicked(input(Message::OnboardSeedSaved));
let seed_sender = sender.clone();
onboard_seed_entry.connect_changed(move |entry| {
seed_sender.input(Message::OnboardSeedChanged(entry.text().to_string()))
});
let seed_go_sender = sender.clone();
onboard_seed_entry.connect_entry_activated(move |_| {
seed_go_sender.input(Message::OnboardRestoreFromSeed)
});
restore_go.connect_clicked(input(Message::OnboardRestoreFromSeed));
let user_sender = sender.clone();
signup_user.connect_changed(move |entry| {
user_sender.input(Message::SignupUserChanged(entry.text().to_string()))
});
let host_sender = sender.clone();
signup_host.connect_changed(move |entry| {
host_sender.input(Message::SignupHostChanged(entry.text().to_string()))
});
let invite_sender = sender.clone();
signup_invite.connect_changed(move |entry| {
invite_sender.input(Message::SignupInviteChanged(entry.text().to_string()))
});
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));
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(),
window_title,
folders,
folder_counts,
drafts_nav,
settings_nav,
fetch_button,
write_button,
stack,
list_stack,
mail_list,
list_empty,
list_empty_fetch,
list_empty_hint,
article_stack,
article_subject,
article_meta,
article_headers,
article_view,
article_body,
actions,
compose_to,
compose_headers,
compose_subject,
compose_body,
compose_send,
compose_pin_card,
compose_pin_title,
compose_pin_body,
compose_pin_key,
compose_pin_fingerprint,
compose_pin_hint,
compose_pin_button,
sidebar_own,
sidebar_contacts: contacts_list,
detail_share,
detail_copy,
detail_qr,
identity_rows,
secret_row,
secret_reveal,
host_row,
public_key_row,
rotations_value,
keep_mail_check,
strict_check,
timeout_row,
onboard_seed_label,
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,
dialog: None,
}
}
}
impl Component for Carrier {
type CommandOutput = OpResult;
type Input = Message;
type Output = ();
type Init = std::path::PathBuf;
type Root = gtk::Window;
type Widgets = CarrierWidgets;
fn init_root() -> Self::Root {
let window = gtk::Window::new();
window.set_title(Some("carrier"));
window.set_default_size(1080, 640);
window
}
fn init(
init: Self::Init,
root: Self::Root,
sender: ComponentSender<Self>,
) -> ComponentParts<Self> {
let (model, op) = Carrier::boot(init);
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 }
}
fn update(&mut self, message: Self::Input, sender: ComponentSender<Self>, _root: &Self::Root) {
for op in self.handle(message) {
spawn_op(self, op, &sender);
}
}
fn update_cmd(
&mut self,
message: Self::CommandOutput,
sender: ComponentSender<Self>,
_root: &Self::Root,
) {
if let Some(op) = self.handle_result(message) {
spawn_op(self, op, &sender);
}
}
fn update_view(&self, widgets: &mut Self::Widgets, sender: ComponentSender<Self>) {
render(self, widgets, &sender);
}
}
/// Runs one `PendingOp` on the blocking pool and reports back. The fetch
/// also gets a ticker: a thread that re-lists the store every 250 ms until
/// the model raises the cancel flag, giving the list live progress.
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 } => sender.spawn_command(move |out| {
let res = ops::register(&store, &master, &addr, invite.as_deref(), timeout);
let _ = out.send(OpResult::Generic(
res.map(|_| i18n::t("status-registered-note")),
));
}),
PendingOp::Restore { addr } => sender.spawn_command(move |out| {
let res = ops::restore(&store, &master, &addr, timeout)
.map(|index| i18n::t1("status-restored-note", "index", index as i64));
let _ = out.send(OpResult::Generic(res));
}),
PendingOp::LearnKey { addr } => sender.spawn_command(move |out| {
let offered = ops::learn_key(&store, &addr, timeout)
.map(|key| key.map(|key| (addr.clone(), key)));
let _ = out.send(OpResult::PinOffered(offered));
}),
PendingOp::Pin { addr, key } => sender.spawn_command(move |out| {
let _ = out.send(OpResult::Pinned(ops::pin(&store, &addr, &key)));
}),
PendingOp::Fetch { cancel } => {
let ticker_cancel = cancel.clone();
let input = sender.input_sender().clone();
std::thread::spawn(move || {
while !ticker_cancel.load(Ordering::Relaxed) {
std::thread::sleep(Duration::from_millis(250));
if ticker_cancel.load(Ordering::Relaxed) {
break;
}
let _ = input.send(Message::FetchTick);
}
});
sender.spawn_command(move |out| {
let _ = out.send(OpResult::Generic(ops::fetch(
&store, &master, cancel, keep_mail, timeout,
)));
});
}
PendingOp::Send {
to,
subject,
body,
reply_to,
headers,
} => sender.spawn_command(move |out| {
let res = ops::send(
&store,
&master,
&to,
if subject.is_empty() {
None
} else {
Some(subject.as_str())
},
&body,
reply_to.as_deref(),
&headers,
timeout,
);
let _ = out.send(OpResult::Generic(res));
}),
PendingOp::Delete {
id,
folder,
everywhere,
} => sender.spawn_command(move |out| {
let res = ops::delete(&store, &master, &id, folder, everywhere, timeout)
.map(|_| i18n::t("status-deleted-note"));
let _ = out.send(OpResult::Generic(res));
}),
PendingOp::Accept { address, identity } => sender.spawn_command(move |out| {
let res = ops::accept_contact(&store, &master, &address, &identity, timeout)
.map(|_| i18n::t1("status-accepted-note", "address", address.clone()));
let _ = out.send(OpResult::Generic(res));
}),
PendingOp::Block { address } => sender.spawn_command(move |out| {
let res = ops::block_contact(&store, &master, &address, timeout).map(|blocked| {
if blocked {
i18n::t1("status-blocked-note", "address", address.clone())
} else {
i18n::t1("status-not-blocked-note", "address", address.clone())
}
});
let _ = out.send(OpResult::Generic(res));
}),
PendingOp::ImportContact { uri } => sender.spawn_command(move |out| {
let res = ops::import_contact(&store, &uri)
.map(|(address, _)| i18n::t1("status-imported-note", "address", address));
let _ = out.send(OpResult::Generic(res));
}),
PendingOp::Rotate => sender.spawn_command(move |out| {
let res = ops::rotate(&store, &master, timeout).map(|(index, key)| {
let mut args = fluent_bundle::FluentArgs::new();
args.set("index", index as i64);
args.set("key_prefix", key[..12].to_string());
i18n::t_args("status-rotated-note", Some(&args))
});
let _ = out.send(OpResult::Generic(res));
}),
PendingOp::Export { path } => sender.spawn_command(move |out| {
let _ = out.send(OpResult::Generic(ops::export_backup(
&store, &master, &path,
)));
}),
PendingOp::ImportBackup { text } => sender.spawn_command(move |out| {
let _ = out.send(OpResult::Generic(ops::import_backup(
&store, &master, &text,
)));
}),
}
}
/// The coarse re-render: called after every input and result. Plain state
/// reads map onto widget updates; lists are rebuilt from scratch, which
/// a modest mailbox makes the honest option.
fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carrier>) {
let input = |msg: Message| {
let sender = sender.clone();
move |_: &gtk::Button| sender.input(msg.clone())
};
// Prefill entries once after a reply, write or draft reopen;
// afterwards typing flows entry -> model only, so the view never
// fights the user. The fresh flag also rebuilds the header rows,
// which otherwise sync on count changes alone.
let mut compose_fresh = false;
if model.compose_fresh.take() {
compose_fresh = true;
if let Some(compose) = &model.compose {
w.compose_to.set_text(&compose.to);
w.compose_subject.set_text(&compose.subject);
w.compose_body.set_text(&compose.body);
}
}
let rows = model.compose.as_ref().map_or(0, |c| c.headers.len());
let mut current = 0;
let mut child = w.compose_headers.first_child();
while let Some(item) = child {
current += 1;
child = item.next_sibling();
}
if compose_fresh || current != rows {
let added = rows > current;
clear_list(&w.compose_headers);
if let Some(compose) = &model.compose {
let mut last_entry = None;
for (index, text) in compose.headers.iter().enumerate() {
let (row, entry) = compose_header_row(index, text, sender);
last_entry = Some(entry);
w.compose_headers.append(&row);
}
// A freshly appended row is the one being typed into.
if added {
if let Some(entry) = last_entry {
entry.grab_focus();
}
}
}
}
// The pin card follows the recipient: a prompt armed for a different
// address simply isn't shown, so editing To retires it with no
// explicit clearing.
let compose_prompt = model.compose.as_ref().and_then(|compose| {
let typed = ops::parse_address(compose.to.trim()).ok()?.short();
model
.pin_prompt
.as_ref()
.filter(|prompt| prompt.addr.short() == typed)
});
w.compose_pin_card.set_visible(compose_prompt.is_some());
if let Some(prompt) = compose_prompt {
w.compose_pin_title.set_text(&i18n::t1(
"compose-pin-title",
"host",
trust_label(&prompt.addr.host, prompt.addr.port),
));
w.compose_pin_body.set_text(&i18n::t1(
"compose-pin-body",
"address",
prompt.addr.short(),
));
match prompt.offered {
Some(key) => {
w.compose_pin_key.set_text(&b32(&key));
w.compose_pin_fingerprint.set_text(&fingerprint(&key));
w.compose_pin_button
.set_label(&i18n::t("compose-pin-button-send"));
}
None => w
.compose_pin_button
.set_label(&i18n::t("compose-pin-button-fetch")),
}
let fetched = prompt.offered.is_some();
for line in [
&w.compose_pin_key,
&w.compose_pin_fingerprint,
&w.compose_pin_hint,
] {
line.set_visible(fetched);
}
}
// Header and sidebar identity.
let address = model
.snapshot
.as_ref()
.map(|s| s.who.address.clone())
.unwrap_or_default();
w.window_title.set_subtitle(&address);
w.sidebar_own.set_text(&address);
// Sidebar destinations: the active one carries the list selection, and
// the list that does not own the current page clears its own.
let unread = |folder: usize| {
model
.lists
.get(folder)
.map(|rows| rows.iter().filter(|r| r.unread).count())
.unwrap_or(0)
};
let draft_count = model.drafts.len();
w.drafts_nav.set_visible(draft_count > 0);
for (count, value) in w
.folder_counts
.iter()
.zip([unread(0), unread(1), 0, draft_count])
{
count.set_text(&value.to_string());
count.set_visible(value > 0);
}
if model.page == Page::Mail {
w.folders
.select_row(w.folders.row_at_index(model.folder as i32).as_ref());
w.settings_nav.unselect_all();
} else if model.page == Page::Settings {
w.folders.unselect_all();
w.settings_nav
.select_row(w.settings_nav.row_at_index(0).as_ref());
} else {
w.folders.unselect_all();
w.settings_nav.unselect_all();
}
let fetching = model.busy.as_deref() == Some("fetching");
w.fetch_button.set_sensitive(model.busy.is_none());
w.write_button.set_sensitive(model.busy.is_none());
// Page stack.
let page = if model.compose.is_some() {
"compose"
} else {
match model.page {
Page::Onboard => match model.onboard.mode {
OnboardMode::Start => "onboard",
OnboardMode::Created => "onboard-created",
OnboardMode::Restore => "onboard-restore",
},
Page::Signup => match model.signup.step {
SignupStep::Server => "signup-server",
SignupStep::Account => "signup-account",
},
Page::Mail => "mail",
Page::Settings => "settings",
}
};
w.stack.set_visible_child_name(page);
// Mail list.
clear_list(&w.mail_list);
let rows = model.rows();
let (icon, title_id, description_id) = match model.folder {
Folder::Inbox => (
None,
"mail-inbox-empty-title",
"mail-inbox-empty-description",
),
Folder::Requests => (
Some("contact-new-symbolic"),
"mail-requests-empty-title",
"mail-requests-empty-description",
),
Folder::Sent => (
Some("mail-send-symbolic"),
"mail-sent-empty-title",
"mail-sent-empty-description",
),
Folder::Drafts => (
Some("document-edit-symbolic"),
"mail-drafts-empty-title",
"mail-drafts-empty-description",
),
};
w.list_empty.set_icon_name(icon);
w.list_empty.set_title(&i18n::t(title_id));
w.list_empty.set_description(Some(&i18n::t(description_id)));
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;
for row in &rows {
let item = mail_row(model.folder, row);
let is_selected = model
.open
.as_ref()
.is_some_and(|(folder, msg)| *folder == model.folder && msg.id == row.id);
if is_selected {
selected_row = Some(item.clone());
}
w.mail_list.append(&item);
}
w.mail_list.select_row(
selected_row
.as_ref()
.map(|row| row.upcast_ref::<gtk::ListBoxRow>()),
);
// Article.
let own_detail = model
.selected_own
.then(|| model.snapshot.as_ref().map(|s| &s.who))
.flatten();
let contact_detail = model.selected_contact.as_ref().and_then(|address| {
model
.snapshot
.as_ref()?
.contacts
.iter()
.find(|c| &c.address == address)
});
// The detail selections show over an open message, but the message
// stays selected underneath: its list highlight holds, and picking
// it again returns to it.
match (&model.open, own_detail, contact_detail) {
(_, Some(own), _) => {
w.article_stack.set_visible_child_name("detail");
w.article_view.add_css_class("monospace");
w.article_subject.set_text(&own.address);
w.article_meta.set_text(&i18n::t1(
"own-identity-meta",
"rotations",
own.rotations as i64,
));
w.article_body
.set_text(&detail_body(&own.address, &own.key, &own.fingerprint));
set_qr(&w.detail_qr, &format!("smol://{}/{}", own.address, own.key));
w.detail_share.set_visible(true);
w.detail_copy.set_visible(true);
clear(&w.actions);
}
(_, _, Some(contact)) => {
w.article_stack.set_visible_child_name("detail");
w.article_view.add_css_class("monospace");
w.article_subject.set_text(&contact.address);
// `contact_state` collapses to accepted/blocked once a token
// exists, which says nothing about where the key came from —
// so the server's pin state is spelled out beside it.
let contact_meta_id = if contact.pinned {
"contact-meta-line-yes"
} else {
"contact-meta-line-no"
};
w.article_meta
.set_text(&i18n::t1(contact_meta_id, "state", contact_state(contact)));
let mut body = detail_body(&contact.address, &contact.key, &contact.fingerprint);
if !contact.server.is_empty() {
body.push_str(&format!(
"\n{:<14}{}",
i18n::t("detail-server-label"),
contact.server
));
}
// A key fetched for this contact's server renders here rather
// than in a second card: the pane is already a key column.
if let Some(key) = model
.pin_prompt
.as_ref()
.filter(|prompt| prompt.addr.short() == contact.address)
.and_then(|prompt| prompt.offered)
{
body.push_str(&format!(
"\n\n{}\n{:<14}{}\n{:<14}{}\n\n{}",
i18n::t("contact-server-key-offered"),
i18n::t("detail-key-label"),
b32(&key),
i18n::t("detail-fingerprint-label"),
fingerprint(&key),
i18n::t("trust-fingerprint-hint"),
));
}
w.article_body.set_text(&body);
set_qr(
&w.detail_qr,
&format!("smol://{}/{}", contact.address, contact.key),
);
w.detail_share.set_visible(true);
w.detail_copy.set_visible(true);
clear(&w.actions);
let write = icon_label_button(
"mail-message-new-symbolic",
&i18n::t("mail-write-button"),
"",
);
let write_msg = Message::ContactWrite(contact.address.clone());
let write_sender = sender.clone();
write.connect_clicked(move |_| write_sender.input(write_msg.clone()));
w.actions.append(&write);
let spacer = gtk::Label::new(None);
spacer.set_hexpand(true);
w.actions.append(&spacer);
if !contact.pinned && !contact.server.is_empty() {
let fetched = model.pin_prompt.as_ref().is_some_and(|prompt| {
prompt.offered.is_some() && prompt.addr.short() == contact.address
});
let pin = icon_label_button(
"channel-secure-symbolic",
&i18n::t(if fetched {
"contacts-pin-button-fetched"
} else {
"contacts-pin-button-unfetched"
}),
if fetched { "accent" } else { "" },
);
pin.connect_clicked(input(Message::PinServerAction));
w.actions.append(&pin);
}
match contact.accepted {
Some(true) => {
let block = icon_label_button(
"action-unavailable-symbolic",
&i18n::t("action-block"),
"danger",
);
let msg = Message::ContactBlock(contact.address.clone());
let block_sender = sender.clone();
block.connect_clicked(move |_| block_sender.input(msg.clone()));
w.actions.append(&block);
}
Some(false) => {
let unblock = icon_label_button(
"object-select-symbolic",
&i18n::t("action-unblock"),
"accent",
);
let msg = Message::ContactUnblock(contact.address.clone());
let unblock_sender = sender.clone();
unblock.connect_clicked(move |_| unblock_sender.input(msg.clone()));
w.actions.append(&unblock);
}
None => {
let accept = icon_label_button(
"object-select-symbolic",
&i18n::t("action-accept"),
"accent",
);
let msg = Message::ContactAccept(contact.address.clone(), contact.key.clone());
let accept_sender = sender.clone();
accept.connect_clicked(move |_| accept_sender.input(msg.clone()));
w.actions.append(&accept);
}
}
}
(Some((folder, msg)), _, _) => {
w.article_stack.set_visible_child_name("detail");
w.detail_share.set_visible(false);
w.detail_copy.set_visible(false);
// A message body is prose; only the detail below is a
// column of keys that has to stay aligned.
w.article_view.remove_css_class("monospace");
let subject = if msg.subject.is_empty() {
i18n::t("mail-no-subject")
} else {
msg.subject.clone()
};
w.article_subject.set_text(&subject);
let label = i18n::t(if *folder == Folder::Sent {
"mail-meta-to"
} else {
"mail-meta-from"
});
// The address opens the sender's contact details when they
// are a contact; an unknown or keyless sender stays text.
let known = model
.snapshot
.as_ref()
.is_some_and(|snap| snap.contacts.iter().any(|c| c.address == msg.from));
let address = escape(&msg.from);
let from = if known {
format!("<a href='{address}'>{address}</a>")
} else {
address
};
let mail_meta_id = if msg.kept {
"mail-meta-line-yes"
} else {
"mail-meta-line-no"
};
let mut meta_args = fluent_bundle::FluentArgs::new();
meta_args.set("label", label);
meta_args.set("from", from);
meta_args.set("time", escape(&ops::when(msg.time)));
meta_args.set("id", msg.id[..msg.id.len().min(8)].to_string());
let meta = i18n::t_args(mail_meta_id, Some(&meta_args));
w.article_meta.set_markup(&meta);
let headers: Vec<String> = msg
.headers
.iter()
.map(|(key, value)| format!("{key}: {value}"))
.collect();
w.article_headers.set_visible(!headers.is_empty());
w.article_headers.set_text(&headers.join("\n"));
w.article_body.set_text(&msg.body);
clear(&w.actions);
let reply =
icon_label_button("mail-reply-sender-symbolic", &i18n::t("action-reply"), "");
reply.connect_clicked(input(Message::ReplyPressed));
w.actions.append(&reply);
if *folder == Folder::Requests {
if msg.reply_to.is_some() {
let accept = icon_label_button(
"object-select-symbolic",
&i18n::t("action-accept"),
"accent",
);
accept.connect_clicked(input(Message::AcceptPressed));
let block = icon_label_button(
"action-unavailable-symbolic",
&i18n::t("action-block"),
"danger",
);
block.connect_clicked(input(Message::BlockPressed));
w.actions.append(&accept);
w.actions.append(&block);
} else {
w.actions.append(&crate::view::label(
&i18n::t("mail-no-reply-address"),
"caption",
));
}
}
let spacer = gtk::Label::new(None);
spacer.set_hexpand(true);
w.actions.append(&spacer);
let delete =
icon_label_button("user-trash-symbolic", &i18n::t("action-delete"), "danger");
delete.connect_clicked(input(Message::DeletePressed));
w.actions.append(&delete);
}
(None, None, None) => {
w.article_stack.set_visible_child_name("empty");
w.detail_share.set_visible(false);
w.detail_copy.set_visible(false);
clear(&w.actions);
}
}
// Compose: a reply says so, on the button and in its icon.
if let Some(compose) = &model.compose {
let replying = compose.reply_to.is_some();
w.compose_send.set_label(&i18n::t(if replying {
"action-reply"
} else {
"compose-send-button"
}));
w.compose_send.set_icon_name(if replying {
"mail-reply-sender-symbolic"
} else {
"mail-send-symbolic"
});
}
// Contacts: the sidebar's bottom panel lists them; the selected one
// carries the list's selection so the detail view has a visible anchor.
clear_list(&w.sidebar_contacts);
if let Some(snap) = &model.snapshot {
if snap.contacts.is_empty() {
let empty = gtk::ListBoxRow::new();
empty.set_activatable(false);
let hint = label(&i18n::t("contacts-empty-hint"), "caption");
hint.set_margin_top(6);
hint.set_margin_bottom(6);
hint.set_margin_start(12);
empty.set_child(Some(&hint));
w.sidebar_contacts.append(&empty);
}
let mut selected_contact_row = None;
for contact in &snap.contacts {
let item = sidebar_contact_row(contact);
if model.selected_contact.as_deref() == Some(contact.address.as_str()) {
selected_contact_row = Some(item.clone());
}
w.sidebar_contacts.append(&item);
}
w.sidebar_contacts.select_row(
selected_contact_row
.as_ref()
.map(|row| row.upcast_ref::<gtk::ListBoxRow>()),
);
}
// Settings: the identity/server rows are fixed, so only their values
// change.
if let Some(snap) = &model.snapshot {
let values = [
snap.who.address.clone(),
snap.who.key.clone(),
snap.who.fingerprint.clone(),
snap.who.uri.clone(),
];
for (row, text) in w.identity_rows.iter().zip(values) {
row.set_subtitle(&text);
}
w.host_row.set_subtitle(&snap.who.host);
// Pins are ordered by host and hold correspondents' servers too,
// so the account's own is found by its label rather than taken
// off the front of the list.
let own = trust_label(&snap.who.host, snap.who.port);
let public_key = snap
.pins
.iter()
.find(|(label, _)| *label == own)
.map(|(_, key)| b32(key))
.unwrap_or_else(|| i18n::t("settings-public-key-not-pinned"));
w.public_key_row.set_subtitle(&public_key);
w.rotations_value.set_text(&snap.who.rotations.to_string());
}
// The recovery secret is masked until revealed; the copy button
// always copies the real value (see Message::CopySecret), not
// whatever's currently on screen.
let secret = ops::seed_text(&model.master.unwrap_or([0u8; KEY_LEN]));
if model.secret_revealed {
w.secret_row.set_title(&secret);
w.secret_reveal.set_icon_name("view-conceal-symbolic");
w.secret_reveal
.set_tooltip_text(Some(&i18n::t("settings-secret-hide-tooltip")));
} else {
w.secret_row.set_title(&"•".repeat(secret.chars().count()));
w.secret_reveal.set_icon_name("view-reveal-symbolic");
w.secret_reveal
.set_tooltip_text(Some(&i18n::t("settings-secret-reveal-tooltip")));
}
if w.keep_mail_check.is_active() != model.keep_mail {
w.keep_mail_check.set_active(model.keep_mail);
}
if w.strict_check.is_active() != model.strict_pinning {
w.strict_check.set_active(model.strict_pinning);
}
if w.timeout_row.value() as u64 != model.timeout {
w.timeout_row.set_value(model.timeout as f64);
}
// 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.
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(&i18n::t("signup-continue-button"));
} 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(&i18n::t("signup-continue-button"));
} else {
w.signup_key_card.set_visible(false);
w.signup_key_hint.set_visible(false);
w.signup_key_button
.set_label(&i18n::t("signup-fetch-key-button"));
}
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
// again would stack a second copy over the first.
match (&model.dialog, w.dialog.is_some()) {
(Some(dialog), false) => {
let kind = dialog.kind();
let alert = match dialog {
Dialog::Delete { kept, .. } => {
let alert = adw::AlertDialog::new(
Some(&i18n::t("delete-dialog-title")),
Some(&i18n::t(if *kept {
"delete-dialog-body-kept"
} else {
"delete-dialog-body-gone"
})),
);
alert.add_response("cancel", &i18n::t("action-cancel"));
alert.add_response("here", &i18n::t("action-delete"));
alert.set_response_appearance("here", adw::ResponseAppearance::Destructive);
if *kept {
alert.add_response(
"everywhere",
&i18n::t("delete-dialog-everywhere-button"),
);
alert.set_response_appearance(
"everywhere",
adw::ResponseAppearance::Destructive,
);
}
alert
}
Dialog::Rotate => {
let alert = adw::AlertDialog::new(
Some(&i18n::t("rotate-dialog-title")),
Some(&i18n::t("advanced-rotations-description")),
);
alert.add_response("cancel", &i18n::t("action-cancel"));
alert.add_response("rotate", &i18n::t("advanced-rotate-button"));
alert.set_response_appearance("rotate", adw::ResponseAppearance::Destructive);
alert
}
Dialog::Reset => {
let alert = adw::AlertDialog::new(
Some(&i18n::t("reset-dialog-title")),
Some(&i18n::t("advanced-logout-description")),
);
alert.add_response("cancel", &i18n::t("action-cancel"));
alert.add_response("reset", &i18n::t("advanced-logout-button"));
alert.set_response_appearance("reset", adw::ResponseAppearance::Destructive);
alert
}
Dialog::Reply { rows, .. } => {
let list = rows
.iter()
.map(String::as_str)
.collect::<Vec<_>>()
.join("\n");
let alert = adw::AlertDialog::new(
Some(&i18n::t("reply-dialog-title")),
Some(&i18n::t1("reply-dialog-body", "list", list)),
);
alert.add_response("cancel", &i18n::t("reply-dialog-cancel-button"));
alert.add_response("without", &i18n::t("reply-dialog-without-button"));
alert.add_response("copy", &i18n::t("reply-dialog-copy-button"));
alert.set_response_appearance("copy", adw::ResponseAppearance::Suggested);
alert
}
Dialog::KeyChange {
address,
known_fingerprint,
offered_fingerprint,
..
} => {
let mut args = fluent_bundle::FluentArgs::new();
args.set("address", address.clone());
args.set("known_fingerprint", known_fingerprint.clone());
args.set("offered_fingerprint", offered_fingerprint.clone());
let alert = adw::AlertDialog::new(
Some(&i18n::t("key-change-dialog-title")),
Some(&i18n::t_args("key-change-dialog-body", Some(&args))),
);
alert.add_response("cancel", &i18n::t("action-cancel"));
alert.add_response("replace", &i18n::t("key-change-dialog-replace-button"));
alert.set_response_appearance("replace", adw::ResponseAppearance::Destructive);
alert
}
};
// The reply dialog carries its own default: Enter answers the
// question instead of cancelling the reply the user asked for.
let close_default = if kind == DialogKind::Reply {
"without"
} else {
"cancel"
};
alert.set_close_response("cancel");
alert.set_default_response(Some(close_default));
let response_sender = sender.clone();
alert.connect_response(None, move |_, response| {
// Anything that is not an explicit confirmation — a
// Cancel, Escape, or the close response — cancels.
response_sender.input(match (kind, response) {
(DialogKind::Delete, "here") => Message::DeleteConfirmed(false),
(DialogKind::Delete, "everywhere") => Message::DeleteConfirmed(true),
(DialogKind::Delete, _) => Message::DeleteCancelled,
(DialogKind::Rotate, "rotate") => Message::RotateConfirmed,
(DialogKind::Rotate, _) => Message::RotateCancelled,
(DialogKind::Reset, "reset") => Message::ResetConfirmed,
(DialogKind::Reset, _) => Message::ResetCancelled,
(DialogKind::Reply, "copy") => Message::ReplyConfirmed(true),
(DialogKind::Reply, "without") => Message::ReplyConfirmed(false),
(DialogKind::Reply, _) => Message::ReplyCancelled,
(DialogKind::KeyChange, "replace") => Message::KeyChangeConfirmed,
(DialogKind::KeyChange, _) => Message::KeyChangeCancelled,
});
});
alert.present(Some(&w.window));
w.dialog = Some(alert);
}
(None, true) => {
// Drop the handle before closing: `close` emits the close
// response, which would otherwise arrive as a second cancel
// and bring us straight back here.
if let Some(alert) = w.dialog.take() {
alert.close();
}
}
_ => {}
}
// The fixed bottom line: work in progress while something runs, the
// last note otherwise, errors tinted until the next note replaces them.
w.spinner.set_visible(model.busy.is_some());
if model.busy.is_some() {
w.spinner.start();
} else {
w.spinner.stop();
}
w.status_label.set_text(&match (&model.busy, fetching) {
(Some(_), true) => {
let inbox = model.lists.first().map(|rows| rows.len()).unwrap_or(0);
i18n::t1("status-fetching-progress", "count", inbox as i64)
}
(Some(busy), false) => match busy.as_str() {
"fetching" => i18n::t("status-busy-fetching"),
"refreshing" => i18n::t("status-busy-refreshing"),
"registering" => i18n::t("status-busy-registering"),
"restoring" => i18n::t("status-busy-restoring"),
other => format!("{other}…"),
},
(None, _) => model.status.clone().unwrap_or_default(),
});
let errored = model.status_error && model.busy.is_none();
if errored {
w.status_label.add_css_class("error");
} else {
w.status_label.remove_css_class("error");
}
w.cancel_button.set_visible(fetching);
}
/// One list row, like a note title: the read flag and subject on the left,
/// sender and date muted on the right. An action row supplies the metrics
/// and the selection paint the stylesheet expects of a list.
fn mail_row(folder: Folder, row: &Row) -> adw::ActionRow {
let item = adw::ActionRow::new();
item.add_prefix(&gtk::Image::from_icon_name(if row.unread {
"mail-unread-symbolic"
} else {
"mail-read-symbolic"
}));
let subject = if row.subject.is_empty() {
i18n::t("mail-no-subject")
} else {
row.subject.clone()
};
// Action row titles are markup, so unread weight goes in as a span
// rather than a class, which would take the suffixes with it.
item.set_title(&if row.unread {
format!("<b>{}</b>", escape(&subject))
} else {
escape(&subject)
});
item.set_title_lines(1);
if !row.unread {
item.add_css_class("dim-label");
}
let from = label(&truncate(&row.from, 24), "caption");
from.set_valign(Align::Center);
let date = label(&ops::when(row.at), "caption numeric");
date.set_valign(Align::Center);
item.add_suffix(&from);
item.add_suffix(&date);
item.set_activatable(true);
// The list's row-activated signal routes the click; the row carries
// its folder and id in the widget name for the handler to read.
item.set_widget_name(&format!("{}|{}", folder as usize, row.id));
item
}
/// One compose header row: a `Key: value` entry plus a remove button.
/// The row carries its own validation state — anything the send would
/// reject is tinted and explained right where it is typed, and the
/// empty rows the send skips are left quiet.
fn compose_header_row<'a>(
index: usize,
text: &str,
sender: &ComponentSender<Carrier>,
) -> (gtk::ListBoxRow, gtk::Entry) {
let entry = gtk::Entry::new();
entry.set_hexpand(true);
entry.set_placeholder_text(Some(&i18n::t("compose-header-placeholder")));
entry.set_text(text);
let style = |entry: &gtk::Entry, text: &str| match crate::ops::header_row(text) {
Err(reason) => {
entry.add_css_class("error");
entry.set_tooltip_text(Some(&reason));
}
Ok(_) => {
entry.remove_css_class("error");
entry.set_tooltip_text(None);
}
};
style(&entry, text);
let change_sender = sender.clone();
let style_entry = entry.clone();
entry.connect_changed(move |changed| {
let text = changed.text().to_string();
style(&style_entry, &text);
change_sender.input(Message::ComposeHeaderChanged(index, text));
});
let remove = icon_button(
"user-trash-symbolic",
&i18n::t("compose-header-remove-tooltip"),
);
let remove_sender = sender.clone();
remove.connect_clicked(move |_| {
remove_sender.input(Message::ComposeHeaderRemoved(index));
});
let line = gtk::Box::new(Orientation::Horizontal, 6);
line.set_margin_top(6);
line.set_margin_bottom(6);
line.set_margin_start(12);
line.set_margin_end(6);
line.append(&entry);
line.append(&remove);
let row = gtk::ListBoxRow::new();
row.set_child(Some(&line));
(row, entry)
}
/// One contact in the sidebar's bottom panel: the address over its trust
/// state; activating it opens the detail pane.
fn sidebar_contact_row(contact: &Contact) -> adw::ActionRow {
let item = adw::ActionRow::builder()
.title(escape(&contact.address))
.subtitle(contact_state(contact))
.activatable(true)
.build();
item.set_title_lines(1);
item.set_subtitle_lines(1);
item.set_widget_name(&contact.address);
item
}
/// The detail view's body: the three lines an address is made of, in
/// the column-aligned form a key block wants.
fn detail_body(address: &str, key: &str, fingerprint: &str) -> String {
format!(
"{:<14}{key}\n{:<14}{fingerprint}\nsmol://{address}/{key}",
i18n::t("detail-key-label"),
i18n::t("detail-fingerprint-label"),
)
}
/// Puts one URI's QR code into the detail header's share popover. A
/// GtkImage renders its paintable at the default icon size — a 16px
/// square, centered — unless pixel-size overrides it, and its widget
/// sizes like an icon slot unless a request overrides that. Both are
/// set to the texture's own dimensions, so the code paints 1:1.
fn set_qr(image: &gtk::Image, uri: &str) {
let Some(texture) = qr_texture(uri) else {
return;
};
let dim = texture.intrinsic_width();
image.set_size_request(dim, dim);
image.set_pixel_size(dim);
image.set_paintable(Some(&texture));
}
/// The QR as an RGBA texture, black on white, taking no cues from the
/// theme. Each module is a whole number of pixels and the four quiet
/// modules the spec asks for are included, so nothing scales the texture
/// and no softening ever lands on an edge. The scale targets ~210px: a
/// popover that does not fit below its anchor is not placed at all.
fn qr_texture(uri: &str) -> Option<gtk::gdk::MemoryTexture> {
const QUIET: i32 = 4;
let code = qrcodegen::QrCode::encode_text(uri, qrcodegen::QrCodeEcc::Medium).ok()?;
let size = code.size();
let scale = (210 / (size + 2 * QUIET)).clamp(3, 6);
let dim = (size + 2 * QUIET) * scale;
let stride = dim as usize * 4;
let mut pixels = vec![0xffu8; (dim * dim) as usize * 4];
for y in 0..size {
for x in 0..size {
if code.get_module(x, y) {
for dy in 0..scale {
for dx in 0..scale {
let px = (x + QUIET) * scale + dx;
let py = (y + QUIET) * scale + dy;
let i = (py * dim + px) as usize * 4;
pixels[i] = 0;
pixels[i + 1] = 0;
pixels[i + 2] = 0;
}
}
}
}
}
Some(gtk::gdk::MemoryTexture::new(
dim,
dim,
gtk::gdk::MemoryFormat::R8g8b8a8,
&gtk::glib::Bytes::from_owned(pixels),
stride,
))
}