[fractal/fractal-next] content: Add MessageText widget
- From: Julian Sparber <jsparber src gnome org>
- To: commits-list gnome org
- Cc:
- Subject: [fractal/fractal-next] content: Add MessageText widget
- Date: Tue, 16 Nov 2021 15:26:36 +0000 (UTC)
commit b653ca79335e03ff46ef17447ee728b808945125
Author: Kévin Commaille <zecakeh tedomum fr>
Date: Tue Nov 16 15:17:01 2021 +0100
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(-)
---
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!(
- "<b>{}</b> {}",
- event.sender().display_name(),
- formatted.body
- ),
- format: MessageFormat::Html,
- };
-
- parse_formatted_body(Some(&body))
- })
- {
- self.show_html(html_blocks);
- } else {
- self.show_text(
- &format!(
- "<b>{}</b> {}",
- 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::<gtk::Label>()) {
- 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<HtmlBlock>) {
- 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("<!-- raw HTML omitted -->")
-}
-
-fn parse_formatted_body(formatted: Option<&FormattedBody>) -> Option<Vec<HtmlBlock>> {
- 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<gio::MenuModel> =
- 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::<gtk::Widget>()
- }
- 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::<gtk::Widget>()
- }
- 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::<gtk::Widget>()
- }
- 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::<gtk::Widget>()
- }
- 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);
+ _ => {
+ let child = MessageText::text(gettext("Unsupported event"));
+ priv_.content.set_child(Some(&child));
}
- bx.upcast::<gtk::Widget>()
- }
- HtmlBlock::Text(s) => {
- let w = gtk::Label::new(None);
- set_label_styles(&w);
- w.set_markup(s);
- w.upcast::<gtk::Widget>()
}
}
}
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<TextFormat>,
+ /// The displayed content of the message.
+ pub body: RefCell<Option<String>>,
+ /// The sender of the message(only used for emotes).
+ pub sender: RefCell<Option<Member>>,
+ }
+
+ #[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<Vec<glib::ParamSpec>> = 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<imp::MessageText>)
+ @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<FormattedBody>, 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<FormattedBody>, body: String, sender: Member) -> Self {
+ if let Some(body) = formatted
+ .filter(|formatted| is_valid_formatted_body(formatted))
+ .and_then(|formatted| {
+ let body = format!("<b>{}</b> {}", 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<String>) {
+ 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<String> {
+ let priv_ = imp::MessageText::from_instance(self);
+ priv_.body.borrow().to_owned()
+ }
+
+ pub fn set_sender(&self, sender: Option<Member>) {
+ 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<Member> {
+ 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!(
+ "<b>{}</b> {}",
+ 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!(
+ "<b>{}</b> {}",
+ 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::<gtk::Label>()) {
+ 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<HtmlBlock>) {
+ 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("<!-- raw HTML omitted -->")
+}
+
+fn parse_formatted_body(formatted: &str) -> Option<Vec<HtmlBlock>> {
+ 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<gio::MenuModel> =
+ 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::<gtk::Widget>()
+ }
+ 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::<gtk::Widget>()
+ }
+ 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::<gtk::Widget>()
+ }
+ 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::<gtk::Widget>()
+ }
+ 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::<gtk::Widget>()
+ }
+ HtmlBlock::Text(s) => {
+ let w = gtk::Label::new(None);
+ set_label_styles(&w);
+ w.set_markup(s);
+ w.upcast::<gtk::Widget>()
+ }
+ }
+}
+
+impl Default for MessageText {
+ fn default() -> Self {
+ Self::text(format!(""))
+ }
+}
[
Date Prev][
Date Next] [
Thread Prev][
Thread Next]
[
Thread Index]
[
Date Index]
[
Author Index]