From b653ca79335e03ff46ef17447ee728b808945125 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= Date: Tue, 16 Nov 2021 15:17:01 +0100 Subject: [PATCH] content: Add MessageText widget Start to separate code for MessageRow, to avoid having every message type implementation in the same file. --- po/POTFILES.in | 1 + src/meson.build | 1 + src/session/content/message_row/mod.rs | 211 ++---------- src/session/content/message_row/text.rs | 410 ++++++++++++++++++++++++ 4 files changed, 434 insertions(+), 189 deletions(-) create mode 100644 src/session/content/message_row/text.rs diff --git a/po/POTFILES.in b/po/POTFILES.in index d2985ca0..09cca574 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -75,6 +75,7 @@ src/session/content/item_row.rs src/session/content/invite.rs src/session/content/markdown_popover.rs src/session/content/message_row/mod.rs +src/session/content/message_row/text.rs src/session/content/mod.rs src/session/content/room_details/mod.rs src/session/content/room_history.rs diff --git a/src/meson.build b/src/meson.build index a4cd6cc1..5769b779 100644 --- a/src/meson.build +++ b/src/meson.build @@ -58,6 +58,7 @@ sources = files( 'session/content/invite.rs', 'session/content/markdown_popover.rs', 'session/content/message_row/mod.rs', + 'session/content/message_row/text.rs', 'session/content/mod.rs', 'session/content/room_history.rs', 'session/content/room_details/mod.rs', diff --git a/src/session/content/message_row/mod.rs b/src/session/content/message_row/mod.rs index cf84584c..a31d611a 100644 --- a/src/session/content/message_row/mod.rs +++ b/src/session/content/message_row/mod.rs @@ -1,22 +1,19 @@ +mod text; + use crate::components::Avatar; use adw::{prelude::*, subclass::prelude::*}; use gettextrs::gettext; use gtk::{ - gio, glib, glib::clone, glib::signal::SignalHandlerId, pango, subclass::prelude::*, - CompositeTemplate, -}; -use html2pango::{ - block::{markup_html, HtmlBlock}, - html_escape, markup_links, + glib, glib::clone, glib::signal::SignalHandlerId, subclass::prelude::*, CompositeTemplate, }; use log::warn; use matrix_sdk::ruma::events::{ - room::message::{FormattedBody, MessageFormat, MessageType, Relation}, + room::message::{MessageType, Relation}, room::redaction::RoomRedactionEventContent, AnyMessageEventContent, AnySyncMessageEvent, AnySyncRoomEvent, }; -use sourceview::prelude::*; +use self::text::MessageText; use crate::prelude::*; use crate::session::room::Event; @@ -243,10 +240,12 @@ impl MessageRow { } fn update_content(&self, event: &Event) { + let priv_ = imp::MessageRow::from_instance(self); let content = self.find_content(event); // TODO: create widgets for all event types // TODO: display reaction events from event.relates_to() + // TODO: we should reuse the already present child widgets when possible match content { Some(AnyMessageEventContent::RoomMessage(message)) => { @@ -258,58 +257,24 @@ impl MessageRow { match msgtype { MessageType::Audio(_message) => {} MessageType::Emote(message) => { - // TODO we need to bind the display name to the sender - if let Some(html_blocks) = message - .formatted - .filter(|formatted| is_valid_formatted_body(formatted)) - .and_then(|formatted| { - let body = FormattedBody { - body: format!( - "{} {}", - event.sender().display_name(), - formatted.body - ), - format: MessageFormat::Html, - }; - - parse_formatted_body(Some(&body)) - }) - { - self.show_html(html_blocks); - } else { - self.show_text( - &format!( - "{} {}", - event.sender().display_name(), - linkify(&message.body) - ), - true, - ); - } + let child = + MessageText::emote(message.formatted, message.body, event.sender()); + priv_.content.set_child(Some(&child)); } MessageType::File(_message) => {} MessageType::Image(_message) => {} MessageType::Location(_message) => {} MessageType::Notice(message) => { - // TODO: we should reuse the already present child widgets when possible - if let Some(html_blocks) = parse_formatted_body(message.formatted.as_ref()) - { - self.show_html(html_blocks); - } else { - self.show_text(&linkify(&message.body), true) - }; + let child = MessageText::markup(message.formatted, message.body); + priv_.content.set_child(Some(&child)); } MessageType::ServerNotice(message) => { - self.show_text(&message.body, false); + let child = MessageText::text(message.body); + priv_.content.set_child(Some(&child)); } MessageType::Text(message) => { - // TODO: we should reuse the already present child widgets when possible - if let Some(html_blocks) = parse_formatted_body(message.formatted.as_ref()) - { - self.show_html(html_blocks); - } else { - self.show_text(&linkify(&message.body), true) - }; + let child = MessageText::markup(message.formatted, message.body); + priv_.content.set_child(Some(&child)); } MessageType::Video(_message) => {} MessageType::VerificationRequest(_message) => {} @@ -320,149 +285,17 @@ impl MessageRow { } Some(AnyMessageEventContent::RoomEncrypted(content)) => { warn!("Couldn't decrypt event {:?}", content); - self.show_text(&gettext("Fractal couldn't decrypt this message."), false) + let child = MessageText::text(gettext("Fractal couldn't decrypt this message.")); + priv_.content.set_child(Some(&child)); } Some(AnyMessageEventContent::RoomRedaction(_)) => { - self.show_text(&gettext("This message was removed."), false) - } - _ => self.show_text(&gettext("Unsupported event"), false), - } - } - - fn show_text(&self, text: &str, use_markup: bool) { - let priv_ = imp::MessageRow::from_instance(self); - - let child = - if let Some(Ok(child)) = priv_.content.child().map(|w| w.downcast::()) { - child - } else { - let child = gtk::Label::new(None); - set_label_styles(&child); + let child = MessageText::text(gettext("This message was removed.")); priv_.content.set_child(Some(&child)); - child - }; - - if use_markup { - child.set_markup(text); - } else { - child.set_text(text); - } - } - - fn show_html(&self, blocks: Vec) { - let priv_ = imp::MessageRow::from_instance(self); - - let child = gtk::Box::new(gtk::Orientation::Vertical, 6); - priv_.content.set_child(Some(&child)); - - for block in blocks { - let widget = create_widget_for_html_block(&block); - child.append(&widget); - } - } -} - -fn linkify(text: &str) -> String { - markup_links(&html_escape(text)) -} - -fn is_valid_formatted_body(formatted: &FormattedBody) -> bool { - formatted.format == MessageFormat::Html && !formatted.body.contains("") -} - -fn parse_formatted_body(formatted: Option<&FormattedBody>) -> Option> { - formatted - .filter(|formatted| is_valid_formatted_body(formatted)) - .and_then(|formatted| markup_html(&formatted.body).ok()) -} - -fn set_label_styles(w: >k::Label) { - w.set_wrap(true); - w.set_wrap_mode(pango::WrapMode::WordChar); - w.set_justify(gtk::Justification::Left); - w.set_xalign(0.0); - w.set_valign(gtk::Align::Start); - w.set_halign(gtk::Align::Fill); - w.set_selectable(true); - let menu_model: Option = - gtk::Builder::from_resource("/org/gnome/FractalNext/content-item-row-menu.ui") - .object("menu_model"); - w.set_extra_menu(menu_model.as_ref()); -} - -fn create_widget_for_html_block(block: &HtmlBlock) -> gtk::Widget { - match block { - HtmlBlock::Heading(n, s) => { - let w = gtk::Label::new(None); - set_label_styles(&w); - w.set_markup(s); - w.add_css_class(&format!("h{}", n)); - w.upcast::() - } - HtmlBlock::UList(elements) => { - let bx = gtk::Box::new(gtk::Orientation::Vertical, 6); - bx.set_margin_end(6); - bx.set_margin_start(6); - - for li in elements.iter() { - let h_box = gtk::Box::new(gtk::Orientation::Horizontal, 6); - let bullet = gtk::Label::new(Some("•")); - bullet.set_valign(gtk::Align::Start); - let w = gtk::Label::new(None); - set_label_styles(&w); - h_box.append(&bullet); - h_box.append(&w); - w.set_markup(li); - bx.append(&h_box); } - - bx.upcast::() - } - HtmlBlock::OList(elements) => { - let bx = gtk::Box::new(gtk::Orientation::Vertical, 6); - bx.set_margin_end(6); - bx.set_margin_start(6); - - for (i, ol) in elements.iter().enumerate() { - let h_box = gtk::Box::new(gtk::Orientation::Horizontal, 6); - let bullet = gtk::Label::new(Some(&format!("{}.", i + 1))); - bullet.set_valign(gtk::Align::Start); - let w = gtk::Label::new(None); - set_label_styles(&w); - h_box.append(&bullet); - h_box.append(&w); - w.set_markup(ol); - bx.append(&h_box); + _ => { + let child = MessageText::text(gettext("Unsupported event")); + priv_.content.set_child(Some(&child)); } - - bx.upcast::() - } - HtmlBlock::Code(s) => { - let scrolled = gtk::ScrolledWindow::new(); - scrolled.set_policy(gtk::PolicyType::Automatic, gtk::PolicyType::Never); - let buffer = sourceview::Buffer::new(None); - buffer.set_highlight_matching_brackets(false); - buffer.set_text(s); - let view = sourceview::View::with_buffer(&buffer); - view.set_editable(false); - view.add_css_class("codeview"); - scrolled.set_child(Some(&view)); - scrolled.upcast::() - } - HtmlBlock::Quote(blocks) => { - let bx = gtk::Box::new(gtk::Orientation::Vertical, 6); - bx.add_css_class("quote"); - for block in blocks.iter() { - let w = create_widget_for_html_block(block); - bx.append(&w); - } - bx.upcast::() - } - HtmlBlock::Text(s) => { - let w = gtk::Label::new(None); - set_label_styles(&w); - w.set_markup(s); - w.upcast::() } } } diff --git a/src/session/content/message_row/text.rs b/src/session/content/message_row/text.rs new file mode 100644 index 00000000..0be55756 --- /dev/null +++ b/src/session/content/message_row/text.rs @@ -0,0 +1,410 @@ +use adw::{prelude::BinExt, subclass::prelude::*}; +use gtk::{gio, glib, pango, prelude::*, subclass::prelude::*}; +use html2pango::{ + block::{markup_html, HtmlBlock}, + html_escape, markup_links, +}; +use matrix_sdk::ruma::events::room::message::{FormattedBody, MessageFormat}; +use sourceview::prelude::*; + +use crate::session::{room::Member, UserExt}; + +#[derive(Debug, Hash, Eq, PartialEq, Clone, Copy, glib::GEnum)] +#[repr(u32)] +#[genum(type_name = "TextFormat")] +pub enum TextFormat { + Text = 0, + Markup = 1, + Html = 2, + Emote = 3, + HtmlEmote = 4, +} + +impl Default for TextFormat { + fn default() -> Self { + TextFormat::Text + } +} + +mod imp { + use super::*; + use once_cell::sync::Lazy; + use std::cell::{Cell, RefCell}; + + #[derive(Debug, Default)] + pub struct MessageText { + /// The format of the text message. + pub format: Cell, + /// The displayed content of the message. + pub body: RefCell>, + /// The sender of the message(only used for emotes). + pub sender: RefCell>, + } + + #[glib::object_subclass] + impl ObjectSubclass for MessageText { + const NAME: &'static str = "ContentMessageText"; + type Type = super::MessageText; + type ParentType = adw::Bin; + } + + impl ObjectImpl for MessageText { + fn properties() -> &'static [glib::ParamSpec] { + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![ + glib::ParamSpec::new_enum( + "format", + "Format", + "The format of the text message", + TextFormat::static_type(), + TextFormat::default() as i32, + glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT_ONLY, + ), + glib::ParamSpec::new_string( + "body", + "Body", + "The displayed content of the message", + None, + glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT_ONLY, + ), + glib::ParamSpec::new_object( + "sender", + "Sender", + "The sender of the message", + Member::static_type(), + glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY, + ), + ] + }); + + PROPERTIES.as_ref() + } + + fn set_property( + &self, + obj: &Self::Type, + _id: usize, + value: &glib::Value, + pspec: &glib::ParamSpec, + ) { + match pspec.name() { + "format" => obj.set_format(value.get().unwrap()), + "body" => obj.set_body(value.get().unwrap()), + "sender" => obj.set_sender(value.get().unwrap()), + _ => unimplemented!(), + } + } + + fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + match pspec.name() { + "format" => obj.format().to_value(), + "body" => obj.body().to_value(), + "sender" => obj.sender().to_value(), + _ => unimplemented!(), + } + } + + fn constructed(&self, obj: &Self::Type) { + self.parent_constructed(obj); + } + } + + impl WidgetImpl for MessageText {} + + impl BinImpl for MessageText {} +} + +glib::wrapper! { + /// A widget displaying the content of a text message. + pub struct MessageText(ObjectSubclass) + @extends gtk::Widget, adw::Bin, @implements gtk::Accessible; +} + +impl MessageText { + // Creates a widget that displays plain text. + pub fn text(body: String) -> Self { + glib::Object::new(&[("body", &body)]).expect("Failed to create MessageText") + } + + // Creates a widget that displays text with markup. It will detect if it should display the body or the formatted body. + pub fn markup(formatted: Option, body: String) -> Self { + if let Some((html_blocks, body)) = formatted + .filter(|formatted| is_valid_formatted_body(formatted)) + .and_then(|formatted| { + parse_formatted_body(&formatted.body) + .and_then(|blocks| Some((blocks, formatted.body))) + }) + { + let self_: Self = glib::Object::new(&[("format", &TextFormat::Html), ("body", &body)]) + .expect("Failed to create MessageText"); + + self_.build_html(html_blocks); + self_ + } else { + let self_: Self = + glib::Object::new(&[("format", &TextFormat::Markup), ("body", &linkify(&body))]) + .expect("Failed to create MessageText"); + + self_.build(); + self_ + } + } + + // Creates a widget that displays an emote. It will detect if it should display the body or the formatted body. + pub fn emote(formatted: Option, body: String, sender: Member) -> Self { + if let Some(body) = formatted + .filter(|formatted| is_valid_formatted_body(formatted)) + .and_then(|formatted| { + let body = format!("{} {}", sender.display_name(), formatted.body); + + parse_formatted_body(&body).and_then(|_| Some(formatted.body)) + }) + { + glib::Object::new(&[ + ("format", &TextFormat::HtmlEmote), + ("body", &body), + ("sender", &Some(sender)), + ]) + .expect("Failed to create MessageText") + } else { + glib::Object::new(&[ + ("format", &TextFormat::Emote), + ("body", &linkify(&body)), + ("sender", &Some(sender)), + ]) + .expect("Failed to create MessageText") + } + } + + pub fn set_format(&self, format: TextFormat) { + let priv_ = imp::MessageText::from_instance(self); + + if format == priv_.format.get() { + return; + } + + priv_.format.set(format); + } + + pub fn format(&self) -> TextFormat { + let priv_ = imp::MessageText::from_instance(self); + priv_.format.get() + } + + pub fn set_body(&self, body: Option) { + let priv_ = imp::MessageText::from_instance(self); + + if body.as_ref() == priv_.body.borrow().as_ref() { + return; + } + + priv_.body.replace(body); + } + + pub fn body(&self) -> Option { + let priv_ = imp::MessageText::from_instance(self); + priv_.body.borrow().to_owned() + } + + pub fn set_sender(&self, sender: Option) { + let priv_ = imp::MessageText::from_instance(self); + + if sender.as_ref() == priv_.sender.borrow().as_ref() { + return; + } + + priv_.sender.replace(sender); + + if self.format() == TextFormat::Emote || self.format() == TextFormat::HtmlEmote { + self.build(); + } + + self.notify("sender"); + } + + pub fn sender(&self) -> Option { + let priv_ = imp::MessageText::from_instance(self); + priv_.sender.borrow().to_owned() + } + + fn build(&self) { + match self.format() { + TextFormat::Text => { + self.build_text(&self.body().unwrap(), false); + } + TextFormat::Markup => { + self.build_text(&self.body().unwrap(), true); + } + TextFormat::Html => { + let formatted = FormattedBody { + body: self.body().unwrap(), + format: MessageFormat::Html, + }; + + let html = parse_formatted_body(&formatted.body).unwrap(); + self.build_html(html); + } + TextFormat::Emote => { + // TODO: we need to bind the display name to the sender + self.build_text( + &format!( + "{} {}", + self.sender().unwrap().display_name(), + &self.body().unwrap() + ), + true, + ); + } + TextFormat::HtmlEmote => { + // TODO: we need to bind the display name to the sender + let formatted = FormattedBody { + body: format!( + "{} {}", + self.sender().unwrap().display_name(), + self.body().unwrap() + ), + format: MessageFormat::Html, + }; + + let html = parse_formatted_body(&formatted.body).unwrap(); + self.build_html(html); + } + } + } + + fn build_text(&self, text: &str, use_markup: bool) { + let child = if let Some(Ok(child)) = self.child().map(|w| w.downcast::()) { + child + } else { + let child = gtk::Label::new(None); + set_label_styles(&child); + self.set_child(Some(&child)); + child + }; + + if use_markup { + child.set_markup(text); + } else { + child.set_text(text); + } + } + + fn build_html(&self, blocks: Vec) { + let child = gtk::Box::new(gtk::Orientation::Vertical, 6); + self.set_child(Some(&child)); + + for block in blocks { + let widget = create_widget_for_html_block(&block); + child.append(&widget); + } + } +} + +fn linkify(text: &str) -> String { + markup_links(&html_escape(text)) +} + +fn is_valid_formatted_body(formatted: &FormattedBody) -> bool { + formatted.format == MessageFormat::Html && !formatted.body.contains("") +} + +fn parse_formatted_body(formatted: &str) -> Option> { + markup_html(formatted).ok() +} + +fn set_label_styles(w: >k::Label) { + w.set_wrap(true); + w.set_wrap_mode(pango::WrapMode::WordChar); + w.set_justify(gtk::Justification::Left); + w.set_xalign(0.0); + w.set_valign(gtk::Align::Start); + w.set_halign(gtk::Align::Fill); + w.set_selectable(true); + let menu_model: Option = + gtk::Builder::from_resource("/org/gnome/FractalNext/content-item-row-menu.ui") + .object("menu_model"); + w.set_extra_menu(menu_model.as_ref()); +} + +fn create_widget_for_html_block(block: &HtmlBlock) -> gtk::Widget { + match block { + HtmlBlock::Heading(n, s) => { + let w = gtk::Label::new(None); + set_label_styles(&w); + w.set_markup(s); + w.add_css_class(&format!("h{}", n)); + w.upcast::() + } + HtmlBlock::UList(elements) => { + let bx = gtk::Box::new(gtk::Orientation::Vertical, 6); + bx.set_margin_end(6); + bx.set_margin_start(6); + + for li in elements.iter() { + let h_box = gtk::Box::new(gtk::Orientation::Horizontal, 6); + let bullet = gtk::Label::new(Some("•")); + bullet.set_valign(gtk::Align::Start); + let w = gtk::Label::new(None); + set_label_styles(&w); + h_box.append(&bullet); + h_box.append(&w); + w.set_markup(li); + bx.append(&h_box); + } + + bx.upcast::() + } + HtmlBlock::OList(elements) => { + let bx = gtk::Box::new(gtk::Orientation::Vertical, 6); + bx.set_margin_end(6); + bx.set_margin_start(6); + + for (i, ol) in elements.iter().enumerate() { + let h_box = gtk::Box::new(gtk::Orientation::Horizontal, 6); + let bullet = gtk::Label::new(Some(&format!("{}.", i + 1))); + bullet.set_valign(gtk::Align::Start); + let w = gtk::Label::new(None); + set_label_styles(&w); + h_box.append(&bullet); + h_box.append(&w); + w.set_markup(ol); + bx.append(&h_box); + } + + bx.upcast::() + } + HtmlBlock::Code(s) => { + let scrolled = gtk::ScrolledWindow::new(); + scrolled.set_policy(gtk::PolicyType::Automatic, gtk::PolicyType::Never); + let buffer = sourceview::Buffer::new(None); + buffer.set_highlight_matching_brackets(false); + buffer.set_text(s); + let view = sourceview::View::with_buffer(&buffer); + view.set_editable(false); + view.add_css_class("codeview"); + scrolled.set_child(Some(&view)); + scrolled.upcast::() + } + HtmlBlock::Quote(blocks) => { + let bx = gtk::Box::new(gtk::Orientation::Vertical, 6); + bx.add_css_class("quote"); + for block in blocks.iter() { + let w = create_widget_for_html_block(block); + bx.append(&w); + } + bx.upcast::() + } + HtmlBlock::Text(s) => { + let w = gtk::Label::new(None); + set_label_styles(&w); + w.set_markup(s); + w.upcast::() + } + } +} + +impl Default for MessageText { + fn default() -> Self { + Self::text(format!("")) + } +}