[fractal/fractal-next] content: Add reaction chooser to context menu
- From: Julian Sparber <jsparber src gnome org>
- To: commits-list gnome org
- Cc:
- Subject: [fractal/fractal-next] content: Add reaction chooser to context menu
- Date: Wed, 19 Jan 2022 17:48:42 +0000 (UTC)
commit 0a47fb71a6239a37b3ec5ba5910a30f448f381af
Author: KΓ©vin Commaille <zecakeh tedomum fr>
Date: Wed Jan 19 18:06:30 2022 +0100
content: Add reaction chooser to context menu
data/resources/resources.gresource.xml | 1 +
data/resources/style.css | 16 +-
data/resources/ui/components-reaction-chooser.ui | 27 +++
data/resources/ui/event-menu.ui | 46 ++++++
src/components/context_menu_bin.rs | 18 ++
src/components/mod.rs | 2 +
src/components/reaction_chooser.rs | 184 +++++++++++++++++++++
src/session/content/room_history/item_row.rs | 82 ++++++++-
.../content/room_history/message_row/text.rs | 11 +-
src/session/room/event_actions.rs | 35 ++--
src/session/room/reaction_list.rs | 5 +-
11 files changed, 400 insertions(+), 27 deletions(-)
---
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 @@
<file compressed="true" preprocess="xml-stripblanks"
alias="identity-verification-widget.ui">ui/identity-verification-widget.ui</file>
<file compressed="true" preprocess="xml-stripblanks"
alias="qr-code-scanner.ui">ui/qr-code-scanner.ui</file>
<file compressed="true" preprocess="xml-stripblanks"
alias="components-video-player.ui">ui/components-video-player.ui</file>
+ <file compressed="true" preprocess="xml-stripblanks"
alias="components-reaction-chooser.ui">ui/components-reaction-chooser.ui</file>
<file compressed="true">style.css</file>
<file compressed="true">style-dark.css</file>
<file preprocess="xml-stripblanks">icons/scalable/actions/send-symbolic.svg</file>
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 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<interface>
+ <template class="ComponentsReactionChooser" parent="AdwBin">
+ <property name="child">
+ <object class="GtkGrid" id="reaction_grid">
+ <property name="row-spacing">4</property>
+ <property name="column-spacing">4</property>
+ <style>
+ <class name="reaction-chooser"/>
+ </style>
+ <child>
+ <object class="GtkButton">
+ <style>
+ <class name="circular"/>
+ </style>
+ <property name="action_name">event.more-reactions</property>
+ <property name="icon_name">view-more-horizontal-symbolic</property>
+ <layout>
+ <property name="column">3</property>
+ <property name="row">1</property>
+ </layout>
+ </object>
+ </child>
+ </object>
+ </property>
+ </template>
+</interface>
\ 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 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<menu id="message_menu_model">
+ <section>
+ <item>
+ <attribute name="custom">reaction-chooser</attribute>
+ </item>
+ </section>
<section>
<item>
<attribute name="label" translatable="yes">_Reply</attribute>
@@ -75,4 +80,45 @@
</item>
</section>
</menu>
+ <menu id="state_menu_model">
+ <section>
+ <item>
+ <attribute name="label" translatable="yes">_Forward</attribute>
+ <attribute name="action">event.forward</attribute>
+ <attribute name="hidden-when">action-missing</attribute>
+ </item>
+ </section>
+ <section>
+ <item>
+ <attribute name="label" translatable="yes">_Select</attribute>
+ <attribute name="action">event.select</attribute>
+ <attribute name="hidden-when">action-missing</attribute>
+ </item>
+ </section>
+ <section>
+ <item>
+ <attribute name="label" translatable="yes">_Copy Text</attribute>
+ <attribute name="action">event.copy-text</attribute>
+ <attribute name="hidden-when">action-disabled</attribute>
+ <attribute name="hidden-when">action-missing</attribute>
+ </item>
+ <item>
+ <attribute name="label" translatable="yes">_Permalink</attribute>
+ <attribute name="action">event.permalink</attribute>
+ <attribute name="hidden-when">action-missing</attribute>
+ </item>
+ <item>
+ <attribute name="label" translatable="yes">_View Source</attribute>
+ <attribute name="action">event.view-source</attribute>
+ <attribute name="hidden-when">action-missing</attribute>
+ </item>
+ </section>
+ <section>
+ <item>
+ <attribute name="label" translatable="yes">Re_move</attribute>
+ <attribute name="action">event.remove</attribute>
+ <attribute name="hidden-when">action-missing</attribute>
+ </item>
+ </section>
+ </menu>
</interface>
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<Self>) {
@@ -171,11 +176,19 @@ pub trait ContextMenuBinExt: 'static {
/// Get the `MenuModel` used in the context menu.
fn context_menu(&self) -> Option<gio::MenuModel>;
+
+ /// Get the `PopoverMenu` used in the context menu.
+ fn popover(&self) -> >k::PopoverMenu;
}
impl<O: IsA<ContextMenuBin>> 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<O: IsA<ContextMenuBin>> 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<Option<ReactionList>>,
+ pub reactions_handler: RefCell<Option<glib::SignalHandlerId>>,
+ pub reaction_bindings: RefCell<HashMap<String, glib::Binding>>,
+ #[template_child]
+ pub reaction_grid: TemplateChild<gtk::Grid>,
+ }
+
+ #[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<Self>) {
+ 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<imp::ReactionChooser>)
+ @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<ReactionList> {
+ let priv_ = imp::ReactionChooser::from_instance(self);
+ priv_.reactions.borrow().clone()
+ }
+
+ pub fn set_reactions(&self, reactions: Option<ReactionList>) {
+ 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<Option<Item>>,
pub menu_model: RefCell<Option<gio::MenuModel>>,
pub event_notify_handler: RefCell<Option<SignalHandlerId>>,
+ pub reaction_chooser: RefCell<Option<ReactionChooser>>,
+ pub emoji_chooser: RefCell<Option<gtk::EmojiChooser>>,
}
#[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<Regex> = 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<MenuModelSendSync> = Lazy::new(|| {
+ MenuModelSendSync(
+ gtk::Builder::from_resource("/org/gnome/FractalNext/event-menu.ui")
+ .object::<gio::MenuModel>("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<gio::SimpleActionGroup> {
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);
}
}
[
Date Prev][
Date Next] [
Thread Prev][
Thread Next]
[
Thread Index]
[
Date Index]
[
Author Index]