diff --git a/data/resources/resources.gresource.xml b/data/resources/resources.gresource.xml index 6911d699..f700e4e1 100644 --- a/data/resources/resources.gresource.xml +++ b/data/resources/resources.gresource.xml @@ -62,6 +62,7 @@ ui/identity-verification-widget.ui ui/qr-code-scanner.ui ui/components-video-player.ui + ui/components-reaction-chooser.ui style.css style-dark.css icons/scalable/actions/send-symbolic.svg diff --git a/data/resources/style.css b/data/resources/style.css index 18f62257..79dd4faa 100644 --- a/data/resources/style.css +++ b/data/resources/style.css @@ -310,8 +310,12 @@ message-reactions .toggle { border: 1px solid @light_4; } -message-reactions .toggle:checked { +message-reactions .toggle:checked, +.reaction-chooser button:checked { background-color: alpha(@blue_1, 0.4); +} + +message-reactions .toggle:checked { border-color: @blue_2; } @@ -324,6 +328,16 @@ message-reactions .reaction-count { padding-left: 5px; } +.reaction-chooser { + margin: 5px; +} + +.reaction-chooser button { + font-size: 1.3em; + -gtk-icon-size: 1.3em; + padding: 2px; +} + .divider-row { font-size: 0.9em; font-weight: bold; diff --git a/data/resources/ui/components-reaction-chooser.ui b/data/resources/ui/components-reaction-chooser.ui new file mode 100644 index 00000000..5e1caa51 --- /dev/null +++ b/data/resources/ui/components-reaction-chooser.ui @@ -0,0 +1,27 @@ + + + + \ No newline at end of file diff --git a/data/resources/ui/event-menu.ui b/data/resources/ui/event-menu.ui index 4970f0e6..48ebaa3d 100644 --- a/data/resources/ui/event-menu.ui +++ b/data/resources/ui/event-menu.ui @@ -1,6 +1,11 @@ +
+ + reaction-chooser + +
_Reply @@ -75,4 +80,45 @@
+ +
+ + _Forward + event.forward + action-missing + +
+
+ + _Select + event.select + action-missing + +
+
+ + _Copy Text + event.copy-text + action-disabled + action-missing + + + _Permalink + event.permalink + action-missing + + + _View Source + event.view-source + action-missing + +
+
+ + Re_move + event.remove + action-missing + +
+
diff --git a/src/components/context_menu_bin.rs b/src/components/context_menu_bin.rs index 0c44e3bb..3a47a6fb 100644 --- a/src/components/context_menu_bin.rs +++ b/src/components/context_menu_bin.rs @@ -57,6 +57,11 @@ mod imp { "context-menu.activate", None, ); + + klass.install_action("context-menu.close", None, move |widget, _, _| { + let priv_ = imp::ContextMenuBin::from_instance(widget); + priv_.popover.popdown(); + }); } fn instance_init(obj: &InitializingObject) { @@ -171,11 +176,19 @@ pub trait ContextMenuBinExt: 'static { /// Get the `MenuModel` used in the context menu. fn context_menu(&self) -> Option; + + /// Get the `PopoverMenu` used in the context menu. + fn popover(&self) -> >k::PopoverMenu; } impl> ContextMenuBinExt for O { fn set_context_menu(&self, menu: Option<&gio::MenuModel>) { let priv_ = imp::ContextMenuBin::from_instance(self.upcast_ref()); + + if self.context_menu().as_ref() == menu { + return; + } + priv_.popover.set_menu_model(menu); self.notify("context-menu"); } @@ -184,6 +197,11 @@ impl> ContextMenuBinExt for O { let priv_ = imp::ContextMenuBin::from_instance(self.upcast_ref()); priv_.popover.menu_model() } + + fn popover(&self) -> >k::PopoverMenu { + let priv_ = imp::ContextMenuBin::from_instance(self.upcast_ref()); + &priv_.popover + } } pub trait ContextMenuBinImpl: BinImpl {} diff --git a/src/components/mod.rs b/src/components/mod.rs index 5c005a97..81319af7 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -7,6 +7,7 @@ mod in_app_notification; mod label_with_widgets; mod loading_listbox_row; mod pill; +mod reaction_chooser; mod room_title; mod spinner_button; mod video_player; @@ -20,6 +21,7 @@ pub use self::in_app_notification::InAppNotification; pub use self::label_with_widgets::LabelWithWidgets; pub use self::loading_listbox_row::LoadingListBoxRow; pub use self::pill::Pill; +pub use self::reaction_chooser::ReactionChooser; pub use self::room_title::RoomTitle; pub use self::spinner_button::SpinnerButton; pub use self::video_player::VideoPlayer; diff --git a/src/components/reaction_chooser.rs b/src/components/reaction_chooser.rs new file mode 100644 index 00000000..211f4a5c --- /dev/null +++ b/src/components/reaction_chooser.rs @@ -0,0 +1,184 @@ +use adw::subclass::prelude::*; +use gtk::{glib, glib::clone, prelude::*, subclass::prelude::*, CompositeTemplate}; + +use crate::session::room::ReactionList; + +struct ReactionGridItem<'a> { + key: &'a str, + column: i32, + row: i32, +} + +static QUICK_REACTIONS: &[ReactionGridItem] = &[ + ReactionGridItem { + key: "👍️", + column: 0, + row: 0, + }, + ReactionGridItem { + key: "👎️", + column: 1, + row: 0, + }, + ReactionGridItem { + key: "😄", + column: 2, + row: 0, + }, + ReactionGridItem { + key: "🎉", + column: 3, + row: 0, + }, + ReactionGridItem { + key: "😕", + column: 0, + row: 1, + }, + ReactionGridItem { + key: "❤️", + column: 1, + row: 1, + }, + ReactionGridItem { + key: "🚀", + column: 2, + row: 1, + }, +]; + +mod imp { + + use super::*; + use glib::subclass::InitializingObject; + use std::{cell::RefCell, collections::HashMap}; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/org/gnome/FractalNext/components-reaction-chooser.ui")] + pub struct ReactionChooser { + /// The `ReactionList` associated to this chooser + pub reactions: RefCell>, + pub reactions_handler: RefCell>, + pub reaction_bindings: RefCell>, + #[template_child] + pub reaction_grid: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for ReactionChooser { + const NAME: &'static str = "ComponentsReactionChooser"; + type Type = super::ReactionChooser; + 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 ReactionChooser { + fn constructed(&self, obj: &Self::Type) { + self.parent_constructed(obj); + + let grid = &self.reaction_grid; + for reaction_item in QUICK_REACTIONS { + let button = gtk::ToggleButton::builder() + .label(reaction_item.key) + .action_name("event.toggle-reaction") + .action_target(&reaction_item.key.to_variant()) + .css_classes(vec!["flat".to_string(), "circular".to_string()]) + .build(); + button.connect_clicked(|button| { + button.activate_action("context-menu.close", None); + }); + grid.attach(&button, reaction_item.column, reaction_item.row, 1, 1); + } + } + } + + impl WidgetImpl for ReactionChooser {} + + impl BinImpl for ReactionChooser {} +} + +glib::wrapper! { + /// A widget displaying a `ReactionChooser` for a `ReactionList`. + pub struct ReactionChooser(ObjectSubclass) + @extends gtk::Widget, adw::Bin, @implements gtk::Accessible; +} + +impl ReactionChooser { + pub fn new() -> Self { + glib::Object::new(&[]).expect("Failed to create ReactionChooser") + } + + pub fn reactions(&self) -> Option { + let priv_ = imp::ReactionChooser::from_instance(self); + priv_.reactions.borrow().clone() + } + + pub fn set_reactions(&self, reactions: Option) { + let priv_ = imp::ReactionChooser::from_instance(self); + let prev_reactions = self.reactions(); + + if prev_reactions == reactions { + return; + } + + if let Some(reactions) = prev_reactions.as_ref() { + if let Some(signal_handler) = priv_.reactions_handler.take() { + reactions.disconnect(signal_handler); + } + for (_, binding) in priv_.reaction_bindings.borrow_mut().drain() { + binding.unbind(); + } + } + + if let Some(reactions) = reactions.as_ref() { + let signal_handler = + reactions.connect_items_changed(clone!(@weak self as obj => move |_, _, _, _| { + obj.update_reactions(); + })); + priv_.reactions_handler.replace(Some(signal_handler)); + } + priv_.reactions.replace(reactions); + self.update_reactions(); + } + + fn update_reactions(&self) { + let priv_ = imp::ReactionChooser::from_instance(self); + let mut reaction_bindings = priv_.reaction_bindings.borrow_mut(); + let reactions = self.reactions(); + + for reaction_item in QUICK_REACTIONS { + if let Some(reaction) = reactions + .as_ref() + .and_then(|reactions| reactions.reaction_group_by_key(reaction_item.key)) + { + if reaction_bindings.get(reaction_item.key).is_none() { + let button = priv_ + .reaction_grid + .child_at(reaction_item.column, reaction_item.row) + .unwrap(); + let binding = reaction + .bind_property("has-user", &button, "active") + .flags(glib::BindingFlags::SYNC_CREATE) + .build() + .unwrap(); + reaction_bindings.insert(reaction_item.key.to_string(), binding); + } + } else if let Some(binding) = reaction_bindings.remove(reaction_item.key) { + binding.unbind(); + } + } + } +} + +impl Default for ReactionChooser { + fn default() -> Self { + Self::new() + } +} diff --git a/src/session/content/room_history/item_row.rs b/src/session/content/room_history/item_row.rs index 1b0c26d5..f32d806a 100644 --- a/src/session/content/room_history/item_row.rs +++ b/src/session/content/room_history/item_row.rs @@ -3,9 +3,9 @@ use gettextrs::gettext; use gtk::{gio, glib, glib::clone, subclass::prelude::*}; use matrix_sdk::ruma::events::AnySyncRoomEvent; -use crate::components::{ContextMenuBin, ContextMenuBinExt, ContextMenuBinImpl}; +use crate::components::{ContextMenuBin, ContextMenuBinExt, ContextMenuBinImpl, ReactionChooser}; use crate::session::content::room_history::{message_row::MessageRow, DividerRow, StateRow}; -use crate::session::room::{Event, EventActions, Item, ItemType}; +use crate::session::room::{Event, EventActions, Item, ItemType, ReactionList}; mod imp { use super::*; @@ -17,6 +17,8 @@ mod imp { pub item: RefCell>, pub menu_model: RefCell>, pub event_notify_handler: RefCell>, + pub reaction_chooser: RefCell>, + pub emoji_chooser: RefCell>, } #[glib::object_subclass] @@ -65,7 +67,7 @@ mod imp { } } - fn dispose(&self, _obj: &Self::Type) { + fn dispose(&self, obj: &Self::Type) { if let Some(ItemType::Event(event)) = self.item.borrow().as_ref().map(|item| item.type_()) { @@ -73,6 +75,8 @@ mod imp { event.disconnect(handler); } } + + obj.remove_reaction_chooser(); } } @@ -116,10 +120,22 @@ impl ItemRow { if let Some(ref item) = item { match item.type_() { ItemType::Event(event) => { - if self.context_menu().is_none() { + let action_group = self.set_event_actions(Some(event)); + + if event.message_content().is_some() { self.set_context_menu(Some(Self::event_message_menu_model())); + self.set_reaction_chooser(event.reactions()); + + // Open emoji chooser + let more_reactions = gio::SimpleAction::new("more-reactions", None); + more_reactions.connect_activate(clone!(@weak self as obj => move |_, _| { + obj.show_emoji_chooser(); + })); + action_group.unwrap().add_action(&more_reactions); + } else { + self.set_context_menu(Some(Self::event_state_menu_model())); + self.remove_reaction_chooser(); } - self.set_event_actions(Some(event)); let event_notify_handler = event.connect_notify_local( Some("event"), @@ -139,6 +155,7 @@ impl ItemRow { if self.context_menu().is_some() { self.set_context_menu(None); self.set_event_actions(None); + self.remove_reaction_chooser(); } let fmt = if date.year() == glib::DateTime::new_now_local().unwrap().year() { @@ -161,6 +178,7 @@ impl ItemRow { if self.context_menu().is_some() { self.set_context_menu(None); self.set_event_actions(None); + self.remove_reaction_chooser(); } let label = gettext("New Messages"); @@ -216,6 +234,60 @@ impl ItemRow { } } } + + /// Set the reaction chooser for the given `reactions`. + /// + /// If it doesn't exist, it is created + fn set_reaction_chooser(&self, reactions: &ReactionList) { + let priv_ = imp::ItemRow::from_instance(self); + + if priv_.reaction_chooser.borrow().is_none() { + let reaction_chooser = ReactionChooser::new(); + self.popover() + .add_child(&reaction_chooser, "reaction-chooser"); + priv_.reaction_chooser.replace(Some(reaction_chooser)); + } + + priv_ + .reaction_chooser + .borrow() + .as_ref() + .unwrap() + .set_reactions(Some(reactions.to_owned())); + } + + /// Remove the reaction chooser and the emoji chooser, if they exist. + fn remove_reaction_chooser(&self) { + let priv_ = imp::ItemRow::from_instance(self); + + if let Some(reaction_chooser) = priv_.reaction_chooser.take() { + reaction_chooser.unparent(); + } + + if let Some(emoji_chooser) = priv_.emoji_chooser.take() { + emoji_chooser.unparent(); + } + } + + fn show_emoji_chooser(&self) { + let priv_ = imp::ItemRow::from_instance(self); + + if priv_.emoji_chooser.borrow().is_none() { + let emoji_chooser = gtk::EmojiChooser::builder().has_arrow(false).build(); + emoji_chooser.connect_emoji_picked(|emoji_chooser, emoji| { + emoji_chooser.activate_action("event.toggle-reaction", Some(&emoji.to_variant())); + }); + emoji_chooser.set_parent(self); + priv_.emoji_chooser.replace(Some(emoji_chooser)); + } + + let emoji_chooser = priv_.emoji_chooser.borrow().clone().unwrap(); + if let Some(rectangle) = self.popover().pointing_to() { + emoji_chooser.set_pointing_to(&rectangle); + } + self.popover().popdown(); + emoji_chooser.popup(); + } } impl Default for ItemRow { diff --git a/src/session/content/room_history/message_row/text.rs b/src/session/content/room_history/message_row/text.rs index c23a5a22..19dbcd2d 100644 --- a/src/session/content/room_history/message_row/text.rs +++ b/src/session/content/room_history/message_row/text.rs @@ -10,11 +10,7 @@ use once_cell::sync::Lazy; use regex::Regex; use sourceview::prelude::*; -use crate::session::{ - content::room_history::ItemRow, - room::{EventActions, Member}, - UserExt, -}; +use crate::session::{room::Member, UserExt}; static EMOJI_REGEX: Lazy = Lazy::new(|| { Regex::new( @@ -259,8 +255,9 @@ fn set_label_styles(w: >k::Label) { w.set_xalign(0.0); w.set_valign(gtk::Align::Start); w.set_halign(gtk::Align::Fill); - w.set_selectable(true); - w.set_extra_menu(Some(ItemRow::event_message_menu_model())); + // FIXME: We have to be able to allow text selection and override popover menu. + // See https://gitlab.gnome.org/GNOME/gtk/-/issues/4606 + // w.set_selectable(true); } fn create_widget_for_html_block(block: &HtmlBlock) -> gtk::Widget { diff --git a/src/session/room/event_actions.rs b/src/session/room/event_actions.rs index 2fc9a625..e9421488 100644 --- a/src/session/room/event_actions.rs +++ b/src/session/room/event_actions.rs @@ -48,15 +48,27 @@ where &MODEL.0 } + /// The default `MenuModel` for common state event actions. + fn event_state_menu_model() -> &'static gio::MenuModel { + static MODEL: Lazy = Lazy::new(|| { + MenuModelSendSync( + gtk::Builder::from_resource("/org/gnome/FractalNext/event-menu.ui") + .object::("state_menu_model") + .unwrap(), + ) + }); + &MODEL.0 + } + /// Set the actions available on `self` for `event`. /// /// Unsets the actions if `event` is `None`. /// - /// Should be used with the compatible model from `event_menu_model`. - fn set_event_actions(&self, event: Option<&Event>) { + /// Should be paired with the `EventActions` menu models. + fn set_event_actions(&self, event: Option<&Event>) -> Option { if event.is_none() { self.insert_action_group("event", gio::NONE_ACTION_GROUP); - return; + return None; } let event = event.unwrap(); @@ -79,15 +91,15 @@ where let key: String = variant.unwrap().get().unwrap(); let room = event.room(); - let reaction_group = event.reactions().reaction_group_by_key(&key); + let reaction_group = event.reactions().reaction_group_by_key(&key); - if let Some(reaction) = reaction_group.and_then(|group| group.user_reaction()) { - // The user already sent that reaction, redact it. - room.redact(reaction.matrix_event_id(), None); - } else { - // The user didn't send that redaction, send it. - room.send_reaction(key, event.matrix_event_id()); - } + if let Some(reaction) = reaction_group.and_then(|group| group.user_reaction()) { + // The user already sent that reaction, redact it. + room.redact(reaction.matrix_event_id(), None); + } else { + // The user didn't send that redaction, send it. + room.send_reaction(key, event.matrix_event_id()); + } })); action_group.add_action(&toggle_reaction); @@ -113,6 +125,7 @@ where } self.insert_action_group("event", Some(&action_group)); + Some(action_group) } /// Save the file in `event`. diff --git a/src/session/room/reaction_list.rs b/src/session/room/reaction_list.rs index cef6806b..bf7532d2 100644 --- a/src/session/room/reaction_list.rs +++ b/src/session/room/reaction_list.rs @@ -119,9 +119,8 @@ impl ReactionList { /// Remove a reaction group by its key. pub fn remove_reaction_group(&self, key: &str) { let priv_ = imp::ReactionList::from_instance(self); - if let Some((pos, _, _)) = priv_.reactions.borrow_mut().shift_remove_full(key) { - self.items_changed(pos as u32, 1, 0); - } + let (pos, ..) = priv_.reactions.borrow_mut().shift_remove_full(key).unwrap(); + self.items_changed(pos as u32, 1, 0); } }