thread: link URLs in message bodies
Open bodies in the thread view now turn http(s) URLs into links that
open in a new tab with rel=noopener; everything else stays escaped
text. A URL runs to whitespace or a character it can't carry
unescaped, and gives back trailing sentence punctuation and closing
brackets it never opened, so "(see https://a.com/x)." links just the
URL. Collapsed one-line previews and list snippets stay plain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
change pmorrqrnsolkrnnznrtuzxyuqovstwws
commit b2dad58189118cec8b5419bea24e05e4d34807a9
author Alpha Chen <alpha@kejadlen.dev>
date
parent zutkltst
diff --git a/src/body.rs b/src/body.rs
new file mode 100644
index 0000000..0877f99
--- /dev/null
+++ b/src/body.rs
@@ -0,0 +1,178 @@
+//! Message bodies as the thread view shows them: plain text, escaped,
+//! with its URLs made into links.
+
+use maud::{Markup, html};
+
+/// A run of a body: plain text, or a URL to link.
+#[derive(Debug, PartialEq, Eq)]
+enum Segment<'a> {
+    Text(&'a str),
+    Url(&'a str),
+}
+
+/// Splits text into plain runs and the http(s) URLs between them. A URL
+/// starts at its scheme, after a non-alphanumeric boundary, and runs to
+/// whitespace or a character that can't appear unescaped in one; trailing
+/// sentence punctuation and unbalanced closing brackets stay text.
+fn segments(text: &str) -> Vec<Segment<'_>> {
+    let mut out = Vec::new();
+    let mut plain = 0;
+    let mut at = 0;
+    while let Some(found) = next_scheme(text, at) {
+        let rest = &text[found..];
+        let end = rest
+            .find(|c: char| c.is_whitespace() || c.is_control() || matches!(c, '<' | '>' | '"'))
+            .unwrap_or(rest.len());
+        let url = trim_url(&rest[..end]);
+        if url.ends_with("//") {
+            // A bare scheme links nowhere.
+            at = found.saturating_add(end);
+            continue;
+        }
+        if plain < found {
+            out.push(Segment::Text(&text[plain..found]));
+        }
+        out.push(Segment::Url(url));
+        plain = found.saturating_add(url.len());
+        at = plain;
+    }
+    if plain < text.len() {
+        out.push(Segment::Text(&text[plain..]));
+    }
+    out
+}
+
+/// The byte offset of the next `http://` or `https://` at or after `from`
+/// that starts a word.
+fn next_scheme(text: &str, from: usize) -> Option<usize> {
+    let mut at = from;
+    loop {
+        let found = at.saturating_add(text[at..].find("http")?);
+        let rest = &text[found..];
+        let starts_word = !text[..found]
+            .chars()
+            .next_back()
+            .is_some_and(char::is_alphanumeric);
+        if starts_word && (rest.starts_with("http://") || rest.starts_with("https://")) {
+            return Some(found);
+        }
+        at = found.saturating_add("http".len());
+    }
+}
+
+/// Drops what reads as the sentence around a URL rather than the URL:
+/// trailing punctuation, and closing brackets the URL never opened.
+fn trim_url(mut url: &str) -> &str {
+    let trims = |url: &str, last: char| {
+        let unbalanced =
+            |open: char, close: char| url.matches(close).count() > url.matches(open).count();
+        match last {
+            '.' | ',' | ';' | ':' | '!' | '?' | '\'' | '*' | '…' => true,
+            ')' => unbalanced('(', ')'),
+            ']' => unbalanced('[', ']'),
+            _ => false,
+        }
+    };
+    while let Some((cut, _)) = url
+        .char_indices()
+        .next_back()
+        .filter(|&(_, last)| trims(url, last))
+    {
+        url = &url[..cut];
+    }
+    url
+}
+
+/// The text, escaped, with each URL a link that opens in a new tab.
+pub fn linked(text: &str) -> Markup {
+    html! {
+        @for segment in segments(text) {
+            @match segment {
+                Segment::Text(t) => (t),
+                Segment::Url(u) => a href=(u) target="_blank" rel="noopener" { (u) },
+            }
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use Segment::{Text, Url};
+
+    #[test]
+    fn urls_split_out_of_the_text() {
+        assert_eq!(
+            segments("See https://example.com/a?b=1&c=2 for details"),
+            [
+                Text("See "),
+                Url("https://example.com/a?b=1&c=2"),
+                Text(" for details")
+            ]
+        );
+        assert_eq!(segments("http://x.org"), [Url("http://x.org")]);
+        assert_eq!(segments("no links here"), [Text("no links here")]);
+        assert_eq!(segments(""), []);
+        // Angle brackets and quotes end a URL.
+        assert_eq!(
+            segments("<https://a.com/x>"),
+            [Text("<"), Url("https://a.com/x"), Text(">")]
+        );
+        assert_eq!(
+            segments("one https://a.com\ntwo https://b.com"),
+            [
+                Text("one "),
+                Url("https://a.com"),
+                Text("\ntwo "),
+                Url("https://b.com")
+            ]
+        );
+    }
+
+    #[test]
+    fn sentence_punctuation_stays_text() {
+        assert_eq!(
+            segments("Go to https://a.com/x."),
+            [Text("Go to "), Url("https://a.com/x"), Text(".")]
+        );
+        assert_eq!(
+            segments("(see https://a.com/x), then"),
+            [Text("(see "), Url("https://a.com/x"), Text("), then")]
+        );
+        // Brackets the URL opened are part of it.
+        assert_eq!(
+            segments("https://en.wikipedia.org/wiki/Rust_(language)!"),
+            [
+                Url("https://en.wikipedia.org/wiki/Rust_(language)"),
+                Text("!")
+            ]
+        );
+        assert_eq!(
+            segments("[https://a.com/x[1]]"),
+            [Text("["), Url("https://a.com/x[1]"), Text("]")]
+        );
+    }
+
+    #[test]
+    fn only_whole_schemes_at_a_word_start_link() {
+        assert_eq!(segments("xhttps://a.com"), [Text("xhttps://a.com")]);
+        assert_eq!(
+            segments("httpd and https:// alone"),
+            [Text("httpd and https:// alone")]
+        );
+        assert_eq!(segments("https://."), [Text("https://.")]);
+        // Non-ASCII around a URL keeps the byte offsets honest.
+        assert_eq!(
+            segments("café→https://a.com/é…"),
+            [Text("café→"), Url("https://a.com/é"), Text("…")]
+        );
+    }
+
+    #[test]
+    fn links_open_in_a_new_tab_and_text_stays_escaped() {
+        assert_eq!(
+            linked("<b> https://a.com/?x=1&y=\"2\"").into_string(),
+            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;"#
+        );
+    }
+}
diff --git a/src/fixtures.rs b/src/fixtures.rs
index ea63ef3..bfa02ab 100644
--- a/src/fixtures.rs
+++ b/src/fixtures.rs
@@ -198,7 +198,7 @@ pub fn seed(store: &Store) -> Result<(), Error> {
         1,
         at(9, 26, 14, 10),
         roofer,
-        "Here are the photos from the site visit.",
+        "Here are the photos from the site visit: https://photos.northwind.example/visit/926.",
         (Inbox, Some("House"), &[]),
         &[],
     );
diff --git a/src/lib.rs b/src/lib.rs
index bca441f..6e4132b 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,3 +1,4 @@
+pub mod body;
 pub mod dates;
 #[cfg(feature = "dev")]
 pub mod dev;
diff --git a/src/views.rs b/src/views.rs
index 3a04a4e..42dbe0d 100644
--- a/src/views.rs
+++ b/src/views.rs
@@ -7,10 +7,10 @@ use std::collections::BTreeSet;
 use jiff::civil::DateTime;
 use maud::{DOCTYPE, Markup, PreEscaped, html};
 
-use crate::dates;
 use crate::lists::{Group, Section, View};
 use crate::model::{Account, Comment, Kind, Message, MessageId, State, Thread, User, Values};
 use crate::store::{Flash, Item};
+use crate::{body, dates};
 
 /// Everything one page shows, loaded before rendering starts.
 pub struct Page<'a> {
@@ -379,7 +379,7 @@ 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] { (m.body) }
+                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) }
             }
             div.gutter {
diff --git a/tests/api.rs b/tests/api.rs
index 00cc4c4..f24e7d4 100644
--- a/tests/api.rs
+++ b/tests/api.rs
@@ -197,6 +197,10 @@ 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>"#));
+    // 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>"#
+    ));
     assert!(body.contains(r#"<span class="badge accent"><span class="mark"></span>Do</span>"#));
     assert!(body.contains(r#"<span class="badge success"><span class="mark"></span>Done</span>"#));
     assert!(body.contains(&format!(