feat: make senders tappable and replace the toasts with a fixed status line

This commit is contained in:
randogoth 2026-10-03 11:04:58 +03:00
parent 22c20427f0
commit 8e9d2a92a6
3 changed files with 112 additions and 92 deletions

BIN
smolmail.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View file

@ -525,14 +525,14 @@ impl Carrier {
self.stash_draft();
self.selected_own = false;
self.selected_contact = Some(address);
self.open = None;
// The open message stays: the detail shows over it while
// its list highlight holds, and picking it again returns.
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 => {
@ -1029,13 +1029,30 @@ mod tests {
assert_eq!(app.busy.as_deref(), Some("fetching"));
}
fn opened() -> OpenedMsg {
OpenedMsg {
id: "ab".repeat(32),
from: String::new(),
subject: String::new(),
time: 0,
body: String::new(),
sender: String::new(),
reply_to: None,
kept: false,
}
}
#[test]
#[test]
fn contact_selection_and_message_selection_supersede_each_other() {
let mut app = app();
// An open message survives a contact selection: the detail pane
// shows the contact while the mail keeps its list highlight.
app.open = Some((Folder::Inbox, opened()));
app.handle(Message::ContactSelected("bob@localhost".into()));
assert_eq!(app.selected_contact.as_deref(), Some("bob@localhost"));
assert_eq!(app.page, Page::Mail);
assert!(app.open.is_none());
assert!(app.open.is_some());
// busy does not block selecting a contact
app.busy = Some("fetching".into());
@ -1046,10 +1063,11 @@ mod tests {
#[test]
fn own_and_contact_selection_supersede_each_other() {
let mut app = app();
app.open = Some((Folder::Inbox, opened()));
app.handle(Message::OwnSelected);
assert!(app.selected_own);
assert_eq!(app.page, Page::Mail);
assert!(app.open.is_none());
assert!(app.open.is_some());
app.handle(Message::ContactSelected("bob@localhost".into()));
assert!(!app.selected_own);

View file

@ -177,9 +177,6 @@ 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>,
@ -242,8 +239,6 @@ pub struct CarrierWidgets {
/// 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 {
@ -774,12 +769,13 @@ impl CarrierWidgets {
stack.add_named(&center(&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.
// One fixed line at the bottom: the spinner and the running
// operation while something is busy, the last finished note
// afterwards — no transient chrome, nothing moving.
let status_bar = gtk::Box::new(Orientation::Horizontal, 10);
status_bar.add_css_class("toolbar");
let spinner = gtk::Spinner::new();
spinner.set_visible(false);
let status_label = label("", "caption");
status_label.set_hexpand(true);
let cancel_button = button("Cancel", "");
@ -791,7 +787,6 @@ impl CarrierWidgets {
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.
@ -843,9 +838,7 @@ impl CarrierWidgets {
}
gtk::glib::ControlFlow::Continue
});
let toasts = adw::ToastOverlay::new();
toasts.set_child(Some(&columns));
root.set_child(Some(&toasts));
root.set_child(Some(&columns));
// --- signal wiring ---------------------------------------------
// Both sidebar lists route through row-activated, which fires only
@ -891,6 +884,12 @@ impl CarrierWidgets {
contact_sender.input(Message::ContactSelected(row.widget_name().to_string()));
});
let meta_link = sender.clone();
article_meta.connect_activate_link(move |_, uri| {
meta_link.input(Message::ContactSelected(uri.to_string()));
gtk::glib::Propagation::Stop
});
compose_send_button.connect_clicked(input(Message::SendPressed));
compose_cancel.connect_clicked(input(Message::ComposeClosed));
let to_sender = sender.clone();
@ -974,8 +973,6 @@ impl CarrierWidgets {
CarrierWidgets {
window: root.clone(),
window_title,
toasts,
toolbar,
folders,
folder_counts,
drafts_nav,
@ -1017,7 +1014,6 @@ impl CarrierWidgets {
status_label,
cancel_button,
dialog: None,
last_status: String::new(),
}
}
}
@ -1341,60 +1337,11 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
.iter()
.find(|c| &c.address == address)
});
// The detail selections show over an open message, but the message
// stays selected underneath: its list highlight holds, and picking
// it again returns to it.
match (&model.open, own_detail, contact_detail) {
(Some((folder, msg)), _, _) => {
w.article_stack.set_visible_child_name("detail");
w.detail_share.set_visible(false);
w.detail_copy.set_visible(false);
// A message body is prose; only the detail below is a
// column of keys that has to stay aligned.
w.article_view.remove_css_class("monospace");
let subject = if msg.subject.is_empty() {
"(no subject)".to_string()
} else {
msg.subject.clone()
};
w.article_subject.set_text(&subject);
let label = if *folder == Folder::Sent {
"to"
} else {
"from"
};
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");
}
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);
}
(None, Some(own), _) => {
(_, Some(own), _) => {
w.article_stack.set_visible_child_name("detail");
w.article_view.add_css_class("monospace");
w.article_subject.set_text(&own.address);
@ -1407,7 +1354,7 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
w.detail_copy.set_visible(true);
clear(&w.actions);
}
(None, _, Some(contact)) => {
(_, _, Some(contact)) => {
w.article_stack.set_visible_child_name("detail");
w.article_view.add_css_class("monospace");
w.article_subject.set_text(&contact.address);
@ -1457,6 +1404,70 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
}
}
}
(Some((folder, msg)), _, _) => {
w.article_stack.set_visible_child_name("detail");
w.detail_share.set_visible(false);
w.detail_copy.set_visible(false);
// A message body is prose; only the detail below is a
// column of keys that has to stay aligned.
w.article_view.remove_css_class("monospace");
let subject = if msg.subject.is_empty() {
"(no subject)".to_string()
} else {
msg.subject.clone()
};
w.article_subject.set_text(&subject);
let label = if *folder == Folder::Sent {
"to"
} else {
"from"
};
// The address opens the sender's contact details when they
// are a contact; an unknown or keyless sender stays text.
let known = model
.snapshot
.as_ref()
.is_some_and(|snap| snap.contacts.iter().any(|c| c.address == msg.from));
let address = escape(&msg.from);
let mut meta = if known {
format!("{label} <a href='{address}'>{address}</a>")
} else {
format!("{label} {address}")
};
meta.push_str(&format!(" · {} UTC", escape(&ops::when(msg.time))));
meta.push_str(&format!(" · id {}…", &msg.id[..msg.id.len().min(8)]));
if msg.kept {
meta.push_str(" · a server copy still exists");
}
w.article_meta.set_markup(&meta);
w.article_body.set_text(&msg.body);
clear(&w.actions);
let reply = icon_label_button("mail-reply-sender-symbolic", "Reply", "");
reply.connect_clicked(input(Message::ReplyPressed));
w.actions.append(&reply);
if *folder == Folder::Requests {
if msg.reply_to.is_some() {
let accept = icon_label_button("object-select-symbolic", "Accept", "accent");
accept.connect_clicked(input(Message::AcceptPressed));
let block = icon_label_button("action-unavailable-symbolic", "Block", "danger");
block.connect_clicked(input(Message::BlockPressed));
w.actions.append(&accept);
w.actions.append(&block);
} else {
w.actions.append(&crate::view::label(
"no reply address: cannot accept or block",
"caption",
));
}
}
let spacer = gtk::Label::new(None);
spacer.set_hexpand(true);
w.actions.append(&spacer);
let delete = icon_label_button("user-trash-symbolic", "Delete", "danger");
delete.connect_clicked(input(Message::DeletePressed));
w.actions.append(&delete);
}
(None, None, None) => {
w.article_stack.set_visible_child_name("empty");
w.detail_share.set_visible(false);
@ -1636,9 +1647,9 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
_ => {}
}
// 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());
// The fixed bottom line: work in progress while something runs, the
// last note otherwise, errors tinted until the next note replaces them.
w.spinner.set_visible(model.busy.is_some());
if model.busy.is_some() {
w.spinner.start();
} else {
@ -1650,24 +1661,15 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
format!("fetching… {inbox} in inbox")
}
(Some(busy), false) => format!("{busy}…"),
(None, _) => String::new(),
(None, _) => model.status.clone().unwrap_or_default(),
});
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(),
};
if !note.is_empty() && note != w.last_status {
let toast = adw::Toast::new(&note);
if model.status_error {
toast.set_timeout(0);
}
w.toasts.add_toast(toast);
let errored = model.status_error && model.busy.is_none();
if errored {
w.status_label.add_css_class("error");
} else {
w.status_label.remove_css_class("error");
}
w.last_status = note;
w.cancel_button.set_visible(fetching);
}
/// One list row, like a note title: the read flag and subject on the left,