feat(chat): clickable links in chat messages (A13)
Chat messages rendered URLs as plain text. Now http/https URLs render as clickable links that open in the system browser (xdg-open). - New pure `sanitize::linkify` splits an (already-sanitized) message into text/URL segments: conservative — only http:// and https:// runs, ending at whitespace, with trailing prose punctuation peeled back out; reassembling the segments reproduces the input exactly. +6 unit tests. - Chat render uses iced `rich_text` with link spans + `on_link_click`. - `OpenUrl` handler re-validates the http(s) scheme (defence in depth) before spawning xdg-open with the URL as a single argv entry (no shell, no injection). Linkify only runs after `sanitize_chat`, so control/format chars are already gone. 214 lib tests green, clippy clean. Manual check: send a message with a URL, click it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+32
-1
@@ -8,6 +8,7 @@ use crate::theme::{AppTheme, Palette};
|
|||||||
use iced::widget::{
|
use iced::widget::{
|
||||||
container, column, row, text, button, text_input, scrollable, slider, checkbox, pick_list,
|
container, column, row, text, button, text_input, scrollable, slider, checkbox, pick_list,
|
||||||
radio, tooltip, progress_bar, canvas, Canvas, Column, stack, mouse_area,
|
radio, tooltip, progress_bar, canvas, Canvas, Column, stack, mouse_area,
|
||||||
|
rich_text, span,
|
||||||
};
|
};
|
||||||
use iced::widget::canvas::{Action, Frame, Geometry, Path, Program};
|
use iced::widget::canvas::{Action, Frame, Geometry, Path, Program};
|
||||||
use iced::{
|
use iced::{
|
||||||
@@ -144,6 +145,8 @@ pub enum AppMessage {
|
|||||||
ChatInputChanged(String),
|
ChatInputChanged(String),
|
||||||
/// Send the current chat input line (Enter or the Send button).
|
/// Send the current chat input line (Enter or the Send button).
|
||||||
ChatSubmit,
|
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
|
/// A room divider was dragged by the given pixel delta along its drag axis
|
||||||
/// (horizontal for the Panels divider, vertical for the Chat divider).
|
/// (horizontal for the Panels divider, vertical for the Chat divider).
|
||||||
DividerDragged(DividerKind, f32),
|
DividerDragged(DividerKind, f32),
|
||||||
@@ -785,6 +788,18 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
state.chat_input.clear();
|
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) => {
|
AppMessage::ToggleMicTest(enabled) => {
|
||||||
state.mic_test_active = enabled;
|
state.mic_test_active = enabled;
|
||||||
if !enabled {
|
if !enabled {
|
||||||
@@ -1895,10 +1910,26 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
} else {
|
} else {
|
||||||
for m in &state.chat_messages {
|
for m in &state.chat_messages {
|
||||||
let name_color = if m.mine { color_green } else { color_lavender };
|
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(
|
chat_col = chat_col.push(
|
||||||
row![
|
row![
|
||||||
text(format!("{}:", m.name)).size(12).color(name_color),
|
text(format!("{}:", m.name)).size(12).color(name_color),
|
||||||
text(&m.text).size(13).color(color_text).width(iced::Length::Fill),
|
body,
|
||||||
]
|
]
|
||||||
.spacing(8),
|
.spacing(8),
|
||||||
);
|
);
|
||||||
|
|||||||
+146
@@ -42,6 +42,61 @@ pub fn sanitize_name(input: &str) -> String {
|
|||||||
collapsed.chars().take(NAME_MAX_CHARS).collect()
|
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<usize> {
|
||||||
|
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<Segment> {
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -79,4 +134,95 @@ mod tests {
|
|||||||
let long = "n".repeat(NAME_MAX_CHARS + 500);
|
let long = "n".repeat(NAME_MAX_CHARS + 500);
|
||||||
assert_eq!(sanitize_name(&long).chars().count(), NAME_MAX_CHARS);
|
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:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user