Browse Source

misc: Use ruma-html to sanitize and render HTML instead of html2pango

merge-requests/1716/head
Kévin Commaille 2 years ago
parent
commit
7f438ac104
No known key found for this signature in database
GPG Key ID: C971D9DBC9D678D
  1. 148
      Cargo.lock
  2. 6
      Cargo.toml
  3. 3
      src/components/pill/mod.rs
  4. 19
      src/components/room_title.rs
  5. 5
      src/prelude.rs
  6. 10
      src/session/view/content/room_history/message_row/content.rs
  7. 540
      src/session/view/content/room_history/message_row/text.rs
  8. 340
      src/session/view/content/room_history/message_row/text/inline_html.rs
  9. 401
      src/session/view/content/room_history/message_row/text/mod.rs
  10. 131
      src/session/view/content/room_history/message_row/text/tests.rs
  11. 392
      src/session/view/content/room_history/message_row/text/widgets.rs
  12. 6
      src/session/view/content/room_history/message_toolbar/mod.rs
  13. 186
      src/utils/matrix.rs
  14. 1
      src/utils/mod.rs
  15. 249
      src/utils/string.rs

148
Cargo.lock generated

@ -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"

6
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.

3
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);

19
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<String>) {
// 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<String>) {
// 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
}

5
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,
},
};

10
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::<MessageText>() {
@ -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::<MessageText>() {
@ -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);
}
}
}

540
src/session/view/content/room_history/message_row/text.rs

@ -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<String>,
/// Whether the original text is HTML.
///
/// Only used for emotes.
#[property(get)]
pub is_html: Cell<bool>,
/// The text format.
#[property(get, builder(ContentFormat::default()))]
pub format: Cell<ContentFormat>,
/// The sender of the message, if we need to listen to changes.
pub sender: BoundObjectWeakRef<Member>,
}
#[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<imp::MessageText>)
@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<FormattedBody>,
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<FormattedBody>,
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!("<b>{}</b> {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!("<b>{}</b> {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!("<b>{sender_name}</b> {}", 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::<gtk::Label>() {
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::<LabelWithWidgets>() {
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<HtmlBlock>, 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("<a href=\"").enumerate() {
if i > 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("<!-- raw HTML omitted -->")
}
fn parse_formatted_body(formatted: &str) -> Option<Vec<HtmlBlock>> {
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!("<tt>{}…</tt>", &s[0..pos])
} else if has_more {
format!("<tt>{s}…</tt>")
} else {
format!("<tt>{s}</tt>")
};
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()
}
}

340
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<Vec<Pill>>) {
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<Item = NodeRef<'a>>,
) -> (String, Option<Vec<Pill>>) {
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<Item = NodeRef<'a>>,
) -> 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("</a>");
}
}
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<Item = NodeRef<'a>>, 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, "</{tag_name}>");
}
/// 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("<span");
if let Some(bg_color) = &span.bg_color {
let _ = write!(self.inner, r#" bgcolor="{bg_color}""#);
}
if let Some(color) = &span.color {
let _ = write!(self.inner, r#" color="{color}""#);
}
self.inner.push('>');
self.append_nodes(children, should_linkify);
self.inner.push_str("</span>");
}
/// 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<Item = NodeRef<'a>>) {
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<Pill>,
/// The room containing the mentions.
room: &'a Room,
},
}

401
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<String>,
/// Whether the original text is HTML.
///
/// Only used for emotes.
#[property(get)]
pub is_html: Cell<bool>,
/// The text format.
#[property(get, builder(ContentFormat::default()))]
pub format: Cell<ContentFormat>,
/// The sender of the message, if we need to listen to changes.
pub sender: BoundObjectWeakRef<Member>,
}
#[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<imp::MessageText>)
@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<FormattedBody>,
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<FormattedBody>,
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::<gtk::Label>() {
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::<LabelWithWidgets>() {
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("<!-- raw HTML omitted -->")
}
/// 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<SanitizerConfig> = Lazy::new(|| {
SanitizerConfig::compat()
.allow_elements(
SUPPORTED_INLINE_ELEMENTS
.iter()
.chain(SUPPORTED_BLOCK_ELEMENTS.iter())
.copied(),
ListBehavior::Override,
)
.remove_reply_fallback()
});

131
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<br>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...<br>...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 <b>my \nfriend</b>.");
let (s, pills) = InlineHtmlBuilder::new(false, false).build_with_nodes(html.children());
assert_eq!(s, "Hello you! You are <b>my friend</b>.");
assert!(pills.is_none());
}
#[test]
fn sanitize_inline_html() {
let html = Html::parse(
r#"A <strong>text</strong> with <a href="https://docs.local/markup"><i>markup</i></a>"#,
);
let (s, pills) = InlineHtmlBuilder::new(false, false).build_with_nodes(html.children());
assert_eq!(
s,
r#"A <b>text</b> with <a href="https://docs.local/markup" title="https://docs.local/markup"><i>markup</i></a>"#
);
assert!(pills.is_none());
}
#[test]
fn escape_markup() {
let html = Html::parse(
r#"Go to <a href="https://docs.local?this=this&that=that">this &amp; that docs</a>"#,
);
let (s, pills) = InlineHtmlBuilder::new(false, false).build_with_nodes(html.children());
assert_eq!(
s,
r#"Go to <a href="https://docs.local?this=this&amp;that=that" title="https://docs.local?this=this&amp;amp;that=that">this &amp; that docs</a>"#
);
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 <a href="https://gnome.org" title="https://gnome.org">https://gnome.org</a>, and you can contact me at <a href="mailto:contact@me.local" title="mailto:contact@me.local">contact@me.local</a>"#
);
assert!(pills.is_none());
}
#[test]
fn do_not_linkify_inside_anchor() {
let html = Html::parse(r#"The homepage is <a href="https://gnome.org">https://gnome.org</a>"#);
let (s, pills) = InlineHtmlBuilder::new(false, false).build_with_nodes(html.children());
assert_eq!(
s,
r#"The homepage is <a href="https://gnome.org" title="https://gnome.org">https://gnome.org</a>"#
);
assert!(pills.is_none());
}
#[test]
fn do_not_linkify_inside_code() {
let html = Html::parse("The homepage is <code>https://gnome.org</code>");
let (s, pills) = InlineHtmlBuilder::new(false, false).build_with_nodes(html.children());
assert_eq!(s, "The homepage is <tt>https://gnome.org</tt>");
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, "<b>Jun</b> sent a beautiful picture.");
assert!(pills.is_none());
}

392
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<Item = NodeRef<'a>>,
room: &Room,
ellipsize: bool,
add_ellipsis: bool,
sender_name: &mut Option<&str>,
) -> Option<gtk::Widget> {
let nodes = nodes.into_iter().collect::<Vec<_>>();
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<NodeRef<'a>>),
/// 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<NodeRef<'_>>) -> Vec<NodeGroup<'_>> {
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<Item = NodeRef<'a>>,
room: &'a Room,
ellipsize: bool,
add_ellipsis: bool,
sender_name: &mut Option<&str>,
) -> Option<gtk::Widget> {
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<gtk::Widget> {
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<gtk::Widget> {
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::<Vec<_>>();
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<OrderedListData> 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<gtk::Widget> {
let children = children.collect::<Vec<_>>();
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::<Vec<_>>();
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!("<tt>{}</tt>", 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())
}

6
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;

186
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<Pill> {
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<Pill> {
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, Self::Error> {
Self::parse(s)
}
}
impl TryFrom<&AnchorUri> for MatrixIdUri {
type Error = MatrixIdUriParseError;
fn try_from(value: &AnchorUri) -> Result<Self, Self::Error> {
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<AnchorUri> for MatrixIdUri {
type Error = MatrixIdUriParseError;
fn try_from(value: AnchorUri) -> Result<Self, Self::Error> {
Self::try_from(&value)
}
}
/// A URI for a Matrix room ID.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MatrixRoomIdUri {

1
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::{

249
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<T> StrExt for T
where
T: AsRef<str>,
{
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<str>);
/// 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<MatrixIdUri>, room: &Room)
-> Option<Pill>;
}
impl PangoStrMutExt for String {
fn append_link_opening_tag(&mut self, uri: impl AsRef<str>) {
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#"<a href="{uri}" title="{title}">"#);
}
fn maybe_append_emote_name(&mut self, name: &mut Option<&str>) {
if let Some(name) = name.take() {
let _ = write!(self, "<b>{}</b> ", name.escape_markup());
}
}
fn maybe_append_mention(
&mut self,
uri: impl TryInto<MatrixIdUri>,
room: &Room,
) -> Option<Pill> {
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<Pill>) -> 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("</a>");
}
}
}
}
/// 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<Pill>,
/// 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)
}
}
Loading…
Cancel
Save