2026-10-02 22:32:12 +03:00
|
|
|
//! 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.
|
2026-10-01 23:12:21 +03:00
|
|
|
|
2026-10-02 22:32:12 +03:00
|
|
|
use std::sync::atomic::Ordering;
|
|
|
|
|
use std::time::Duration;
|
2026-10-01 23:12:21 +03:00
|
|
|
|
2026-10-02 22:32:12 +03:00
|
|
|
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};
|
2026-10-01 23:12:21 +03:00
|
|
|
|
2026-10-02 22:32:12 +03:00
|
|
|
use crate::app::{Carrier, Dialog, Message, OnboardMode, OpResult, Page, PendingOp};
|
2026-10-01 23:12:21 +03:00
|
|
|
use crate::ops::{self, Contact, Folder, Row};
|
2026-10-02 22:32:12 +03:00
|
|
|
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),
|
2026-10-01 23:12:21 +03:00
|
|
|
}
|
2026-10-02 22:32:12 +03:00
|
|
|
}
|
|
|
|
|
label.set_xalign(0.0);
|
|
|
|
|
label
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn escape(text: &str) -> String {
|
|
|
|
|
gtk::glib::markup_escape_text(text).to_string()
|
2026-10-01 23:12:21 +03:00
|
|
|
}
|
|
|
|
|
|
2026-10-02 22:32:12 +03:00
|
|
|
/// 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
|
|
|
|
|
}
|
2026-10-01 23:12:21 +03:00
|
|
|
|
2026-10-02 22:32:12 +03:00
|
|
|
/// 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
|
2026-10-01 23:12:21 +03:00
|
|
|
}
|
|
|
|
|
|
2026-10-02 22:32:12 +03:00
|
|
|
/// 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
|
2026-10-01 23:12:21 +03:00
|
|
|
}
|
|
|
|
|
|
2026-10-02 22:32:12 +03:00
|
|
|
/// 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)
|
2026-10-01 23:12:21 +03:00
|
|
|
}
|
|
|
|
|
|
2026-10-02 22:32:12 +03:00
|
|
|
/// 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
|
2026-10-01 23:12:21 +03:00
|
|
|
}
|
|
|
|
|
|
2026-10-02 22:32:12 +03:00
|
|
|
/// 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);
|
|
|
|
|
}
|
2026-10-01 23:12:21 +03:00
|
|
|
}
|
|
|
|
|
|
2026-10-02 22:32:12 +03:00
|
|
|
/// The same for lists.
|
|
|
|
|
fn clear_list(list: >k::ListBox) {
|
|
|
|
|
while let Some(child) = list.first_child() {
|
|
|
|
|
list.remove(&child);
|
|
|
|
|
}
|
2026-10-01 23:12:21 +03:00
|
|
|
}
|
|
|
|
|
|
2026-10-02 22:32:12 +03:00
|
|
|
pub fn run(dir: std::path::PathBuf) {
|
|
|
|
|
let app: RelmApp<_> = RelmApp::new("com.randogoth.carrier");
|
|
|
|
|
app.run::<Carrier>(dir);
|
2026-10-01 23:12:21 +03:00
|
|
|
}
|
|
|
|
|
|
2026-10-02 22:32:12 +03:00
|
|
|
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}…")
|
|
|
|
|
}
|
2026-10-01 23:12:21 +03:00
|
|
|
}
|
|
|
|
|
|
2026-10-02 22:32:12 +03:00
|
|
|
fn buffer_text(buffer: >k::TextBuffer) -> String {
|
|
|
|
|
buffer
|
|
|
|
|
.text(&buffer.start_iter(), &buffer.end_iter(), false)
|
|
|
|
|
.to_string()
|
2026-10-01 23:12:21 +03:00
|
|
|
}
|
|
|
|
|
|
2026-10-02 22:32:12 +03:00
|
|
|
/// 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",
|
2026-10-01 23:12:21 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-02 22:32:12 +03:00
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
pub struct CarrierWidgets {
|
|
|
|
|
/// `AlertDialog::present` needs a parent, and only the window is one.
|
|
|
|
|
window: gtk::Window,
|
|
|
|
|
window_title: adw::WindowTitle,
|
|
|
|
|
toasts: adw::ToastOverlay,
|
|
|
|
|
toolbar: adw::ToolbarView,
|
|
|
|
|
|
|
|
|
|
// sidebar top: actions and destinations
|
|
|
|
|
folders: gtk::ListBox,
|
|
|
|
|
folder_counts: Vec<gtk::Label>,
|
|
|
|
|
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_view: gtk::TextView,
|
|
|
|
|
article_body: gtk::TextBuffer,
|
|
|
|
|
actions: gtk::Box,
|
|
|
|
|
|
|
|
|
|
// compose page
|
|
|
|
|
compose_to: adw::EntryRow,
|
|
|
|
|
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,
|
|
|
|
|
|
|
|
|
|
// contacts page
|
|
|
|
|
own_label: gtk::Label,
|
|
|
|
|
qr: gtk::Image,
|
|
|
|
|
contacts_list: gtk::ListBox,
|
|
|
|
|
|
|
|
|
|
// 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>,
|
|
|
|
|
/// The note already spoken as a toast, for the same reason.
|
|
|
|
|
last_status: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
sidebar.set_position(250);
|
|
|
|
|
// 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"),
|
|
|
|
|
];
|
|
|
|
|
let mut folder_counts = Vec::new();
|
|
|
|
|
for (row, count) in folder_rows {
|
|
|
|
|
folders.append(&row);
|
|
|
|
|
folder_counts.push(count);
|
|
|
|
|
}
|
|
|
|
|
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, with the copy the sidebar never
|
|
|
|
|
// used to offer.
|
|
|
|
|
let own_row = gtk::Box::new(Orientation::Horizontal, 6);
|
|
|
|
|
own_row.add_css_class("card");
|
|
|
|
|
own_row.set_margin_top(6);
|
|
|
|
|
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");
|
|
|
|
|
sidebar_own.set_selectable(true);
|
|
|
|
|
sidebar_own.set_ellipsize(gtk::pango::EllipsizeMode::End);
|
|
|
|
|
own_text.append(&own_caption);
|
|
|
|
|
own_text.append(&sidebar_own);
|
|
|
|
|
let own_copy_small = icon_button("edit-copy-symbolic", "Copy your address");
|
|
|
|
|
own_copy_small.set_margin_end(6);
|
|
|
|
|
own_row.append(&own_text);
|
|
|
|
|
own_row.append(&own_copy_small);
|
|
|
|
|
sidebar_bottom.append(&own_row);
|
|
|
|
|
|
|
|
|
|
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(250);
|
|
|
|
|
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);
|
|
|
|
|
let article_meta = label("", "caption");
|
|
|
|
|
article_meta.set_wrap(true);
|
|
|
|
|
article_head.append(&article_subject);
|
|
|
|
|
article_head.append(&article_meta);
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
let article_empty = adw::StatusPage::builder()
|
|
|
|
|
.icon_name("mail-read-symbolic")
|
|
|
|
|
.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());
|
2026-10-01 23:12:21 +03:00
|
|
|
}
|
2026-10-02 22:32:12 +03:00
|
|
|
});
|
|
|
|
|
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);
|
|
|
|
|
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"));
|
|
|
|
|
|
|
|
|
|
// contacts
|
|
|
|
|
let contacts = gtk::Box::new(Orientation::Vertical, 12);
|
|
|
|
|
contacts.set_margin_top(12);
|
|
|
|
|
contacts.set_margin_bottom(12);
|
|
|
|
|
contacts.set_margin_start(12);
|
|
|
|
|
contacts.set_margin_end(12);
|
|
|
|
|
let own_card = gtk::Box::new(Orientation::Horizontal, 12);
|
|
|
|
|
own_card.add_css_class("card");
|
|
|
|
|
let own_column = gtk::Box::new(Orientation::Vertical, 6);
|
|
|
|
|
own_column.set_hexpand(true);
|
|
|
|
|
own_column.set_valign(Align::Center);
|
|
|
|
|
own_column.set_margin_top(12);
|
|
|
|
|
own_column.set_margin_bottom(12);
|
|
|
|
|
own_column.set_margin_start(12);
|
|
|
|
|
own_column.append(&label("you", "caption"));
|
|
|
|
|
let own_label = label("", "mono");
|
|
|
|
|
own_label.set_wrap(true);
|
|
|
|
|
own_label.set_wrap_mode(gtk::pango::WrapMode::Char);
|
|
|
|
|
own_label.set_selectable(true);
|
|
|
|
|
own_column.append(&own_label);
|
|
|
|
|
let own_copy = icon_label_button("edit-copy-symbolic", "Copy", "");
|
|
|
|
|
own_copy.set_halign(Align::Start);
|
|
|
|
|
own_column.append(&own_copy);
|
|
|
|
|
own_card.append(&own_column);
|
|
|
|
|
let qr = gtk::Image::new();
|
|
|
|
|
qr.set_pixel_size(144);
|
|
|
|
|
qr.set_margin_top(12);
|
|
|
|
|
qr.set_margin_bottom(12);
|
|
|
|
|
qr.set_margin_end(12);
|
|
|
|
|
own_card.append(&qr);
|
|
|
|
|
contacts.append(&own_card);
|
|
|
|
|
let import_list = boxed_list();
|
|
|
|
|
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);
|
|
|
|
|
import_list.append(&contact_import);
|
|
|
|
|
contacts.append(&import_list);
|
|
|
|
|
let contacts_page_scroll = gtk::ScrolledWindow::new();
|
|
|
|
|
contacts_page_scroll.set_vexpand(true);
|
|
|
|
|
let contacts_page_list = boxed_list();
|
|
|
|
|
contacts_page_list.set_valign(Align::Start);
|
|
|
|
|
contacts_page_scroll.set_child(Some(&contacts_page_list));
|
|
|
|
|
contacts.append(&contacts_page_scroll);
|
|
|
|
|
stack.add_named(&contacts, Some("contacts"));
|
|
|
|
|
|
|
|
|
|
// 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);
|
2026-10-01 23:12:21 +03:00
|
|
|
}
|
2026-10-02 22:32:12 +03:00
|
|
|
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 -------------------------------------------------
|
|
|
|
|
// Work in progress gets its own bottom bar, revealed only while
|
|
|
|
|
// something runs; finished notes speak through a toast instead, so
|
|
|
|
|
// the chrome is present exactly as long as the work is.
|
|
|
|
|
let status_bar = gtk::Box::new(Orientation::Horizontal, 10);
|
|
|
|
|
status_bar.add_css_class("toolbar");
|
|
|
|
|
let spinner = gtk::Spinner::new();
|
|
|
|
|
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);
|
|
|
|
|
toolbar.set_reveal_bottom_bars(false);
|
|
|
|
|
|
|
|
|
|
// 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(225);
|
|
|
|
|
let toasts = adw::ToastOverlay::new();
|
|
|
|
|
toasts.set_child(Some(&columns));
|
|
|
|
|
root.set_child(Some(&toasts));
|
|
|
|
|
|
|
|
|
|
// --- 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,
|
|
|
|
|
_ => return,
|
2026-10-01 23:12:21 +03:00
|
|
|
};
|
2026-10-02 22:32:12 +03:00
|
|
|
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,
|
|
|
|
|
_ => 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()));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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_copy.connect_clicked(input(Message::CopyOwnAddress));
|
|
|
|
|
own_copy_small.connect_clicked(input(Message::CopyOwnAddress));
|
|
|
|
|
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,
|
|
|
|
|
toasts,
|
|
|
|
|
toolbar,
|
|
|
|
|
folders,
|
|
|
|
|
folder_counts,
|
|
|
|
|
settings_nav,
|
|
|
|
|
fetch_button,
|
|
|
|
|
write_button,
|
|
|
|
|
stack,
|
|
|
|
|
list_stack,
|
|
|
|
|
mail_list,
|
|
|
|
|
list_empty,
|
|
|
|
|
list_empty_fetch,
|
|
|
|
|
article_stack,
|
|
|
|
|
article_subject,
|
|
|
|
|
article_meta,
|
|
|
|
|
article_view,
|
|
|
|
|
article_body,
|
|
|
|
|
actions,
|
|
|
|
|
compose_to,
|
|
|
|
|
compose_subject,
|
|
|
|
|
compose_body,
|
|
|
|
|
compose_send,
|
|
|
|
|
sidebar_own,
|
|
|
|
|
sidebar_contacts: contacts_list,
|
|
|
|
|
own_label,
|
|
|
|
|
qr,
|
|
|
|
|
contacts_list: contacts_page_list,
|
|
|
|
|
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,
|
|
|
|
|
last_status: String::new(),
|
2026-10-01 23:12:21 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-02 22:32:12 +03:00
|
|
|
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);
|
2026-10-01 23:12:21 +03:00
|
|
|
}
|
2026-10-02 22:32:12 +03:00
|
|
|
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);
|
2026-10-01 23:12:21 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-02 22:32:12 +03:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-01 23:12:21 +03:00
|
|
|
|
2026-10-02 22:32:12 +03:00
|
|
|
fn update_view(&self, widgets: &mut Self::Widgets, sender: ComponentSender<Self>) {
|
|
|
|
|
render(self, widgets, &sender);
|
|
|
|
|
}
|
2026-10-01 23:12:21 +03:00
|
|
|
}
|
|
|
|
|
|
2026-10-02 22:32:12 +03:00
|
|
|
/// 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,
|
|
|
|
|
} => 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(),
|
|
|
|
|
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,
|
|
|
|
|
)));
|
|
|
|
|
}),
|
2026-10-01 23:12:21 +03:00
|
|
|
}
|
|
|
|
|
}
|
2026-10-02 22:32:12 +03:00
|
|
|
/// 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())
|
2026-10-02 00:08:48 +03:00
|
|
|
};
|
2026-10-01 23:12:21 +03:00
|
|
|
|
2026-10-02 22:32:12 +03:00
|
|
|
// Prefill entries once after a reply or write-to-contact; afterwards
|
|
|
|
|
// typing flows entry -> model only, so the view never fights the user.
|
|
|
|
|
if model.compose_fresh.take() {
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-01 23:12:21 +03:00
|
|
|
|
2026-10-02 22:32:12 +03:00
|
|
|
// 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)
|
2026-10-01 23:12:21 +03:00
|
|
|
};
|
2026-10-02 22:32:12 +03:00
|
|
|
for (count, value) in w.folder_counts.iter().zip([unread(0), unread(1), 0]) {
|
|
|
|
|
count.set_text(&value.to_string());
|
|
|
|
|
count.set_visible(value > 0);
|
2026-10-01 23:12:21 +03:00
|
|
|
}
|
2026-10-02 22:32:12 +03:00
|
|
|
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());
|
2026-10-02 00:08:48 +03:00
|
|
|
} else {
|
2026-10-02 22:32:12 +03:00
|
|
|
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());
|
2026-10-01 23:12:21 +03:00
|
|
|
|
2026-10-02 22:32:12 +03:00
|
|
|
// Page stack.
|
|
|
|
|
let page = if model.compose.is_some() {
|
|
|
|
|
"compose"
|
2026-10-01 23:12:21 +03:00
|
|
|
} else {
|
2026-10-02 22:32:12 +03:00
|
|
|
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::Contacts => "contacts",
|
|
|
|
|
Page::Settings => "settings",
|
|
|
|
|
}
|
2026-10-01 23:12:21 +03:00
|
|
|
};
|
2026-10-02 22:32:12 +03:00
|
|
|
w.stack.set_visible_child_name(page);
|
2026-10-01 23:12:21 +03:00
|
|
|
|
2026-10-02 22:32:12 +03:00
|
|
|
// Mail list.
|
|
|
|
|
clear_list(&w.mail_list);
|
|
|
|
|
let rows = model
|
|
|
|
|
.lists
|
|
|
|
|
.get(model.folder as usize)
|
|
|
|
|
.cloned()
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
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.",
|
|
|
|
|
),
|
2026-10-01 23:12:21 +03:00
|
|
|
};
|
2026-10-02 22:32:12 +03:00
|
|
|
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);
|
2026-10-01 23:12:21 +03:00
|
|
|
}
|
2026-10-02 22:32:12 +03:00
|
|
|
w.mail_list.select_row(
|
|
|
|
|
selected_row
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map(|row| row.upcast_ref::<gtk::ListBoxRow>()),
|
|
|
|
|
);
|
2026-10-01 23:12:21 +03:00
|
|
|
|
2026-10-02 22:32:12 +03:00
|
|
|
// Article.
|
|
|
|
|
let contact_detail = model.selected_contact.as_ref().and_then(|address| {
|
|
|
|
|
model
|
|
|
|
|
.snapshot
|
|
|
|
|
.as_ref()?
|
|
|
|
|
.contacts
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|c| &c.address == address)
|
|
|
|
|
});
|
|
|
|
|
match (&model.open, contact_detail) {
|
|
|
|
|
(Some((folder, msg)), _) => {
|
|
|
|
|
w.article_stack.set_visible_child_name("detail");
|
|
|
|
|
// A message body is prose; only the contact 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"
|
2026-10-01 23:12:21 +03:00
|
|
|
} else {
|
2026-10-02 22:32:12 +03:00
|
|
|
"from"
|
|
|
|
|
};
|
|
|
|
|
let mut meta = format!("{label} {} · {} UTC", msg.from, 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");
|
2026-10-01 23:12:21 +03:00
|
|
|
}
|
2026-10-02 22:32:12 +03:00
|
|
|
w.article_meta.set_text(&meta);
|
|
|
|
|
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);
|
2026-10-01 23:12:21 +03:00
|
|
|
}
|
2026-10-02 22:32:12 +03:00
|
|
|
(None, 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(&format!(
|
|
|
|
|
"key {}\nfingerprint {}\nsmol://{}/{}",
|
|
|
|
|
contact.key, contact.key, contact.address, contact.key
|
2026-10-01 23:12:21 +03:00
|
|
|
));
|
2026-10-02 22:32:12 +03:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-01 23:12:21 +03:00
|
|
|
}
|
2026-10-02 22:32:12 +03:00
|
|
|
(None, None) => {
|
|
|
|
|
w.article_stack.set_visible_child_name("empty");
|
|
|
|
|
clear(&w.actions);
|
2026-10-01 23:12:21 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-02 22:32:12 +03:00
|
|
|
// 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"
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-10-01 23:12:21 +03:00
|
|
|
|
2026-10-02 22:32:12 +03:00
|
|
|
// 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>()),
|
2026-10-01 23:12:21 +03:00
|
|
|
);
|
|
|
|
|
}
|
2026-10-02 22:32:12 +03:00
|
|
|
if let Some(snap) = &model.snapshot {
|
|
|
|
|
let uri = format!("smol://{}/{}", snap.who.address, snap.who.key);
|
|
|
|
|
w.own_label.set_text(&uri);
|
|
|
|
|
if let Some(texture) = qr_texture(&uri) {
|
|
|
|
|
w.qr.set_paintable(Some(&texture));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
clear_list(&w.contacts_list);
|
|
|
|
|
if let Some(snap) = &model.snapshot {
|
|
|
|
|
if snap.contacts.is_empty() {
|
|
|
|
|
let empty = adw::ActionRow::builder()
|
|
|
|
|
.title("No contacts yet")
|
|
|
|
|
.subtitle("Import a smol:// address above to add one.")
|
|
|
|
|
.build();
|
|
|
|
|
w.contacts_list.append(&empty);
|
|
|
|
|
}
|
|
|
|
|
for contact in &snap.contacts {
|
|
|
|
|
w.contacts_list.append(&contact_row(contact, sender));
|
|
|
|
|
}
|
2026-10-01 23:12:21 +03:00
|
|
|
}
|
|
|
|
|
|
2026-10-02 22:32:12 +03:00
|
|
|
// 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);
|
2026-10-01 23:12:21 +03:00
|
|
|
|
2026-10-02 22:32:12 +03:00
|
|
|
// Onboarding.
|
|
|
|
|
if model.onboard.mode == OnboardMode::Created {
|
|
|
|
|
w.onboard_seed_label.set_text(&model.onboard.seed_shown);
|
|
|
|
|
}
|
2026-10-01 23:12:21 +03:00
|
|
|
|
2026-10-02 22:32:12 +03:00
|
|
|
// Signup.
|
|
|
|
|
w.signup_back.set_visible(model.signup.fresh);
|
2026-10-01 23:12:21 +03:00
|
|
|
|
2026-10-02 22:32:12 +03:00
|
|
|
// 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 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
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
alert.set_close_response("cancel");
|
|
|
|
|
alert.set_default_response(Some("cancel"));
|
|
|
|
|
let response_sender = sender.clone();
|
|
|
|
|
alert.connect_response(None, move |_, response| {
|
|
|
|
|
response_sender.input(match (rotating, response) {
|
|
|
|
|
(true, "rotate") => Message::RotateConfirmed,
|
|
|
|
|
(true, _) => Message::RotateCancelled,
|
|
|
|
|
(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();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
2026-10-01 23:12:21 +03:00
|
|
|
|
2026-10-02 22:32:12 +03:00
|
|
|
// The bottom bar carries work in progress and nothing else, so it is
|
|
|
|
|
// revealed exactly as long as something runs.
|
|
|
|
|
w.toolbar.set_reveal_bottom_bars(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, _) => String::new(),
|
|
|
|
|
});
|
|
|
|
|
w.cancel_button.set_visible(fetching);
|
|
|
|
|
|
|
|
|
|
// A finished result speaks once, as a toast. Errors hold until they are
|
|
|
|
|
// dismissed; everything else fades on its own.
|
|
|
|
|
let note = match (&model.busy, &model.status) {
|
|
|
|
|
(None, Some(status)) => status.clone(),
|
|
|
|
|
_ => String::new(),
|
2026-10-01 23:12:21 +03:00
|
|
|
};
|
2026-10-02 22:32:12 +03:00
|
|
|
if !note.is_empty() && note != w.last_status {
|
|
|
|
|
let toast = adw::Toast::new(¬e);
|
|
|
|
|
if model.status_error {
|
|
|
|
|
toast.set_timeout(0);
|
|
|
|
|
}
|
|
|
|
|
w.toasts.add_toast(toast);
|
|
|
|
|
}
|
|
|
|
|
w.last_status = note;
|
2026-10-01 23:12:21 +03:00
|
|
|
}
|
|
|
|
|
|
2026-10-02 22:32:12 +03:00
|
|
|
/// 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");
|
2026-10-01 23:12:21 +03:00
|
|
|
}
|
2026-10-02 22:32:12 +03:00
|
|
|
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
|
2026-10-01 23:12:21 +03:00
|
|
|
}
|
|
|
|
|
|
2026-10-02 22:32:12 +03:00
|
|
|
/// 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
|
|
|
|
|
}
|
2026-10-01 23:12:21 +03:00
|
|
|
|
2026-10-02 22:32:12 +03:00
|
|
|
/// One contact on the contacts page: the address over its key, the trust
|
|
|
|
|
/// state and the actions that state allows.
|
|
|
|
|
fn contact_row(contact: &Contact, sender: &ComponentSender<Carrier>) -> adw::ActionRow {
|
|
|
|
|
let row = adw::ActionRow::builder()
|
|
|
|
|
.title(escape(&contact.address))
|
|
|
|
|
.subtitle(escape(&contact.key))
|
|
|
|
|
.build();
|
|
|
|
|
row.set_title_lines(1);
|
|
|
|
|
row.set_subtitle_lines(1);
|
|
|
|
|
let state = label(contact_state(contact), "caption");
|
|
|
|
|
state.set_valign(Align::Center);
|
|
|
|
|
row.add_suffix(&state);
|
|
|
|
|
|
|
|
|
|
let write = icon_button("mail-message-new-symbolic", "Write to this contact");
|
|
|
|
|
let write_msg = Message::ContactWrite(contact.address.clone());
|
|
|
|
|
let write_sender = sender.clone();
|
|
|
|
|
write.connect_clicked(move |_| write_sender.input(write_msg.clone()));
|
|
|
|
|
row.add_suffix(&write);
|
|
|
|
|
|
|
|
|
|
match contact.accepted {
|
|
|
|
|
Some(true) => {
|
|
|
|
|
let block = icon_button("action-unavailable-symbolic", "Block this contact");
|
|
|
|
|
let msg = Message::ContactBlock(contact.address.clone());
|
|
|
|
|
let block_sender = sender.clone();
|
|
|
|
|
block.connect_clicked(move |_| block_sender.input(msg.clone()));
|
|
|
|
|
row.add_suffix(&block);
|
|
|
|
|
}
|
|
|
|
|
Some(false) => {
|
|
|
|
|
let unblock = icon_button("object-select-symbolic", "Unblock this contact");
|
|
|
|
|
let msg = Message::ContactUnblock(contact.address.clone());
|
|
|
|
|
let unblock_sender = sender.clone();
|
|
|
|
|
unblock.connect_clicked(move |_| unblock_sender.input(msg.clone()));
|
|
|
|
|
row.add_suffix(&unblock);
|
|
|
|
|
}
|
|
|
|
|
None => {
|
|
|
|
|
let accept = icon_button("object-select-symbolic", "Accept this contact");
|
|
|
|
|
let msg = Message::ContactAccept(contact.address.clone(), contact.key.clone());
|
|
|
|
|
let accept_sender = sender.clone();
|
|
|
|
|
accept.connect_clicked(move |_| accept_sender.input(msg.clone()));
|
|
|
|
|
row.add_suffix(&accept);
|
|
|
|
|
}
|
2026-10-01 23:12:21 +03:00
|
|
|
}
|
2026-10-02 22:32:12 +03:00
|
|
|
row
|
2026-10-01 23:12:21 +03:00
|
|
|
}
|
|
|
|
|
|
2026-10-02 22:32:12 +03:00
|
|
|
/// The own-address QR as an RGBA texture; black on white, the form every
|
|
|
|
|
/// scanner expects, taking no cues from the theme.
|
|
|
|
|
fn qr_texture(uri: &str) -> Option<gtk::gdk::MemoryTexture> {
|
|
|
|
|
let code = qrcodegen::QrCode::encode_text(uri, qrcodegen::QrCodeEcc::Medium).ok()?;
|
|
|
|
|
let size = code.size();
|
|
|
|
|
let stride = size as usize * 4;
|
|
|
|
|
let mut pixels = Vec::with_capacity(size as usize * size as usize * 4);
|
|
|
|
|
for y in 0..size {
|
|
|
|
|
for x in 0..size {
|
|
|
|
|
let dark = code.get_module(x, y);
|
|
|
|
|
let (r, g, b) = if dark { (0, 0, 0) } else { (0xff, 0xff, 0xff) };
|
|
|
|
|
pixels.extend_from_slice(&[r, g, b, 0xff]);
|
|
|
|
|
}
|
2026-10-01 23:12:21 +03:00
|
|
|
}
|
2026-10-02 22:32:12 +03:00
|
|
|
Some(gtk::gdk::MemoryTexture::new(
|
|
|
|
|
size,
|
|
|
|
|
size,
|
|
|
|
|
gtk::gdk::MemoryFormat::R8g8b8a8,
|
|
|
|
|
>k::glib::Bytes::from_owned(pixels),
|
|
|
|
|
stride,
|
|
|
|
|
))
|
2026-10-01 23:12:21 +03:00
|
|
|
}
|