diff --git a/src/app/mod.rs b/src/app/mod.rs index 35640ed..1a6b2d0 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -8,6 +8,7 @@ use crate::theme::{AppTheme, Palette}; use iced::widget::{ container, column, row, text, button, text_input, scrollable, slider, checkbox, pick_list, radio, tooltip, progress_bar, canvas, Canvas, Column, stack, mouse_area, + rich_text, span, }; use iced::widget::canvas::{Action, Frame, Geometry, Path, Program}; use iced::{ @@ -144,6 +145,8 @@ pub enum AppMessage { ChatInputChanged(String), /// Send the current chat input line (Enter or the Send button). ChatSubmit, + /// Open a clicked chat link in the system browser (A13). + OpenUrl(String), /// A room divider was dragged by the given pixel delta along its drag axis /// (horizontal for the Panels divider, vertical for the Chat divider). DividerDragged(DividerKind, f32), @@ -785,6 +788,18 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.chat_input.clear(); } } + AppMessage::OpenUrl(url) => { + // Defence in depth: only ever hand http(s) URLs to the opener. The + // link span's href came from `linkify`, which only emits http/https, + // but re-check here so this can't be widened into launching arbitrary + // schemes/args. `xdg-open` receives the URL as a single argv entry + // (no shell), so there's no injection surface. + if (url.starts_with("http://") || url.starts_with("https://")) + && let Err(e) = std::process::Command::new("xdg-open").arg(&url).spawn() + { + crate::log_msg(&format!("Failed to open URL {url:?}: {e}")); + } + } AppMessage::ToggleMicTest(enabled) => { state.mic_test_active = enabled; if !enabled { @@ -1895,10 +1910,26 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { } else { for m in &state.chat_messages { let name_color = if m.mine { color_green } else { color_lavender }; + // Split the (already-sanitized) message into text + URL spans so + // links render clickable and open in the system browser (A13). + let spans: Vec<_> = crate::sanitize::linkify(&m.text) + .into_iter() + .map(|seg| match seg { + crate::sanitize::Segment::Text(t) => { + span(t).size(13).color(color_text) + } + crate::sanitize::Segment::Link(u) => { + span(u.clone()).size(13).color(color_blue).link(u) + } + }) + .collect(); + let body = rich_text(spans) + .on_link_click(AppMessage::OpenUrl) + .width(iced::Length::Fill); chat_col = chat_col.push( row![ text(format!("{}:", m.name)).size(12).color(name_color), - text(&m.text).size(13).color(color_text).width(iced::Length::Fill), + body, ] .spacing(8), ); diff --git a/src/sanitize.rs b/src/sanitize.rs index 6d42ad9..da0d7c6 100644 --- a/src/sanitize.rs +++ b/src/sanitize.rs @@ -42,6 +42,61 @@ pub fn sanitize_name(input: &str) -> String { collapsed.chars().take(NAME_MAX_CHARS).collect() } +/// A piece of a chat message after URL detection: literal text or a link. +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum Segment { + /// Plain text to render as-is. + Text(String), + /// A detected URL to render as a clickable link (also its href). + Link(String), +} + +/// Trailing characters commonly adjacent to a URL in prose that should NOT be +/// part of the link (so "see http://x.com." or "(http://x.com)" linkify cleanly). +fn is_url_trailing_punct(c: char) -> bool { + matches!(c, '.' | ',' | '!' | '?' | ';' | ':' | ')' | ']' | '}' | '>' | '"' | '\'') +} + +/// Find the byte index of the earliest `http://` or `https://` scheme in `s`, +/// scanning only on char boundaries so slicing is always safe. +fn find_scheme(s: &str) -> Option { + s.char_indices().find_map(|(i, _)| { + let tail = &s[i..]; + (tail.starts_with("http://") || tail.starts_with("https://")).then_some(i) + }) +} + +/// Split an (already chat-sanitized) message into plain-text and URL [`Segment`]s +/// for rendering. **Conservative on purpose:** only `http://` / `https://` runs +/// are treated as links, each ending at the first whitespace, with trailing prose +/// punctuation peeled back into the following text. Concatenating every segment's +/// inner string reproduces the input exactly (no characters added or dropped), so +/// it's purely a presentational split. Linkify AFTER sanitizing so control/format +/// chars are already gone (the URL can't smuggle them). Pure → unit-testable. +pub fn linkify(input: &str) -> Vec { + let mut out = Vec::new(); + let mut rest = input; + while !rest.is_empty() { + let Some(start) = find_scheme(rest) else { + out.push(Segment::Text(rest.to_string())); + break; + }; + if start > 0 { + out.push(Segment::Text(rest[..start].to_string())); + } + let after = &rest[start..]; + let end = after.find(char::is_whitespace).unwrap_or(after.len()); + let candidate = &after[..end]; + // Peel trailing punctuation back out of the link. + let url = candidate.trim_end_matches(is_url_trailing_punct); + out.push(Segment::Link(url.to_string())); + // Continue past just the URL; any peeled punctuation + the rest (incl. the + // whitespace) is reconsidered as ordinary text on the next iteration. + rest = &after[url.len()..]; + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -79,4 +134,95 @@ mod tests { let long = "n".repeat(NAME_MAX_CHARS + 500); assert_eq!(sanitize_name(&long).chars().count(), NAME_MAX_CHARS); } + + // --- linkify ----------------------------------------------------------- + + /// Concatenating every segment's inner text must reproduce the input exactly. + fn reassemble(segs: &[Segment]) -> String { + segs.iter() + .map(|s| match s { + Segment::Text(t) | Segment::Link(t) => t.as_str(), + }) + .collect() + } + + #[test] + fn linkify_plain_text_has_no_links() { + let segs = linkify("just a normal message, nothing here"); + assert_eq!(segs, vec![Segment::Text("just a normal message, nothing here".into())]); + } + + #[test] + fn linkify_detects_http_and_https() { + assert_eq!( + linkify("see http://example.com now"), + vec![ + Segment::Text("see ".into()), + Segment::Link("http://example.com".into()), + Segment::Text(" now".into()), + ] + ); + assert_eq!( + linkify("https://a.test/path?q=1"), + vec![Segment::Link("https://a.test/path?q=1".into())] + ); + } + + #[test] + fn linkify_peels_trailing_punctuation() { + // Sentence-final period is not part of the link. + assert_eq!( + linkify("go to https://x.com."), + vec![ + Segment::Text("go to ".into()), + Segment::Link("https://x.com".into()), + Segment::Text(".".into()), + ] + ); + // Parenthesized URL. + assert_eq!( + linkify("(https://x.com)"), + vec![ + Segment::Text("(".into()), + Segment::Link("https://x.com".into()), + Segment::Text(")".into()), + ] + ); + } + + #[test] + fn linkify_handles_multiple_urls() { + let segs = linkify("a http://one.com b https://two.com c"); + assert_eq!( + segs, + vec![ + Segment::Text("a ".into()), + Segment::Link("http://one.com".into()), + Segment::Text(" b ".into()), + Segment::Link("https://two.com".into()), + Segment::Text(" c".into()), + ] + ); + } + + #[test] + fn linkify_only_matches_http_schemes() { + // Non-web schemes and bare domains are NOT linkified (conservative). + let segs = linkify("email me@x.com or ftp://x.com or visit x.com"); + assert_eq!(segs, vec![Segment::Text("email me@x.com or ftp://x.com or visit x.com".into())]); + } + + #[test] + fn linkify_preserves_input_exactly() { + for msg in [ + "", + "no urls at all", + "http://a.com", + "pre http://a.com/x?y=z&w=1 mid https://b.org/p, end!", + "weird))) http://c.com]]] tail", + "unicode 世界 http://d.com/路径 more 世界", + ] { + assert_eq!(reassemble(&linkify(msg)), msg, "roundtrip failed for {msg:?}"); + } + } }