1922 lines
79 KiB
Rust
1922 lines
79 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::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, Message, OnboardMode, OpResult, Page, PendingOp};
|
|
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) -> &'static str {
|
|
match contact.accepted {
|
|
Some(true) => "accepted",
|
|
Some(false) => "blocked",
|
|
None if contact.verified => "verified",
|
|
None => "trust on first use",
|
|
}
|
|
}
|
|
|
|
#[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,
|
|
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,
|
|
|
|
// 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_values: Vec<gtk::Label>,
|
|
offer_group: adw::PreferencesGroup,
|
|
offer_row: adw::ActionRow,
|
|
pins_group: adw::PreferencesGroup,
|
|
pin_rows: Vec<adw::ActionRow>,
|
|
timeout_row: adw::SpinRow,
|
|
backup_label: gtk::Label,
|
|
|
|
// onboarding
|
|
onboard_seed_label: gtk::Label,
|
|
|
|
// signup
|
|
signup_back: gtk::Button,
|
|
signup_offer_list: gtk::ListBox,
|
|
signup_offer_row: adw::ActionRow,
|
|
|
|
// 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", "Fetch", "accent");
|
|
let write_button = icon_label_button("mail-message-new-symbolic", "Write", "");
|
|
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", "Inbox"),
|
|
nav_row("contact-new-symbolic", "Requests"),
|
|
nav_row("mail-send-symbolic", "Sent"),
|
|
nav_row("document-edit-symbolic", "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", "Settings");
|
|
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("you", "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("contacts", "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("Import a smol:// address")
|
|
.build();
|
|
let import_button = icon_label_button("document-open-symbolic", "Import", "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("Import a smol:// address"));
|
|
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", "Fetch", "pill");
|
|
list_empty_fetch.set_halign(Align::Center);
|
|
list_empty.set_child(Some(&list_empty_fetch));
|
|
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("Show the address as a QR code"));
|
|
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", "Copy the address");
|
|
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("No message selected")
|
|
.description("Pick a message from the list above to read it.")
|
|
.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("To").build();
|
|
let compose_subject = adw::EntryRow::builder().title("Subject").build();
|
|
compose_fields.append(&compose_to);
|
|
compose_fields.append(&compose_subject);
|
|
compose.append(&compose_fields);
|
|
// 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("+ header", "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("Send")
|
|
.build();
|
|
compose_send_button.set_child(Some(&compose_send));
|
|
let compose_cancel = button("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: the preferences page every GNOME app shows, one group
|
|
// per concern, so scrolling, width and margins come for free.
|
|
let settings = adw::PreferencesPage::new();
|
|
|
|
let identity_group = adw::PreferencesGroup::builder().title("Identity").build();
|
|
let identity_actions = gtk::Box::new(Orientation::Horizontal, 6);
|
|
let rotate_button = icon_label_button("view-refresh-symbolic", "Rotate", "");
|
|
let learn_button = button("Learn key", "");
|
|
identity_actions.append(&learn_button);
|
|
identity_actions.append(&rotate_button);
|
|
identity_group.set_header_suffix(Some(&identity_actions));
|
|
let mut identity_values = Vec::new();
|
|
for title in ["Address", "Key", "Fingerprint", "Rotations"] {
|
|
let row = adw::ActionRow::builder().title(title).build();
|
|
let value = label("", "mono dim");
|
|
value.set_selectable(true);
|
|
value.set_ellipsize(gtk::pango::EllipsizeMode::End);
|
|
value.set_max_width_chars(48);
|
|
value.set_valign(Align::Center);
|
|
row.add_suffix(&value);
|
|
identity_group.add(&row);
|
|
identity_values.push(value);
|
|
}
|
|
settings.add(&identity_group);
|
|
|
|
// The pin offer is a group that appears in place rather than a
|
|
// dialog: pinning is a decision about this host, taken next to the
|
|
// identity it belongs to.
|
|
let offer_group = adw::PreferencesGroup::builder()
|
|
.title("Server key offered")
|
|
.visible(false)
|
|
.build();
|
|
let offer_row = adw::ActionRow::builder().title("").build();
|
|
offer_row.add_css_class("monospace");
|
|
offer_row.set_subtitle_lines(2);
|
|
let offer_pin = button("Pin", "accent");
|
|
offer_pin.set_valign(Align::Center);
|
|
let offer_discard = button("Discard", "");
|
|
offer_discard.set_valign(Align::Center);
|
|
offer_row.add_suffix(&offer_discard);
|
|
offer_row.add_suffix(&offer_pin);
|
|
offer_group.add(&offer_row);
|
|
settings.add(&offer_group);
|
|
|
|
let pins_group = adw::PreferencesGroup::builder()
|
|
.title("Server keys")
|
|
.build();
|
|
settings.add(&pins_group);
|
|
|
|
let network_group = adw::PreferencesGroup::builder().title("Network").build();
|
|
let timeout_row = adw::SpinRow::with_range(1.0, 600.0, 1.0);
|
|
timeout_row.set_title("Timeout");
|
|
timeout_row.set_subtitle("Seconds before a network call gives up");
|
|
network_group.add(&timeout_row);
|
|
settings.add(&network_group);
|
|
|
|
let backup_group = adw::PreferencesGroup::builder()
|
|
.title("Backup")
|
|
.description(
|
|
"Sealed to a key derived from the seed, so it restores only where this\n identity already lives. The seed itself is never in the file.",
|
|
)
|
|
.build();
|
|
let backup_actions = gtk::Box::new(Orientation::Horizontal, 6);
|
|
let export_button = icon_label_button("document-save-symbolic", "Export", "");
|
|
let copy_export = icon_button("edit-copy-symbolic", "Copy the backup");
|
|
backup_actions.append(©_export);
|
|
backup_actions.append(&export_button);
|
|
backup_group.set_header_suffix(Some(&backup_actions));
|
|
let backup_label = label("", "mono caption");
|
|
backup_label.set_wrap(true);
|
|
backup_label.set_wrap_mode(gtk::pango::WrapMode::Char);
|
|
backup_label.set_selectable(true);
|
|
let backup_scroll = gtk::ScrolledWindow::new();
|
|
backup_scroll.add_css_class("card");
|
|
backup_scroll.set_height_request(72);
|
|
backup_scroll.set_child(Some(&backup_label));
|
|
backup_group.add(&backup_scroll);
|
|
settings.add(&backup_group);
|
|
|
|
let import_group = adw::PreferencesGroup::builder()
|
|
.title("Restore")
|
|
.description("Paste a gsmol backup to restore the contacts, pins and mail it carries.")
|
|
.build();
|
|
let import_view = gtk::TextView::new();
|
|
import_view.set_wrap_mode(WrapMode::WordChar);
|
|
import_view.set_top_margin(8);
|
|
import_view.set_bottom_margin(8);
|
|
import_view.set_left_margin(8);
|
|
import_view.set_right_margin(8);
|
|
import_view.add_css_class("monospace");
|
|
let import_body = import_view.buffer();
|
|
let import_scroll = gtk::ScrolledWindow::new();
|
|
import_scroll.add_css_class("card");
|
|
import_scroll.set_height_request(96);
|
|
import_scroll.set_child(Some(&import_view));
|
|
import_group.add(&import_scroll);
|
|
let import_backup = icon_label_button("document-open-symbolic", "Restore", "");
|
|
import_backup.set_halign(Align::End);
|
|
import_backup.set_margin_top(6);
|
|
import_group.add(&import_backup);
|
|
settings.add(&import_group);
|
|
|
|
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("smolmail: one seed, sealed mail, five operations", "dim");
|
|
tagline.set_halign(Align::Center);
|
|
tagline.set_margin_bottom(12);
|
|
onboard_start.append(&tagline);
|
|
let create_button = button("Create an identity", "pill");
|
|
let restore_button = button("Restore from a seed", "pill-quiet");
|
|
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("Write this seed down", "subtitle");
|
|
onboard_created.append(&created_title);
|
|
onboard_created.append(&label(
|
|
"It is the only secret, and it is shown once.",
|
|
"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("I saved it", "pill");
|
|
let created_back = button("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("Restore from a seed", "subtitle"));
|
|
let restore_list = boxed_list();
|
|
let onboard_seed_entry = adw::EntryRow::builder()
|
|
.title("Seed — 32 bytes, base32 or hex")
|
|
.build();
|
|
restore_list.append(&onboard_seed_entry);
|
|
onboard_restore.append(&restore_list);
|
|
let restore_go = button("Restore", "pill");
|
|
let restore_back = button("Back", "pill-quiet");
|
|
onboard_restore.append(&restore_go);
|
|
onboard_restore.append(&restore_back);
|
|
stack.add_named(¢er(&onboard_restore), Some("onboard-restore"));
|
|
|
|
// signup
|
|
let signup = gtk::Box::new(Orientation::Vertical, 12);
|
|
signup.append(&label("Set up the address", "subtitle"));
|
|
let signup_group = adw::PreferencesGroup::builder()
|
|
.description(
|
|
"The invite token and server key are only needed when the operator published them.",
|
|
)
|
|
.build();
|
|
let signup_user = adw::EntryRow::builder().title("User").build();
|
|
let signup_host = adw::EntryRow::builder().title("Host").build();
|
|
let signup_invite = adw::EntryRow::builder().title("Invite token").build();
|
|
let signup_key = adw::EntryRow::builder().title("Server key").build();
|
|
for row in [&signup_user, &signup_host, &signup_invite, &signup_key] {
|
|
signup_group.add(row);
|
|
}
|
|
signup.append(&signup_group);
|
|
let signup_learn = button("Learn the server key and pin what it presents", "");
|
|
signup.append(&signup_learn);
|
|
let signup_offer_list = boxed_list();
|
|
signup_offer_list.set_visible(false);
|
|
let signup_offer_row = adw::ActionRow::builder()
|
|
.title("Server key offered")
|
|
.build();
|
|
signup_offer_row.set_subtitle_lines(2);
|
|
let signup_offer_pin = button("Pin", "accent");
|
|
signup_offer_pin.set_valign(Align::Center);
|
|
let signup_offer_discard = button("Discard", "");
|
|
signup_offer_discard.set_valign(Align::Center);
|
|
signup_offer_row.add_suffix(&signup_offer_discard);
|
|
signup_offer_row.add_suffix(&signup_offer_pin);
|
|
signup_offer_list.append(&signup_offer_row);
|
|
signup.append(&signup_offer_list);
|
|
let register_button = button("Register", "pill");
|
|
let recall_button = button("I already have this address", "pill-quiet");
|
|
signup.append(®ister_button);
|
|
signup.append(&recall_button);
|
|
let signup_back = button("Back", "quiet");
|
|
signup_back.set_visible(false);
|
|
signup.append(&signup_back);
|
|
stack.add_named(¢er(&signup), Some("signup"));
|
|
|
|
// --- 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("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_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));
|
|
copy_export.connect_clicked(input(Message::CopyExport));
|
|
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));
|
|
learn_button.connect_clicked(input(Message::SettingsLearnKey));
|
|
offer_pin.connect_clicked(input(Message::PinAccept));
|
|
signup_offer_pin.connect_clicked(input(Message::PinAccept));
|
|
offer_discard.connect_clicked(input(Message::PinDiscard));
|
|
signup_offer_discard.connect_clicked(input(Message::PinDiscard));
|
|
export_button.connect_clicked(input(Message::ExportPressed));
|
|
import_backup.connect_clicked(input(Message::ImportPressed));
|
|
let timeout_sender = sender.clone();
|
|
timeout_row.connect_value_notify(move |row| {
|
|
timeout_sender.input(Message::TimeoutChanged((row.value() as u64).to_string()))
|
|
});
|
|
let import_body_sender = sender.clone();
|
|
import_body.connect_changed(move |buffer| {
|
|
import_body_sender.input(Message::ImportBodyChanged(buffer_text(buffer)))
|
|
});
|
|
|
|
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()))
|
|
});
|
|
let key_sender = sender.clone();
|
|
signup_key.connect_changed(move |entry| {
|
|
key_sender.input(Message::SignupKeyChanged(entry.text().to_string()))
|
|
});
|
|
signup_learn.connect_clicked(input(Message::SignupLearnKey));
|
|
register_button.connect_clicked(input(Message::SignupRegister));
|
|
recall_button.connect_clicked(input(Message::SignupRecall));
|
|
signup_back.connect_clicked(input(Message::SignupBack));
|
|
|
|
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,
|
|
article_stack,
|
|
article_subject,
|
|
article_meta,
|
|
article_headers,
|
|
article_view,
|
|
article_body,
|
|
actions,
|
|
compose_to,
|
|
compose_headers,
|
|
compose_subject,
|
|
compose_body,
|
|
compose_send,
|
|
sidebar_own,
|
|
sidebar_contacts: contacts_list,
|
|
detail_share,
|
|
detail_copy,
|
|
detail_qr,
|
|
identity_values,
|
|
offer_group,
|
|
offer_row,
|
|
pins_group,
|
|
pin_rows: Vec::new(),
|
|
timeout_row,
|
|
backup_label,
|
|
onboard_seed_label,
|
|
signup_back,
|
|
signup_offer_list,
|
|
signup_offer_row,
|
|
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 widgets = CarrierWidgets::new(&root, &sender);
|
|
if let Some(op) = op {
|
|
spawn_op(&model, op, &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;
|
|
match op {
|
|
PendingOp::Snapshot { read } => sender.spawn_command(move |out| {
|
|
let _ = out.send(OpResult::SnapshotLoaded(ops::snapshot(
|
|
&store, &master, &read,
|
|
)));
|
|
}),
|
|
PendingOp::Register { addr, invite, key } => sender.spawn_command(move |out| {
|
|
let res = if key.is_empty() {
|
|
ops::register(&store, &master, &addr, invite.as_deref(), timeout)
|
|
} else {
|
|
ops::parse_key(&key).and_then(|key| {
|
|
ops::pin(&store, &addr, &key)?;
|
|
ops::register(&store, &master, &addr, invite.as_deref(), timeout)
|
|
})
|
|
};
|
|
let _ = out.send(OpResult::Generic(res.map(|_| "registered".to_string())));
|
|
}),
|
|
PendingOp::Restore { addr } => sender.spawn_command(move |out| {
|
|
let res = ops::restore(&store, &master, &addr, timeout)
|
|
.map(|index| format!("restored at rotation {index}"));
|
|
let _ = out.send(OpResult::Generic(res));
|
|
}),
|
|
PendingOp::LearnKey { addr } => sender.spawn_command(move |out| {
|
|
let offered =
|
|
ops::learn_key(&store, &addr, 30).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, 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(|_| "deleted".to_string());
|
|
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(|_| format!("accepted {address}"));
|
|
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 {
|
|
format!("blocked {address}")
|
|
} else {
|
|
format!("{address} was not blocked")
|
|
}
|
|
});
|
|
let _ = out.send(OpResult::Generic(res));
|
|
}),
|
|
PendingOp::ImportContact { uri } => sender.spawn_command(move |out| {
|
|
let res = ops::import_contact(&store, &uri)
|
|
.map(|(address, _)| format!("imported {address} (verified)"));
|
|
let _ = out.send(OpResult::Generic(res));
|
|
}),
|
|
PendingOp::Rotate => sender.spawn_command(move |out| {
|
|
let res = ops::rotate(&store, &master, timeout)
|
|
.map(|(index, key)| format!("rotated to index {index}; new key {}…", &key[..12]));
|
|
let _ = out.send(OpResult::Generic(res));
|
|
}),
|
|
PendingOp::Unpin { label } => sender.spawn_command(move |out| {
|
|
let res = ops::unpin(&store, &label).map(|_| format!("unpinned {label}"));
|
|
let _ = out.send(OpResult::Generic(res));
|
|
}),
|
|
PendingOp::Export { dir } => sender.spawn_command(move |out| {
|
|
let exported = ops::export_backup(&store, &master, &dir)
|
|
.map(|(path, text)| (format!("backup written to {}", path.display()), text));
|
|
let _ = out.send(OpResult::Exported(exported));
|
|
}),
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 => "signup",
|
|
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, description) = match model.folder {
|
|
Folder::Inbox => (
|
|
"mail-read-symbolic",
|
|
"Inbox is empty",
|
|
"Fetch to check the mailbox.",
|
|
),
|
|
Folder::Requests => (
|
|
"contact-new-symbolic",
|
|
"No requests",
|
|
"Mail from senders you have not accepted yet arrives here.",
|
|
),
|
|
Folder::Sent => (
|
|
"mail-send-symbolic",
|
|
"Nothing sent yet",
|
|
"Messages you send keep a sealed copy here.",
|
|
),
|
|
Folder::Drafts => (
|
|
"document-edit-symbolic",
|
|
"No drafts",
|
|
"A compose closed from the sidebar is kept here.",
|
|
),
|
|
};
|
|
w.list_empty.set_icon_name(Some(icon));
|
|
w.list_empty.set_title(title);
|
|
w.list_empty.set_description(Some(description));
|
|
w.list_empty_fetch
|
|
.set_visible(model.folder == Folder::Inbox);
|
|
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(&format!("you · rotation {}", own.rotations));
|
|
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);
|
|
w.article_meta.set_text(contact_state(contact));
|
|
w.article_body.set_text(&detail_body(
|
|
&contact.address,
|
|
&contact.key,
|
|
&contact.fingerprint,
|
|
));
|
|
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", "Write", "");
|
|
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);
|
|
match contact.accepted {
|
|
Some(true) => {
|
|
let block = icon_label_button("action-unavailable-symbolic", "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", "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", "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() {
|
|
"(no subject)".to_string()
|
|
} else {
|
|
msg.subject.clone()
|
|
};
|
|
w.article_subject.set_text(&subject);
|
|
let label = if *folder == Folder::Sent {
|
|
"to"
|
|
} else {
|
|
"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 mut meta = if known {
|
|
format!("{label} <a href='{address}'>{address}</a>")
|
|
} else {
|
|
format!("{label} {address}")
|
|
};
|
|
meta.push_str(&format!(" · {} UTC", escape(&ops::when(msg.time))));
|
|
meta.push_str(&format!(" · id {}…", &msg.id[..msg.id.len().min(8)]));
|
|
if msg.kept {
|
|
meta.push_str(" · a server copy still exists");
|
|
}
|
|
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", "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", "Accept", "accent");
|
|
accept.connect_clicked(input(Message::AcceptPressed));
|
|
let block = icon_label_button("action-unavailable-symbolic", "Block", "danger");
|
|
block.connect_clicked(input(Message::BlockPressed));
|
|
w.actions.append(&accept);
|
|
w.actions.append(&block);
|
|
} else {
|
|
w.actions.append(&crate::view::label(
|
|
"no reply address: cannot accept or block",
|
|
"caption",
|
|
));
|
|
}
|
|
}
|
|
let spacer = gtk::Label::new(None);
|
|
spacer.set_hexpand(true);
|
|
w.actions.append(&spacer);
|
|
let delete = icon_label_button("user-trash-symbolic", "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(if replying { "Reply" } else { "Send" });
|
|
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("no contacts", "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 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.rotations.to_string(),
|
|
];
|
|
for (value, text) in w.identity_values.iter().zip(values) {
|
|
value.set_text(&text);
|
|
}
|
|
}
|
|
if let Some((_, key)) = &model.pin_offer {
|
|
w.offer_row.set_title(&escape(&fingerprint(key)));
|
|
w.offer_row.set_subtitle(&escape(&b32(key)));
|
|
w.offer_group.set_visible(true);
|
|
w.signup_offer_row.set_subtitle(&escape(&fingerprint(key)));
|
|
w.signup_offer_list.set_visible(true);
|
|
} else {
|
|
w.offer_group.set_visible(false);
|
|
w.signup_offer_list.set_visible(false);
|
|
}
|
|
if w.timeout_row.value() as u64 != model.timeout {
|
|
w.timeout_row.set_value(model.timeout as f64);
|
|
}
|
|
for row in std::mem::take(&mut w.pin_rows) {
|
|
w.pins_group.remove(&row);
|
|
}
|
|
if let Some(snap) = &model.snapshot {
|
|
if snap.pins.is_empty() {
|
|
let empty = adw::ActionRow::builder()
|
|
.title("No server keys pinned")
|
|
.build();
|
|
w.pins_group.add(&empty);
|
|
w.pin_rows.push(empty);
|
|
}
|
|
for (pin_label, key) in &snap.pins {
|
|
let row = adw::ActionRow::builder()
|
|
.title(escape(pin_label))
|
|
.subtitle(escape(&b32(key)))
|
|
.build();
|
|
row.set_subtitle_lines(1);
|
|
let unpin = icon_button("user-trash-symbolic", "Unpin this server key");
|
|
unpin.add_css_class("destructive-action");
|
|
unpin.connect_clicked(input(Message::UnpinPressed(pin_label.clone())));
|
|
row.add_suffix(&unpin);
|
|
w.pins_group.add(&row);
|
|
w.pin_rows.push(row);
|
|
}
|
|
}
|
|
let backup_text = model
|
|
.backup_text
|
|
.as_ref()
|
|
.map(|text| text.chars().take(2000).collect::<String>())
|
|
.unwrap_or_default();
|
|
w.backup_label.set_text(&backup_text);
|
|
|
|
// Onboarding.
|
|
if model.onboard.mode == OnboardMode::Created {
|
|
w.onboard_seed_label.set_text(&model.onboard.seed_shown);
|
|
}
|
|
|
|
// Signup.
|
|
w.signup_back.set_visible(model.signup.fresh);
|
|
|
|
// 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 rotating = matches!(dialog, Dialog::Rotate);
|
|
let replying = matches!(dialog, Dialog::Reply { .. });
|
|
let alert = match dialog {
|
|
Dialog::Delete { kept, .. } => {
|
|
let alert = adw::AlertDialog::new(
|
|
Some("Delete this message?"),
|
|
Some(if *kept {
|
|
"Deleting it here leaves the server's copy in place. Deleting it everywhere asks the server to drop that copy too."
|
|
} else {
|
|
"The sealed copy in the local store is removed."
|
|
}),
|
|
);
|
|
alert.add_response("cancel", "Cancel");
|
|
alert.add_response("here", "Delete");
|
|
alert.set_response_appearance("here", adw::ResponseAppearance::Destructive);
|
|
if *kept {
|
|
alert.add_response("everywhere", "Delete Everywhere");
|
|
alert.set_response_appearance(
|
|
"everywhere",
|
|
adw::ResponseAppearance::Destructive,
|
|
);
|
|
}
|
|
alert
|
|
}
|
|
Dialog::Rotate => {
|
|
let alert = adw::AlertDialog::new(
|
|
Some("Rotate the identity?"),
|
|
Some("The old key stays derivable from the seed, but the rotation index never moves back."),
|
|
);
|
|
alert.add_response("cancel", "Cancel");
|
|
alert.add_response("rotate", "Rotate");
|
|
alert.set_response_appearance("rotate", 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("Carry the sender's fields into the reply?"),
|
|
Some(&format!(
|
|
"This message carries its own frontmatter:\n\n{list}\n\nReply with copies of these fields, or start with none. The fields stay editable either way."
|
|
)),
|
|
);
|
|
alert.add_response("cancel", "Cancel Reply");
|
|
alert.add_response("without", "Reply Without");
|
|
alert.add_response("copy", "Copy Fields");
|
|
alert.set_response_appearance("copy", adw::ResponseAppearance::Suggested);
|
|
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 replying { "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| {
|
|
response_sender.input(match (rotating, replying, response) {
|
|
(true, _, "rotate") => Message::RotateConfirmed,
|
|
(true, _, _) => Message::RotateCancelled,
|
|
(_, true, "copy") => Message::ReplyConfirmed(true),
|
|
(_, true, "without") => Message::ReplyConfirmed(false),
|
|
(_, true, _) => Message::ReplyCancelled,
|
|
(false, _, "here") => Message::DeleteConfirmed(false),
|
|
(false, _, "everywhere") => Message::DeleteConfirmed(true),
|
|
(false, _, _) => Message::DeleteCancelled,
|
|
});
|
|
});
|
|
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);
|
|
format!("fetching… {inbox} in inbox")
|
|
}
|
|
(Some(busy), false) => format!("{busy}…"),
|
|
(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() {
|
|
"(no subject)".to_string()
|
|
} 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("Key: value"));
|
|
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", "Remove this header");
|
|
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!("key {key}\nfingerprint {fingerprint}\nsmol://{address}/{key}")
|
|
}
|
|
|
|
/// 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,
|
|
))
|
|
}
|