fractal/session/view/content/room_history/message_row/text/
mod.rsuse std::sync::LazyLock;
use adw::{prelude::BinExt, subclass::prelude::*};
use gtk::{glib, glib::clone, pango, prelude::*};
use matrix_sdk::ruma::events::room::message::FormattedBody;
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, HtmlWidgetConfig};
use super::ContentFormat;
use crate::{
components::{AtRoom, 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 {
#[property(get)]
pub original_text: RefCell<String>,
#[property(get)]
pub is_html: Cell<bool>,
#[property(get, builder(ContentFormat::default()))]
pub format: Cell<ContentFormat>,
pub detect_at_room: Cell<bool>,
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! {
pub struct MessageText(ObjectSubclass<imp::MessageText>)
@extends gtk::Widget, adw::Bin, @implements gtk::Accessible;
}
impl MessageText {
pub fn new() -> Self {
glib::Object::new()
}
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);
}
pub fn with_markup(
&self,
formatted: Option<FormattedBody>,
body: String,
room: &Room,
format: ContentFormat,
detect_at_room: bool,
) {
self.set_detect_at_room(detect_at_room);
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);
}
pub fn with_emote(
&self,
formatted: Option<FormattedBody>,
body: String,
sender: &Member,
room: &Room,
format: ContentFormat,
detect_at_room: bool,
) {
self.set_detect_at_room(detect_at_room);
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(rename_to = obj)]
self,
#[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 sender_name = sender.disambiguated_name();
self.build_text(&body, room, Some(&sender_name));
self.set_original_text(body);
let handler = sender.connect_disambiguated_name_notify(clone!(
#[weak(rename_to = obj)]
self,
#[weak]
room,
move |sender| {
obj.update_emote(&room, &sender.disambiguated_name());
}
));
self.imp().sender.set(sender, vec![handler]);
}
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, Some(sender_name));
}
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);
}
fn build_text(&self, text: &str, room: &Room, mut sender_name: Option<&str>) {
let detect_at_room = self.detect_at_room();
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, detect_at_room)
.linkify(text);
result.truncate_end_whitespaces();
if pills.is_empty() {
self.build_plain_text(result);
return;
};
let ellipsize = self.format() == ContentFormat::Ellipsized;
for pill in &pills {
if !pill.source().is_some_and(|s| s.is::<AtRoom>()) {
pill.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);
}
fn build_html(&self, html: &str, room: &Room, mut sender_name: Option<&str>) -> Result<(), ()> {
let detect_at_room = self.detect_at_room();
let ellipsize = self.format() == ContentFormat::Ellipsized;
let 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(),
HtmlWidgetConfig {
room,
detect_at_room,
ellipsize,
},
false,
&mut sender_name,
) else {
return Err(());
};
self.set_child(Some(&child));
Ok(())
}
fn original_text_changed(&self, text: &str) -> bool {
*self.imp().original_text.borrow() != text
}
fn set_original_text(&self, text: String) {
self.imp().original_text.replace(text);
self.notify_original_text();
}
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();
}
fn format_changed(&self, format: ContentFormat) -> bool {
self.format() != format
}
fn set_format(&self, format: ContentFormat) {
self.imp().format.set(format);
self.notify_format();
}
fn detect_at_room(&self) -> bool {
self.imp().detect_at_room.get()
}
fn set_detect_at_room(&self, detect_at_room: bool) {
self.imp().detect_at_room.set(detect_at_room);
}
fn sender_changed(&self, sender: &Member) -> bool {
self.imp().sender.obj().as_ref() == Some(sender)
}
fn reset(&self) {
self.imp().sender.disconnect_signals();
self.remove_css_class("emote");
}
}
impl Default for MessageText {
fn default() -> Self {
Self::new()
}
}
fn formatted_body_is_html(formatted: &FormattedBody) -> bool {
formatted.format == MessageFormat::Html && !formatted.body.contains("<!-- raw HTML omitted -->")
}
const SUPPORTED_INLINE_ELEMENTS: &[&str] = &[
"del", "a", "sup", "sub", "b", "i", "u", "strong", "em", "s", "code", "br", "span",
];
const SUPPORTED_BLOCK_ELEMENTS: &[&str] = &[
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"blockquote",
"p",
"ul",
"ol",
"li",
"hr",
"div",
"pre",
"details",
"summary",
];
static HTML_MESSAGE_SANITIZER_CONFIG: LazyLock<SanitizerConfig> = LazyLock::new(|| {
SanitizerConfig::compat()
.allow_elements(
SUPPORTED_INLINE_ELEMENTS
.iter()
.chain(SUPPORTED_BLOCK_ELEMENTS.iter())
.copied(),
ListBehavior::Override,
)
.remove_reply_fallback()
});