thread: fold quoted replies behind a toggle
Replies carried their whole quoted history, so a thread repeated
itself message after message. A body now splits into what it says and
the quote it trails, which waits behind a "Show quoted text" toggle.
The quote is a closing run of `>` lines with the "On …, … wrote:"
attribution above it (wrapped onto two lines if need be), or
everything after an Outlook "-----Original Message-----" or a rule
over "From:". Only a trailing quote folds: inline replies keep the
lines they answer, and an all-quote body stays whole rather than
render empty.
Body links also take the text's ink instead of Gloss's teal, which
docket.css reserves for Do and Comment.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
diff --git a/assets/docket.css b/assets/docket.css
index e88d7a9..e7c09f3 100644
--- a/assets/docket.css
+++ b/assets/docket.css
@@ -90,6 +90,15 @@ button { font: inherit; color: inherit; }
.item .addr, .item .copies { color: var(--gl-color-text-tertiary); }
.item .copies { font-size: var(--gl-step--2); }
.item .text { line-height: 1.6; white-space: pre-line; overflow-wrap: anywhere; }
+/* A reply's quoted history: folded behind a quiet toggle, faint when open. */
+.quote-btn { all: unset; box-sizing: border-box; display: inline-flex; align-items: center; align-self: flex-start; cursor: pointer; padding: 4px 6px; margin-left: -6px; border-radius: var(--gl-radius); color: var(--gl-color-text-tertiary); font: var(--gl-step--2) var(--gl-font-mono); transition: background var(--gl-dur-fast) var(--gl-ease); }
+.quote-btn:hover { background: var(--gl-color-surface-fill); color: var(--gl-color-text-primary); }
+.quote-btn:focus-visible { outline: 2px solid var(--gl-color-accent); outline-offset: 1px; }
+.item .text.quoted { color: var(--gl-color-text-tertiary); }
+/* Body links stay in the text's ink — teal is spoken for — and say
+ they're links with the underline alone. */
+.item .text a { color: inherit; }
+.item .text a:hover { color: var(--gl-color-text-primary); }
.item .text.closed { cursor: pointer; color: var(--gl-color-text-tertiary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* The gutter: date, then the email's values, right-aligned. */
@@ -176,7 +185,7 @@ dialog form { display: flex; justify-content: flex-end; gap: var(--gl-space-xs);
more, below which iOS Safari zooms the page on focus. */
@media (pointer: coarse) {
.field { font-size: max(16px, var(--gl-step--1)); }
- .ctrl-btn, .del-btn, .opt, .nav-item, .who-btn { box-sizing: border-box; min-height: 44px; align-items: center; }
+ .ctrl-btn, .del-btn, .quote-btn, .opt, .nav-item, .who-btn { box-sizing: border-box; min-height: 44px; align-items: center; }
.who-btn { align-items: stretch; justify-content: center; }
.icon-btn, .back, .search-btn { min-width: 44px; min-height: 44px; justify-content: center; align-items: center; }
.thread-head .back { margin-left: -14px; }
diff --git a/src/body.rs b/src/body.rs
index 0877f99..d906f8a 100644
--- a/src/body.rs
+++ b/src/body.rs
@@ -1,5 +1,6 @@
//! Message bodies as the thread view shows them: plain text, escaped,
-//! with its URLs made into links.
+//! with its URLs made into links and the quoted history it trails split
+//! off to hide.
use maud::{Markup, html};
@@ -95,6 +96,79 @@ pub fn linked(text: &str) -> Markup {
}
}
+/// Splits a reply into what it says and the quoted history it trails:
+/// from an Outlook-style separator ("-----Original Message-----", or a
+/// rule over a "From:" line) to the end, or else a closing run of `>`
+/// lines with the "On …, … wrote:" attribution above it. Only a trailing
+/// quote goes — inline replies need the lines they answer — and a body
+/// that is all quote keeps it, so nothing ever renders empty.
+pub fn split_quoted(text: &str) -> (&str, Option<&str>) {
+ let mut at = 0usize;
+ let lines: Vec<(usize, &str)> = text
+ .split_inclusive('\n')
+ .map(|line| {
+ let start = at;
+ at = at.saturating_add(line.len());
+ (start, line.trim())
+ })
+ .collect();
+ let Some(start) = separator(&lines).or_else(|| quote_tail(&lines)) else {
+ return (text, None);
+ };
+ let said = text[..start].trim_end();
+ if said.is_empty() {
+ return (text, None);
+ }
+ (said, Some(text[start..].trim()))
+}
+
+/// Where an Outlook-style separator starts the quoted history.
+fn separator(lines: &[(usize, &str)]) -> Option<usize> {
+ let next = lines.iter().skip(1).map(Some).chain([None]);
+ lines
+ .iter()
+ .zip(next)
+ .find(|((_, line), next)| {
+ let original =
+ line.starts_with("-----") && line.to_lowercase().contains("original message");
+ let rule = line.len() >= 10 && line.chars().all(|c| c == '_');
+ original || (rule && next.is_some_and(|(_, n)| n.starts_with("From:")))
+ })
+ .map(|((at, _), _)| *at)
+}
+
+/// Where a closing run of `>` lines starts, taking in the attribution
+/// line above it — wrapped onto two lines if need be.
+fn quote_tail(lines: &[(usize, &str)]) -> Option<usize> {
+ let quoted = |line: &str| line.is_empty() || line.starts_with('>');
+ let first = lines
+ .iter()
+ .rposition(|(_, line)| !quoted(line))
+ .map_or(0, |i| i.saturating_add(1));
+ if !lines
+ .iter()
+ .skip(first)
+ .any(|(_, line)| line.starts_with('>'))
+ {
+ return None;
+ }
+ // The line above index i, with its index.
+ let above = |i: usize| {
+ let j = i.checked_sub(1)?;
+ lines.get(j).map(|(_, line)| (j, *line))
+ };
+ let mut start = first;
+ if let Some((j, wrote)) = above(first).filter(|(_, line)| line.ends_with("wrote:")) {
+ start = j;
+ // A wrapped attribution's first line opens with "On" and a date.
+ let opens = |on: &str| on.starts_with("On ") && on.contains(|c: char| c.is_ascii_digit());
+ if let Some((k, _)) = above(j).filter(|(_, on)| !wrote.starts_with("On ") && opens(on)) {
+ start = k;
+ }
+ }
+ lines.get(start).map(|(at, _)| *at)
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -175,4 +249,76 @@ mod tests {
r#"<b> <a href="https://a.com/?x=1&y=" target="_blank" rel="noopener">https://a.com/?x=1&y=</a>"2""#
);
}
+
+ #[test]
+ fn a_trailing_quote_splits_off_with_its_attribution() {
+ assert_eq!(
+ split_quoted("Sounds good.\n\nOn Mon, Oct 5, Sam wrote:\n> Thursday?\n>\n> -S\n"),
+ (
+ "Sounds good.",
+ Some("On Mon, Oct 5, Sam wrote:\n> Thursday?\n>\n> -S")
+ )
+ );
+ // An attribution wrapped onto two lines comes along whole.
+ assert_eq!(
+ split_quoted(
+ "Yes.\nOn Mon, Oct 5, 2026 at 9:41 AM\nSam <sam@example.com> wrote:\n> Thursday?"
+ ),
+ (
+ "Yes.",
+ Some("On Mon, Oct 5, 2026 at 9:41 AM\nSam <sam@example.com> wrote:\n> Thursday?")
+ )
+ );
+ // A bare quote with no attribution, CRLF line ends.
+ assert_eq!(
+ split_quoted("Yes.\r\n> Thursday?\r\n"),
+ ("Yes.", Some("> Thursday?"))
+ );
+ // A "wrote:" line on its own is attribution enough; the line
+ // above it stays when it doesn't open one.
+ assert_eq!(
+ split_quoted("Yes.\nSam wrote:\n> Thursday?"),
+ ("Yes.", Some("Sam wrote:\n> Thursday?"))
+ );
+ assert_eq!(
+ split_quoted("On it.\nI wrote:\n> Thursday?"),
+ ("On it.", Some("I wrote:\n> Thursday?"))
+ );
+ }
+
+ #[test]
+ fn outlook_separators_quote_everything_after() {
+ assert_eq!(
+ split_quoted("Approved.\n\n-----Original Message-----\nFrom: Pat\nPlease approve."),
+ (
+ "Approved.",
+ Some("-----Original Message-----\nFrom: Pat\nPlease approve.")
+ )
+ );
+ assert_eq!(
+ split_quoted("Approved.\n________________________________\nFrom: Pat\nSent: Monday"),
+ (
+ "Approved.",
+ Some("________________________________\nFrom: Pat\nSent: Monday")
+ )
+ );
+ // A rule that heads no "From:" is just a rule.
+ let rule = "Totals\n__________\nFrom here, it's $40.";
+ assert_eq!(
+ split_quoted("Totals\n__________\n$40"),
+ ("Totals\n__________\n$40", None)
+ );
+ assert_eq!(split_quoted(rule), (rule, None));
+ }
+
+ #[test]
+ fn inline_and_all_quote_bodies_stay_whole() {
+ // Replies between quoted lines need them for context.
+ let inline = "> Thursday?\nYes.\n> Who drives?\nMe.";
+ assert_eq!(split_quoted(inline), (inline, None));
+ // Nothing but quote: hiding it would leave nothing.
+ assert_eq!(split_quoted("> fwd\n> fwd"), ("> fwd\n> fwd", None));
+ assert_eq!(split_quoted("Plain.\n\n"), ("Plain.\n\n", None));
+ assert_eq!(split_quoted(""), ("", None));
+ }
}
diff --git a/src/fixtures.rs b/src/fixtures.rs
index bfa02ab..5e857df 100644
--- a/src/fixtures.rs
+++ b/src/fixtures.rs
@@ -207,7 +207,7 @@ pub fn seed(store: &Store) -> Result<(), Error> {
1,
at(9, 27, 10, 2),
roofer,
- "Revised estimate attached, includes the downspout: $2,120. We can start October 14 if you confirm by the 3rd.",
+ "Revised estimate attached, includes the downspout: $2,120. We can start October 14 if you confirm by the 3rd.\n\nOn Sat, Sep 26, 2026 at 3:02 PM, Sam wrote:\n> Does this include the downspout on the north side?",
(Inbox, Some("House"), &[]),
&["Sam", "Alex"],
);
diff --git a/src/views.rs b/src/views.rs
index 42dbe0d..288b960 100644
--- a/src/views.rs
+++ b/src/views.rs
@@ -366,10 +366,13 @@ fn message(p: &Page<'_>, m: &Message, open: bool, selected: bool, account: &Acco
copies.push(format!("bcc {}", m.bcc.join(", ")));
}
let state = if open {
- "{ open: true }"
+ "{ open: true, quoted: false }"
} else {
- "{ open: false }"
+ "{ open: false, quoted: false }"
};
+ // The quoted history a reply trails stays folded behind a toggle, so
+ // a thread doesn't repeat itself message after message.
+ let (said, quoted) = body::split_quoted(&m.body);
html! {
div.item.card.sel[selected] id=(format!("m{}", m.id)) x-data=(state) {
div.body {
@@ -379,8 +382,14 @@ fn message(p: &Page<'_>, m: &Message, open: bool, selected: bool, account: &Acco
@if !copies.is_empty() {
span.copies { (copies.join(" · ")) }
}
- span.text x-show="open" x-cloak[!open] { (body::linked(&m.body)) }
- span.text.closed x-show="!open" x-cloak[open] "@click"="open = true" { (m.body) }
+ span.text x-show="open" x-cloak[!open] { (body::linked(said)) }
+ @if let Some(quoted) = quoted {
+ button.quote-btn type="button" x-show="open" x-cloak[!open]
+ "@click"="quoted = !quoted" ":aria-expanded"="quoted"
+ x-text="quoted ? 'Hide quoted text' : 'Show quoted text'" { "Show quoted text" }
+ span.text.quoted x-show="open && quoted" x-cloak { (body::linked(quoted)) }
+ }
+ span.text.closed x-show="!open" x-cloak[open] "@click"="open = true" { (said) }
}
div.gutter {
span.when {
diff --git a/tests/api.rs b/tests/api.rs
index f24e7d4..6d3b494 100644
--- a/tests/api.rs
+++ b/tests/api.rs
@@ -197,6 +197,12 @@ async fn thread_view_shows_the_chain_with_values_in_the_gutter() {
assert!(body.contains(&format!(r#"class="item card sel" id="m{}""#, mid(4))));
assert!(body.contains(&format!(r#"class="item card" id="m{}""#, mid(3))));
assert!(body.contains(r#"<span class="type-label sent">Sent</span>"#));
+ // A reply's quoted history folds behind a toggle, out of the body
+ // and its one-line preview.
+ assert!(body.contains(r#"confirm by the 3rd.</span><button class="quote-btn" type="button""#));
+ assert!(body.contains(
+ r#"x-show="open && quoted" x-cloak>On Sat, Sep 26, 2026 at 3:02 PM, Sam wrote:"#
+ ));
// URLs in an open body link out; the sentence's period stays text.
assert!(body.contains(
r#"site visit: <a href="https://photos.northwind.example/visit/926" target="_blank" rel="noopener">https://photos.northwind.example/visit/926</a>.</span>"#