feat: stash composes closed by sidebar trips as drafts

This commit is contained in:
randogoth 2026-10-03 09:44:19 +03:00
parent f55821213d
commit 84650e50bd
5 changed files with 310 additions and 13 deletions

View file

@ -15,7 +15,7 @@ use fumi_core::crypto::{unb32, KEY_LEN};
use fumi_core::store::Store;
use gtk::prelude::*;
use crate::ops::{self, Folder, OpenedMsg, Row, Snapshot};
use crate::ops::{self, Draft, Folder, OpenedMsg, Row, Snapshot};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Page {
@ -273,6 +273,9 @@ pub struct Carrier {
/// the sidebar's bottom panel; a message selection clears it.
pub selected_contact: Option<String>,
pub compose: Option<Compose>,
/// Composes set aside by a sidebar trip instead of Cancel, kept in
/// the drafts sidecar; the folder only shows once it holds one.
pub drafts: Vec<Draft>,
pub snapshot: Option<Snapshot>,
pub pin_offer: Option<(Address, [u8; KEY_LEN])>,
pub backup_text: Option<String>,
@ -301,6 +304,7 @@ impl Carrier {
(true, false) => Page::Signup,
(true, true) => Page::Mail,
};
let drafts = ops::load_drafts(&dir.join("drafts.txt"));
let app = Carrier {
marks: ReadMarks::load(dir.join("read.txt")),
dir,
@ -330,6 +334,7 @@ impl Carrier {
open: None,
selected_contact: None,
compose: None,
drafts,
snapshot: None,
pin_offer: None,
backup_text: None,
@ -352,6 +357,77 @@ impl Carrier {
self.dir.join("carrier.key")
}
fn drafts_path(&self) -> PathBuf {
self.dir.join("drafts.txt")
}
/// Every sidebar trip closes the compose this way: a compose with
/// anything in it becomes a draft, an empty one is thrown away, and
/// Cancel stays the only way to discard work outright.
fn stash_draft(&mut self) {
let Some(compose) = self.compose.take() else {
return;
};
if compose.to.trim().is_empty()
&& compose.subject.trim().is_empty()
&& compose.body.trim().is_empty()
{
return;
}
self.drafts.push(Draft {
id: ops::draft_id(),
to: compose.to,
subject: compose.subject,
body: compose.body,
reply_to: compose.reply_to,
at: fumi_core::store::now(),
});
ops::save_drafts(&self.drafts_path(), &self.drafts);
self.note("draft saved".into());
}
/// Tapping a draft in the folder puts it back in the compose it came
/// from, out of the sidecar, so saving it again cannot fork it.
fn open_draft(&mut self, id: &str) {
let Some(index) = self.drafts.iter().position(|d| d.id == id) else {
return;
};
let draft = self.drafts.remove(index);
ops::save_drafts(&self.drafts_path(), &self.drafts);
self.compose = Some(Compose {
to: draft.to,
subject: draft.subject,
body: draft.body,
reply_to: draft.reply_to,
});
self.compose_fresh.set(true);
}
/// The current folder's rows, drafts included: drafts carry the
/// recipient where mail carries the sender, newest first.
pub fn rows(&self) -> Vec<Row> {
if self.folder == Folder::Drafts {
let mut rows: Vec<Row> = self
.drafts
.iter()
.map(|draft| Row {
id: draft.id.clone(),
from: draft.to.clone(),
subject: draft.subject.clone(),
at: draft.at,
unread: false,
})
.collect();
rows.sort_by_key(|row| -row.at);
rows
} else {
self.lists
.get(self.folder as usize)
.cloned()
.unwrap_or_default()
}
}
fn note(&mut self, text: String) {
self.status = Some(text);
self.status_error = false;
@ -442,6 +518,7 @@ impl Carrier {
self.import_body = text;
}
Message::ContactSelected(address) => {
self.stash_draft();
self.selected_contact = Some(address);
self.open = None;
self.page = Page::Mail;
@ -577,6 +654,7 @@ impl Carrier {
return vec![PendingOp::Restore { addr }];
}
Message::GotoMail(folder) => {
self.stash_draft();
if self.page == Page::Mail && self.folder == folder {
return Vec::new();
}
@ -587,19 +665,24 @@ impl Carrier {
return self.refresh().into_iter().collect();
}
Message::Goto(page) => {
self.stash_draft();
self.page = page;
self.open = None;
self.selected_contact = None;
self.compose = None;
if page == Page::Mail || page == Page::Contacts || page == Page::Settings {
return self.refresh().into_iter().collect();
}
}
Message::MessageSelected(folder, id) => {
self.selected_contact = None;
self.select_message(folder, id);
if folder == Folder::Drafts {
self.open_draft(&id);
} else {
self.select_message(folder, id);
}
}
Message::FetchPressed => {
self.stash_draft();
if !self.start("fetching") {
return Vec::new();
}
@ -608,6 +691,7 @@ impl Carrier {
}];
}
Message::ComposePressed => {
self.stash_draft();
self.compose = Some(Compose {
to: String::new(),
subject: String::new(),
@ -970,8 +1054,51 @@ mod tests {
assert_eq!(app.busy.as_deref(), Some("fetching"));
}
/// The store is committed envelope by envelope, so a tick mid-fetch
/// sees a message that the running fetch has already verified.
/// A compose closed by any sidebar trip becomes a draft in the
/// sidecar; Cancel stays the only way to throw work away.
#[test]
fn sidebar_trips_stash_the_compose_as_a_draft() {
let mut app = app();
app.handle(Message::ComposePressed);
app.handle(Message::ComposeToChanged("alice@example.org".into()));
app.handle(Message::ComposeSubjectChanged("half written".into()));
app.handle(Message::ComposeBodyChanged("still typing".into()));
// The same folder the compose was opened over still closes it.
app.folder = Folder::Inbox;
app.page = Page::Mail;
app.handle(Message::GotoMail(Folder::Inbox));
assert!(app.compose.is_none());
assert_eq!(app.drafts.len(), 1);
assert!(app.dir.join("drafts.txt").exists());
let draft = app.drafts[0].clone();
assert_eq!(draft.to, "alice@example.org");
assert_eq!(draft.subject, "half written");
app.folder = Folder::Drafts;
assert_eq!(app.rows()[0].id, draft.id);
// Opening the draft moves it back into the compose, out of the
// sidecar, so saving it again cannot fork it.
app.handle(Message::MessageSelected(Folder::Drafts, draft.id.clone()));
assert!(app.compose.is_some());
assert!(app.drafts.is_empty());
assert_eq!(app.compose.as_ref().unwrap().subject, "half written");
// Write while editing stashes the draft being edited first, and the
// fresh empty compose a trip then discards adds nothing.
app.handle(Message::ComposePressed);
assert_eq!(app.drafts.len(), 1);
app.handle(Message::GotoMail(Folder::Sent));
assert!(app.compose.is_none());
assert_eq!(app.drafts.len(), 1);
// An empty compose discarded by a trip leaves no draft behind.
app.drafts.clear();
app.handle(Message::ComposePressed);
app.handle(Message::GotoMail(Folder::Sent));
assert!(app.drafts.is_empty());
}
#[test]
fn fetch_tick_lists_mail_as_it_lands() {
use fumi_core::message::{build_frontmatter, message_id, seal};