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::{
|
||||
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<AppMessage> {
|
||||
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),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user