2495 lines
103 KiB
Rust
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(>k::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: >k::Box) {
|
|
while let Some(child) = boxed.first_child() {
|
|
boxed.remove(&child);
|
|
}
|
|
}
|
|
|
|
/// The same for lists.
|
|
fn clear_list(list: >k::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: >k::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: >k::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 |_: >k::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(>k::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(©_source.subtitle().unwrap_or_default());
|
|
}
|
|
});
|
|
row.add_suffix(©);
|
|
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(>k::Image::from_icon_name("document-save-symbolic"));
|
|
export_row.add_suffix(>k::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(>k::Image::from_icon_name("document-open-symbolic"));
|
|
import_row.add_suffix(>k::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: >k::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(¢er(&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(¢er(&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(¢er(&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(¢er(&signup_server), Some("signup-server"));
|
|
|
|
// signup: step 2 — bind a username, or recall an existing address
|
|
let signup_account = gtk::Box::new(Orientation::Vertical, 12);
|
|
signup_account.append(&label(&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(®ister_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(¢er(&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 |_: >k::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(>k::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: >k::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: >k::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,
|
|
>k::glib::Bytes::from_owned(pixels),
|
|
stride,
|
|
))
|
|
}
|