feat: unify the detail view with QR sharing and default pane layout
This commit is contained in:
parent
84650e50bd
commit
22c20427f0
4 changed files with 256 additions and 164 deletions
|
|
@ -9,7 +9,7 @@ A minimalist desktop client for [Smol Mail](../smolmail): one compact window —
|
||||||
- Fetch (with cancel), read, reply and send sealed mail; the inbox, the requests tier and sent copies each keep their own folder.
|
- Fetch (with cancel), read, reply and send sealed mail; the inbox, the requests tier and sent copies each keep their own folder.
|
||||||
- Composes closed by any sidebar trip are stashed as drafts — kept beside the store, reopened from a folder that appears only once one exists; Cancel is the only way to throw one away.
|
- Composes closed by any sidebar trip are stashed as drafts — kept beside the store, reopened from a folder that appears only once one exists; Cancel is the only way to throw one away.
|
||||||
- Accept or block a requester when their mail carried a self-certifying reply address; accept tokens attach to accepted contacts automatically.
|
- Accept or block a requester when their mail carried a self-certifying reply address; accept tokens attach to accepted contacts automatically.
|
||||||
- Contacts with verified/tofu marks and smol:// import; your own address as a URI, a QR code and a clipboard line.
|
- Contacts with verified/tofu marks and smol:// import; your own identity opens in the same detail view, its address in the header bar with a QR share beside it, key, fingerprint and URI below.
|
||||||
- Key rotation with a confirm-first step, server pin management, a network timeout, and gsmol backup export/import that never contains the seed.
|
- Key rotation with a confirm-first step, server pin management, a network timeout, and gsmol backup export/import that never contains the seed.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
|
||||||
50
src/app.rs
50
src/app.rs
|
|
@ -22,7 +22,6 @@ pub enum Page {
|
||||||
Onboard,
|
Onboard,
|
||||||
Signup,
|
Signup,
|
||||||
Mail,
|
Mail,
|
||||||
Contacts,
|
|
||||||
Settings,
|
Settings,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -159,13 +158,15 @@ pub enum Message {
|
||||||
// Contacts
|
// Contacts
|
||||||
/// A sidebar contact was clicked: the article pane shows its details.
|
/// A sidebar contact was clicked: the article pane shows its details.
|
||||||
ContactSelected(String),
|
ContactSelected(String),
|
||||||
|
/// The own identity was clicked: the same detail view, this time with
|
||||||
|
/// our own key lines in it.
|
||||||
|
OwnSelected,
|
||||||
ContactImportChanged(String),
|
ContactImportChanged(String),
|
||||||
ContactImportPressed,
|
ContactImportPressed,
|
||||||
ContactWrite(String),
|
ContactWrite(String),
|
||||||
ContactAccept(String, String),
|
ContactAccept(String, String),
|
||||||
ContactBlock(String),
|
ContactBlock(String),
|
||||||
ContactUnblock(String),
|
ContactUnblock(String),
|
||||||
CopyOwnAddress,
|
|
||||||
CopyExport,
|
CopyExport,
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
|
|
@ -272,6 +273,8 @@ pub struct Carrier {
|
||||||
/// The contact whose details the article pane shows, selected from
|
/// The contact whose details the article pane shows, selected from
|
||||||
/// the sidebar's bottom panel; a message selection clears it.
|
/// the sidebar's bottom panel; a message selection clears it.
|
||||||
pub selected_contact: Option<String>,
|
pub selected_contact: Option<String>,
|
||||||
|
/// True when the article pane shows our own identity's details.
|
||||||
|
pub selected_own: bool,
|
||||||
pub compose: Option<Compose>,
|
pub compose: Option<Compose>,
|
||||||
/// Composes set aside by a sidebar trip instead of Cancel, kept in
|
/// Composes set aside by a sidebar trip instead of Cancel, kept in
|
||||||
/// the drafts sidecar; the folder only shows once it holds one.
|
/// the drafts sidecar; the folder only shows once it holds one.
|
||||||
|
|
@ -333,6 +336,7 @@ impl Carrier {
|
||||||
lists: vec![Vec::new(), Vec::new(), Vec::new()],
|
lists: vec![Vec::new(), Vec::new(), Vec::new()],
|
||||||
open: None,
|
open: None,
|
||||||
selected_contact: None,
|
selected_contact: None,
|
||||||
|
selected_own: false,
|
||||||
compose: None,
|
compose: None,
|
||||||
drafts,
|
drafts,
|
||||||
snapshot: None,
|
snapshot: None,
|
||||||
|
|
@ -519,10 +523,18 @@ impl Carrier {
|
||||||
}
|
}
|
||||||
Message::ContactSelected(address) => {
|
Message::ContactSelected(address) => {
|
||||||
self.stash_draft();
|
self.stash_draft();
|
||||||
|
self.selected_own = false;
|
||||||
self.selected_contact = Some(address);
|
self.selected_contact = Some(address);
|
||||||
self.open = None;
|
self.open = None;
|
||||||
self.page = Page::Mail;
|
self.page = Page::Mail;
|
||||||
}
|
}
|
||||||
|
Message::OwnSelected => {
|
||||||
|
self.stash_draft();
|
||||||
|
self.selected_own = true;
|
||||||
|
self.selected_contact = None;
|
||||||
|
self.open = None;
|
||||||
|
self.page = Page::Mail;
|
||||||
|
}
|
||||||
Message::FetchCancelled => {
|
Message::FetchCancelled => {
|
||||||
if self.busy.is_some() {
|
if self.busy.is_some() {
|
||||||
self.cancel.store(true, Ordering::Relaxed);
|
self.cancel.store(true, Ordering::Relaxed);
|
||||||
|
|
@ -661,6 +673,7 @@ impl Carrier {
|
||||||
self.page = Page::Mail;
|
self.page = Page::Mail;
|
||||||
self.folder = folder;
|
self.folder = folder;
|
||||||
self.open = None;
|
self.open = None;
|
||||||
|
self.selected_own = false;
|
||||||
self.selected_contact = None;
|
self.selected_contact = None;
|
||||||
return self.refresh().into_iter().collect();
|
return self.refresh().into_iter().collect();
|
||||||
}
|
}
|
||||||
|
|
@ -668,12 +681,14 @@ impl Carrier {
|
||||||
self.stash_draft();
|
self.stash_draft();
|
||||||
self.page = page;
|
self.page = page;
|
||||||
self.open = None;
|
self.open = None;
|
||||||
|
self.selected_own = false;
|
||||||
self.selected_contact = None;
|
self.selected_contact = None;
|
||||||
if page == Page::Mail || page == Page::Contacts || page == Page::Settings {
|
if page == Page::Mail || page == Page::Settings {
|
||||||
return self.refresh().into_iter().collect();
|
return self.refresh().into_iter().collect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Message::MessageSelected(folder, id) => {
|
Message::MessageSelected(folder, id) => {
|
||||||
|
self.selected_own = false;
|
||||||
self.selected_contact = None;
|
self.selected_contact = None;
|
||||||
if folder == Folder::Drafts {
|
if folder == Folder::Drafts {
|
||||||
self.open_draft(&id);
|
self.open_draft(&id);
|
||||||
|
|
@ -866,14 +881,6 @@ impl Carrier {
|
||||||
};
|
};
|
||||||
return vec![PendingOp::Accept { address, identity }];
|
return vec![PendingOp::Accept { address, identity }];
|
||||||
}
|
}
|
||||||
Message::CopyOwnAddress => {
|
|
||||||
if let Some(snap) = &self.snapshot {
|
|
||||||
let uri = format!("smol://{}/{}", snap.who.address, snap.who.key);
|
|
||||||
if let Some(display) = gtk::gdk::Display::default() {
|
|
||||||
display.clipboard().set_text(&uri);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Message::CopyExport => {
|
Message::CopyExport => {
|
||||||
if let Some(text) = &self.backup_text {
|
if let Some(text) = &self.backup_text {
|
||||||
if let Some(display) = gtk::gdk::Display::default() {
|
if let Some(display) = gtk::gdk::Display::default() {
|
||||||
|
|
@ -1036,6 +1043,27 @@ mod tests {
|
||||||
assert_eq!(app.busy.as_deref(), Some("fetching"));
|
assert_eq!(app.busy.as_deref(), Some("fetching"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn own_and_contact_selection_supersede_each_other() {
|
||||||
|
let mut app = app();
|
||||||
|
app.handle(Message::OwnSelected);
|
||||||
|
assert!(app.selected_own);
|
||||||
|
assert_eq!(app.page, Page::Mail);
|
||||||
|
assert!(app.open.is_none());
|
||||||
|
|
||||||
|
app.handle(Message::ContactSelected("bob@localhost".into()));
|
||||||
|
assert!(!app.selected_own);
|
||||||
|
assert_eq!(app.selected_contact.as_deref(), Some("bob@localhost"));
|
||||||
|
|
||||||
|
app.handle(Message::OwnSelected);
|
||||||
|
assert!(app.selected_own);
|
||||||
|
assert!(app.selected_contact.is_none());
|
||||||
|
|
||||||
|
// A folder trip closes the detail, own included.
|
||||||
|
app.handle(Message::GotoMail(Folder::Sent));
|
||||||
|
assert!(!app.selected_own);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn text_edits_pass_the_busy_guard() {
|
fn text_edits_pass_the_busy_guard() {
|
||||||
let mut app = app();
|
let mut app = app();
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,8 @@ pub struct OpenedMsg {
|
||||||
pub struct Contact {
|
pub struct Contact {
|
||||||
pub address: String,
|
pub address: String,
|
||||||
pub key: String,
|
pub key: String,
|
||||||
|
/// The key's short grouped form, the line the detail view shows.
|
||||||
|
pub fingerprint: String,
|
||||||
pub verified: bool,
|
pub verified: bool,
|
||||||
/// Some(true) accepted, Some(false) blocked, None never decided.
|
/// Some(true) accepted, Some(false) blocked, None never decided.
|
||||||
pub accepted: Option<bool>,
|
pub accepted: Option<bool>,
|
||||||
|
|
@ -472,6 +474,7 @@ pub fn contacts(store: &Store) -> Result<Vec<Contact>, String> {
|
||||||
.map(|(address, identity, verified, accepted)| Contact {
|
.map(|(address, identity, verified, accepted)| Contact {
|
||||||
address,
|
address,
|
||||||
key: b32(&identity),
|
key: b32(&identity),
|
||||||
|
fingerprint: fingerprint(&identity),
|
||||||
verified,
|
verified,
|
||||||
accepted: accepted.map(|state| state == 1),
|
accepted: accepted.map(|state| state == 1),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
365
src/view.rs
365
src/view.rs
|
|
@ -213,10 +213,10 @@ pub struct CarrierWidgets {
|
||||||
sidebar_own: gtk::Label,
|
sidebar_own: gtk::Label,
|
||||||
sidebar_contacts: gtk::ListBox,
|
sidebar_contacts: gtk::ListBox,
|
||||||
|
|
||||||
// contacts page
|
// the QR-only share popover on the detail view's header
|
||||||
own_label: gtk::Label,
|
detail_share: gtk::MenuButton,
|
||||||
qr: gtk::Image,
|
detail_copy: gtk::Button,
|
||||||
contacts_list: gtk::ListBox,
|
detail_qr: gtk::Image,
|
||||||
|
|
||||||
// settings page
|
// settings page
|
||||||
identity_values: Vec<gtk::Label>,
|
identity_values: Vec<gtk::Label>,
|
||||||
|
|
@ -268,7 +268,9 @@ impl CarrierWidgets {
|
||||||
// the actions and the destinations; the bottom panel leads with
|
// the actions and the destinations; the bottom panel leads with
|
||||||
// our own identity, carded apart from the contact list.
|
// our own identity, carded apart from the contact list.
|
||||||
let sidebar = gtk::Paned::new(Orientation::Vertical);
|
let sidebar = gtk::Paned::new(Orientation::Vertical);
|
||||||
sidebar.set_position(250);
|
// 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
|
// Refuse to shrink the top panel past its own minimum: otherwise
|
||||||
// dragging the divider up clips it from the top and the actions
|
// 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
|
// are the first thing to go. The filler below the folders has no
|
||||||
|
|
@ -337,11 +339,8 @@ impl CarrierWidgets {
|
||||||
sidebar_top.append(&settings_nav);
|
sidebar_top.append(&settings_nav);
|
||||||
|
|
||||||
// Our own identity heads the bottom panel, carded so it reads
|
// Our own identity heads the bottom panel, carded so it reads
|
||||||
// apart from the contacts below, with the copy the sidebar never
|
// apart from the contacts below. Tapping it opens the detail
|
||||||
// used to offer.
|
// view the contacts get, with our own key lines in it.
|
||||||
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);
|
let own_text = gtk::Box::new(Orientation::Vertical, 0);
|
||||||
own_text.set_hexpand(true);
|
own_text.set_hexpand(true);
|
||||||
own_text.set_margin_top(8);
|
own_text.set_margin_top(8);
|
||||||
|
|
@ -349,16 +348,51 @@ impl CarrierWidgets {
|
||||||
own_text.set_margin_start(12);
|
own_text.set_margin_start(12);
|
||||||
let own_caption = label("you", "caption");
|
let own_caption = label("you", "caption");
|
||||||
let sidebar_own = label("", "mono heading");
|
let sidebar_own = label("", "mono heading");
|
||||||
sidebar_own.set_selectable(true);
|
// 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);
|
sidebar_own.set_ellipsize(gtk::pango::EllipsizeMode::End);
|
||||||
own_text.append(&own_caption);
|
own_text.append(&own_caption);
|
||||||
own_text.append(&sidebar_own);
|
own_text.append(&sidebar_own);
|
||||||
let own_copy_small = icon_button("edit-copy-symbolic", "Copy your address");
|
let own_row = gtk::Button::new();
|
||||||
own_copy_small.set_margin_end(6);
|
own_row.add_css_class("card");
|
||||||
own_row.append(&own_text);
|
own_row.set_halign(Align::Fill);
|
||||||
own_row.append(&own_copy_small);
|
own_row.set_margin_top(6);
|
||||||
|
own_row.set_child(Some(&own_text));
|
||||||
sidebar_bottom.append(&own_row);
|
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();
|
let contacts_scroll = gtk::ScrolledWindow::new();
|
||||||
contacts_scroll.set_vexpand(true);
|
contacts_scroll.set_vexpand(true);
|
||||||
let contacts_list = gtk::ListBox::new();
|
let contacts_list = gtk::ListBox::new();
|
||||||
|
|
@ -373,7 +407,7 @@ impl CarrierWidgets {
|
||||||
|
|
||||||
// mail: the split — list above, article below
|
// mail: the split — list above, article below
|
||||||
let paned = gtk::Paned::new(Orientation::Vertical);
|
let paned = gtk::Paned::new(Orientation::Vertical);
|
||||||
paned.set_position(250);
|
paned.set_position(1);
|
||||||
let list_scroll = gtk::ScrolledWindow::new();
|
let list_scroll = gtk::ScrolledWindow::new();
|
||||||
let mail_list = gtk::ListBox::new();
|
let mail_list = gtk::ListBox::new();
|
||||||
mail_list.set_selection_mode(gtk::SelectionMode::Single);
|
mail_list.set_selection_mode(gtk::SelectionMode::Single);
|
||||||
|
|
@ -401,9 +435,44 @@ impl CarrierWidgets {
|
||||||
article_head.set_margin_end(12);
|
article_head.set_margin_end(12);
|
||||||
let article_subject = label("", "subtitle");
|
let article_subject = label("", "subtitle");
|
||||||
article_subject.set_wrap(true);
|
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");
|
let article_meta = label("", "caption");
|
||||||
article_meta.set_wrap(true);
|
article_meta.set_wrap(true);
|
||||||
article_head.append(&article_subject);
|
article_head.append(&subject_row);
|
||||||
article_head.append(&article_meta);
|
article_head.append(&article_meta);
|
||||||
article.append(&article_head);
|
article.append(&article_head);
|
||||||
article.append(>k::Separator::new(Orientation::Horizontal));
|
article.append(>k::Separator::new(Orientation::Horizontal));
|
||||||
|
|
@ -493,54 +562,6 @@ impl CarrierWidgets {
|
||||||
compose.append(&compose_actions);
|
compose.append(&compose_actions);
|
||||||
stack.add_named(&compose, Some("compose"));
|
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
|
// settings: the preferences page every GNOME app shows, one group
|
||||||
// per concern, so scrolling, width and margins come for free.
|
// per concern, so scrolling, width and margins come for free.
|
||||||
let settings = adw::PreferencesPage::new();
|
let settings = adw::PreferencesPage::new();
|
||||||
|
|
@ -778,7 +799,50 @@ impl CarrierWidgets {
|
||||||
columns.set_start_child(Some(&sidebar));
|
columns.set_start_child(Some(&sidebar));
|
||||||
columns.set_end_child(Some(&toolbar));
|
columns.set_end_child(Some(&toolbar));
|
||||||
columns.set_resize_start_child(false);
|
columns.set_resize_start_child(false);
|
||||||
columns.set_position(225);
|
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
|
||||||
|
});
|
||||||
let toasts = adw::ToastOverlay::new();
|
let toasts = adw::ToastOverlay::new();
|
||||||
toasts.set_child(Some(&columns));
|
toasts.set_child(Some(&columns));
|
||||||
root.set_child(Some(&toasts));
|
root.set_child(Some(&toasts));
|
||||||
|
|
@ -842,8 +906,7 @@ impl CarrierWidgets {
|
||||||
body_sender.input(Message::ComposeBodyChanged(buffer_text(buffer)))
|
body_sender.input(Message::ComposeBodyChanged(buffer_text(buffer)))
|
||||||
});
|
});
|
||||||
|
|
||||||
own_copy.connect_clicked(input(Message::CopyOwnAddress));
|
own_row.connect_clicked(input(Message::OwnSelected));
|
||||||
own_copy_small.connect_clicked(input(Message::CopyOwnAddress));
|
|
||||||
copy_export.connect_clicked(input(Message::CopyExport));
|
copy_export.connect_clicked(input(Message::CopyExport));
|
||||||
import_button.connect_clicked(input(Message::ContactImportPressed));
|
import_button.connect_clicked(input(Message::ContactImportPressed));
|
||||||
let import_sender = sender.clone();
|
let import_sender = sender.clone();
|
||||||
|
|
@ -936,9 +999,9 @@ impl CarrierWidgets {
|
||||||
compose_send,
|
compose_send,
|
||||||
sidebar_own,
|
sidebar_own,
|
||||||
sidebar_contacts: contacts_list,
|
sidebar_contacts: contacts_list,
|
||||||
own_label,
|
detail_share,
|
||||||
qr,
|
detail_copy,
|
||||||
contacts_list: contacts_page_list,
|
detail_qr,
|
||||||
identity_values,
|
identity_values,
|
||||||
offer_group,
|
offer_group,
|
||||||
offer_row,
|
offer_row,
|
||||||
|
|
@ -1210,7 +1273,6 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
|
||||||
},
|
},
|
||||||
Page::Signup => "signup",
|
Page::Signup => "signup",
|
||||||
Page::Mail => "mail",
|
Page::Mail => "mail",
|
||||||
Page::Contacts => "contacts",
|
|
||||||
Page::Settings => "settings",
|
Page::Settings => "settings",
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -1267,6 +1329,10 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
|
||||||
);
|
);
|
||||||
|
|
||||||
// Article.
|
// 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| {
|
let contact_detail = model.selected_contact.as_ref().and_then(|address| {
|
||||||
model
|
model
|
||||||
.snapshot
|
.snapshot
|
||||||
|
|
@ -1275,10 +1341,12 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
|
||||||
.iter()
|
.iter()
|
||||||
.find(|c| &c.address == address)
|
.find(|c| &c.address == address)
|
||||||
});
|
});
|
||||||
match (&model.open, contact_detail) {
|
match (&model.open, own_detail, contact_detail) {
|
||||||
(Some((folder, msg)), _) => {
|
(Some((folder, msg)), _, _) => {
|
||||||
w.article_stack.set_visible_child_name("detail");
|
w.article_stack.set_visible_child_name("detail");
|
||||||
// A message body is prose; only the contact detail below is a
|
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.
|
// column of keys that has to stay aligned.
|
||||||
w.article_view.remove_css_class("monospace");
|
w.article_view.remove_css_class("monospace");
|
||||||
let subject = if msg.subject.is_empty() {
|
let subject = if msg.subject.is_empty() {
|
||||||
|
|
@ -1326,15 +1394,35 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
|
||||||
delete.connect_clicked(input(Message::DeletePressed));
|
delete.connect_clicked(input(Message::DeletePressed));
|
||||||
w.actions.append(&delete);
|
w.actions.append(&delete);
|
||||||
}
|
}
|
||||||
(None, Some(contact)) => {
|
(None, 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);
|
||||||
|
}
|
||||||
|
(None, _, Some(contact)) => {
|
||||||
w.article_stack.set_visible_child_name("detail");
|
w.article_stack.set_visible_child_name("detail");
|
||||||
w.article_view.add_css_class("monospace");
|
w.article_view.add_css_class("monospace");
|
||||||
w.article_subject.set_text(&contact.address);
|
w.article_subject.set_text(&contact.address);
|
||||||
w.article_meta.set_text(contact_state(contact));
|
w.article_meta.set_text(contact_state(contact));
|
||||||
w.article_body.set_text(&format!(
|
w.article_body.set_text(&detail_body(
|
||||||
"key {}\nfingerprint {}\nsmol://{}/{}",
|
&contact.address,
|
||||||
contact.key, contact.key, contact.address, contact.key
|
&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);
|
clear(&w.actions);
|
||||||
let write = icon_label_button("mail-message-new-symbolic", "Write", "");
|
let write = icon_label_button("mail-message-new-symbolic", "Write", "");
|
||||||
|
|
@ -1369,8 +1457,10 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(None, None) => {
|
(None, None, None) => {
|
||||||
w.article_stack.set_visible_child_name("empty");
|
w.article_stack.set_visible_child_name("empty");
|
||||||
|
w.detail_share.set_visible(false);
|
||||||
|
w.detail_copy.set_visible(false);
|
||||||
clear(&w.actions);
|
clear(&w.actions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1415,26 +1505,6 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
|
||||||
.map(|row| row.upcast_ref::<gtk::ListBoxRow>()),
|
.map(|row| row.upcast_ref::<gtk::ListBoxRow>()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settings: the identity rows are fixed, so only their values change.
|
// Settings: the identity rows are fixed, so only their values change.
|
||||||
if let Some(snap) = &model.snapshot {
|
if let Some(snap) = &model.snapshot {
|
||||||
|
|
@ -1653,68 +1723,59 @@ fn sidebar_contact_row(contact: &Contact) -> adw::ActionRow {
|
||||||
item
|
item
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One contact on the contacts page: the address over its key, the trust
|
/// The detail view's body: the three lines an address is made of, in
|
||||||
/// state and the actions that state allows.
|
/// the column-aligned form a key block wants.
|
||||||
fn contact_row(contact: &Contact, sender: &ComponentSender<Carrier>) -> adw::ActionRow {
|
fn detail_body(address: &str, key: &str, fingerprint: &str) -> String {
|
||||||
let row = adw::ActionRow::builder()
|
format!("key {key}\nfingerprint {fingerprint}\nsmol://{address}/{key}")
|
||||||
.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
row
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The own-address QR as an RGBA texture; black on white, the form every
|
/// Puts one URI's QR code into the detail header's share popover. A
|
||||||
/// scanner expects, taking no cues from the theme.
|
/// 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> {
|
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 code = qrcodegen::QrCode::encode_text(uri, qrcodegen::QrCodeEcc::Medium).ok()?;
|
||||||
let size = code.size();
|
let size = code.size();
|
||||||
let stride = size as usize * 4;
|
let scale = (210 / (size + 2 * QUIET)).clamp(3, 6);
|
||||||
let mut pixels = Vec::with_capacity(size as usize * size as usize * 4);
|
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 y in 0..size {
|
||||||
for x in 0..size {
|
for x in 0..size {
|
||||||
let dark = code.get_module(x, y);
|
if code.get_module(x, y) {
|
||||||
let (r, g, b) = if dark { (0, 0, 0) } else { (0xff, 0xff, 0xff) };
|
for dy in 0..scale {
|
||||||
pixels.extend_from_slice(&[r, g, b, 0xff]);
|
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(
|
Some(gtk::gdk::MemoryTexture::new(
|
||||||
size,
|
dim,
|
||||||
size,
|
dim,
|
||||||
gtk::gdk::MemoryFormat::R8g8b8a8,
|
gtk::gdk::MemoryFormat::R8g8b8a8,
|
||||||
>k::glib::Bytes::from_owned(pixels),
|
>k::glib::Bytes::from_owned(pixels),
|
||||||
stride,
|
stride,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue