feat: make senders tappable and replace the toasts with a fixed status line
This commit is contained in:
parent
22c20427f0
commit
8e9d2a92a6
3 changed files with 112 additions and 92 deletions
BIN
smolmail.png
Normal file
BIN
smolmail.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
26
src/app.rs
26
src/app.rs
|
|
@ -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);
|
||||
|
|
|
|||
178
src/view.rs
178
src/view.rs
|
|
@ -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(¢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.
|
||||
// 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(¬e);
|
||||
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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue