feat: stash composes closed by sidebar trips as drafts
This commit is contained in:
parent
f55821213d
commit
84650e50bd
5 changed files with 310 additions and 13 deletions
|
|
@ -7,6 +7,7 @@ A minimalist desktop client for [Smol Mail](../smolmail): one compact window —
|
||||||
- Create or restore an identity from a seed, shown once at creation time.
|
- Create or restore an identity from a seed, shown once at creation time.
|
||||||
- Pin a server's key — paste the operator's, or connect and pin what it presents — then register a username or recall an existing address.
|
- Pin a server's key — paste the operator's, or connect and pin what it presents — then register a username or recall an existing address.
|
||||||
- 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.
|
||||||
- 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 address as a URI, a QR code and a clipboard line.
|
||||||
- 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.
|
||||||
|
|
|
||||||
137
src/app.rs
137
src/app.rs
|
|
@ -15,7 +15,7 @@ use fumi_core::crypto::{unb32, KEY_LEN};
|
||||||
use fumi_core::store::Store;
|
use fumi_core::store::Store;
|
||||||
use gtk::prelude::*;
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum Page {
|
pub enum Page {
|
||||||
|
|
@ -273,6 +273,9 @@ pub struct Carrier {
|
||||||
/// 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>,
|
||||||
pub compose: Option<Compose>,
|
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 snapshot: Option<Snapshot>,
|
||||||
pub pin_offer: Option<(Address, [u8; KEY_LEN])>,
|
pub pin_offer: Option<(Address, [u8; KEY_LEN])>,
|
||||||
pub backup_text: Option<String>,
|
pub backup_text: Option<String>,
|
||||||
|
|
@ -301,6 +304,7 @@ impl Carrier {
|
||||||
(true, false) => Page::Signup,
|
(true, false) => Page::Signup,
|
||||||
(true, true) => Page::Mail,
|
(true, true) => Page::Mail,
|
||||||
};
|
};
|
||||||
|
let drafts = ops::load_drafts(&dir.join("drafts.txt"));
|
||||||
let app = Carrier {
|
let app = Carrier {
|
||||||
marks: ReadMarks::load(dir.join("read.txt")),
|
marks: ReadMarks::load(dir.join("read.txt")),
|
||||||
dir,
|
dir,
|
||||||
|
|
@ -330,6 +334,7 @@ impl Carrier {
|
||||||
open: None,
|
open: None,
|
||||||
selected_contact: None,
|
selected_contact: None,
|
||||||
compose: None,
|
compose: None,
|
||||||
|
drafts,
|
||||||
snapshot: None,
|
snapshot: None,
|
||||||
pin_offer: None,
|
pin_offer: None,
|
||||||
backup_text: None,
|
backup_text: None,
|
||||||
|
|
@ -352,6 +357,77 @@ impl Carrier {
|
||||||
self.dir.join("carrier.key")
|
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) {
|
fn note(&mut self, text: String) {
|
||||||
self.status = Some(text);
|
self.status = Some(text);
|
||||||
self.status_error = false;
|
self.status_error = false;
|
||||||
|
|
@ -442,6 +518,7 @@ impl Carrier {
|
||||||
self.import_body = text;
|
self.import_body = text;
|
||||||
}
|
}
|
||||||
Message::ContactSelected(address) => {
|
Message::ContactSelected(address) => {
|
||||||
|
self.stash_draft();
|
||||||
self.selected_contact = Some(address);
|
self.selected_contact = Some(address);
|
||||||
self.open = None;
|
self.open = None;
|
||||||
self.page = Page::Mail;
|
self.page = Page::Mail;
|
||||||
|
|
@ -577,6 +654,7 @@ impl Carrier {
|
||||||
return vec![PendingOp::Restore { addr }];
|
return vec![PendingOp::Restore { addr }];
|
||||||
}
|
}
|
||||||
Message::GotoMail(folder) => {
|
Message::GotoMail(folder) => {
|
||||||
|
self.stash_draft();
|
||||||
if self.page == Page::Mail && self.folder == folder {
|
if self.page == Page::Mail && self.folder == folder {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
|
@ -587,19 +665,24 @@ impl Carrier {
|
||||||
return self.refresh().into_iter().collect();
|
return self.refresh().into_iter().collect();
|
||||||
}
|
}
|
||||||
Message::Goto(page) => {
|
Message::Goto(page) => {
|
||||||
|
self.stash_draft();
|
||||||
self.page = page;
|
self.page = page;
|
||||||
self.open = None;
|
self.open = None;
|
||||||
self.selected_contact = None;
|
self.selected_contact = None;
|
||||||
self.compose = None;
|
|
||||||
if page == Page::Mail || page == Page::Contacts || page == Page::Settings {
|
if page == Page::Mail || page == Page::Contacts || 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_contact = None;
|
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 => {
|
Message::FetchPressed => {
|
||||||
|
self.stash_draft();
|
||||||
if !self.start("fetching") {
|
if !self.start("fetching") {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
|
@ -608,6 +691,7 @@ impl Carrier {
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
Message::ComposePressed => {
|
Message::ComposePressed => {
|
||||||
|
self.stash_draft();
|
||||||
self.compose = Some(Compose {
|
self.compose = Some(Compose {
|
||||||
to: String::new(),
|
to: String::new(),
|
||||||
subject: String::new(),
|
subject: String::new(),
|
||||||
|
|
@ -970,8 +1054,51 @@ mod tests {
|
||||||
assert_eq!(app.busy.as_deref(), Some("fetching"));
|
assert_eq!(app.busy.as_deref(), Some("fetching"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The store is committed envelope by envelope, so a tick mid-fetch
|
/// A compose closed by any sidebar trip becomes a draft in the
|
||||||
/// sees a message that the running fetch has already verified.
|
/// 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]
|
#[test]
|
||||||
fn fetch_tick_lists_mail_as_it_lands() {
|
fn fetch_tick_lists_mail_as_it_lands() {
|
||||||
use fumi_core::message::{build_frontmatter, message_id, seal};
|
use fumi_core::message::{build_frontmatter, message_id, seal};
|
||||||
|
|
|
||||||
13
src/demo.rs
13
src/demo.rs
|
|
@ -219,6 +219,19 @@ fn plant(dir: &Path) -> Result<(), String> {
|
||||||
// pre-marked read rows, so unread dots have both states to show
|
// pre-marked read rows, so unread dots have both states to show
|
||||||
std::fs::write(dir.join("read.txt"), read_ids.join("\n") + "\n").map_err(|e| e.to_string())?;
|
std::fs::write(dir.join("read.txt"), read_ids.join("\n") + "\n").map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// one sample draft, so the drafts folder has a row to show
|
||||||
|
crate::ops::save_drafts(
|
||||||
|
&dir.join("drafts.txt"),
|
||||||
|
&[crate::ops::Draft {
|
||||||
|
id: "0000000000000000".into(),
|
||||||
|
to: "grace@example.org".into(),
|
||||||
|
subject: "Re: quarterly infrastructure review".into(),
|
||||||
|
body: "started a reply, will finish it after the review call".into(),
|
||||||
|
reply_to: None,
|
||||||
|
at: now() - 3 * 3600,
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
|
||||||
let _ = b32(&pk);
|
let _ = b32(&pk);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
137
src/ops.rs
137
src/ops.rs
|
|
@ -78,12 +78,15 @@ pub fn snapshot(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The three folders, in sidebar order.
|
/// The sidebar's folders, in its order. Drafts is carrier's own folder:
|
||||||
|
/// it has no table in the sealed store, its rows come from the drafts
|
||||||
|
/// sidecar, and it only appears in the sidebar once it holds one.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum Folder {
|
pub enum Folder {
|
||||||
Inbox,
|
Inbox,
|
||||||
Requests,
|
Requests,
|
||||||
Sent,
|
Sent,
|
||||||
|
Drafts,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Folder {
|
impl Folder {
|
||||||
|
|
@ -94,10 +97,110 @@ impl Folder {
|
||||||
Folder::Inbox => "inbox",
|
Folder::Inbox => "inbox",
|
||||||
Folder::Requests => "requests",
|
Folder::Requests => "requests",
|
||||||
Folder::Sent => "sent",
|
Folder::Sent => "sent",
|
||||||
|
// Never reached: `handle` routes draft selection away from
|
||||||
|
// the store before any table lookup happens.
|
||||||
|
Folder::Drafts => "drafts",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A compose set aside instead of sent, kept beside the store like the read
|
||||||
|
/// marks — it is plaintext by nature until it is sealed on the way out.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Draft {
|
||||||
|
pub id: String,
|
||||||
|
pub to: String,
|
||||||
|
pub subject: String,
|
||||||
|
pub body: String,
|
||||||
|
/// The message this draft replies to, as 64 hex characters.
|
||||||
|
pub reply_to: Option<String>,
|
||||||
|
pub at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fresh draft id: 8 random bytes as hex, unique within the sidecar.
|
||||||
|
pub fn draft_id() -> String {
|
||||||
|
use rand_core::RngCore;
|
||||||
|
let mut bytes = [0u8; 8];
|
||||||
|
rand_core::OsRng.fill_bytes(&mut bytes);
|
||||||
|
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line per field, with backslash escapes so no field can break the
|
||||||
|
/// line format: `\` becomes `\\`, a newline `\n`, a carriage return `\r`.
|
||||||
|
fn line(text: &str) -> String {
|
||||||
|
text.replace('\\', "\\\\")
|
||||||
|
.replace('\n', "\\n")
|
||||||
|
.replace('\r', "\\r")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unline(text: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(text.len());
|
||||||
|
let mut chars = text.chars();
|
||||||
|
while let Some(c) = chars.next() {
|
||||||
|
if c == '\\' {
|
||||||
|
match chars.next() {
|
||||||
|
Some('n') => out.push('\n'),
|
||||||
|
Some('r') => out.push('\r'),
|
||||||
|
Some(other) => out.push(other),
|
||||||
|
None => out.push('\\'),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Four lines per draft: header, to, subject, body. The whole file is
|
||||||
|
/// rewritten on every change, which a handful of drafts makes honest.
|
||||||
|
pub fn save_drafts(path: &Path, drafts: &[Draft]) {
|
||||||
|
let mut text = String::new();
|
||||||
|
for draft in drafts {
|
||||||
|
let reply_to = draft.reply_to.as_deref().unwrap_or("-");
|
||||||
|
use std::fmt::Write;
|
||||||
|
let _ = writeln!(text, "draft {} {} {}", draft.id, draft.at, reply_to);
|
||||||
|
let _ = writeln!(text, "{}", line(&draft.to));
|
||||||
|
let _ = writeln!(text, "{}", line(&draft.subject));
|
||||||
|
let _ = writeln!(text, "{}", line(&draft.body));
|
||||||
|
}
|
||||||
|
let _ = std::fs::write(path, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the drafts sidecar; a missing or torn tail only costs the
|
||||||
|
/// incomplete record at the end.
|
||||||
|
pub fn load_drafts(path: &Path) -> Vec<Draft> {
|
||||||
|
let text = std::fs::read_to_string(path).unwrap_or_default();
|
||||||
|
let lines: Vec<&str> = text.lines().collect();
|
||||||
|
let mut drafts = Vec::new();
|
||||||
|
for record in lines.chunks(4) {
|
||||||
|
let [header, to, subject, body] = record else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let mut parts = header.split_whitespace();
|
||||||
|
let (Some("draft"), Some(id), Some(at), reply_to) =
|
||||||
|
(parts.next(), parts.next(), parts.next(), parts.next())
|
||||||
|
else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let Ok(at) = at.parse::<i64>() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let id = id.to_string();
|
||||||
|
drafts.push(Draft {
|
||||||
|
id,
|
||||||
|
to: unline(to),
|
||||||
|
subject: unline(subject),
|
||||||
|
body: unline(body),
|
||||||
|
reply_to: match reply_to {
|
||||||
|
None | Some("-") => None,
|
||||||
|
Some(other) => Some(other.to_string()),
|
||||||
|
},
|
||||||
|
at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
drafts
|
||||||
|
}
|
||||||
|
|
||||||
fn explain(e: Error) -> String {
|
fn explain(e: Error) -> String {
|
||||||
e.to_string()
|
e.to_string()
|
||||||
}
|
}
|
||||||
|
|
@ -535,6 +638,38 @@ mod tests {
|
||||||
use fumi_core::message::{build_frontmatter, message_id, seal};
|
use fumi_core::message::{build_frontmatter, message_id, seal};
|
||||||
use fumi_core::store::now;
|
use fumi_core::store::now;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drafts_round_trip_through_the_sidecar() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("drafts.txt");
|
||||||
|
let drafts = vec![
|
||||||
|
Draft {
|
||||||
|
id: draft_id(),
|
||||||
|
to: "smol://alice@example.org/KEY".into(),
|
||||||
|
subject: "line breaks\nand \\backslashes\r".into(),
|
||||||
|
body: "a draft\n\nwith paragraphs\nand a \\ literal".into(),
|
||||||
|
reply_to: Some("ab".repeat(32)),
|
||||||
|
at: now(),
|
||||||
|
},
|
||||||
|
Draft {
|
||||||
|
id: draft_id(),
|
||||||
|
to: "bob@example.org".into(),
|
||||||
|
subject: "no reply".into(),
|
||||||
|
body: String::new(),
|
||||||
|
reply_to: None,
|
||||||
|
at: now(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
save_drafts(&path, &drafts);
|
||||||
|
assert_eq!(load_drafts(&path), drafts);
|
||||||
|
|
||||||
|
// A torn tail costs only its own incomplete record.
|
||||||
|
let text = std::fs::read_to_string(&path).unwrap();
|
||||||
|
let torn = text.lines().take(6).collect::<Vec<_>>().join("\n") + "\n";
|
||||||
|
std::fs::write(&path, torn).unwrap();
|
||||||
|
assert_eq!(load_drafts(&path).len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
fn master() -> [u8; KEY_LEN] {
|
fn master() -> [u8; KEY_LEN] {
|
||||||
let mut seed = [0u8; KEY_LEN];
|
let mut seed = [0u8; KEY_LEN];
|
||||||
for (i, b) in seed.iter_mut().enumerate() {
|
for (i, b) in seed.iter_mut().enumerate() {
|
||||||
|
|
|
||||||
35
src/view.rs
35
src/view.rs
|
|
@ -183,6 +183,8 @@ pub struct CarrierWidgets {
|
||||||
// sidebar top: actions and destinations
|
// sidebar top: actions and destinations
|
||||||
folders: gtk::ListBox,
|
folders: gtk::ListBox,
|
||||||
folder_counts: Vec<gtk::Label>,
|
folder_counts: Vec<gtk::Label>,
|
||||||
|
/// The Drafts row, hidden until the folder holds one.
|
||||||
|
drafts_nav: gtk::ListBoxRow,
|
||||||
settings_nav: gtk::ListBox,
|
settings_nav: gtk::ListBox,
|
||||||
fetch_button: gtk::Button,
|
fetch_button: gtk::Button,
|
||||||
write_button: gtk::Button,
|
write_button: gtk::Button,
|
||||||
|
|
@ -303,12 +305,21 @@ impl CarrierWidgets {
|
||||||
nav_row("folder-download-symbolic", "Inbox"),
|
nav_row("folder-download-symbolic", "Inbox"),
|
||||||
nav_row("contact-new-symbolic", "Requests"),
|
nav_row("contact-new-symbolic", "Requests"),
|
||||||
nav_row("mail-send-symbolic", "Sent"),
|
nav_row("mail-send-symbolic", "Sent"),
|
||||||
|
nav_row("document-edit-symbolic", "Drafts"),
|
||||||
];
|
];
|
||||||
let mut folder_counts = Vec::new();
|
let mut folder_counts = Vec::new();
|
||||||
for (row, count) in folder_rows {
|
let mut drafts_nav = None;
|
||||||
|
for (index, (row, count)) in folder_rows.into_iter().enumerate() {
|
||||||
|
// The drafts folder is carrier's own: it stays out of the
|
||||||
|
// sidebar until a compose has been set aside into it.
|
||||||
|
if index == 3 {
|
||||||
|
row.set_visible(false);
|
||||||
|
drafts_nav = Some(row.clone());
|
||||||
|
}
|
||||||
folders.append(&row);
|
folders.append(&row);
|
||||||
folder_counts.push(count);
|
folder_counts.push(count);
|
||||||
}
|
}
|
||||||
|
let drafts_nav = drafts_nav.expect("four folder rows");
|
||||||
sidebar_top.append(&folders);
|
sidebar_top.append(&folders);
|
||||||
|
|
||||||
// Settings closes the top panel, pinned to its bottom. It is its
|
// Settings closes the top panel, pinned to its bottom. It is its
|
||||||
|
|
@ -782,6 +793,7 @@ impl CarrierWidgets {
|
||||||
0 => Folder::Inbox,
|
0 => Folder::Inbox,
|
||||||
1 => Folder::Requests,
|
1 => Folder::Requests,
|
||||||
2 => Folder::Sent,
|
2 => Folder::Sent,
|
||||||
|
3 => Folder::Drafts,
|
||||||
_ => return,
|
_ => return,
|
||||||
};
|
};
|
||||||
folder_sender.input(Message::GotoMail(folder));
|
folder_sender.input(Message::GotoMail(folder));
|
||||||
|
|
@ -804,6 +816,7 @@ impl CarrierWidgets {
|
||||||
"0" => Folder::Inbox,
|
"0" => Folder::Inbox,
|
||||||
"1" => Folder::Requests,
|
"1" => Folder::Requests,
|
||||||
"2" => Folder::Sent,
|
"2" => Folder::Sent,
|
||||||
|
"3" => Folder::Drafts,
|
||||||
_ => return,
|
_ => return,
|
||||||
};
|
};
|
||||||
mail_sender.input(Message::MessageSelected(folder, id.to_string()));
|
mail_sender.input(Message::MessageSelected(folder, id.to_string()));
|
||||||
|
|
@ -902,6 +915,7 @@ impl CarrierWidgets {
|
||||||
toolbar,
|
toolbar,
|
||||||
folders,
|
folders,
|
||||||
folder_counts,
|
folder_counts,
|
||||||
|
drafts_nav,
|
||||||
settings_nav,
|
settings_nav,
|
||||||
fetch_button,
|
fetch_button,
|
||||||
write_button,
|
write_button,
|
||||||
|
|
@ -1158,7 +1172,13 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
|
||||||
.map(|rows| rows.iter().filter(|r| r.unread).count())
|
.map(|rows| rows.iter().filter(|r| r.unread).count())
|
||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
};
|
};
|
||||||
for (count, value) in w.folder_counts.iter().zip([unread(0), unread(1), 0]) {
|
let draft_count = model.drafts.len();
|
||||||
|
w.drafts_nav.set_visible(draft_count > 0);
|
||||||
|
for (count, value) in w
|
||||||
|
.folder_counts
|
||||||
|
.iter()
|
||||||
|
.zip([unread(0), unread(1), 0, draft_count])
|
||||||
|
{
|
||||||
count.set_text(&value.to_string());
|
count.set_text(&value.to_string());
|
||||||
count.set_visible(value > 0);
|
count.set_visible(value > 0);
|
||||||
}
|
}
|
||||||
|
|
@ -1198,11 +1218,7 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
|
||||||
|
|
||||||
// Mail list.
|
// Mail list.
|
||||||
clear_list(&w.mail_list);
|
clear_list(&w.mail_list);
|
||||||
let rows = model
|
let rows = model.rows();
|
||||||
.lists
|
|
||||||
.get(model.folder as usize)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_default();
|
|
||||||
let (icon, title, description) = match model.folder {
|
let (icon, title, description) = match model.folder {
|
||||||
Folder::Inbox => (
|
Folder::Inbox => (
|
||||||
"mail-read-symbolic",
|
"mail-read-symbolic",
|
||||||
|
|
@ -1219,6 +1235,11 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
|
||||||
"Nothing sent yet",
|
"Nothing sent yet",
|
||||||
"Messages you send keep a sealed copy here.",
|
"Messages you send keep a sealed copy here.",
|
||||||
),
|
),
|
||||||
|
Folder::Drafts => (
|
||||||
|
"document-edit-symbolic",
|
||||||
|
"No drafts",
|
||||||
|
"A compose closed from the sidebar is kept here.",
|
||||||
|
),
|
||||||
};
|
};
|
||||||
w.list_empty.set_icon_name(Some(icon));
|
w.list_empty.set_icon_name(Some(icon));
|
||||||
w.list_empty.set_title(title);
|
w.list_empty.set_title(title);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue