list: send snippets, not whole bodies
Every list row carried its message's entire body and let CSS clip it
to one line, so each page shipped every listed body: 11.4 MB of HTML
for 3,000 messages with ~4 KB bodies. Rows now get the start of what
the message says — quote split off, whitespace collapsed, 160
characters — and the same page is 1.0 MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
change wqszzzxsxlxtsuwmotlqqqvoszrnltxl
commit 60f78e912125d9a661b39dfd25f5f00044e34068
author Alpha Chen <alpha@kejadlen.dev>
date
parent psvzlupz
diff --git a/src/body.rs b/src/body.rs
index d906f8a..c0efcbc 100644
--- a/src/body.rs
+++ b/src/body.rs
@@ -1,6 +1,6 @@
 //! Message bodies as the thread view shows them: plain text, escaped,
 //! with its URLs made into links and the quoted history it trails split
-//! off to hide.
+//! off to hide — and the one-line snippet a list row shows.
 
 use maud::{Markup, html};
 
@@ -96,6 +96,21 @@ pub fn linked(text: &str) -> Markup {
     }
 }
 
+/// How much of a body a list row carries: more than a row ever shows, so
+/// CSS still does the clipping, without shipping the whole body.
+const SNIPPET: usize = 160;
+
+/// The start of what a body says, on one line, for a list row.
+pub fn snippet(text: &str) -> String {
+    let (said, _) = split_quoted(text);
+    said.split_whitespace()
+        .flat_map(|word| [" ", word])
+        .skip(1)
+        .flat_map(str::chars)
+        .take(SNIPPET)
+        .collect()
+}
+
 /// 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 `>`
@@ -311,6 +326,19 @@ mod tests {
         assert_eq!(split_quoted(rule), (rule, None));
     }
 
+    #[test]
+    fn snippets_are_one_capped_line_without_the_quote() {
+        assert_eq!(
+            snippet(
+                "  Sounds\tgood.\n\nSee you\r\nthen.\n\nOn Mon, Oct 5, Sam wrote:\n> Thursday?"
+            ),
+            "Sounds good. See you then."
+        );
+        let long = "é".repeat(500);
+        assert_eq!(snippet(&long).chars().count(), SNIPPET);
+        assert_eq!(snippet(""), "");
+    }
+
     #[test]
     fn inline_and_all_quote_bodies_stay_whole() {
         // Replies between quoted lines need them for context.
diff --git a/src/views.rs b/src/views.rs
index 992b09f..be8589c 100644
--- a/src/views.rs
+++ b/src/views.rs
@@ -260,7 +260,7 @@ fn solo(p: &Page<'_>, group: &Group, m: &Message, compact: bool) -> Markup {
             span.line {
                 span.from { (sender(&p.users, m)) }
                 @if !compact {
-                    span.snip { " — " (m.body) }
+                    span.snip { " — " (body::snippet(&m.body)) }
                 }
             }
         }
@@ -287,7 +287,7 @@ fn row(p: &Page<'_>, m: &Message, compact: bool) -> Markup {
             span.from { (sender(&p.users, m)) }
             span.type-figure.age { (dates::short(p.now, m.at)) }
             @if !compact {
-                span.snip { (m.body) }
+                span.snip { (body::snippet(&m.body)) }
             }
         }
     }