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>
change xmnsomzsmlstvukspymvrxutllmzllql
commit d995722538e8097fe60ad72f7535d18e5bfc248a
author Alpha Chen <alpha@kejadlen.dev>
date
parent pmorrqrn
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#"&lt;b&gt; <a href="https://a.com/?x=1&amp;y=" target="_blank" rel="noopener">https://a.com/?x=1&amp;y=</a>&quot;2&quot;"#
         );
     }
+
+    #[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 &amp;&amp; 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>"#