feat: let composes carry custom frontmatter headers
This commit is contained in:
parent
74ec9a2492
commit
68f5620d35
5 changed files with 401 additions and 10 deletions
122
src/app.rs
122
src/app.rs
|
|
@ -93,6 +93,8 @@ pub struct Compose {
|
|||
pub body: String,
|
||||
/// The message this replies to, as 64 hex characters.
|
||||
pub reply_to: Option<String>,
|
||||
/// Frontmatter rows as typed — `Key: value` text, parsed at send.
|
||||
pub headers: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -146,6 +148,9 @@ pub enum Message {
|
|||
ComposeToChanged(String),
|
||||
ComposeSubjectChanged(String),
|
||||
ComposeBodyChanged(String),
|
||||
ComposeHeaderAdded,
|
||||
ComposeHeaderChanged(usize, String),
|
||||
ComposeHeaderRemoved(usize),
|
||||
ComposeClosed,
|
||||
SendPressed,
|
||||
ReplyPressed,
|
||||
|
|
@ -225,6 +230,7 @@ pub enum PendingOp {
|
|||
subject: String,
|
||||
body: String,
|
||||
reply_to: Option<String>,
|
||||
headers: Vec<(String, String)>,
|
||||
},
|
||||
Delete {
|
||||
id: String,
|
||||
|
|
@ -372,9 +378,11 @@ impl Carrier {
|
|||
let Some(compose) = self.compose.take() else {
|
||||
return;
|
||||
};
|
||||
let rows = compose.headers.iter().any(|row| !row.trim().is_empty());
|
||||
if compose.to.trim().is_empty()
|
||||
&& compose.subject.trim().is_empty()
|
||||
&& compose.body.trim().is_empty()
|
||||
&& !rows
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
@ -384,6 +392,7 @@ impl Carrier {
|
|||
subject: compose.subject,
|
||||
body: compose.body,
|
||||
reply_to: compose.reply_to,
|
||||
headers: compose.headers,
|
||||
at: fumi_core::store::now(),
|
||||
});
|
||||
ops::save_drafts(&self.drafts_path(), &self.drafts);
|
||||
|
|
@ -403,6 +412,7 @@ impl Carrier {
|
|||
subject: draft.subject,
|
||||
body: draft.body,
|
||||
reply_to: draft.reply_to,
|
||||
headers: draft.headers,
|
||||
});
|
||||
self.compose_fresh.set(true);
|
||||
}
|
||||
|
|
@ -518,6 +528,25 @@ impl Carrier {
|
|||
compose.body = body;
|
||||
}
|
||||
}
|
||||
Message::ComposeHeaderAdded => {
|
||||
if let Some(compose) = &mut self.compose {
|
||||
compose.headers.push(String::new());
|
||||
}
|
||||
}
|
||||
Message::ComposeHeaderChanged(index, text) => {
|
||||
if let Some(compose) = &mut self.compose {
|
||||
if let Some(row) = compose.headers.get_mut(index) {
|
||||
*row = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::ComposeHeaderRemoved(index) => {
|
||||
if let Some(compose) = &mut self.compose {
|
||||
if index < compose.headers.len() {
|
||||
compose.headers.remove(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::ImportBodyChanged(text) => {
|
||||
self.import_body = text;
|
||||
}
|
||||
|
|
@ -712,6 +741,7 @@ impl Carrier {
|
|||
subject: String::new(),
|
||||
body: String::new(),
|
||||
reply_to: None,
|
||||
headers: Vec::new(),
|
||||
});
|
||||
self.compose_fresh.set(true);
|
||||
}
|
||||
|
|
@ -738,6 +768,7 @@ impl Carrier {
|
|||
subject,
|
||||
body: String::new(),
|
||||
reply_to: Some(msg.id.clone()),
|
||||
headers: Vec::new(),
|
||||
});
|
||||
self.compose_fresh.set(true);
|
||||
}
|
||||
|
|
@ -752,11 +783,27 @@ impl Carrier {
|
|||
self.fail("enter a recipient".into());
|
||||
return Vec::new();
|
||||
}
|
||||
let mut headers = Vec::new();
|
||||
for (index, row) in compose.headers.iter().enumerate() {
|
||||
match ops::header_row(row) {
|
||||
Ok(Some(pair)) => headers.push(pair),
|
||||
Ok(None) => {}
|
||||
Err(reason) => {
|
||||
self.fail(format!("header {}: {reason}", index + 1));
|
||||
return Vec::new();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Err(reason) = ops::validate_headers(&headers) {
|
||||
self.fail(reason);
|
||||
return Vec::new();
|
||||
}
|
||||
let op = PendingOp::Send {
|
||||
to: compose.to.trim().to_string(),
|
||||
subject: compose.subject.trim().to_string(),
|
||||
body: compose.body.clone(),
|
||||
reply_to: compose.reply_to.clone(),
|
||||
headers,
|
||||
};
|
||||
self.compose = None;
|
||||
return vec![op];
|
||||
|
|
@ -850,6 +897,7 @@ impl Carrier {
|
|||
subject: String::new(),
|
||||
body: String::new(),
|
||||
reply_to: None,
|
||||
headers: Vec::new(),
|
||||
});
|
||||
self.compose_fresh.set(true);
|
||||
}
|
||||
|
|
@ -1042,7 +1090,6 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[test]
|
||||
fn contact_selection_and_message_selection_supersede_each_other() {
|
||||
let mut app = app();
|
||||
|
|
@ -1090,6 +1137,7 @@ mod tests {
|
|||
subject: String::new(),
|
||||
body: String::new(),
|
||||
reply_to: None,
|
||||
headers: Vec::new(),
|
||||
});
|
||||
app.busy = Some("fetching".into());
|
||||
app.handle(Message::ComposeBodyChanged("typing while busy".into()));
|
||||
|
|
@ -1145,6 +1193,78 @@ mod tests {
|
|||
assert!(app.drafts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_rows_pass_the_busy_guard_and_edit_the_compose() {
|
||||
let mut app = app();
|
||||
app.handle(Message::ComposePressed);
|
||||
app.busy = Some("fetching".into());
|
||||
app.handle(Message::ComposeHeaderAdded);
|
||||
app.handle(Message::ComposeHeaderAdded);
|
||||
app.handle(Message::ComposeHeaderChanged(0, "Urgency: high".into()));
|
||||
app.handle(Message::ComposeHeaderRemoved(1));
|
||||
assert_eq!(
|
||||
app.compose.as_ref().expect("compose open").headers,
|
||||
["Urgency: high"]
|
||||
);
|
||||
// An index that no longer exists is ignored, not a panic.
|
||||
app.handle(Message::ComposeHeaderChanged(5, "no".into()));
|
||||
assert_eq!(app.compose.as_ref().unwrap().headers, ["Urgency: high"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_invalid_header_blocks_the_send() {
|
||||
let mut app = app();
|
||||
app.handle(Message::ComposePressed);
|
||||
app.handle(Message::ComposeToChanged("bob@localhost".into()));
|
||||
|
||||
// A reserved key shadows the client's own field, so it is rejected.
|
||||
app.handle(Message::ComposeHeaderAdded);
|
||||
app.handle(Message::ComposeHeaderChanged(0, "Subject: shadow".into()));
|
||||
assert!(app.handle(Message::SendPressed).is_empty());
|
||||
assert!(app.compose.is_some());
|
||||
assert!(app.status_error);
|
||||
|
||||
// Case-insensitive duplicates are rejected too.
|
||||
app.handle(Message::ComposeHeaderChanged(0, "Urgency: high".into()));
|
||||
app.handle(Message::ComposeHeaderAdded);
|
||||
app.handle(Message::ComposeHeaderChanged(1, "urgency: low".into()));
|
||||
assert!(app.handle(Message::SendPressed).is_empty());
|
||||
assert!(app.status_error);
|
||||
|
||||
// Removing the duplicate lets the send go through with the pair;
|
||||
// the earlier error note stays until the op result replaces it.
|
||||
app.handle(Message::ComposeHeaderRemoved(1));
|
||||
let ops = app.handle(Message::SendPressed);
|
||||
assert_eq!(ops.len(), 1);
|
||||
assert!(app.compose.is_none());
|
||||
assert!(matches!(
|
||||
&ops[0],
|
||||
PendingOp::Send { headers, .. }
|
||||
if headers == &[("Urgency".to_string(), "high".to_string())]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headers_survive_a_stash_and_reopen() {
|
||||
let mut app = app();
|
||||
app.handle(Message::ComposePressed);
|
||||
app.handle(Message::ComposeToChanged("bob@localhost".into()));
|
||||
app.handle(Message::ComposeHeaderAdded);
|
||||
app.handle(Message::ComposeHeaderChanged(0, "Urgency: high".into()));
|
||||
app.handle(Message::ComposeHeaderAdded);
|
||||
app.handle(Message::ComposeHeaderChanged(1, "not valid yet".into()));
|
||||
// A contact trip stashes without starting a refresh, so the
|
||||
// reopen below is not swallowed by the busy guard.
|
||||
app.handle(Message::ContactSelected("bob@localhost".into()));
|
||||
assert_eq!(app.drafts.len(), 1);
|
||||
let id = app.drafts[0].id.clone();
|
||||
app.handle(Message::MessageSelected(Folder::Drafts, id));
|
||||
let compose = app.compose.as_ref().expect("draft reopened");
|
||||
// Rows return exactly as typed, valid or not: validation is a
|
||||
// send-time concern, not a stash-time one.
|
||||
assert_eq!(compose.headers, ["Urgency: high", "not valid yet"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_tick_lists_mail_as_it_lands() {
|
||||
use fumi_core::message::{build_frontmatter, message_id, seal};
|
||||
|
|
|
|||
|
|
@ -227,6 +227,7 @@ fn plant(dir: &Path) -> Result<(), String> {
|
|||
to: "grace@example.org".into(),
|
||||
subject: "Re: quarterly infrastructure review".into(),
|
||||
body: "started a reply, will finish it after the review call".into(),
|
||||
headers: vec!["Urgency: low".into()],
|
||||
reply_to: None,
|
||||
at: now() - 3 * 3600,
|
||||
}],
|
||||
|
|
|
|||
189
src/ops.rs
189
src/ops.rs
|
|
@ -11,6 +11,7 @@ use fumi_core::address::{Address, DEFAULT_PORT};
|
|||
use fumi_core::client::{FetchOptions, TrustChange};
|
||||
use fumi_core::crypto::{b32, fingerprint, unb32, KEY_LEN};
|
||||
use fumi_core::error::Error;
|
||||
use fumi_core::message::{FRONTMATTER_KEYS, FRONTMATTER_MAX};
|
||||
use fumi_core::store::Store;
|
||||
use fumi_core::{account, client, export, store};
|
||||
|
||||
|
|
@ -116,6 +117,8 @@ pub struct Draft {
|
|||
pub body: String,
|
||||
/// The message this draft replies to, as 64 hex characters.
|
||||
pub reply_to: Option<String>,
|
||||
/// Frontmatter rows as typed — `Key: value` text, parsed at send.
|
||||
pub headers: Vec<String>,
|
||||
pub at: i64,
|
||||
}
|
||||
|
||||
|
|
@ -133,6 +136,7 @@ fn line(text: &str) -> String {
|
|||
text.replace('\\', "\\\\")
|
||||
.replace('\n', "\\n")
|
||||
.replace('\r', "\\r")
|
||||
.replace('\u{1f}', "\\\u{1e}")
|
||||
}
|
||||
|
||||
fn unline(text: &str) -> String {
|
||||
|
|
@ -143,6 +147,7 @@ fn unline(text: &str) -> String {
|
|||
match chars.next() {
|
||||
Some('n') => out.push('\n'),
|
||||
Some('r') => out.push('\r'),
|
||||
Some('\u{1e}') => out.push('\u{1f}'),
|
||||
Some(other) => out.push(other),
|
||||
None => out.push('\\'),
|
||||
}
|
||||
|
|
@ -159,11 +164,16 @@ 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("-");
|
||||
let headers: Vec<String> = draft.headers.iter().map(|row| line(row)).collect();
|
||||
use std::fmt::Write;
|
||||
let _ = writeln!(text, "draft {} {} {}", draft.id, draft.at, reply_to);
|
||||
let _ = writeln!(text, "draft2 {} {} {}", draft.id, draft.at, reply_to);
|
||||
let _ = writeln!(text, "{}", line(&draft.to));
|
||||
let _ = writeln!(text, "{}", line(&draft.subject));
|
||||
let _ = writeln!(text, "{}", line(&draft.body));
|
||||
// Rows joined on a control separator, so any typed text is
|
||||
// reversible; keys are [A-Za-z0-9-] but values can hold colons,
|
||||
// semicolons — anything but a newline, which line() escapes.
|
||||
let _ = writeln!(text, "{}", headers.join("\u{1f}"));
|
||||
}
|
||||
let _ = std::fs::write(path, text);
|
||||
}
|
||||
|
|
@ -174,12 +184,12 @@ 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 index = 0;
|
||||
while index + 4 <= lines.len() {
|
||||
let header = lines[index];
|
||||
let (to, subject, body) = (lines[index + 1], lines[index + 2], lines[index + 3]);
|
||||
let mut parts = header.split_whitespace();
|
||||
let (Some("draft"), Some(id), Some(at), reply_to) =
|
||||
let (kind, Some(id), Some(at), reply_to) =
|
||||
(parts.next(), parts.next(), parts.next(), parts.next())
|
||||
else {
|
||||
break;
|
||||
|
|
@ -188,6 +198,22 @@ pub fn load_drafts(path: &Path) -> Vec<Draft> {
|
|||
break;
|
||||
};
|
||||
let id = id.to_string();
|
||||
index += 4;
|
||||
// draft2 carries a fifth line of header rows; the old draft
|
||||
// records stop at four and the next line is another record.
|
||||
let headers = if kind == Some("draft2") && index < lines.len() {
|
||||
index += 1;
|
||||
let raw = lines[index - 1];
|
||||
if raw.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
raw.split('\u{1f}').map(unline).collect()
|
||||
}
|
||||
} else if kind == Some("draft") {
|
||||
Vec::new()
|
||||
} else {
|
||||
break;
|
||||
};
|
||||
drafts.push(Draft {
|
||||
id,
|
||||
to: unline(to),
|
||||
|
|
@ -197,6 +223,7 @@ pub fn load_drafts(path: &Path) -> Vec<Draft> {
|
|||
None | Some("-") => None,
|
||||
Some(other) => Some(other.to_string()),
|
||||
},
|
||||
headers,
|
||||
at,
|
||||
});
|
||||
}
|
||||
|
|
@ -369,6 +396,69 @@ pub fn fetch(
|
|||
Ok(note)
|
||||
}
|
||||
|
||||
/// Fields fumi itself attaches to every message (sec 5.7, 5.8); a
|
||||
/// user copy of any of them would shadow the client's own, since the
|
||||
/// reader keeps the first occurrence of a key.
|
||||
const RESERVED_HEADERS: [&str; 4] = ["subject", "in-reply-to", "reply-to", "accept"];
|
||||
|
||||
/// Parses one compose header row as the user typed it — `Key: value`.
|
||||
/// An empty row is allowed and simply skipped on send; anything else
|
||||
/// that breaks the frontmatter block (sec 5.5) is rejected, because a
|
||||
/// single malformed line makes the reader drop every field.
|
||||
pub fn header_row(text: &str) -> Result<Option<(String, String)>, String> {
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some((key, value)) = text.split_once(':') else {
|
||||
return Err("a header is `Key: value` — the colon is missing".into());
|
||||
};
|
||||
let key = key.trim();
|
||||
if RESERVED_HEADERS
|
||||
.iter()
|
||||
.any(|reserved| key.eq_ignore_ascii_case(reserved))
|
||||
{
|
||||
return Err(format!("{key} is attached by the client and cannot be set"));
|
||||
}
|
||||
if key.is_empty()
|
||||
|| key.len() > 64
|
||||
|| !key
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
|
||||
{
|
||||
return Err("keys are 1-64 characters of letters, digits and dashes".into());
|
||||
}
|
||||
if value.contains('\n') || value.contains('\r') {
|
||||
return Err("a value must stay on one line".into());
|
||||
}
|
||||
Ok(Some((key.to_string(), value.trim().to_string())))
|
||||
}
|
||||
|
||||
/// The rules across all rows: keys are case-insensitive and must be
|
||||
/// unique, and the whole block — fumi's own fields included — has to
|
||||
/// stay inside the reader's budget (sec 5.5).
|
||||
pub fn validate_headers(headers: &[(String, String)]) -> Result<(), String> {
|
||||
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
for (key, _) in headers {
|
||||
if !seen.insert(key.to_ascii_lowercase()) {
|
||||
return Err(format!("{key} is set twice"));
|
||||
}
|
||||
}
|
||||
let budget: usize = headers
|
||||
.iter()
|
||||
.map(|(key, value)| format!("{key}: {value}").len() + 1)
|
||||
.sum();
|
||||
// The client adds Subject, In-Reply-To, a Reply-To URI and possibly an
|
||||
// Accept token on top of the user's rows; 512 bytes covers them all.
|
||||
if budget + 512 > FRONTMATTER_MAX {
|
||||
return Err("the headers exceed the frontmatter budget".into());
|
||||
}
|
||||
if headers.len() + 4 > FRONTMATTER_KEYS {
|
||||
return Err("too many headers".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn send(
|
||||
store: &Store,
|
||||
master: &[u8; KEY_LEN],
|
||||
|
|
@ -376,16 +466,21 @@ pub fn send(
|
|||
subject: Option<&str>,
|
||||
body: &str,
|
||||
reply_to: Option<&str>,
|
||||
headers: &[(String, String)],
|
||||
timeout: u64,
|
||||
) -> Result<String, String> {
|
||||
let addr = parse_address(to)?;
|
||||
let me = account(store, master)?;
|
||||
let owned: Vec<(&str, &str)> = headers
|
||||
.iter()
|
||||
.map(|(key, value)| (key.as_str(), value.as_str()))
|
||||
.collect();
|
||||
let draft = client::SendDraft {
|
||||
address: &addr,
|
||||
text: body.to_string(),
|
||||
subject,
|
||||
reply_to,
|
||||
headers: &[],
|
||||
headers: &owned,
|
||||
anonymous: false,
|
||||
no_pad: false,
|
||||
};
|
||||
|
|
@ -652,6 +747,13 @@ mod tests {
|
|||
subject: "line breaks\nand \\backslashes\r".into(),
|
||||
body: "a draft\n\nwith paragraphs\nand a \\ literal".into(),
|
||||
reply_to: Some("ab".repeat(32)),
|
||||
// Rows return as typed: colons and separators included.
|
||||
headers: vec![
|
||||
"Urgency: high".into(),
|
||||
"Note: a: value: with: colons".into(),
|
||||
String::from_utf8_lossy(&[0x1f, b's', b'e', b'p']).into_owned(),
|
||||
String::new(),
|
||||
],
|
||||
at: now(),
|
||||
},
|
||||
Draft {
|
||||
|
|
@ -660,6 +762,7 @@ mod tests {
|
|||
subject: "no reply".into(),
|
||||
body: String::new(),
|
||||
reply_to: None,
|
||||
headers: Vec::new(),
|
||||
at: now(),
|
||||
},
|
||||
];
|
||||
|
|
@ -671,6 +774,78 @@ mod tests {
|
|||
let torn = text.lines().take(6).collect::<Vec<_>>().join("\n") + "\n";
|
||||
std::fs::write(&path, torn).unwrap();
|
||||
assert_eq!(load_drafts(&path).len(), 1);
|
||||
|
||||
// The old four-line records, written before header rows existed,
|
||||
// still read as drafts with no headers.
|
||||
let legacy = format!(
|
||||
"draft {} {} {}\n{}\n{}\n{}\n",
|
||||
"0f".repeat(8),
|
||||
100,
|
||||
"-",
|
||||
line("carol@example.org"),
|
||||
line("before headers"),
|
||||
line("an old body"),
|
||||
);
|
||||
std::fs::write(&path, legacy).unwrap();
|
||||
let loaded = load_drafts(&path);
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert_eq!(loaded[0].to, "carol@example.org");
|
||||
assert!(loaded[0].headers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_rows_follow_the_frontmatter_rules() {
|
||||
// The allowed forms: a pair, and the empty row a send skips.
|
||||
assert_eq!(
|
||||
header_row("Urgency: high"),
|
||||
Ok(Some(("Urgency".into(), "high".into())))
|
||||
);
|
||||
assert_eq!(
|
||||
header_row(" spaced-key : trimmed value "),
|
||||
Ok(Some(("spaced-key".into(), "trimmed value".into())))
|
||||
);
|
||||
assert_eq!(header_row(""), Ok(None));
|
||||
assert_eq!(header_row(" "), Ok(None));
|
||||
|
||||
// Everything the reader would reject the whole block for.
|
||||
assert!(header_row("no colon").is_err());
|
||||
assert!(header_row(": value").is_err());
|
||||
assert!(header_row("bad key: v").is_err());
|
||||
assert!(header_row("ke\ty: v").is_err());
|
||||
assert!(header_row(&format!("{}: v", "k".repeat(65))).is_err());
|
||||
// A trailing newline is trimmed out of the key; an inner one is
|
||||
// not, and the row is rejected.
|
||||
assert_eq!(
|
||||
header_row("value\n: v"),
|
||||
Ok(Some(("value".into(), "v".into())))
|
||||
);
|
||||
assert!(header_row("multi\nline: v").is_err());
|
||||
// The client's own fields would be shadowed by a copy.
|
||||
for reserved in [
|
||||
"Subject: x",
|
||||
"subject: x",
|
||||
"Reply-To: x",
|
||||
"In-Reply-To: x",
|
||||
"accept: x",
|
||||
] {
|
||||
assert!(header_row(reserved).is_err(), "{reserved}");
|
||||
}
|
||||
|
||||
// Across rows: keys are case-insensitive and must be unique.
|
||||
assert_eq!(
|
||||
validate_headers(&[("Urgency".into(), "high".into())]),
|
||||
Ok(())
|
||||
);
|
||||
assert!(validate_headers(&[
|
||||
("Urgency".into(), "high".into()),
|
||||
("urgency".into(), "low".into()),
|
||||
])
|
||||
.is_err());
|
||||
// And the whole block stays inside the reader's budget.
|
||||
let big = "v".repeat(1024);
|
||||
let many: Vec<(String, String)> =
|
||||
(0..5).map(|i| (format!("X-Key{i}"), big.clone())).collect();
|
||||
assert!(validate_headers(&many).is_err());
|
||||
}
|
||||
|
||||
fn master() -> [u8; KEY_LEN] {
|
||||
|
|
|
|||
98
src/view.rs
98
src/view.rs
|
|
@ -202,6 +202,7 @@ pub struct CarrierWidgets {
|
|||
|
||||
// compose page
|
||||
compose_to: adw::EntryRow,
|
||||
compose_headers: gtk::ListBox,
|
||||
compose_subject: adw::EntryRow,
|
||||
compose_body: gtk::TextBuffer,
|
||||
compose_send: adw::ButtonContent,
|
||||
|
|
@ -531,6 +532,14 @@ impl CarrierWidgets {
|
|||
compose_fields.append(&compose_to);
|
||||
compose_fields.append(&compose_subject);
|
||||
compose.append(&compose_fields);
|
||||
// Frontmatter rows: one `Key: value` entry each, plus a flat
|
||||
// add button below. Rows are rebuilt in render on structural
|
||||
// changes only, so the entries never lose focus while typing.
|
||||
let compose_headers = boxed_list();
|
||||
compose.append(&compose_headers);
|
||||
let add_header = button("+ header", "quiet");
|
||||
add_header.set_halign(Align::Start);
|
||||
compose.append(&add_header);
|
||||
let compose_view = gtk::TextView::new();
|
||||
compose_view.set_wrap_mode(WrapMode::WordChar);
|
||||
compose_view.set_top_margin(8);
|
||||
|
|
@ -890,6 +899,8 @@ impl CarrierWidgets {
|
|||
gtk::glib::Propagation::Stop
|
||||
});
|
||||
|
||||
add_header.connect_clicked(input(Message::ComposeHeaderAdded));
|
||||
|
||||
compose_send_button.connect_clicked(input(Message::SendPressed));
|
||||
compose_cancel.connect_clicked(input(Message::ComposeClosed));
|
||||
let to_sender = sender.clone();
|
||||
|
|
@ -991,6 +1002,7 @@ impl CarrierWidgets {
|
|||
article_body,
|
||||
actions,
|
||||
compose_to,
|
||||
compose_headers,
|
||||
compose_subject,
|
||||
compose_body,
|
||||
compose_send,
|
||||
|
|
@ -1128,6 +1140,7 @@ fn spawn_op(model: &Carrier, op: PendingOp, sender: &ComponentSender<Carrier>) {
|
|||
subject,
|
||||
body,
|
||||
reply_to,
|
||||
headers,
|
||||
} => sender.spawn_command(move |out| {
|
||||
let res = ops::send(
|
||||
&store,
|
||||
|
|
@ -1140,6 +1153,7 @@ fn spawn_op(model: &Carrier, op: PendingOp, sender: &ComponentSender<Carrier>) {
|
|||
},
|
||||
&body,
|
||||
reply_to.as_deref(),
|
||||
&headers,
|
||||
timeout,
|
||||
);
|
||||
let _ = out.send(OpResult::Generic(res));
|
||||
|
|
@ -1203,15 +1217,44 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
|
|||
move |_: >k::Button| sender.input(msg.clone())
|
||||
};
|
||||
|
||||
// Prefill entries once after a reply or write-to-contact; afterwards
|
||||
// typing flows entry -> model only, so the view never fights the user.
|
||||
// Prefill entries once after a reply, write or draft reopen;
|
||||
// afterwards typing flows entry -> model only, so the view never
|
||||
// fights the user. The fresh flag also rebuilds the header rows,
|
||||
// which otherwise sync on count changes alone.
|
||||
let mut compose_fresh = false;
|
||||
if model.compose_fresh.take() {
|
||||
compose_fresh = true;
|
||||
if let Some(compose) = &model.compose {
|
||||
w.compose_to.set_text(&compose.to);
|
||||
w.compose_subject.set_text(&compose.subject);
|
||||
w.compose_body.set_text(&compose.body);
|
||||
}
|
||||
}
|
||||
let rows = model.compose.as_ref().map_or(0, |c| c.headers.len());
|
||||
let mut current = 0;
|
||||
let mut child = w.compose_headers.first_child();
|
||||
while let Some(item) = child {
|
||||
current += 1;
|
||||
child = item.next_sibling();
|
||||
}
|
||||
if compose_fresh || current != rows {
|
||||
let added = rows > current;
|
||||
clear_list(&w.compose_headers);
|
||||
if let Some(compose) = &model.compose {
|
||||
let mut last_entry = None;
|
||||
for (index, text) in compose.headers.iter().enumerate() {
|
||||
let (row, entry) = compose_header_row(index, text, sender);
|
||||
last_entry = Some(entry);
|
||||
w.compose_headers.append(&row);
|
||||
}
|
||||
// A freshly appended row is the one being typed into.
|
||||
if added {
|
||||
if let Some(entry) = last_entry {
|
||||
entry.grab_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Header and sidebar identity.
|
||||
let address = model
|
||||
|
|
@ -1711,6 +1754,57 @@ fn mail_row(folder: Folder, row: &Row) -> adw::ActionRow {
|
|||
item
|
||||
}
|
||||
|
||||
/// One compose header row: a `Key: value` entry plus a remove button.
|
||||
/// The row carries its own validation state — anything the send would
|
||||
/// reject is tinted and explained right where it is typed, and the
|
||||
/// empty rows the send skips are left quiet.
|
||||
fn compose_header_row<'a>(
|
||||
index: usize,
|
||||
text: &str,
|
||||
sender: &ComponentSender<Carrier>,
|
||||
) -> (gtk::ListBoxRow, gtk::Entry) {
|
||||
let entry = gtk::Entry::new();
|
||||
entry.set_hexpand(true);
|
||||
entry.set_placeholder_text(Some("Key: value"));
|
||||
entry.set_text(text);
|
||||
let style = |entry: >k::Entry, text: &str| match crate::ops::header_row(text) {
|
||||
Err(reason) => {
|
||||
entry.add_css_class("error");
|
||||
entry.set_tooltip_text(Some(&reason));
|
||||
}
|
||||
Ok(_) => {
|
||||
entry.remove_css_class("error");
|
||||
entry.set_tooltip_text(None);
|
||||
}
|
||||
};
|
||||
style(&entry, text);
|
||||
let change_sender = sender.clone();
|
||||
let style_entry = entry.clone();
|
||||
entry.connect_changed(move |changed| {
|
||||
let text = changed.text().to_string();
|
||||
style(&style_entry, &text);
|
||||
change_sender.input(Message::ComposeHeaderChanged(index, text));
|
||||
});
|
||||
|
||||
let remove = icon_button("user-trash-symbolic", "Remove this header");
|
||||
let remove_sender = sender.clone();
|
||||
remove.connect_clicked(move |_| {
|
||||
remove_sender.input(Message::ComposeHeaderRemoved(index));
|
||||
});
|
||||
|
||||
let line = gtk::Box::new(Orientation::Horizontal, 6);
|
||||
line.set_margin_top(6);
|
||||
line.set_margin_bottom(6);
|
||||
line.set_margin_start(12);
|
||||
line.set_margin_end(6);
|
||||
line.append(&entry);
|
||||
line.append(&remove);
|
||||
|
||||
let row = gtk::ListBoxRow::new();
|
||||
row.set_child(Some(&line));
|
||||
(row, entry)
|
||||
}
|
||||
|
||||
/// One contact in the sidebar's bottom panel: the address over its trust
|
||||
/// state; activating it opens the detail pane.
|
||||
fn sidebar_contact_row(contact: &Contact) -> adw::ActionRow {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue