From 7f438ac104083c1b255190224c6b4f19f4dfc28b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= Date: Mon, 6 May 2024 17:46:39 +0200 Subject: [PATCH] misc: Use ruma-html to sanitize and render HTML instead of html2pango --- Cargo.lock | 148 +---- Cargo.toml | 6 +- src/components/pill/mod.rs | 3 +- src/components/room_title.rs | 19 +- src/prelude.rs | 5 +- .../room_history/message_row/content.rs | 10 +- .../content/room_history/message_row/text.rs | 540 ------------------ .../message_row/text/inline_html.rs | 340 +++++++++++ .../room_history/message_row/text/mod.rs | 401 +++++++++++++ .../room_history/message_row/text/tests.rs | 131 +++++ .../room_history/message_row/text/widgets.rs | 392 +++++++++++++ .../room_history/message_toolbar/mod.rs | 6 +- src/utils/matrix.rs | 186 +++--- src/utils/mod.rs | 1 + src/utils/string.rs | 249 ++++++++ 15 files changed, 1651 insertions(+), 786 deletions(-) delete mode 100644 src/session/view/content/room_history/message_row/text.rs create mode 100644 src/session/view/content/room_history/message_row/text/inline_html.rs create mode 100644 src/session/view/content/room_history/message_row/text/mod.rs create mode 100644 src/session/view/content/room_history/message_row/text/tests.rs create mode 100644 src/session/view/content/room_history/message_row/text/widgets.rs create mode 100644 src/utils/string.rs diff --git a/Cargo.lock b/Cargo.lock index 6ef6ef1e..bfe49735 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -83,19 +83,6 @@ version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" -[[package]] -name = "ammonia" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64e6d1c7838db705c9b756557ee27c384ce695a1c51a6fe528784cb1c6840170" -dependencies = [ - "html5ever 0.26.0", - "maplit", - "once_cell", - "tendril", - "url", -] - [[package]] name = "android-tzdata" version = "0.1.1" @@ -1466,13 +1453,11 @@ dependencies = [ "gstreamer-play", "gstreamer-video", "gtk4", - "html-escape", - "html2pango", - "html5gum", "image", "indexmap", "libadwaita", "libshumate", + "linkify", "matrix-sdk", "matrix-sdk-ui", "mime", @@ -2342,45 +2327,6 @@ dependencies = [ "digest", ] -[[package]] -name = "html-escape" -version = "0.2.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" -dependencies = [ - "utf8-width", -] - -[[package]] -name = "html2pango" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5f061cc3c0538033f81a94417f209e2b1908e3dab8b87b205d84e6109c8091b" -dependencies = [ - "ammonia", - "anyhow", - "html5ever 0.26.0", - "linkify", - "maplit", - "markup5ever_rcdom", - "once_cell", - "regex", -] - -[[package]] -name = "html5ever" -version = "0.26.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bea68cab48b8459f17cf1c944c67ddc572d272d9f2b274140f223ecb1da4a3b7" -dependencies = [ - "log", - "mac", - "markup5ever 0.11.0", - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "html5ever" version = "0.27.0" @@ -2389,21 +2335,12 @@ checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4" dependencies = [ "log", "mac", - "markup5ever 0.12.1", + "markup5ever", "proc-macro2", "quote", "syn 2.0.61", ] -[[package]] -name = "html5gum" -version = "0.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c4e556171a058ba117bbe88b059fb37b6289023e007d2903ea6dca3a3cbff14" -dependencies = [ - "jetscii", -] - [[package]] name = "http" version = "1.1.0" @@ -2717,12 +2654,6 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" -[[package]] -name = "jetscii" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47f142fe24a9c9944451e8349de0a56af5f3e7226dc46f3ed4d4ecc0b85af75e" - [[package]] name = "jobserver" version = "0.1.31" @@ -2942,9 +2873,9 @@ dependencies = [ [[package]] name = "linkify" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96dd5884008358112bc66093362197c7248ece00d46624e2cf71e50029f8cff5" +checksum = "f1dfa36d52c581e9ec783a7ce2a5e0143da6237be5811a0b3153fedfdbe9f780" dependencies = [ "memchr", ] @@ -3070,20 +3001,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" -[[package]] -name = "markup5ever" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2629bb1404f3d34c2e921f21fd34ba00b206124c81f65c50b43b6aaefeb016" -dependencies = [ - "log", - "phf 0.10.1", - "phf_codegen 0.10.0", - "string_cache", - "string_cache_codegen", - "tendril", -] - [[package]] name = "markup5ever" version = "0.12.1" @@ -3091,25 +3008,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45" dependencies = [ "log", - "phf 0.11.2", - "phf_codegen 0.11.2", + "phf", + "phf_codegen", "string_cache", "string_cache_codegen", "tendril", ] -[[package]] -name = "markup5ever_rcdom" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9521dd6750f8e80ee6c53d65e2e4656d7de37064f3a7a5d2d11d05df93839c2" -dependencies = [ - "html5ever 0.26.0", - "markup5ever 0.11.0", - "tendril", - "xml5ever", -] - [[package]] name = "matchers" version = "0.1.0" @@ -3885,15 +3790,6 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" -[[package]] -name = "phf" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" -dependencies = [ - "phf_shared 0.10.0", -] - [[package]] name = "phf" version = "0.11.2" @@ -3904,16 +3800,6 @@ dependencies = [ "phf_shared 0.11.2", ] -[[package]] -name = "phf_codegen" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd" -dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", -] - [[package]] name = "phf_codegen" version = "0.11.2" @@ -4654,8 +4540,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb6d948779da5fb4d1fc2d4c7a3f0cab21453dd14765a72fbee38ef758613d7f" dependencies = [ "as_variant", - "html5ever 0.27.0", - "phf 0.11.2", + "html5ever", + "phf", + "ruma-common", "tracing", "wildmatch", ] @@ -5678,12 +5565,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" -[[package]] -name = "utf8-width" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3" - [[package]] name = "uuid" version = "1.6.1" @@ -6115,17 +5996,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "xml5ever" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4034e1d05af98b51ad7214527730626f019682d797ba38b51689212118d8e650" -dependencies = [ - "log", - "mac", - "markup5ever 0.11.0", -] - [[package]] name = "yansi-term" version = "0.1.2" diff --git a/Cargo.toml b/Cargo.toml index 127703a7..11791edb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,11 +27,9 @@ futures-channel = "0.3" futures-util = "0.3" geo-uri = "0.2" gettext-rs = { version = "0.7", features = ["gettext-system"] } -html-escape = "0.2" -html2pango = "0.6" -html5gum = "0.5" image = "0.25" indexmap = "2" +linkify = "0.10.0" mime = "0.3" mime_guess = "2" once_cell = "1" @@ -94,7 +92,7 @@ features = [ "compat-optional", "compat-unset-avatar", "compat-get-3pids", - "html", + "html-matrix", ] # Linux-only dependencies. diff --git a/src/components/pill/mod.rs b/src/components/pill/mod.rs index 28eb1f56..69880432 100644 --- a/src/components/pill/mod.rs +++ b/src/components/pill/mod.rs @@ -12,6 +12,7 @@ pub use self::{ }; use super::{Avatar, JoinRoomDialog, UserProfileDialog}; use crate::{ + prelude::*, session::{ model::{Member, RemoteRoom, Room}, view::SessionView, @@ -164,7 +165,7 @@ mod imp { let is_ellipsized = maybe_ellipsized.len() < label.len(); if is_ellipsized { - maybe_ellipsized.push('…'); + maybe_ellipsized.append_ellipsis(); } self.display_name.set_label(&maybe_ellipsized); diff --git a/src/components/room_title.rs b/src/components/room_title.rs index 4815a39e..0b0a736e 100644 --- a/src/components/room_title.rs +++ b/src/components/room_title.rs @@ -1,6 +1,7 @@ use adw::subclass::prelude::*; use gtk::{glib, prelude::*, CompositeTemplate}; -use html2pango::markup; + +use crate::{prelude::*, utils::string::linkify}; mod imp { use std::cell::RefCell; @@ -51,8 +52,7 @@ mod imp { impl RoomTitle { /// Set the title of the room. fn set_title(&self, title: Option) { - // Parse and escape markup in title. - let title = title.map(|s| markup(&s)); + let title = title.map(|s| to_pango_markup(&s)); if *self.title.borrow() == title { return; @@ -66,8 +66,7 @@ mod imp { /// Set the subtitle of the room. pub fn set_subtitle(&self, subtitle: Option) { - // Parse and escape markup in subtitle. - let subtitle = subtitle.map(|s| markup(&s)); + let subtitle = subtitle.map(|s| to_pango_markup(&s)); if *self.subtitle.borrow() == subtitle { return; @@ -99,3 +98,13 @@ impl Default for RoomTitle { Self::new() } } + +/// Convert the given string to be used by Pango. +/// +/// This linkifies the text, removes newlines, escapes markup and removes +/// trailing spaces. +fn to_pango_markup(s: &str) -> String { + let mut result = linkify(s).replace('\n', " "); + result.truncate_end_whitespaces(); + result +} diff --git a/src/prelude.rs b/src/prelude.rs index 7e210faa..0dec3272 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -4,5 +4,8 @@ pub use crate::{ session::model::{TimelineItemExt, UserExt}, session_list::SessionInfoExt, user_facing_error::UserFacingError, - utils::LocationExt, + utils::{ + string::{StrExt, StrMutExt}, + LocationExt, + }, }; diff --git a/src/session/view/content/room_history/message_row/content.rs b/src/session/view/content/room_history/message_row/content.rs index 4b5106c9..23f52e48 100644 --- a/src/session/view/content/room_history/message_row/content.rs +++ b/src/session/view/content/room_history/message_row/content.rs @@ -287,7 +287,7 @@ fn build_content( parent.set_child(Some(&child)); child }; - child.with_text(message.body.clone(), format); + child.with_plain_text(message.body.clone(), format); } MessageType::Text(message) => { let child = if let Some(child) = parent.child().and_downcast::() { @@ -319,7 +319,7 @@ fn build_content( parent.set_child(Some(&child)); child }; - child.with_text(gettext("Unsupported event"), format); + child.with_plain_text(gettext("Unsupported event"), format); } }, TimelineItemContent::Sticker(sticker) => { @@ -340,7 +340,7 @@ fn build_content( parent.set_child(Some(&child)); child }; - child.with_text(gettext("Could not decrypt this message, decryption will be retried once the keys are available."), format); + child.with_plain_text(gettext("Could not decrypt this message, decryption will be retried once the keys are available."), format); } TimelineItemContent::RedactedMessage => { let child = if let Some(child) = parent.child().and_downcast::() { @@ -350,7 +350,7 @@ fn build_content( parent.set_child(Some(&child)); child }; - child.with_text(gettext("This message was removed."), format); + child.with_plain_text(gettext("This message was removed."), format); } content => { warn!("Unsupported event content: {content:?}"); @@ -361,7 +361,7 @@ fn build_content( parent.set_child(Some(&child)); child }; - child.with_text(gettext("Unsupported event"), format); + child.with_plain_text(gettext("Unsupported event"), format); } } } diff --git a/src/session/view/content/room_history/message_row/text.rs b/src/session/view/content/room_history/message_row/text.rs deleted file mode 100644 index 4b1e8d01..00000000 --- a/src/session/view/content/room_history/message_row/text.rs +++ /dev/null @@ -1,540 +0,0 @@ -use std::fmt::Write; - -use adw::{prelude::BinExt, subclass::prelude::*}; -use gtk::{glib, glib::clone, pango, prelude::*}; -use html2pango::{ - block::{markup_html, HtmlBlock}, - html_escape, markup_links, -}; -use matrix_sdk::ruma::events::room::message::{FormattedBody, MessageFormat}; - -use super::ContentFormat; -use crate::{ - components::LabelWithWidgets, - prelude::*, - session::model::{Member, Room}, - utils::{matrix::extract_mentions, BoundObjectWeakRef, EMOJI_REGEX}, -}; - -enum WithMentions<'a> { - Yes(&'a Room), - No, -} - -mod imp { - use std::cell::{Cell, RefCell}; - - use super::*; - - #[derive(Debug, Default, glib::Properties)] - #[properties(wrapper_type = super::MessageText)] - pub struct MessageText { - /// The original text of the message that is displayed. - #[property(get)] - pub original_text: RefCell, - /// Whether the original text is HTML. - /// - /// Only used for emotes. - #[property(get)] - pub is_html: Cell, - /// The text format. - #[property(get, builder(ContentFormat::default()))] - pub format: Cell, - /// The sender of the message, if we need to listen to changes. - pub sender: BoundObjectWeakRef, - } - - #[glib::object_subclass] - impl ObjectSubclass for MessageText { - const NAME: &'static str = "ContentMessageText"; - type Type = super::MessageText; - type ParentType = adw::Bin; - } - - #[glib::derived_properties] - impl ObjectImpl for MessageText {} - - impl WidgetImpl for MessageText {} - impl BinImpl for MessageText {} -} - -glib::wrapper! { - /// A widget displaying the content of a text message. - // FIXME: We have to be able to allow text selection and override popover - // menu. See https://gitlab.gnome.org/GNOME/gtk/-/issues/4606 - pub struct MessageText(ObjectSubclass) - @extends gtk::Widget, adw::Bin, @implements gtk::Accessible; -} - -impl MessageText { - /// Creates a text widget. - pub fn new() -> Self { - glib::Object::new() - } - - /// Display the given plain text. - pub fn with_text(&self, body: String, format: ContentFormat) { - if !self.original_text_changed(&body) && !self.format_changed(format) { - return; - } - - self.reset(); - self.set_original_text(body.clone()); - self.set_format(format); - - self.build_text(body, WithMentions::No, false); - } - - /// Display the given text with markup. - /// - /// It will detect if it should display the body or the formatted body. - pub fn with_markup( - &self, - formatted: Option, - body: String, - room: &Room, - format: ContentFormat, - ) { - if let Some(formatted) = formatted.filter(is_valid_formatted_body).map(|f| f.body) { - if !self.original_text_changed(&formatted) && !self.format_changed(format) { - return; - } - - if let Some(html_blocks) = parse_formatted_body(&formatted) { - self.reset(); - self.set_original_text(formatted); - self.set_format(format); - - self.build_html(html_blocks, room); - return; - } - } - - if !self.original_text_changed(&body) && !self.format_changed(format) { - return; - } - - let linkified_body = linkify(&body); - - self.reset(); - self.set_original_text(body); - self.set_format(format); - - self.build_text(linkified_body, WithMentions::Yes(room), false); - } - - /// Display the given emote for `sender`. - /// - /// It will detect if it should display the body or the formatted body. - pub fn with_emote( - &self, - formatted: Option, - body: String, - sender: Member, - room: &Room, - format: ContentFormat, - ) { - if let Some(body) = formatted.filter(is_valid_formatted_body).map(|f| f.body) { - if !self.original_text_changed(&body) - && !self.format_changed(format) - && !self.sender_changed(&sender) - { - return; - } - - let with_sender = format!("{} {body}", sender.disambiguated_name()); - - if let Some(html_blocks) = parse_formatted_body(&with_sender) { - self.reset(); - self.add_css_class("emote"); - self.set_original_text(body); - self.set_is_html(true); - self.set_format(format); - - let handler = sender.connect_disambiguated_name_notify( - clone!(@weak self as obj, @weak room => move |sender| { - obj.update_emote(&room, &sender.disambiguated_name()); - }), - ); - self.imp().sender.set(&sender, vec![handler]); - - self.build_html(html_blocks, room); - return; - } - } - - let body = linkify(&body); - - if !self.original_text_changed(&body) - && !self.format_changed(format) - && !self.sender_changed(&sender) - { - return; - } - - let with_sender = format!("{} {body}", sender.disambiguated_name()); - - self.reset(); - self.add_css_class("emote"); - self.set_original_text(body.clone()); - self.set_is_html(false); - self.set_format(format); - - let handler = sender.connect_disambiguated_name_notify( - clone!(@weak self as obj, @weak room => move |sender| { - obj.update_emote(&room, &sender.disambiguated_name()); - }), - ); - self.imp().sender.set(&sender, vec![handler]); - - self.build_text(with_sender, WithMentions::Yes(room), true); - } - - fn update_emote(&self, room: &Room, sender_name: &str) { - let with_sender = format!("{sender_name} {}", self.original_text()); - - if self.is_html() { - if let Some(html_blocks) = parse_formatted_body(&with_sender) { - self.build_html(html_blocks, room); - return; - } - } - - self.build_text(with_sender, WithMentions::Yes(room), true); - } - - fn build_text(&self, text: String, with_mentions: WithMentions, use_markup: bool) { - let ellipsize = self.format() == ContentFormat::Ellipsized; - - let (linkified, (label, widgets)) = match with_mentions { - WithMentions::Yes(room) => (true, extract_mentions(&text, room)), - WithMentions::No => (false, (text, Vec::new())), - }; - - // FIXME: This should not be necessary but spaces at the end of the string cause - // criticals. - let label = label.trim_end_matches(' '); - - if widgets.is_empty() { - let child = if let Some(child) = self.child().and_downcast::() { - child - } else { - let child = new_label(); - self.set_child(Some(&child)); - child - }; - - if EMOJI_REGEX.is_match(label) { - child.add_css_class("emoji"); - } else { - child.remove_css_class("emoji"); - } - - child.set_ellipsize(if ellipsize { - pango::EllipsizeMode::End - } else { - pango::EllipsizeMode::None - }); - - child.set_use_markup(use_markup || linkified); - child.set_label(label); - } else { - let widgets = widgets - .into_iter() - .map(|(p, _)| { - // Show the profile on click. - p.set_activatable(true); - p - }) - .collect(); - let child = if let Some(child) = self.child().and_downcast::() { - child - } else { - let child = LabelWithWidgets::new(); - self.set_child(Some(&child)); - child - }; - - child.set_ellipsize(ellipsize); - child.set_use_markup(true); - child.set_label(Some(label.to_owned())); - child.set_widgets(widgets); - } - } - - fn build_html(&self, blocks: Vec, room: &Room) { - let ellipsize = self.format() == ContentFormat::Ellipsized; - - if blocks.len() == 1 { - let widget = create_widget_for_html_block(&blocks[0], room, ellipsize, false); - self.set_child(Some(&widget)); - } else { - let child = gtk::Grid::builder().row_spacing(6).build(); - self.set_child(Some(&child)); - - for (row, block) in blocks.into_iter().enumerate() { - let widget = create_widget_for_html_block(&block, room, ellipsize, true); - child.attach(&widget, 0, row as i32, 1, 1); - - if ellipsize { - break; - } - } - } - } - - /// Whether the given text is different than the current original text. - fn original_text_changed(&self, text: &str) -> bool { - *self.imp().original_text.borrow() != text - } - - /// Set the original text of the message to display. - fn set_original_text(&self, text: String) { - self.imp().original_text.replace(text); - self.notify_original_text(); - } - - /// Set whether the original text of the message is HTML. - fn set_is_html(&self, is_html: bool) { - if self.is_html() == is_html { - return; - } - - self.imp().is_html.set(is_html); - self.notify_is_html(); - } - - /// Whether the given format is different than the current format. - fn format_changed(&self, format: ContentFormat) -> bool { - self.format() != format - } - - /// Set the text format. - fn set_format(&self, format: ContentFormat) { - self.imp().format.set(format); - self.notify_format(); - } - - /// Whether the sender of the message changed. - fn sender_changed(&self, sender: &Member) -> bool { - self.imp().sender.obj().as_ref() == Some(sender) - } - - /// Reset this `MessageText`. - fn reset(&self) { - self.imp().sender.disconnect_signals(); - self.remove_css_class("emote"); - } -} - -impl Default for MessageText { - fn default() -> Self { - Self::new() - } -} - -/// Transform URLs into links. -fn linkify(text: &str) -> String { - hoverify_links(&markup_links(&html_escape(text))) -} - -/// Make links show up on hover. -fn hoverify_links(text: &str) -> String { - let mut res = String::with_capacity(text.len()); - - for (i, chunk) in text.split_inclusive(" 0 { - if let Some((url, end)) = chunk.split_once('"') { - let escaped_url = html_escape(url); - write!(&mut res, "{url}\" title=\"{escaped_url}\"{end}").unwrap(); - - continue; - } - } - - res.push_str(chunk); - } - - res -} - -fn is_valid_formatted_body(formatted: &FormattedBody) -> bool { - formatted.format == MessageFormat::Html && !formatted.body.contains("") -} - -fn parse_formatted_body(formatted: &str) -> Option> { - markup_html(formatted).ok() -} - -fn create_widget_for_html_block( - block: &HtmlBlock, - room: &Room, - ellipsize: bool, - has_more: bool, -) -> gtk::Widget { - match block { - HtmlBlock::Heading(n, s) => { - let w = create_label_for_html(s, room, ellipsize, has_more); - w.add_css_class(&format!("h{n}")); - w - } - HtmlBlock::UList(elements) => { - let grid = gtk::Grid::builder() - .row_spacing(6) - .column_spacing(6) - .margin_end(6) - .margin_start(6) - .build(); - - for (row, li) in elements.iter().enumerate() { - let bullet = gtk::Label::builder() - .label("•") - .valign(gtk::Align::Baseline) - .build(); - - let w = create_label_for_html(li, room, ellipsize, has_more || elements.len() > 1); - - grid.attach(&bullet, 0, row as i32, 1, 1); - grid.attach(&w, 1, row as i32, 1, 1); - - if ellipsize { - break; - } - } - - grid.upcast() - } - HtmlBlock::OList(elements) => { - let grid = gtk::Grid::builder() - .row_spacing(6) - .column_spacing(6) - .margin_end(6) - .margin_start(6) - .build(); - - for (row, ol) in elements.iter().enumerate() { - let bullet = gtk::Label::builder() - .label(format!("{}.", row + 1)) - .valign(gtk::Align::Baseline) - .build(); - - let w = create_label_for_html(ol, room, ellipsize, has_more || elements.len() > 1); - - grid.attach(&bullet, 0, row as i32, 1, 1); - grid.attach(&w, 1, row as i32, 1, 1); - - if ellipsize { - break; - } - } - - grid.upcast() - } - HtmlBlock::Code(s) => { - if ellipsize { - let label = if let Some(pos) = s.find('\n') { - format!("{}…", &s[0..pos]) - } else if has_more { - format!("{s}…") - } else { - format!("{s}") - }; - - gtk::Label::builder() - .label(label) - .use_markup(true) - .ellipsize(if ellipsize { - pango::EllipsizeMode::End - } else { - pango::EllipsizeMode::None - }) - .build() - .upcast() - } else { - let scrolled = gtk::ScrolledWindow::new(); - scrolled.set_policy(gtk::PolicyType::Automatic, gtk::PolicyType::Never); - let buffer = sourceview::Buffer::builder() - .highlight_matching_brackets(false) - .text(s) - .build(); - crate::utils::sourceview::setup_style_scheme(&buffer); - let view = sourceview::View::builder() - .buffer(&buffer) - .editable(false) - .css_classes(["codeview", "frame"]) - .hexpand(true) - .build(); - scrolled.set_child(Some(&view)); - scrolled.upcast() - } - } - HtmlBlock::Quote(blocks) => { - let grid = gtk::Grid::builder() - .row_spacing(6) - .css_classes(["quote"]) - .build(); - - for (row, block) in blocks.iter().enumerate() { - let w = create_widget_for_html_block( - block, - room, - ellipsize, - has_more || blocks.len() > 1, - ); - grid.attach(&w, 0, row as i32, 1, 1); - - if ellipsize { - break; - } - } - - grid.upcast() - } - HtmlBlock::Text(s) => create_label_for_html(s, room, ellipsize, has_more).upcast(), - HtmlBlock::Separator => gtk::Separator::new(gtk::Orientation::Horizontal).upcast(), - } -} - -fn new_label() -> gtk::Label { - gtk::Label::builder() - .wrap(true) - .wrap_mode(pango::WrapMode::WordChar) - .xalign(0.0) - .valign(gtk::Align::Start) - .build() -} - -fn create_label_for_html(label: &str, room: &Room, ellipsize: bool, cut_text: bool) -> gtk::Widget { - // FIXME: This should not be necessary but spaces at the end of the string cause - // criticals. - let label = label.trim_end_matches(' '); - let (label, widgets) = extract_mentions(label, room); - let mut label = hoverify_links(&label); - if ellipsize && cut_text && !label.ends_with('…') && !label.ends_with("...") { - label.push('…'); - } - - if widgets.is_empty() { - let w = new_label(); - w.set_markup(&label); - w.set_ellipsize(if ellipsize { - pango::EllipsizeMode::End - } else { - pango::EllipsizeMode::None - }); - w.upcast() - } else { - let widgets = widgets - .into_iter() - .map(|(p, _)| { - // Show the profile on click. - p.set_activatable(true); - p - }) - .collect(); - let w = LabelWithWidgets::with_label_and_widgets(&label, widgets); - w.set_use_markup(true); - w.set_ellipsize(ellipsize); - w.upcast() - } -} diff --git a/src/session/view/content/room_history/message_row/text/inline_html.rs b/src/session/view/content/room_history/message_row/text/inline_html.rs new file mode 100644 index 00000000..755aa86e --- /dev/null +++ b/src/session/view/content/room_history/message_row/text/inline_html.rs @@ -0,0 +1,340 @@ +//! Helpers for making Pango-compatible strings from inline HTML. + +use std::fmt::Write; + +use ruma::html::{ + matrix::{AnchorUri, MatrixElement, SpanData}, + Children, NodeData, NodeRef, +}; +use tracing::warn; + +use crate::{ + components::Pill, + prelude::*, + session::model::Room, + utils::string::{Linkifier, PangoStrMutExt}, +}; + +/// Helper type to construct a Pango-compatible string from inline HTML nodes. +#[derive(Debug)] +pub(super) struct InlineHtmlBuilder<'a> { + /// Whether this string should be on a single line. + single_line: bool, + /// Whether to append an ellipsis at the end of the string. + ellipsis: bool, + /// The mentions detection setting and results. + mentions: MentionsMode<'a>, + /// The inner string. + inner: String, + /// Whether this string was truncated because at the first newline. + truncated: bool, +} + +impl<'a> InlineHtmlBuilder<'a> { + /// Constructs a new inline HTML string builder for the given room. + /// + /// If `single_line` is set to `true`, the string will be ellipsized at the + /// first line break. + /// + /// If `ellipsis` is set to `true`, and ellipsis will be added at the end of + /// the string. + pub(super) fn new(single_line: bool, ellipsis: bool) -> Self { + Self { + single_line, + ellipsis, + mentions: MentionsMode::default(), + inner: String::new(), + truncated: false, + } + } + + /// Enable mentions detection in the given room. + pub(super) fn detect_mentions(mut self, room: &'a Room) -> Self { + self.mentions = MentionsMode::WithMentions { + room, + pills: Vec::new(), + }; + self + } + + /// Append and consume the given sender name for an emote, if it is set. + pub(super) fn append_emote_with_name(mut self, name: &mut Option<&str>) -> Self { + self.inner.maybe_append_emote_name(name); + self + } + + /// Export the Pango-compatible string and the [`Pill`]s that were + /// constructed, if any. + pub(super) fn build(self) -> (String, Option>) { + let mut inner = self.inner; + let ellipsis = self.ellipsis | self.truncated; + + if ellipsis { + inner.append_ellipsis(); + } else { + inner.truncate_end_whitespaces(); + } + + let pills = if let MentionsMode::WithMentions { pills, .. } = self.mentions { + (!pills.is_empty()).then_some(pills) + } else { + None + }; + + (inner, pills) + } + + /// Construct the string with the given inline nodes by converting them to + /// Pango markup. + /// + /// Returns the Pango-compatible string and the [`Pill`]s that were + /// constructed, if any. + pub(super) fn build_with_nodes( + mut self, + nodes: impl IntoIterator>, + ) -> (String, Option>) { + self.append_nodes(nodes, true); + self.build() + } + + /// Construct the string by traversing the nodes an returning only the text + /// it contains. + /// + /// Node that markup contained in the text is not escaped and newlines are + /// not removed. + pub(super) fn build_with_nodes_text( + mut self, + nodes: impl IntoIterator>, + ) -> String { + self.append_nodes_text(nodes); + + let (inner, _) = self.build(); + inner + } + + /// Append the given inline node by converting it to Pango markup. + fn append_node(&mut self, node: NodeRef<'a>, should_linkify: bool) { + match node.data() { + NodeData::Element(data) => { + let data = data.to_matrix(); + match data.element { + MatrixElement::Del | MatrixElement::S => { + self.append_tags_and_children("s", node.children(), should_linkify); + } + MatrixElement::A(anchor) => { + // First, check if it's a mention, if we detect mentions. + if let Some(uri) = &anchor.href { + if let MentionsMode::WithMentions { pills, room } = &mut self.mentions { + if let Some(pill) = self.inner.maybe_append_mention(uri, room) { + pills.push(pill); + + return; + } + } + } + + // It's not a mention, render the link, if it has a URI. + let mut has_opening_tag = false; + + if let Some(uri) = &anchor.href { + has_opening_tag = self.append_link_opening_tag_from_anchor_uri(uri) + } + + // Always render the children. + for node in node.children() { + // Don't try to linkify text if we render the element, it does not make + // sense to nest links. + self.append_node(node, !has_opening_tag && should_linkify); + } + + if has_opening_tag { + self.inner.push_str(""); + } + } + MatrixElement::Sup => { + self.append_tags_and_children("sup", node.children(), should_linkify); + } + MatrixElement::Sub => { + self.append_tags_and_children("sub", node.children(), should_linkify); + } + MatrixElement::B | MatrixElement::Strong => { + self.append_tags_and_children("b", node.children(), should_linkify); + } + MatrixElement::I | MatrixElement::Em => { + self.append_tags_and_children("i", node.children(), should_linkify); + } + MatrixElement::U => { + self.append_tags_and_children("u", node.children(), should_linkify); + } + MatrixElement::Code(_) => { + // Don't try to linkify text, it does not make sense to detect links inside + // code. + self.append_tags_and_children("tt", node.children(), false); + } + MatrixElement::Br => { + if self.single_line { + self.truncated = true; + } else { + self.inner.push('\n'); + } + } + MatrixElement::Span(span) => { + self.append_span(&span, node.children(), should_linkify); + } + element => warn!("Got unexpected inline HTML element: {element:?}"), + } + } + NodeData::Text(text) => { + let text = text.remove_newlines(); + + if should_linkify { + if let MentionsMode::WithMentions { pills, room } = &mut self.mentions { + Linkifier::new(&mut self.inner) + .detect_mentions(room, pills) + .linkify(&text); + } else { + Linkifier::new(&mut self.inner).linkify(&text); + } + } else { + self.inner.push_str(&text.escape_markup()); + } + } + data => { + warn!("Got HTML node that should have been sanitized: {data:?}"); + } + } + } + + /// Append the given inline nodes, converted to Pango markup. + fn append_nodes(&mut self, nodes: impl IntoIterator>, should_linkify: bool) { + for node in nodes { + self.append_node(node, should_linkify); + + if self.truncated { + // Stop as soon as the string is truncated. + break; + } + } + } + + /// Append the given inline children, converted to Pango markup, surrounded + /// by tags with the given name. + fn append_tags_and_children( + &mut self, + tag_name: &str, + children: Children<'a>, + should_linkify: bool, + ) { + let _ = write!(self.inner, "<{tag_name}>"); + + self.append_nodes(children, should_linkify); + + let _ = write!(self.inner, ""); + } + + /// Append the opening Pango markup link tag of the given anchor URI. + /// + /// The URI is also used as a title, so users can preview the link on hover. + /// + /// Returns `true` if the opening tag was successfully constructed. + fn append_link_opening_tag_from_anchor_uri(&mut self, uri: &AnchorUri) -> bool { + match uri { + AnchorUri::Matrix(uri) => { + self.inner.append_link_opening_tag(uri.to_string()); + true + } + AnchorUri::MatrixTo(uri) => { + self.inner.append_link_opening_tag(uri.to_string()); + true + } + AnchorUri::Other(uri) => { + self.inner.append_link_opening_tag(uri); + true + } + _ => { + warn!("Got unknown anchor URI format"); + false + } + } + } + + /// Append the span with the given data and inline children as Pango Markup. + /// + /// Whether we are an inside an anchor or not decides if we try to linkify + /// the text contained in the children nodes. + fn append_span(&mut self, span: &SpanData, children: Children<'a>, should_linkify: bool) { + self.inner.push_str("'); + + self.append_nodes(children, should_linkify); + + self.inner.push_str(""); + } + + /// Append the text contained in the nodes to the string. + /// + /// Returns `true` if the text was ellipsized. + fn append_nodes_text(&mut self, nodes: impl IntoIterator>) { + for node in nodes.into_iter() { + match node.data() { + NodeData::Text(t) => { + let t = t.as_ref(); + + if self.single_line { + if let Some(newline) = t.find(|c: char| c == '\n') { + self.truncated = true; + + self.inner.push_str(&t[..newline]); + self.inner.append_ellipsis(); + + break; + } + } + + self.inner.push_str(t); + } + NodeData::Element(data) => { + if data.name.local.as_ref() == "br" { + if self.single_line { + self.truncated = true; + break; + } + + self.inner.push('\n'); + } else { + self.append_nodes_text(node.children()); + } + } + _ => {} + } + + if self.truncated { + // Stop as soon as the string is truncated. + break; + } + } + } +} + +/// The mentions mode of the [`InlineHtmlBuilder`]. +#[derive(Debug, Default)] +enum MentionsMode<'a> { + /// The builder will not detect mentions. + #[default] + NoMentions, + /// The builder will detect mentions. + WithMentions { + /// The pills for the detected mentions. + pills: Vec, + /// The room containing the mentions. + room: &'a Room, + }, +} diff --git a/src/session/view/content/room_history/message_row/text/mod.rs b/src/session/view/content/room_history/message_row/text/mod.rs new file mode 100644 index 00000000..64579a10 --- /dev/null +++ b/src/session/view/content/room_history/message_row/text/mod.rs @@ -0,0 +1,401 @@ +use adw::{prelude::BinExt, subclass::prelude::*}; +use gtk::{glib, glib::clone, pango, prelude::*}; +use matrix_sdk::ruma::events::room::message::FormattedBody; +use once_cell::sync::Lazy; +use ruma::{ + events::room::message::MessageFormat, + html::{Html, ListBehavior, SanitizerConfig}, +}; + +mod inline_html; +#[cfg(test)] +mod tests; +mod widgets; + +use self::widgets::{new_message_label, widget_for_html_nodes}; +use super::ContentFormat; +use crate::{ + components::LabelWithWidgets, + prelude::*, + session::model::{Member, Room}, + utils::{ + string::{Linkifier, PangoStrMutExt}, + BoundObjectWeakRef, EMOJI_REGEX, + }, +}; + +mod imp { + use std::cell::{Cell, RefCell}; + + use super::*; + + #[derive(Debug, Default, glib::Properties)] + #[properties(wrapper_type = super::MessageText)] + pub struct MessageText { + /// The original text of the message that is displayed. + #[property(get)] + pub original_text: RefCell, + /// Whether the original text is HTML. + /// + /// Only used for emotes. + #[property(get)] + pub is_html: Cell, + /// The text format. + #[property(get, builder(ContentFormat::default()))] + pub format: Cell, + /// The sender of the message, if we need to listen to changes. + pub sender: BoundObjectWeakRef, + } + + #[glib::object_subclass] + impl ObjectSubclass for MessageText { + const NAME: &'static str = "ContentMessageText"; + type Type = super::MessageText; + type ParentType = adw::Bin; + } + + #[glib::derived_properties] + impl ObjectImpl for MessageText {} + + impl WidgetImpl for MessageText {} + impl BinImpl for MessageText {} +} + +glib::wrapper! { + /// A widget displaying the content of a text message. + // FIXME: We have to be able to allow text selection and override popover + // menu. See https://gitlab.gnome.org/GNOME/gtk/-/issues/4606 + pub struct MessageText(ObjectSubclass) + @extends gtk::Widget, adw::Bin, @implements gtk::Accessible; +} + +impl MessageText { + /// Creates a text widget. + pub fn new() -> Self { + glib::Object::new() + } + + /// Display the given plain text. + pub fn with_plain_text(&self, body: String, format: ContentFormat) { + if !self.original_text_changed(&body) && !self.format_changed(format) { + return; + } + + self.reset(); + self.set_format(format); + + let mut escaped_body = body.escape_markup(); + escaped_body.truncate_end_whitespaces(); + + self.build_plain_text(escaped_body); + self.set_original_text(body); + } + + /// Display the given text with possible markup. + /// + /// It will detect if it should display the body or the formatted body. + pub fn with_markup( + &self, + formatted: Option, + body: String, + room: &Room, + format: ContentFormat, + ) { + if let Some(formatted) = formatted.filter(formatted_body_is_html).map(|f| f.body) { + if !self.original_text_changed(&formatted) && !self.format_changed(format) { + return; + } + + self.reset(); + self.set_format(format); + + if self.build_html(&formatted, room, None).is_ok() { + self.set_original_text(formatted); + return; + } + } + + if !self.original_text_changed(&body) && !self.format_changed(format) { + return; + } + + self.reset(); + self.set_format(format); + + self.build_text(&body, room, None); + self.set_original_text(body); + } + + /// Display the given emote for `sender`. + /// + /// It will detect if it should display the body or the formatted body. + pub fn with_emote( + &self, + formatted: Option, + body: String, + sender: Member, + room: &Room, + format: ContentFormat, + ) { + if let Some(formatted) = formatted.filter(formatted_body_is_html).map(|f| f.body) { + if !self.original_text_changed(&body) + && !self.format_changed(format) + && !self.sender_changed(&sender) + { + return; + } + + self.reset(); + self.set_format(format); + + let sender_name = sender.disambiguated_name(); + + if self + .build_html(&formatted, room, Some(&sender_name)) + .is_ok() + { + self.add_css_class("emote"); + self.set_is_html(true); + self.set_original_text(formatted); + + let handler = sender.connect_disambiguated_name_notify( + clone!(@weak self as obj, @weak room => move |sender| { + obj.update_emote(&room, &sender.disambiguated_name()); + }), + ); + self.imp().sender.set(&sender, vec![handler]); + + return; + } + } + + if !self.original_text_changed(&body) + && !self.format_changed(format) + && !self.sender_changed(&sender) + { + return; + } + + self.reset(); + self.set_format(format); + self.add_css_class("emote"); + self.set_is_html(false); + + let handler = sender.connect_disambiguated_name_notify( + clone!(@weak self as obj, @weak room => move |sender| { + obj.update_emote(&room, &sender.disambiguated_name()); + }), + ); + self.imp().sender.set(&sender, vec![handler]); + + let sender_name = sender.disambiguated_name(); + self.build_text(&body, room, Some(&sender_name)); + self.set_original_text(body); + } + + fn update_emote(&self, room: &Room, sender_name: &str) { + let text = self.original_text(); + + if self.is_html() && self.build_html(&text, room, Some(sender_name)).is_ok() { + return; + } + + self.build_text(&text, room, None); + } + + /// Build the message for the given plain text. + /// + /// The text must have been escaped and the end whitespaces removed before + /// calling this method. + fn build_plain_text(&self, mut text: String) { + let child = if let Some(child) = self.child().and_downcast::() { + child + } else { + let child = new_message_label(); + self.set_child(Some(&child)); + child + }; + + if EMOJI_REGEX.is_match(&text) { + child.add_css_class("emoji"); + } else { + child.remove_css_class("emoji"); + } + + let ellipsize = self.format() == ContentFormat::Ellipsized; + if ellipsize { + text.truncate_newline(); + } + + let ellipsize_mode = if ellipsize { + pango::EllipsizeMode::End + } else { + pango::EllipsizeMode::None + }; + child.set_ellipsize(ellipsize_mode); + + child.set_label(&text); + } + + /// Build the message for the given text in the given room. + /// + /// We will try to detect URIs in the text. + /// + /// If `sender` is provided, it is added as a prefix. This is used for + /// emotes. + fn build_text(&self, text: &str, room: &Room, mut sender_name: Option<&str>) { + let mut result = String::with_capacity(text.len()); + + result.maybe_append_emote_name(&mut sender_name); + + let mut pills = Vec::new(); + Linkifier::new(&mut result) + .detect_mentions(room, &mut pills) + .linkify(text); + + result.truncate_end_whitespaces(); + + if pills.is_empty() { + self.build_plain_text(result); + return; + }; + + let ellipsize = self.format() == ContentFormat::Ellipsized; + pills.iter().for_each(|p| { + // Show the profile on click. + p.set_activatable(true); + }); + + let child = if let Some(child) = self.child().and_downcast::() { + child + } else { + let child = LabelWithWidgets::new(); + self.set_child(Some(&child)); + child + }; + + child.set_ellipsize(ellipsize); + child.set_use_markup(true); + child.set_label(Some(result)); + child.set_widgets(pills); + } + + /// Build the message for the given HTML in the given room. + /// + /// We will try to detect URIs in the text. + /// + /// If `sender` is provided, it is added as a prefix. This is used for + /// emotes. + /// + /// Returns an error if the HTML string doesn't contain any HTML. + fn build_html(&self, html: &str, room: &Room, mut sender_name: Option<&str>) -> Result<(), ()> { + let ellipsize = self.format() == ContentFormat::Ellipsized; + + let mut html = Html::parse(html.trim_matches('\n')); + html.sanitize_with(&HTML_MESSAGE_SANITIZER_CONFIG); + + if !html.has_children() { + return Err(()); + } + + let Some(child) = + widget_for_html_nodes(html.children(), room, ellipsize, false, &mut sender_name) + else { + return Err(()); + }; + + self.set_child(Some(&child)); + + Ok(()) + } + + /// Whether the given text is different than the current original text. + fn original_text_changed(&self, text: &str) -> bool { + *self.imp().original_text.borrow() != text + } + + /// Set the original text of the message to display. + fn set_original_text(&self, text: String) { + self.imp().original_text.replace(text); + self.notify_original_text(); + } + + /// Set whether the original text of the message is HTML. + fn set_is_html(&self, is_html: bool) { + if self.is_html() == is_html { + return; + } + + self.imp().is_html.set(is_html); + self.notify_is_html(); + } + + /// Whether the given format is different than the current format. + fn format_changed(&self, format: ContentFormat) -> bool { + self.format() != format + } + + /// Set the text format. + fn set_format(&self, format: ContentFormat) { + self.imp().format.set(format); + self.notify_format(); + } + + /// Whether the sender of the message changed. + fn sender_changed(&self, sender: &Member) -> bool { + self.imp().sender.obj().as_ref() == Some(sender) + } + + /// Reset this `MessageText`. + fn reset(&self) { + self.imp().sender.disconnect_signals(); + self.remove_css_class("emote"); + } +} + +impl Default for MessageText { + fn default() -> Self { + Self::new() + } +} + +/// Whether the given [`FormattedBody`] contains HTML. +fn formatted_body_is_html(formatted: &FormattedBody) -> bool { + formatted.format == MessageFormat::Html && !formatted.body.contains("") +} + +/// All supported inline elements from the Matrix spec. +const SUPPORTED_INLINE_ELEMENTS: &[&str] = &[ + "del", "a", "sup", "sub", "b", "i", "u", "strong", "em", "s", "code", "br", "span", +]; + +/// All supported block elements from the Matrix spec. +const SUPPORTED_BLOCK_ELEMENTS: &[&str] = &[ + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "blockquote", + "p", + "ul", + "ol", + "li", + "hr", + "div", + "pre", +]; + +/// HTML sanitizer config for HTML messages. +static HTML_MESSAGE_SANITIZER_CONFIG: Lazy = Lazy::new(|| { + SanitizerConfig::compat() + .allow_elements( + SUPPORTED_INLINE_ELEMENTS + .iter() + .chain(SUPPORTED_BLOCK_ELEMENTS.iter()) + .copied(), + ListBehavior::Override, + ) + .remove_reply_fallback() +}); diff --git a/src/session/view/content/room_history/message_row/text/tests.rs b/src/session/view/content/room_history/message_row/text/tests.rs new file mode 100644 index 00000000..e71d5437 --- /dev/null +++ b/src/session/view/content/room_history/message_row/text/tests.rs @@ -0,0 +1,131 @@ +use ruma::html::Html; + +use super::inline_html::InlineHtmlBuilder; + +#[test] +fn text_with_no_markup() { + let html = Html::parse("A simple text"); + let (s, pills) = InlineHtmlBuilder::new(false, false).build_with_nodes(html.children()); + + assert_eq!(s, "A simple text"); + assert!(pills.is_none()); +} + +#[test] +fn single_line() { + let html = Html::parse("A simple text
on several lines"); + let (s, pills) = InlineHtmlBuilder::new(true, false).build_with_nodes(html.children()); + + assert_eq!(s, "A simple text…"); + assert!(pills.is_none()); +} + +#[test] +fn add_ellipsis() { + let html = Html::parse("A simple text"); + let (s, pills) = InlineHtmlBuilder::new(false, true).build_with_nodes(html.children()); + + assert_eq!(s, "A simple text…"); + assert!(pills.is_none()); +} + +#[test] +fn no_duplicate_ellipsis() { + let html = Html::parse("A simple text...
...on several lines"); + let (s, pills) = InlineHtmlBuilder::new(true, false).build_with_nodes(html.children()); + + assert_eq!(s, "A simple text..."); + assert!(pills.is_none()); +} + +#[test] +fn trim_end_spaces() { + let html = Html::parse("A high-altitude text 🗻 "); + let (s, pills) = InlineHtmlBuilder::new(false, false).build_with_nodes(html.children()); + + assert_eq!(s, "A high-altitude text 🗻"); + assert!(pills.is_none()); +} + +#[test] +fn ignore_newlines() { + let html = Html::parse("Hello \nyou! \nYou are my \nfriend."); + let (s, pills) = InlineHtmlBuilder::new(false, false).build_with_nodes(html.children()); + + assert_eq!(s, "Hello you! You are my friend."); + assert!(pills.is_none()); +} + +#[test] +fn sanitize_inline_html() { + let html = Html::parse( + r#"A text with markup"#, + ); + let (s, pills) = InlineHtmlBuilder::new(false, false).build_with_nodes(html.children()); + + assert_eq!( + s, + r#"A text with markup"# + ); + assert!(pills.is_none()); +} + +#[test] +fn escape_markup() { + let html = Html::parse( + r#"Go to this & that docs"#, + ); + let (s, pills) = InlineHtmlBuilder::new(false, false).build_with_nodes(html.children()); + + assert_eq!( + s, + r#"Go to this & that docs"# + ); + assert!(pills.is_none()); +} + +#[test] +fn linkify() { + let html = Html::parse( + "The homepage is https://gnome.org, and you can contact me at contact@me.local", + ); + let (s, pills) = InlineHtmlBuilder::new(false, false).build_with_nodes(html.children()); + + assert_eq!( + s, + r#"The homepage is https://gnome.org, and you can contact me at contact@me.local"# + ); + assert!(pills.is_none()); +} + +#[test] +fn do_not_linkify_inside_anchor() { + let html = Html::parse(r#"The homepage is https://gnome.org"#); + let (s, pills) = InlineHtmlBuilder::new(false, false).build_with_nodes(html.children()); + + assert_eq!( + s, + r#"The homepage is https://gnome.org"# + ); + assert!(pills.is_none()); +} + +#[test] +fn do_not_linkify_inside_code() { + let html = Html::parse("The homepage is https://gnome.org"); + let (s, pills) = InlineHtmlBuilder::new(false, false).build_with_nodes(html.children()); + + assert_eq!(s, "The homepage is https://gnome.org"); + assert!(pills.is_none()); +} + +#[test] +fn emote_name() { + let html = Html::parse("sent a beautiful picture."); + let (s, pills) = InlineHtmlBuilder::new(false, false) + .append_emote_with_name(&mut Some("Jun")) + .build_with_nodes(html.children()); + + assert_eq!(s, "Jun sent a beautiful picture."); + assert!(pills.is_none()); +} diff --git a/src/session/view/content/room_history/message_row/text/widgets.rs b/src/session/view/content/room_history/message_row/text/widgets.rs new file mode 100644 index 00000000..99ee6a45 --- /dev/null +++ b/src/session/view/content/room_history/message_row/text/widgets.rs @@ -0,0 +1,392 @@ +//! Build HTML messages. + +use gtk::{pango, prelude::*}; +use ruma::html::{ + matrix::{MatrixElement, OrderedListData}, + Children, NodeRef, +}; +use sourceview::prelude::*; + +use super::{inline_html::InlineHtmlBuilder, SUPPORTED_BLOCK_ELEMENTS}; +use crate::{components::LabelWithWidgets, prelude::*, session::model::Room}; + +/// Construct a new label for displaying a message's content. +pub(super) fn new_message_label() -> gtk::Label { + gtk::Label::builder() + .wrap(true) + .wrap_mode(pango::WrapMode::WordChar) + .xalign(0.0) + .valign(gtk::Align::Start) + .use_markup(true) + .build() +} + +/// Create a widget for the given HTML nodes in the given room. +/// +/// If `ellipsize` is true, we will only render the first block. +/// +/// If the sender name is set, it will be added as soon as possible. +/// +/// Returns `None` if the widget would have been empty. +pub(super) fn widget_for_html_nodes<'a>( + nodes: impl IntoIterator>, + room: &Room, + ellipsize: bool, + add_ellipsis: bool, + sender_name: &mut Option<&str>, +) -> Option { + let nodes = nodes.into_iter().collect::>(); + + if nodes.is_empty() { + return None; + } + + let groups = group_inline_nodes(nodes); + let len = groups.len(); + + let mut children = Vec::new(); + for (i, group) in groups.into_iter().enumerate() { + let is_last = i == (len - 1); + let add_ellipsis = add_ellipsis || (ellipsize && !is_last); + + match group { + NodeGroup::Inline(inline_nodes) => { + if let Some(widget) = + label_for_inline_html(inline_nodes, room, ellipsize, add_ellipsis, sender_name) + { + children.push(widget); + } + } + NodeGroup::Block(block_node) => { + let Some(widget) = + widget_for_html_block(block_node, room, ellipsize, add_ellipsis, sender_name) + else { + continue; + }; + + // Include sender name before, if the child widget did not handle it. + if let Some(sender_name) = sender_name.take() { + let label = new_message_label(); + let (text, _) = InlineHtmlBuilder::new(false, false) + .append_emote_with_name(&mut Some(sender_name)) + .build(); + label.set_label(&text); + + children.push(label.upcast()); + } + + children.push(widget); + } + } + + if ellipsize { + // Stop at the first constructed child. + break; + } + } + + if children.is_empty() { + return None; + } + if children.len() == 1 { + return children.into_iter().next(); + } + + let grid = gtk::Grid::builder() + .row_spacing(6) + .accessible_role(gtk::AccessibleRole::Group) + .build(); + + for (row, child) in children.into_iter().enumerate() { + grid.attach(&child, 0, row as i32, 1, 1); + } + + Some(grid.upcast()) +} + +/// A group of nodes, representing the nodes contained in a single widget. +enum NodeGroup<'a> { + /// A group of inline nodes. + Inline(Vec>), + /// A block node. + Block(NodeRef<'a>), +} + +/// Group subsequent nodes that are inline. +/// +/// Allows to group nodes by widget that will need to be constructed. +fn group_inline_nodes(nodes: Vec>) -> Vec> { + let mut result = Vec::new(); + let mut inline_group = None; + + for node in nodes { + let is_block = node + .as_element() + .is_some_and(|element| SUPPORTED_BLOCK_ELEMENTS.contains(&element.name.local.as_ref())); + + if is_block { + if let Some(inline) = inline_group.take() { + result.push(NodeGroup::Inline(inline)); + } + + result.push(NodeGroup::Block(node)); + } else { + let inline = inline_group.get_or_insert_with(Vec::default); + inline.push(node); + } + } + + if let Some(inline) = inline_group.take() { + result.push(NodeGroup::Inline(inline)); + } + + result +} + +/// Construct a `GtkLabel` for the given inline nodes. +/// +/// Returns `None` if the label would have been empty. +fn label_for_inline_html<'a>( + nodes: impl IntoIterator>, + room: &'a Room, + ellipsize: bool, + add_ellipsis: bool, + sender_name: &mut Option<&str>, +) -> Option { + let (text, widgets) = InlineHtmlBuilder::new(ellipsize, add_ellipsis) + .detect_mentions(room) + .append_emote_with_name(sender_name) + .build_with_nodes(nodes); + + if text.is_empty() { + return None; + } + + if let Some(widgets) = widgets { + widgets.iter().for_each(|p| { + // Show the profile on click. + p.set_activatable(true); + }); + let w = LabelWithWidgets::with_label_and_widgets(&text, widgets); + w.set_use_markup(true); + w.set_ellipsize(ellipsize); + Some(w.upcast()) + } else { + let w = new_message_label(); + w.set_markup(&text); + w.set_ellipsize(if ellipsize { + pango::EllipsizeMode::End + } else { + pango::EllipsizeMode::None + }); + Some(w.upcast()) + } +} + +/// Create a widget for the given HTML block node. +fn widget_for_html_block( + node: NodeRef<'_>, + room: &Room, + ellipsize: bool, + add_ellipsis: bool, + sender_name: &mut Option<&str>, +) -> Option { + let widget = match node.as_element()?.to_matrix().element { + MatrixElement::H(heading) => { + // Heading should only have inline elements as children. + let w = + label_for_inline_html(node.children(), room, ellipsize, add_ellipsis, sender_name) + .unwrap_or_else(|| { + // We should show an empty title. + new_message_label().upcast() + }); + w.add_css_class(&format!("h{}", heading.level.value())); + w + } + MatrixElement::Blockquote => { + let w = + widget_for_html_nodes(node.children(), room, ellipsize, add_ellipsis, &mut None)?; + w.add_css_class("quote"); + w + } + MatrixElement::P | MatrixElement::Div | MatrixElement::Li => { + widget_for_html_nodes(node.children(), room, ellipsize, add_ellipsis, sender_name)? + } + MatrixElement::Ul => widget_for_list( + ListType::Unordered, + node.children(), + room, + ellipsize, + add_ellipsis, + )?, + MatrixElement::Ol(list) => { + widget_for_list(list.into(), node.children(), room, ellipsize, add_ellipsis)? + } + MatrixElement::Hr => gtk::Separator::new(gtk::Orientation::Horizontal).upcast(), + MatrixElement::Pre => { + widget_for_preformatted_text(node.children(), ellipsize, add_ellipsis)? + } + _ => return None, + }; + + Some(widget) +} + +/// Create a widget for a list. +fn widget_for_list( + list_type: ListType, + list_items: Children<'_>, + room: &Room, + ellipsize: bool, + add_ellipsis: bool, +) -> Option { + let list_items = list_items + // Lists are supposed to only have list items as children. + .filter(|node| { + node.as_element() + .is_some_and(|element| element.name.local.as_ref() == "li") + }) + .collect::>(); + + if list_items.is_empty() { + return None; + } + + let grid = gtk::Grid::builder() + .row_spacing(6) + .column_spacing(6) + .margin_end(6) + .margin_start(6) + .build(); + + let len = list_items.len(); + + for (pos, li) in list_items.into_iter().enumerate() { + let is_last = pos == (len - 1); + let add_ellipsis = add_ellipsis || (ellipsize && !is_last); + + let w = widget_for_html_nodes(li.children(), room, ellipsize, add_ellipsis, &mut None) + // We should show an empty list item. + .unwrap_or_else(|| new_message_label().upcast()); + + let bullet = list_type.bullet(pos); + + grid.attach(&bullet, 0, pos as i32, 1, 1); + grid.attach(&w, 1, pos as i32, 1, 1); + + if ellipsize { + break; + } + } + + Some(grid.upcast()) +} + +/// The type of bullet for a list. +#[derive(Debug, Clone, Copy)] +enum ListType { + /// An unordered list. + Unordered, + /// An ordered list. + Ordered { + /// The number to start counting from. + start: i64, + }, +} + +impl ListType { + /// Construct the widget for the bullet of the current type at the given + /// position. + fn bullet(&self, position: usize) -> gtk::Label { + let bullet = gtk::Label::builder().valign(gtk::Align::Baseline).build(); + + match self { + ListType::Unordered => bullet.set_label("•"), + ListType::Ordered { start } => { + bullet.set_label(&format!("{}.", *start + position as i64)) + } + } + + bullet + } +} + +impl From for ListType { + fn from(value: OrderedListData) -> Self { + Self::Ordered { + start: value.start.unwrap_or(1), + } + } +} + +/// Create a widget for preformatted text. +fn widget_for_preformatted_text( + children: Children<'_>, + ellipsize: bool, + add_ellipsis: bool, +) -> Option { + let children = children.collect::>(); + + if children.is_empty() { + return None; + } + + let unique_code_child = (children.len() == 1) + .then_some(&children[0]) + .and_then(|child| child.as_element()) + .and_then(|element| match element.to_matrix().element { + MatrixElement::Code(code) => Some(code), + _ => None, + }); + + let (children, code_language) = if let Some(code) = unique_code_child { + let children = children[0].children().collect::>(); + + if children.is_empty() { + return None; + } + + (children, code.language) + } else { + (children, None) + }; + + let text = InlineHtmlBuilder::new(ellipsize, add_ellipsis).build_with_nodes_text(children); + + if ellipsize { + // Present text as inline code. + let text = format!("{}", text.escape_markup()); + + let label = new_message_label(); + label.set_ellipsize(if ellipsize { + pango::EllipsizeMode::End + } else { + pango::EllipsizeMode::None + }); + label.set_label(&text); + + return Some(label.upcast()); + } + + let buffer = sourceview::Buffer::builder() + .highlight_matching_brackets(false) + .text(text) + .build(); + crate::utils::sourceview::setup_style_scheme(&buffer); + + let language = code_language + .and_then(|lang| sourceview::LanguageManager::default().language(lang.as_ref())); + buffer.set_language(language.as_ref()); + + let view = sourceview::View::builder() + .buffer(&buffer) + .editable(false) + .css_classes(["codeview", "frame"]) + .hexpand(true) + .build(); + + let scrolled = gtk::ScrolledWindow::new(); + scrolled.set_policy(gtk::PolicyType::Automatic, gtk::PolicyType::Never); + scrolled.set_child(Some(&view)); + Some(scrolled.upcast()) +} diff --git a/src/session/view/content/room_history/message_toolbar/mod.rs b/src/session/view/content/room_history/message_toolbar/mod.rs index c0ee7702..32ac05c0 100644 --- a/src/session/view/content/room_history/message_toolbar/mod.rs +++ b/src/session/view/content/room_history/message_toolbar/mod.rs @@ -32,7 +32,7 @@ use crate::{ session::model::{Event, Member, Room}, spawn, toast, utils::{ - matrix::extract_mentions, + matrix::find_html_mentions, media::{filename_for_mime, get_audio_info, get_image_info, get_video_info, load_file}, template_callbacks::TemplateCallbacks, Location, LocationError, TokioDrop, @@ -420,7 +420,7 @@ impl MessageToolbar { let mentions = if let Some(html) = formatted.and_then(|f| (f.format == MessageFormat::Html).then_some(f.body)) { - let (_, mentions) = extract_mentions(&html, &event.room()); + let mentions = find_html_mentions(&html, &event.room()); let mut pos = 0; // This is looking for the mention link's inner text in the Markdown // so it is not super reliable: if there is other text that matches @@ -431,7 +431,7 @@ impl MessageToolbar { mentions .into_iter() .filter_map(|(pill, s)| { - text[pos..].find(&s).map(|index| { + text[pos..].find(s.as_ref()).map(|index| { let start = pos + index; let end = start + s.len(); pos = end; diff --git a/src/utils/matrix.rs b/src/utils/matrix.rs index 973e9e5b..7d45aa96 100644 --- a/src/utils/matrix.rs +++ b/src/utils/matrix.rs @@ -1,12 +1,7 @@ //! Collection of methods related to the Matrix specification. -use std::{ - fmt::{self, Write}, - str::FromStr, -}; +use std::{fmt, str::FromStr}; -use html2pango::html_escape; -use html5gum::{HtmlString, Token, Tokenizer}; use matrix_sdk::{ config::RequestConfig, deserialized_responses::RawAnySyncOrStrippedTimelineEvent, @@ -19,20 +14,23 @@ use ruma::{ AnyMessageLikeEventContent, AnyStrippedStateEvent, AnySyncMessageLikeEvent, AnySyncTimelineEvent, }, - html::{HtmlSanitizerMode, RemoveReplyFallback}, + html::{ + matrix::{AnchorUri, MatrixElement}, + Children, Html, HtmlSanitizerMode, NodeRef, RemoveReplyFallback, StrTendril, + }, matrix_uri::MatrixId, serde::Raw, - EventId, IdParseError, MatrixToUri, MatrixUri, OwnedEventId, OwnedRoomAliasId, OwnedRoomId, - OwnedRoomOrAliasId, OwnedServerName, OwnedUserId, RoomOrAliasId, UserId, + EventId, IdParseError, MatrixToUri, MatrixUri, MatrixUriError, OwnedEventId, OwnedRoomAliasId, + OwnedRoomId, OwnedRoomOrAliasId, OwnedServerName, OwnedUserId, RoomOrAliasId, UserId, }; use thiserror::Error; use crate::{ - components::{LabelWithWidgets, Pill}, + components::Pill, gettext_f, prelude::*, secret::StoredSession, - session::model::{RemoteRoom, Room, Session}, + session::model::{RemoteRoom, Room}, spawn_tokio, }; @@ -385,96 +383,57 @@ macro_rules! matrix_caption { }}; } -/// Extract mentions from the given string. +/// Find mentions in the given HTML string. /// -/// Returns a new string with placeholders and the corresponding widgets and the -/// string they are replacing. -pub fn extract_mentions(s: &str, room: &Room) -> (String, Vec<(Pill, String)>) { - let session = room.session().unwrap(); +/// Returns a list of `(pill, mention_content)` tuples. +pub fn find_html_mentions(html: &str, room: &Room) -> Vec<(Pill, StrTendril)> { let mut mentions = Vec::new(); - let mut mention = None; - let mut new_string = String::new(); - - for token in Tokenizer::new(s).infallible() { - match token { - Token::StartTag(tag) => { - if tag.name == HtmlString(b"a".to_vec()) && !tag.self_closing { - if let Some(pill) = tag - .attributes - .get(&HtmlString(b"href".to_vec())) - .map(|href| String::from_utf8_lossy(href)) - .and_then(|s| parse_pill(&s, room, &session)) - { - mention = Some((pill, String::new())); - new_string.push_str(LabelWithWidgets::DEFAULT_PLACEHOLDER); - continue; - } - } + let html = Html::parse(html); - mention = None; - - // Restore HTML. - write!(new_string, "<{}", String::from_utf8_lossy(&tag.name)).unwrap(); - for (attr_name, attr_value) in &tag.attributes { - write!( - new_string, - r#" {}="{}""#, - String::from_utf8_lossy(attr_name), - html_escape(&String::from_utf8_lossy(attr_value)), - ) - .unwrap(); - } - if tag.self_closing { - write!(new_string, " /").unwrap(); - } - write!(new_string, ">").unwrap(); - } - Token::String(s) => { - if let Some((_, string)) = &mut mention { - write!(string, "{}", String::from_utf8_lossy(&s)).unwrap(); - continue; - } + append_children_mentions(&mut mentions, html.children(), room); - write!(new_string, "{}", html_escape(&String::from_utf8_lossy(&s))).unwrap(); - } - Token::EndTag(tag) => { - if let Some(mention) = mention.take() { - mentions.push(mention); - continue; - } + mentions +} - write!(new_string, "", String::from_utf8_lossy(&tag.name)).unwrap(); - } - _ => {} +/// Find mentions in the given child nodes and append them to the given list. +fn append_children_mentions( + mentions: &mut Vec<(Pill, StrTendril)>, + children: Children<'_>, + room: &Room, +) { + for node in children { + if let Some(mention) = node_as_mention(node, room) { + mentions.push(mention); + continue; } - } - (new_string, mentions) + append_children_mentions(mentions, node.children(), room); + } } -/// Try to parse the given string to a Matrix URI and generate a pill for it. -fn parse_pill(s: &str, room: &Room, session: &Session) -> Option { - let uri = html_escape::decode_html_entities(s); - - let Ok(id) = MatrixIdUri::parse(&uri) else { +/// Try to convert the given node to a mention. +/// +/// This does not recurse into children. +fn node_as_mention(node: NodeRef<'_>, room: &Room) -> Option<(Pill, StrTendril)> { + // Mentions are links. + let MatrixElement::A(anchor) = node.as_element()?.to_matrix().element else { return None; }; - match id { - MatrixIdUri::Room(room_uri) => session - .room_list() - .get_by_identifier(&room_uri.id) - .as_ref() - .map(Pill::new) - .or_else(|| Some(Pill::new(&RemoteRoom::new(session, room_uri)))), - MatrixIdUri::User(user_id) => { - // We should have a strong reference to the list wherever we show a user pill, - // so we can use `get_or_create_members()`. - let user = room.get_or_create_members().get_or_create(user_id); - Some(Pill::new(&user)) - } - _ => None, + // Mentions contain Matrix URIs. + let id = MatrixIdUri::try_from(anchor.href?).ok()?; + + // Mentions contain one text child node. + let child = node.children().next()?; + + if child.next_sibling().is_some() { + return None; } + + let content = child.as_text()?.clone(); + let pill = id.into_pill(room)?; + + Some((pill, content)) } /// Compare two raw JSON sources. @@ -611,6 +570,28 @@ impl MatrixIdUri { MatrixUri::parse(s)?.try_into() } + + /// Try to construct a [`Pill`] from this ID in the given room. + pub fn into_pill(self, room: &Room) -> Option { + match self { + Self::Room(room_uri) => { + let session = room.session()?; + session + .room_list() + .get_by_identifier(&room_uri.id) + .as_ref() + .map(Pill::new) + .or_else(|| Some(Pill::new(&RemoteRoom::new(&session, room_uri)))) + } + MatrixIdUri::User(user_id) => { + // We should have a strong reference to the list wherever we show a user pill, + // so we can use `get_or_create_members()`. + let user = room.get_or_create_members().get_or_create(user_id); + Some(Pill::new(&user)) + } + _ => None, + } + } } impl TryFrom<&MatrixUri> for MatrixIdUri { @@ -656,6 +637,35 @@ impl FromStr for MatrixIdUri { } } +impl TryFrom<&str> for MatrixIdUri { + type Error = MatrixIdUriParseError; + + fn try_from(s: &str) -> Result { + Self::parse(s) + } +} + +impl TryFrom<&AnchorUri> for MatrixIdUri { + type Error = MatrixIdUriParseError; + + fn try_from(value: &AnchorUri) -> Result { + match value { + AnchorUri::Matrix(uri) => MatrixIdUri::try_from(uri), + AnchorUri::MatrixTo(uri) => MatrixIdUri::try_from(uri), + // The same error that should be returned by `parse()` when parsing a non-Matrix URI. + _ => Err(IdParseError::InvalidMatrixUri(MatrixUriError::WrongScheme).into()), + } + } +} + +impl TryFrom for MatrixIdUri { + type Error = MatrixIdUriParseError; + + fn try_from(value: AnchorUri) -> Result { + Self::try_from(&value) + } +} + /// A URI for a Matrix room ID. #[derive(Debug, Clone, PartialEq, Eq)] pub struct MatrixRoomIdUri { diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 71e6f855..5d178e0d 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -10,6 +10,7 @@ pub mod media; pub mod message_dialog; pub mod notifications; pub mod sourceview; +pub mod string; pub mod template_callbacks; use std::{ diff --git a/src/utils/string.rs b/src/utils/string.rs new file mode 100644 index 00000000..fec32541 --- /dev/null +++ b/src/utils/string.rs @@ -0,0 +1,249 @@ +//! Helper traits and methods for strings. + +use std::fmt::{self, Write}; + +use gtk::glib::markup_escape_text; +use linkify::{LinkFinder, LinkKind}; + +use super::matrix::MatrixIdUri; +use crate::{ + components::{LabelWithWidgets, Pill}, + session::model::Room, +}; + +/// Common extensions to strings. +pub trait StrExt { + /// Escape markup for compatibility with Pango. + fn escape_markup(&self) -> String; + + /// Remove newlines from the string. + fn remove_newlines(&self) -> String; +} + +impl StrExt for T +where + T: AsRef, +{ + fn escape_markup(&self) -> String { + markup_escape_text(self.as_ref()).into() + } + + fn remove_newlines(&self) -> String { + self.as_ref().replace('\n', "") + } +} + +/// Common extensions to mutable strings. +pub trait StrMutExt { + /// Truncate this string at the first newline. + /// + /// Appends an ellipsis if the string was truncated. + /// + /// Returns `true` if the string was truncated. + fn truncate_newline(&mut self) -> bool; + + /// Truncate whitespaces at the end of the string. + fn truncate_end_whitespaces(&mut self); + + /// Append an ellipsis, except if this string already ends with an ellipsis. + fn append_ellipsis(&mut self); +} + +impl StrMutExt for String { + fn truncate_newline(&mut self) -> bool { + let newline = self.find(|c: char| c == '\n'); + + if let Some(newline) = newline { + self.truncate(newline); + self.append_ellipsis(); + } + + newline.is_some() + } + + fn truncate_end_whitespaces(&mut self) { + if self.is_empty() { + return; + } + + let rspaces_idx = self + .rfind(|c: char| !c.is_whitespace()) + .map(|idx| { + // We have the position of the last non-whitespace character, so the first + // whitespace character is the next one. + let mut idx = idx + 1; + + while !self.is_char_boundary(idx) { + idx += 1; + } + + idx + }) + // 0 means that there are only whitespaces in the string. + .unwrap_or_default(); + + if rspaces_idx < self.len() { + self.truncate(rspaces_idx); + } + } + + fn append_ellipsis(&mut self) { + if !self.ends_with('…') && !self.ends_with("..") { + self.push('…'); + } + } +} + +/// Common extensions for adding Pango markup to mutable strings. +pub trait PangoStrMutExt { + /// Append the opening Pango markup link tag of the given URI parts. + /// + /// The URI is also used as a title, so users can preview the link on hover. + fn append_link_opening_tag(&mut self, uri: impl AsRef); + + /// Append the given emote's sender name and consumes it, if it is set. + fn maybe_append_emote_name(&mut self, name: &mut Option<&str>); + + /// Append the given URI as a mention, if it is one. + /// + /// Returns the created [`Pill`], it the URI was added as a mention. + fn maybe_append_mention(&mut self, uri: impl TryInto, room: &Room) + -> Option; +} + +impl PangoStrMutExt for String { + fn append_link_opening_tag(&mut self, uri: impl AsRef) { + let uri = uri.escape_markup(); + // We need to escape the title twice because GTK doesn't take care of it. + let title = uri.escape_markup(); + + let _ = write!(self, r#""#); + } + + fn maybe_append_emote_name(&mut self, name: &mut Option<&str>) { + if let Some(name) = name.take() { + let _ = write!(self, "{} ", name.escape_markup()); + } + } + + fn maybe_append_mention( + &mut self, + uri: impl TryInto, + room: &Room, + ) -> Option { + let pill = uri.try_into().ok().and_then(|uri| uri.into_pill(room))?; + + self.push_str(LabelWithWidgets::DEFAULT_PLACEHOLDER); + + Some(pill) + } +} + +/// Linkify the given text. +/// +/// The text will also be escaped with [`StrExt::escape_markup()`]. +pub fn linkify(text: &str) -> String { + let mut linkified = String::with_capacity(text.len()); + Linkifier::new(&mut linkified).linkify(text); + linkified +} + +/// A helper type to linkify text. +pub struct Linkifier<'a> { + /// The string containing the result. + inner: &'a mut String, + /// The mentions detection setting and results. + mentions: MentionsMode<'a>, +} + +impl<'a> Linkifier<'a> { + /// Construct a new linkifier that will add text in the given string. + pub fn new(inner: &'a mut String) -> Self { + Self { + inner, + mentions: MentionsMode::NoMentions, + } + } + + /// Enable mentions detection in the given room and add pills to the given + /// list. + pub fn detect_mentions(mut self, room: &'a Room, pills: &'a mut Vec) -> Self { + self.mentions = MentionsMode::WithMentions { pills, room }; + self + } + + /// Search and replace links in the given text. + /// + /// Returns the list of mentions, if any where found. + pub fn linkify(mut self, text: &str) { + let finder = LinkFinder::new(); + + for span in finder.spans(text) { + let span_text = span.as_str(); + + let uri = match span.kind() { + Some(LinkKind::Url) => { + if let MentionsMode::WithMentions { pills, room } = &mut self.mentions { + if let Some(pill) = self.inner.maybe_append_mention(span_text, room) { + pills.push(pill); + + continue; + } + } + + Some(UriParts { + prefix: None, + uri: span_text, + }) + } + Some(LinkKind::Email) => Some(UriParts { + prefix: Some("mailto:"), + uri: span_text, + }), + _ => None, + }; + + if let Some(uri) = uri { + self.inner.append_link_opening_tag(uri.to_string()); + } + + self.inner.push_str(&span_text.escape_markup()); + + if uri.is_some() { + self.inner.push_str(""); + } + } + } +} + +/// The mentions mode of the [`Linkifier`]. +#[derive(Debug, Default)] +enum MentionsMode<'a> { + /// The builder will not detect mentions. + #[default] + NoMentions, + /// The builder will detect mentions. + WithMentions { + /// The pills for the detected mentions. + pills: &'a mut Vec, + /// The room containing the mentions. + room: &'a Room, + }, +} + +/// A URI that is possibly into parts. +#[derive(Debug, Clone, Copy)] +struct UriParts<'a> { + prefix: Option<&'a str>, + uri: &'a str, +} + +impl<'a> fmt::Display for UriParts<'a> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if let Some(prefix) = self.prefix { + f.write_str(prefix)?; + } + + f.write_str(self.uri) + } +}