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

523 lines
16 KiB
Rust

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.display_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_notify_local(
Some("display-name"),
clone!(@weak self as obj, @weak room => move |sender, _| {
obj.update_emote(&room, &sender.display_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.display_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_notify_local(
Some("display-name"),
clone!(@weak self as obj, @weak room => move |sender, _| {
obj.update_emote(&room, &sender.display_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(|(w, _)| w).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");
}
}
/// 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(|(w, _)| w).collect();
let w = LabelWithWidgets::with_label_and_widgets(&label, widgets);
w.set_use_markup(true);
w.set_ellipsize(ellipsize);
w.upcast()
}
}