diff --git a/data/resources/resources.gresource.xml b/data/resources/resources.gresource.xml index aae78ef5..499c665b 100644 --- a/data/resources/resources.gresource.xml +++ b/data/resources/resources.gresource.xml @@ -3,14 +3,20 @@ ui/shortcuts.ui ui/content.ui + ui/content-item.ui + ui/content-message-row.ui + ui/content-divider-row.ui + ui/content-state-row.ui ui/login.ui ui/session.ui ui/sidebar.ui ui/sidebar-item.ui ui/sidebar-room-row.ui ui/window.ui + ui/context-menu-bin.ui style.css icons/scalable/actions/send-symbolic.svg icons/scalable/status/welcome.svg + diff --git a/data/resources/style.css b/data/resources/style.css index 0360bab4..650883e3 100644 --- a/data/resources/style.css +++ b/data/resources/style.css @@ -51,4 +51,11 @@ background-color: @theme_selected_bg_color; } - +/* Content */ +.codeview { + border-radius: 5px; + padding: 6px; + font-family: monospace; + background-color: @text_view_bg; + color: @theme_text_color; +} diff --git a/data/resources/ui/content-divider-row.ui b/data/resources/ui/content-divider-row.ui new file mode 100644 index 00000000..540b7e3c --- /dev/null +++ b/data/resources/ui/content-divider-row.ui @@ -0,0 +1,35 @@ + + + + diff --git a/data/resources/ui/content-item-row-menu.ui b/data/resources/ui/content-item-row-menu.ui new file mode 100644 index 00000000..5e4fe940 --- /dev/null +++ b/data/resources/ui/content-item-row-menu.ui @@ -0,0 +1,102 @@ + + + + False + + + True + False + 6 + 6 + 6 + 6 + vertical + + + True + True + True + message.reply + Reply + + + + + True + True + Open With… + message.open_with + + + + + True + True + Save Image As… + message.save_as + + + + + True + True + Save Video As… + message.save_as + + + + + True + True + Copy Image + message.copy_image + + + + + True + True + Copy Selection + + + + + True + True + True + Copy Text + message.copy_text + + + + + True + True + True + message.show_source + View Source + + + + + True + False + + + + + True + True + True + message.delete + Delete Message + + + + + main + 1 + + + + diff --git a/data/resources/ui/content-item.ui b/data/resources/ui/content-item.ui new file mode 100644 index 00000000..3dbf6b97 --- /dev/null +++ b/data/resources/ui/content-item.ui @@ -0,0 +1,18 @@ + + + + diff --git a/data/resources/ui/content-message-row.ui b/data/resources/ui/content-message-row.ui new file mode 100644 index 00000000..37c74018 --- /dev/null +++ b/data/resources/ui/content-message-row.ui @@ -0,0 +1,50 @@ + + + + diff --git a/data/resources/ui/content-state-row.ui b/data/resources/ui/content-state-row.ui new file mode 100644 index 00000000..8fbf5983 --- /dev/null +++ b/data/resources/ui/content-state-row.ui @@ -0,0 +1,21 @@ + + + + diff --git a/data/resources/ui/content.ui b/data/resources/ui/content.ui index d59ed88b..298c1fd1 100644 --- a/data/resources/ui/content.ui +++ b/data/resources/ui/content.ui @@ -23,7 +23,7 @@ - + True @@ -35,20 +35,37 @@ - + True - True + never - - + + + True + True + + + + + + /org/gnome/FractalNext/content-item.ui + + + + Room History + + + - + - + @@ -86,3 +103,4 @@ + diff --git a/data/resources/ui/context-menu-bin.ui b/data/resources/ui/context-menu-bin.ui new file mode 100644 index 00000000..51b296c5 --- /dev/null +++ b/data/resources/ui/context-menu-bin.ui @@ -0,0 +1,18 @@ + + + + diff --git a/src/components/context_menu_bin.rs b/src/components/context_menu_bin.rs new file mode 100644 index 00000000..420e8d08 --- /dev/null +++ b/src/components/context_menu_bin.rs @@ -0,0 +1,188 @@ +use adw::subclass::prelude::*; +use gtk::prelude::*; +use gtk::subclass::prelude::*; +use gtk::{gdk, gio, glib, glib::clone, CompositeTemplate}; +use log::debug; + +mod imp { + use super::*; + use glib::subclass::InitializingObject; + + #[derive(Debug, CompositeTemplate)] + #[template(resource = "/org/gnome/FractalNext/context-menu-bin.ui")] + pub struct ContextMenuBin { + #[template_child] + pub click_gesture: TemplateChild, + #[template_child] + pub long_press_gesture: TemplateChild, + pub popover: gtk::PopoverMenu, + } + + #[glib::object_subclass] + impl ObjectSubclass for ContextMenuBin { + const NAME: &'static str = "ContextMenuBin"; + type Type = super::ContextMenuBin; + type ParentType = adw::Bin; + + fn new() -> Self { + Self { + click_gesture: TemplateChild::default(), + long_press_gesture: TemplateChild::default(), + // WORKAROUND: there is some issue with creating the popover from the template + popover: gtk::PopoverMenuBuilder::new() + .position(gtk::PositionType::Bottom) + .has_arrow(false) + .halign(gtk::Align::Start) + .build(), + } + } + + fn class_init(klass: &mut Self::Class) { + Self::bind_template(klass); + + klass.install_action("context-menu.activate", None, move |widget, _, _| { + widget.open_menu_at(0, 0) + }); + klass.add_binding_action( + gdk::keys::constants::F10, + gdk::ModifierType::SHIFT_MASK, + "context-menu.activate", + None, + ); + klass.add_binding_action( + gdk::keys::constants::Menu, + gdk::ModifierType::empty(), + "context-menu.activate", + None, + ); + } + + fn instance_init(obj: &InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for ContextMenuBin { + fn properties() -> &'static [glib::ParamSpec] { + use once_cell::sync::Lazy; + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![glib::ParamSpec::new_object( + "context-menu", + "Context Menu", + "The context menu", + gio::MenuModel::static_type(), + glib::ParamFlags::READWRITE, + )] + }); + + PROPERTIES.as_ref() + } + + fn set_property( + &self, + obj: &Self::Type, + _id: usize, + value: &glib::Value, + pspec: &glib::ParamSpec, + ) { + match pspec.name() { + "context-menu" => { + let context_menu = value + .get::>() + .expect("type conformity checked by `Object::set_property`"); + obj.set_context_menu(context_menu); + } + _ => unimplemented!(), + } + } + + fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + match pspec.name() { + "context-menu" => obj.context_menu().to_value(), + _ => unimplemented!(), + } + } + + fn constructed(&self, obj: &Self::Type) { + self.popover.set_parent(obj); + self.long_press_gesture + .connect_pressed(clone!(@weak obj => move |gesture, x, y| { + gesture.set_state(gtk::EventSequenceState::Claimed); + gesture.reset(); + obj.open_menu_at(x as i32, y as i32); + })); + + self.click_gesture.connect_released( + clone!(@weak obj => move |gesture, n_press, x, y| { + if n_press > 1 { + return; + } + + gesture.set_state(gtk::EventSequenceState::Claimed); + obj.open_menu_at(x as i32, y as i32); + }), + ); + self.parent_constructed(obj); + } + + fn dispose(&self, _obj: &Self::Type) { + self.popover.unparent(); + } + } + + impl WidgetImpl for ContextMenuBin {} + + impl BinImpl for ContextMenuBin {} +} + +glib::wrapper! { + pub struct ContextMenuBin(ObjectSubclass) + @extends gtk::Widget, adw::Bin, @implements gtk::Accessible; +} + +/// A Bin widget that adds a conext menu +impl ContextMenuBin { + pub fn new() -> Self { + glib::Object::new(&[]).expect("Failed to create ContextMenuBin") + } + + pub fn set_context_menu(&self, menu: Option) { + let priv_ = imp::ContextMenuBin::from_instance(self); + priv_.popover.set_menu_model(menu.as_ref()); + } + + pub fn context_menu(&self) -> Option { + let priv_ = imp::ContextMenuBin::from_instance(self); + priv_.popover.menu_model() + } + + fn open_menu_at(&self, x: i32, y: i32) { + let priv_ = imp::ContextMenuBin::from_instance(self); + let popover = &priv_.popover; + + debug!("Context menu was activated"); + + if popover.menu_model().is_none() { + return; + } + + popover.set_pointing_to(&gdk::Rectangle { + x, + y, + width: 0, + height: 0, + }); + popover.popup(); + } +} + +unsafe impl IsSubclassable for ContextMenuBin { + fn class_init(class: &mut glib::Class) { + >::class_init(class); + } + fn instance_init(instance: &mut glib::subclass::InitializingObject) { + >::instance_init(instance); + } +} + +pub trait ContextMenuBinImpl: BinImpl {} diff --git a/src/components/mod.rs b/src/components/mod.rs new file mode 100644 index 00000000..8ea2852e --- /dev/null +++ b/src/components/mod.rs @@ -0,0 +1,3 @@ +mod context_menu_bin; + +pub use self::context_menu_bin::{ContextMenuBin, ContextMenuBinImpl}; diff --git a/src/main.rs b/src/main.rs index bba4ce57..ea741574 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ mod application; #[rustfmt::skip] mod config; +mod components; mod login; mod secret; mod session; diff --git a/src/meson.build b/src/meson.build index 1e192571..692a9999 100644 --- a/src/meson.build +++ b/src/meson.build @@ -32,7 +32,12 @@ sources = files( 'session/categories/category.rs', 'session/categories/category_type.rs', 'session/categories/mod.rs', - 'session/content.rs', + 'session/content/content.rs', + 'session/content/divider_row.rs', + 'session/content/item_row.rs', + 'session/content/message_row.rs', + 'session/content/mod.rs', + 'session/content/state_row.rs', 'session/room/event.rs', 'session/room/highlight_flags.rs', 'session/room/item.rs', diff --git a/src/session/content.rs b/src/session/content/content.rs similarity index 53% rename from src/session/content.rs rename to src/session/content/content.rs index b3a8c61b..66ad426f 100644 --- a/src/session/content.rs +++ b/src/session/content/content.rs @@ -1,23 +1,26 @@ -use adw; -use adw::subclass::prelude::BinImpl; -use gtk::subclass::prelude::*; -use gtk::{self, prelude::*}; -use gtk::{glib, glib::SyncSender, CompositeTemplate}; -use matrix_sdk::identifiers::RoomId; +use adw::subclass::prelude::*; +use gtk::{glib, glib::clone, prelude::*, subclass::prelude::*, CompositeTemplate}; + +use crate::session::{ + content::ItemRow, + room::{Room, Timeline}, +}; mod imp { use super::*; use glib::subclass::InitializingObject; use std::cell::Cell; - #[derive(Debug, CompositeTemplate)] + #[derive(Debug, Default, CompositeTemplate)] #[template(resource = "/org/gnome/FractalNext/content.ui")] pub struct Content { pub compact: Cell, #[template_child] pub headerbar: TemplateChild, #[template_child] - pub room_history: TemplateChild, + pub listview: TemplateChild, + #[template_child] + pub scrolled_window: TemplateChild, } #[glib::object_subclass] @@ -26,16 +29,10 @@ mod imp { type Type = super::Content; type ParentType = adw::Bin; - fn new() -> Self { - Self { - compact: Cell::new(false), - headerbar: TemplateChild::default(), - room_history: TemplateChild::default(), - } - } - fn class_init(klass: &mut Self::Class) { + ItemRow::static_type(); Self::bind_template(klass); + klass.set_accessible_role(gtk::AccessibleRole::Group); } fn instance_init(obj: &InitializingObject) { @@ -68,9 +65,7 @@ mod imp { ) { match pspec.name() { "compact" => { - let compact = value - .get() - .expect("type conformity checked by `Object::set_property`"); + let compact = value.get().unwrap(); self.compact.set(compact); } _ => unimplemented!(), @@ -83,6 +78,23 @@ mod imp { _ => unimplemented!(), } } + + fn constructed(&self, obj: &Self::Type) { + let adj = self.scrolled_window.vadjustment().unwrap(); + // TODO: make sure that we have enough messages to fill at least to scroll pages, if the room history is long enough + + adj.connect_value_changed(clone!(@weak obj => move |adj| { + // Load more message when the user gets close to the end of the known room history + // Use the page size twice to detect if the user gets close the end + if adj.value() < adj.page_size() * 2.0 { + if let Some(room) = obj.room() { + room.load_previous_events(); + } + } + })); + + self.parent_constructed(obj); + } } impl WidgetImpl for Content {} @@ -99,13 +111,22 @@ impl Content { glib::Object::new(&[]).expect("Failed to create Content") } - /// Sets up the required channel to recive async updates from the `Client` - pub fn setup_channel(&self) -> SyncSender { - let (sender, receiver) = glib::MainContext::sync_channel::(Default::default(), 100); - receiver.attach(None, move |_room_id| { - //TODO: actually do something: update the message GListModel - glib::Continue(true) - }); - sender + pub fn set_room(&self, room: &Room) { + let priv_ = imp::Content::from_instance(self); + // TODO: use gtk::MultiSelection to allow selection + priv_ + .listview + .set_model(Some(>k::NoSelection::new(Some(room.timeline())))); + } + + fn room(&self) -> Option { + let priv_ = imp::Content::from_instance(self); + priv_ + .listview + .model() + .and_then(|model| model.downcast::().ok()) + .and_then(|model| model.model()) + .and_then(|model| model.downcast::().ok()) + .map(|timeline| timeline.room().to_owned()) } } diff --git a/src/session/content/divider_row.rs b/src/session/content/divider_row.rs new file mode 100644 index 00000000..a27ba5fb --- /dev/null +++ b/src/session/content/divider_row.rs @@ -0,0 +1,92 @@ +use adw::subclass::prelude::*; +use gtk::{glib, prelude::*, subclass::prelude::*, CompositeTemplate}; + +mod imp { + use super::*; + use glib::subclass::InitializingObject; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/org/gnome/FractalNext/content-divider-row.ui")] + pub struct DividerRow { + #[template_child] + pub label: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for DividerRow { + const NAME: &'static str = "ContentDividerRow"; + type Type = super::DividerRow; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + Self::bind_template(klass); + } + + fn instance_init(obj: &InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for DividerRow { + fn properties() -> &'static [glib::ParamSpec] { + use once_cell::sync::Lazy; + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![glib::ParamSpec::new_string( + "label", + "Label", + "The label for this divider", + None, + glib::ParamFlags::READWRITE, + )] + }); + + PROPERTIES.as_ref() + } + + fn set_property( + &self, + obj: &Self::Type, + _id: usize, + value: &glib::Value, + pspec: &glib::ParamSpec, + ) { + match pspec.name() { + "label" => { + let label = value.get().unwrap(); + obj.set_label(label); + } + _ => unimplemented!(), + } + } + + fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + match pspec.name() { + "label" => obj.label().to_value(), + _ => unimplemented!(), + } + } + } + impl WidgetImpl for DividerRow {} + impl BinImpl for DividerRow {} +} + +glib::wrapper! { + pub struct DividerRow(ObjectSubclass) + @extends gtk::Widget, adw::Bin, @implements gtk::Accessible; +} + +impl DividerRow { + pub fn new(label: String) -> Self { + glib::Object::new(&[("label", &label)]).expect("Failed to create DividerRow") + } + + pub fn set_label(&self, label: &str) { + let priv_ = imp::DividerRow::from_instance(self); + priv_.label.set_text(label); + } + + pub fn label(&self) -> String { + let priv_ = imp::DividerRow::from_instance(self); + priv_.label.text().as_str().to_owned() + } +} diff --git a/src/session/content/item_row.rs b/src/session/content/item_row.rs new file mode 100644 index 00000000..cfb86606 --- /dev/null +++ b/src/session/content/item_row.rs @@ -0,0 +1,180 @@ +use adw::{prelude::*, subclass::prelude::*}; +use chrono::{offset::Local, Datelike}; +use gettextrs::gettext; +use gtk::{glib, prelude::*, subclass::prelude::*}; + +use crate::components::{ContextMenuBin, ContextMenuBinImpl}; +use crate::session::content::{DividerRow, MessageRow, StateRow}; +use crate::session::room::{Item, ItemType}; +use matrix_sdk::events::AnyRoomEvent; + +mod imp { + use super::*; + use std::cell::RefCell; + + #[derive(Debug, Default)] + pub struct ItemRow { + pub item: RefCell>, + } + + #[glib::object_subclass] + impl ObjectSubclass for ItemRow { + const NAME: &'static str = "ContentItemRow"; + type Type = super::ItemRow; + type ParentType = ContextMenuBin; + } + + impl ObjectImpl for ItemRow { + fn properties() -> &'static [glib::ParamSpec] { + use once_cell::sync::Lazy; + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![glib::ParamSpec::new_object( + "item", + "item", + "The item represented by this row", + Item::static_type(), + glib::ParamFlags::READWRITE, + )] + }); + + PROPERTIES.as_ref() + } + + fn set_property( + &self, + obj: &Self::Type, + _id: usize, + value: &glib::Value, + pspec: &glib::ParamSpec, + ) { + match pspec.name() { + "item" => { + let item = value.get::>().unwrap(); + obj.set_item(item); + } + _ => unimplemented!(), + } + } + + fn property(&self, _obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + match pspec.name() { + "item" => self.item.borrow().to_value(), + _ => unimplemented!(), + } + } + + fn constructed(&self, obj: &Self::Type) { + self.parent_constructed(obj); + } + } + + impl WidgetImpl for ItemRow {} + impl BinImpl for ItemRow {} + impl ContextMenuBinImpl for ItemRow {} +} + +glib::wrapper! { + pub struct ItemRow(ObjectSubclass) + @extends gtk::Widget, ContextMenuBin, adw::Bin, @implements gtk::Accessible; +} + +// TODO: +// - [ ] Add context menu for operations +// - [ ] Don't show rows for items that don't have a visible UI +impl ItemRow { + pub fn new() -> Self { + glib::Object::new(&[]).expect("Failed to create ItemRow") + } + + /// This method sets this row to a new `Item`. + /// + /// It tries to reuse the widget and only update the content whenever possible, but it will + /// create a new widget and drop the old one if it has to. + fn set_item(&self, item: Option) { + let priv_ = imp::ItemRow::from_instance(&self); + + if let Some(ref item) = item { + match item.type_() { + ItemType::Event(event) => match event.matrix_event() { + AnyRoomEvent::Message(_message) => { + let child = if let Some(Ok(child)) = + self.child().map(|w| w.downcast::()) + { + child + } else { + let child = MessageRow::new(); + self.set_child(Some(&child)); + child + }; + child.set_event(event.clone()); + } + AnyRoomEvent::State(state) => { + let child = if let Some(Ok(child)) = + self.child().map(|w| w.downcast::()) + { + child + } else { + let child = StateRow::new(); + self.set_child(Some(&child)); + child + }; + + child.update(&state); + } + AnyRoomEvent::RedactedMessage(_) => { + let child = if let Some(Ok(child)) = + self.child().map(|w| w.downcast::()) + { + child + } else { + let child = MessageRow::new(); + self.set_child(Some(&child)); + child + }; + child.set_event(event.clone()); + } + AnyRoomEvent::RedactedState(_) => { + let child = if let Some(Ok(child)) = + self.child().map(|w| w.downcast::()) + { + child + } else { + let child = MessageRow::new(); + self.set_child(Some(&child)); + child + }; + child.set_event(event.clone()); + } + }, + ItemType::DayDivider(date) => { + let fmt = if date.year() == Local::today().year() { + // Translators: This is a date format in the day divider without the year + gettext("%A, %B %e") + } else { + // Translators: This is a date format in the day divider with the year + gettext("%A, %B %e, %Y") + }; + let date = date.format(&fmt).to_string(); + + if let Some(Ok(child)) = self.child().map(|w| w.downcast::()) { + child.set_label(&date); + } else { + let child = DividerRow::new(date); + self.set_child(Some(&child)); + }; + } + ItemType::NewMessageDivider => { + let label = gettext("New Messages"); + + if let Some(Ok(child)) = self.child().map(|w| w.downcast::()) { + child.set_label(&label); + } else { + let child = DividerRow::new(label); + self.set_child(Some(&child)); + }; + } + } + } + priv_.item.replace(item); + } +} diff --git a/src/session/content/message_row.rs b/src/session/content/message_row.rs new file mode 100644 index 00000000..9247f526 --- /dev/null +++ b/src/session/content/message_row.rs @@ -0,0 +1,416 @@ +use adw::{prelude::*, subclass::prelude::*}; +use gtk::{ + glib, glib::clone, glib::signal::SignalHandlerId, prelude::*, subclass::prelude::*, + CompositeTemplate, +}; +use html2pango::{ + block::{markup_html, HtmlBlock}, + html_escape, markup_links, +}; +use log::warn; +use matrix_sdk::events::{ + room::message::MessageFormat, + room::message::{FormattedBody, MessageType}, + room::redaction::RedactionEventContent, + AnyMessageEvent, AnyMessageEventContent, AnyRoomEvent, +}; + +use crate::session::room::Event; + +mod imp { + use super::*; + use glib::subclass::InitializingObject; + use once_cell::sync::Lazy; + use std::cell::RefCell; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/org/gnome/FractalNext/content-message-row.ui")] + pub struct MessageRow { + #[template_child] + pub avatar: TemplateChild, + #[template_child] + pub header: TemplateChild, + #[template_child] + pub display_name: TemplateChild, + #[template_child] + pub timestamp: TemplateChild, + #[template_child] + pub content: TemplateChild, + pub relates_to_changed_handler: RefCell>, + pub bindings: RefCell>, + pub event: RefCell>, + } + + #[glib::object_subclass] + impl ObjectSubclass for MessageRow { + const NAME: &'static str = "ContentMessageRow"; + type Type = super::MessageRow; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + Self::bind_template(klass); + } + + fn instance_init(obj: &InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for MessageRow { + fn properties() -> &'static [glib::ParamSpec] { + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![ + glib::ParamSpec::new_boolean( + "show-header", + "Show Header", + "Whether this item should show a header or not. This does do nothing if this event doesn't have a header. ", + false, + glib::ParamFlags::READWRITE, + ), + ] + }); + + PROPERTIES.as_ref() + } + + fn set_property( + &self, + obj: &Self::Type, + _id: usize, + value: &glib::Value, + pspec: &glib::ParamSpec, + ) { + match pspec.name() { + "show-header" => { + let show_header = value.get().unwrap(); + let _ = obj.set_show_header(show_header); + } + _ => unimplemented!(), + } + } + + fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + match pspec.name() { + "show-header" => obj.show_header().to_value(), + _ => unimplemented!(), + } + } + } + impl WidgetImpl for MessageRow {} + impl BinImpl for MessageRow {} +} + +glib::wrapper! { + pub struct MessageRow(ObjectSubclass) + @extends gtk::Widget, adw::Bin, @implements gtk::Accessible; +} + +//TODO +// - [] Implement widgets to show message events +impl MessageRow { + pub fn new() -> Self { + glib::Object::new(&[]).expect("Failed to create MessageRow") + } + + pub fn show_header(&self) -> bool { + let priv_ = imp::MessageRow::from_instance(self); + priv_.avatar.is_visible() && priv_.header.is_visible() + } + + pub fn set_show_header(&self, visible: bool) { + let priv_ = imp::MessageRow::from_instance(self); + priv_.avatar.set_visible(visible); + priv_.header.set_visible(visible); + self.notify("show-header"); + } + + pub fn set_event(&self, event: Event) { + let priv_ = imp::MessageRow::from_instance(self); + // Remove signals and bindings from the previous event + if let Some(event) = priv_.event.take() { + if let Some(relates_to_changed_handler) = priv_.relates_to_changed_handler.take() { + event.disconnect(relates_to_changed_handler); + } + + while let Some(binding) = priv_.bindings.borrow_mut().pop() { + binding.unbind(); + } + } + + //TODO: bind the user's avatar to the message row + let display_name_binding = event + .sender() + .bind_property("display-name", &priv_.display_name.get(), "label") + .flags(glib::BindingFlags::SYNC_CREATE) + .build() + .unwrap(); + + let show_header_binding = event + .bind_property("show-header", self, "show-header") + .flags(glib::BindingFlags::SYNC_CREATE) + .build() + .unwrap(); + + priv_ + .bindings + .borrow_mut() + .append(&mut vec![display_name_binding, show_header_binding]); + + priv_ + .relates_to_changed_handler + .replace(Some(event.connect_relates_to_changed( + clone!(@weak self as obj => move |event| { + obj.update_content(&event); + }), + ))); + self.update_content(&event); + priv_.event.replace(Some(event)); + } + + fn find_last_event(&self, event: &Event) -> Event { + if let Some(replacement_event) = event.relates_to().iter().rev().find(|event| { + let matrix_event = event.matrix_event(); + match matrix_event { + AnyRoomEvent::Message(AnyMessageEvent::RoomMessage(message)) => { + message.content.new_content.is_some() + } + AnyRoomEvent::Message(AnyMessageEvent::RoomRedaction(_)) => true, + _ => false, + } + }) { + if !replacement_event.relates_to().is_empty() { + self.find_last_event(replacement_event) + } else { + replacement_event.clone() + } + } else { + event.clone() + } + } + /// Find the content we need to display + fn find_content(&self, event: &Event) -> AnyMessageEventContent { + match self.find_last_event(event).matrix_event() { + AnyRoomEvent::Message(message) => message.content(), + AnyRoomEvent::RedactedMessage(message) => { + if let Some(ref redaction_event) = message.unsigned().redacted_because { + AnyMessageEvent::RoomRedaction(*redaction_event.clone()).content() + } else { + AnyMessageEventContent::RoomRedaction(RedactionEventContent { reason: None }) + } + } + AnyRoomEvent::RedactedState(state) => { + if let Some(ref redaction_event) = state.unsigned().redacted_because { + AnyMessageEvent::RoomRedaction(*redaction_event.clone()).content() + } else { + AnyMessageEventContent::RoomRedaction(RedactionEventContent { reason: None }) + } + } + _ => panic!("This event isn't a room message event or redacted event"), + } + } + + 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() + match content { + AnyMessageEventContent::RoomMessage(message) => { + let msgtype = if let Some(new_message) = message.new_content { + new_message.msgtype + } else { + message.msgtype + }; + match msgtype { + MessageType::Audio(_message) => {} + MessageType::Emote(message) => { + let text = if let Some(formatted) = message + .formatted + .filter(|m| m.format == MessageFormat::Html) + { + markup_links(&html_escape(&formatted.body)) + } else { + message.body + }; + // TODO we need to bind the display name to the sender + self.show_label_with_markup(&format!( + "{} {}", + event.sender().display_name(), + text + )); + } + MessageType::File(_message) => {} + MessageType::Image(_message) => {} + MessageType::Location(_message) => {} + MessageType::Notice(message) => { + // TODO: we should reuse the already present child widgets when possible + let child = if let Some(html_blocks) = + parse_formatted_body(message.formatted.as_ref()) + { + create_widget_for_html_message(html_blocks) + } else { + let child = gtk::Label::new(Some(&message.body)); + set_label_styles(&child); + child.upcast::() + }; + + priv_.content.set_child(Some(&child)); + } + MessageType::ServerNotice(message) => { + self.show_label_with_text(&message.body); + } + MessageType::Text(message) => { + // TODO: we should reuse the already present child widgets when possible + let child = if let Some(html_blocks) = + parse_formatted_body(message.formatted.as_ref()) + { + create_widget_for_html_message(html_blocks) + } else { + let child = gtk::Label::new(Some(&message.body)); + set_label_styles(&child); + child.upcast::() + }; + + priv_.content.set_child(Some(&child)); + } + MessageType::Video(_message) => {} + MessageType::VerificationRequest(_message) => {} + _ => { + warn!("Event not supported: {:?}", msgtype) + } + } + } + AnyMessageEventContent::RoomRedaction(_) => { + self.show_label_with_text("This message was removed."); + } + _ => warn!("Event not supported: {:?}", content), + } + } + + fn show_label_with_text(&self, text: &str) { + let priv_ = imp::MessageRow::from_instance(self); + if let Some(Ok(child)) = priv_.content.child().map(|w| w.downcast::()) { + child.set_text(&text); + } else { + let child = gtk::Label::new(Some(&text)); + set_label_styles(&child); + priv_.content.set_child(Some(&child)); + } + } + + fn show_label_with_markup(&self, text: &str) { + let priv_ = imp::MessageRow::from_instance(self); + if let Some(Ok(child)) = priv_.content.child().map(|w| w.downcast::()) { + child.set_markup(&text); + } else { + let child = gtk::Label::new(None); + child.set_markup(&text); + set_label_styles(&child); + priv_.content.set_child(Some(&child)); + } + } +} + +fn parse_formatted_body(formatted: Option<&FormattedBody>) -> Option> { + formatted + .filter(|m| m.format == MessageFormat::Html) + .filter(|formatted| !formatted.body.contains("")) + .and_then(|formatted| markup_html(&formatted.body).ok()) +} + +fn create_widget_for_html_message(blocks: Vec) -> gtk::Widget { + let container = gtk::Box::new(gtk::Orientation::Vertical, 6); + for block in blocks { + let widget = create_widget_for_html_block(&block); + container.append(&widget); + } + container.upcast::() +} + +fn set_label_styles(w: >k::Label) { + w.set_wrap(true); + 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); +} + +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) => { + use sourceview::BufferExt; + 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/mod.rs b/src/session/content/mod.rs new file mode 100644 index 00000000..6831a481 --- /dev/null +++ b/src/session/content/mod.rs @@ -0,0 +1,11 @@ +mod content; +mod divider_row; +mod item_row; +mod message_row; +mod state_row; + +pub use self::content::Content; +use self::divider_row::DividerRow; +use self::item_row::ItemRow; +use self::message_row::MessageRow; +use self::state_row::StateRow; diff --git a/src/session/content/state_row.rs b/src/session/content/state_row.rs new file mode 100644 index 00000000..a027637b --- /dev/null +++ b/src/session/content/state_row.rs @@ -0,0 +1,80 @@ +use adw::{prelude::*, subclass::prelude::*}; +use gtk::{glib, prelude::*, subclass::prelude::*, CompositeTemplate}; +use matrix_sdk::events::{AnyStateEvent, AnyStateEventContent}; + +mod imp { + use super::*; + use glib::subclass::InitializingObject; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/org/gnome/FractalNext/content-state-row.ui")] + pub struct StateRow { + #[template_child] + pub timestamp: TemplateChild, + #[template_child] + pub content: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for StateRow { + const NAME: &'static str = "ContentStateRow"; + type Type = super::StateRow; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + Self::bind_template(klass); + } + + fn instance_init(obj: &InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for StateRow {} + impl WidgetImpl for StateRow {} + impl BinImpl for StateRow {} +} + +glib::wrapper! { + pub struct StateRow(ObjectSubclass) + @extends gtk::Widget, adw::Bin, @implements gtk::Accessible; +} + +//TODO +// - [] Implement widgets to show state events +impl StateRow { + pub fn new() -> Self { + glib::Object::new(&[]).expect("Failed to create StateRow") + } + + pub fn update(&self, state: &AnyStateEvent) { + let _priv_ = imp::StateRow::from_instance(self); + // We may want to show more state events in the future + // For a full list of state events see: + // https://matrix-org.github.io/matrix-rust-sdk/matrix_sdk/events/enum.AnyStateEventContent.html + let message = match state.content() { + AnyStateEventContent::RoomCreate(_event) => format!("The beginning of this room."), + AnyStateEventContent::RoomEncryption(_event) => format!("This room is now encrypted."), + AnyStateEventContent::RoomMember(_event) => { + // TODO: fully implement this state event + format!("A member did change something: state, avatar, name ...") + } + AnyStateEventContent::RoomThirdPartyInvite(event) => { + format!("{} was invited.", event.display_name) + } + AnyStateEventContent::RoomTombstone(event) => { + format!("The room was upgraded: {}", event.body) + // Todo: add button for new room with acction session.show_room::room_id + } + _ => { + format!("Unsupported Event: this shouldn't be shown.") + } + }; + if let Some(Ok(child)) = self.child().map(|w| w.downcast::()) { + child.set_text(&message); + } else { + let child = gtk::Label::new(Some(&message)); + self.set_child(Some(&child)); + }; + } +} diff --git a/src/session/mod.rs b/src/session/mod.rs index c19ca8aa..78c595da 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -326,9 +326,13 @@ impl Session { secret::store_session(homeserver, session) } - // TODO: handle show room fn handle_show_room_action(&self, room_id: RoomId) { - warn!("TODO: implement room action: {:?}", room_id); + let priv_ = imp::Session::from_instance(self); + if let Some(room) = priv_.rooms.borrow().get(&room_id) { + priv_.content.set_room(room); + } else { + warn!("No room with {} was found", room_id); + } } fn handle_sync_reposne(&self, response: SyncResponse) {