15 changed files with 1651 additions and 786 deletions
@ -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() |
||||
} |
||||
} |
||||
@ -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, |
||||
}, |
||||
} |
||||
@ -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() |
||||
}); |
||||
@ -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 & 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&that=that" title="https://docs.local?this=this&amp;that=that">this & 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()); |
||||
} |
||||
@ -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()) |
||||
} |
||||
@ -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…
Reference in new issue