fractal/session/view/content/room_history/
item_row_context_menu.rs

1use adw::{prelude::*, subclass::prelude::*};
2use gettextrs::gettext;
3use gtk::{
4    gio, glib,
5    glib::{clone, closure_local},
6    CompositeTemplate,
7};
8
9use crate::{session::model::ReactionList, utils::BoundObject};
10
11/// Helper struct for the context menu of an `ItemRow`.
12#[derive(Debug)]
13pub(super) struct ItemRowContextMenu {
14    /// The popover of the context menu.
15    pub(super) popover: gtk::PopoverMenu,
16    /// The menu model of the popover.
17    menu_model: gio::Menu,
18    /// The quick reaction chooser in the context menu.
19    quick_reaction_chooser: QuickReactionChooser,
20}
21
22impl ItemRowContextMenu {
23    /// The identifier in the context menu for the quick reaction chooser.
24    const QUICK_REACTION_CHOOSER_ID: &str = "quick-reaction-chooser";
25
26    /// Whether the menu includes an item for the quick reaction chooser.
27    fn has_quick_reaction_chooser(&self) -> bool {
28        let first_section = self
29            .menu_model
30            .item_link(0, gio::MENU_LINK_SECTION)
31            .and_downcast::<gio::Menu>()
32            .expect("item row context menu has at least one section");
33        first_section
34            .item_attribute_value(0, "custom", Some(&String::static_variant_type()))
35            .and_then(|variant| variant.get::<String>())
36            .is_some_and(|value| value == Self::QUICK_REACTION_CHOOSER_ID)
37    }
38
39    /// Add the quick reaction chooser to this menu, if it is not already
40    /// present, and set the reaction list.
41    pub(super) fn add_quick_reaction_chooser(&self, reactions: ReactionList) {
42        if !self.has_quick_reaction_chooser() {
43            let section_menu = gio::Menu::new();
44            let item = gio::MenuItem::new(None, None);
45            item.set_attribute_value(
46                "custom",
47                Some(&Self::QUICK_REACTION_CHOOSER_ID.to_variant()),
48            );
49            section_menu.append_item(&item);
50            self.menu_model.insert_section(0, None, &section_menu);
51
52            self.popover.add_child(
53                &self.quick_reaction_chooser,
54                Self::QUICK_REACTION_CHOOSER_ID,
55            );
56        }
57
58        self.quick_reaction_chooser.set_reactions(Some(reactions));
59    }
60
61    /// Remove the quick reaction chooser from this menu, if it is present.
62    pub(super) fn remove_quick_reaction_chooser(&self) {
63        if !self.has_quick_reaction_chooser() {
64            return;
65        }
66
67        self.popover.remove_child(&self.quick_reaction_chooser);
68        self.menu_model.remove(0);
69    }
70}
71
72impl Default for ItemRowContextMenu {
73    fn default() -> Self {
74        let menu_model = gtk::Builder::from_resource(
75            "/org/gnome/Fractal/ui/session/view/content/room_history/event_context_menu.ui",
76        )
77        .object::<gio::Menu>("event-menu")
78        .expect("resource and menu exist");
79
80        let popover = gtk::PopoverMenu::builder()
81            .has_arrow(false)
82            .halign(gtk::Align::Start)
83            .menu_model(&menu_model)
84            .build();
85        popover.update_property(&[gtk::accessible::Property::Label(&gettext("Context Menu"))]);
86
87        Self {
88            popover,
89            menu_model,
90            quick_reaction_chooser: Default::default(),
91        }
92    }
93}
94
95/// A quick reaction.
96#[derive(Debug, Clone, Copy)]
97struct QuickReaction {
98    /// The emoji that is presented.
99    key: &'static str,
100    /// The number of the column where this reaction is presented.
101    ///
102    /// There are 4 columns in total.
103    column: i32,
104    /// The number of the row where this reaction is presented.
105    ///
106    /// There are 2 rows in total.
107    row: i32,
108}
109
110/// The quick reactions to present.
111static QUICK_REACTIONS: &[QuickReaction] = &[
112    QuickReaction {
113        key: "👍️",
114        column: 0,
115        row: 0,
116    },
117    QuickReaction {
118        key: "👎️",
119        column: 1,
120        row: 0,
121    },
122    QuickReaction {
123        key: "😄",
124        column: 2,
125        row: 0,
126    },
127    QuickReaction {
128        key: "🎉",
129        column: 3,
130        row: 0,
131    },
132    QuickReaction {
133        key: "😕",
134        column: 0,
135        row: 1,
136    },
137    QuickReaction {
138        key: "❤️",
139        column: 1,
140        row: 1,
141    },
142    QuickReaction {
143        key: "🚀",
144        column: 2,
145        row: 1,
146    },
147];
148
149mod imp {
150
151    use std::{cell::RefCell, collections::HashMap, sync::LazyLock};
152
153    use glib::subclass::{InitializingObject, Signal};
154
155    use super::*;
156
157    #[derive(Debug, Default, CompositeTemplate, glib::Properties)]
158    #[template(
159        resource = "/org/gnome/Fractal/ui/session/view/content/room_history/quick_reaction_chooser.ui"
160    )]
161    #[properties(wrapper_type = super::QuickReactionChooser)]
162    pub struct QuickReactionChooser {
163        #[template_child]
164        reaction_grid: TemplateChild<gtk::Grid>,
165        /// The list of reactions of the event for which this chooser is
166        /// presented.
167        #[property(get, set = Self::set_reactions, explicit_notify, nullable)]
168        reactions: BoundObject<ReactionList>,
169        reaction_bindings: RefCell<HashMap<String, glib::Binding>>,
170    }
171
172    #[glib::object_subclass]
173    impl ObjectSubclass for QuickReactionChooser {
174        const NAME: &'static str = "QuickReactionChooser";
175        type Type = super::QuickReactionChooser;
176        type ParentType = adw::Bin;
177
178        fn class_init(klass: &mut Self::Class) {
179            Self::bind_template(klass);
180            Self::bind_template_callbacks(klass);
181        }
182
183        fn instance_init(obj: &InitializingObject<Self>) {
184            obj.init_template();
185        }
186    }
187
188    #[glib::derived_properties]
189    impl ObjectImpl for QuickReactionChooser {
190        fn signals() -> &'static [Signal] {
191            static SIGNALS: LazyLock<Vec<Signal>> =
192                LazyLock::new(|| vec![Signal::builder("more-reactions-activated").build()]);
193            SIGNALS.as_ref()
194        }
195
196        fn constructed(&self) {
197            self.parent_constructed();
198
199            // Construct the quick reactions.
200            let grid = &self.reaction_grid;
201            for reaction in QUICK_REACTIONS {
202                let button = gtk::ToggleButton::builder()
203                    .label(reaction.key)
204                    .action_name("event.toggle-reaction")
205                    .action_target(&reaction.key.to_variant())
206                    .css_classes(["flat", "circular"])
207                    .build();
208                button.connect_clicked(|button| {
209                    button.activate_action("context-menu.close", None).unwrap();
210                });
211                grid.attach(&button, reaction.column, reaction.row, 1, 1);
212            }
213        }
214    }
215
216    impl WidgetImpl for QuickReactionChooser {}
217    impl BinImpl for QuickReactionChooser {}
218
219    #[gtk::template_callbacks]
220    impl QuickReactionChooser {
221        /// Set the list of reactions of the event for which this chooser is
222        /// presented.
223        fn set_reactions(&self, reactions: Option<ReactionList>) {
224            let prev_reactions = self.reactions.obj();
225
226            if prev_reactions == reactions {
227                return;
228            }
229
230            self.reactions.disconnect_signals();
231            for (_, binding) in self.reaction_bindings.borrow_mut().drain() {
232                binding.unbind();
233            }
234
235            // Reset the state of the buttons.
236            for row in 0..=1 {
237                for column in 0..=3 {
238                    if let Some(button) = self
239                        .reaction_grid
240                        .child_at(column, row)
241                        .and_downcast::<gtk::ToggleButton>()
242                    {
243                        button.set_active(false);
244                    }
245                }
246            }
247
248            if let Some(reactions) = reactions {
249                let signal_handler = reactions.connect_items_changed(clone!(
250                    #[weak(rename_to = imp)]
251                    self,
252                    move |_, _, _, _| {
253                        imp.update_reactions();
254                    }
255                ));
256                self.reactions.set(reactions, vec![signal_handler]);
257            }
258
259            self.update_reactions();
260        }
261
262        /// Update the state of the quick reactions.
263        fn update_reactions(&self) {
264            let mut reaction_bindings = self.reaction_bindings.borrow_mut();
265            let reactions = self.reactions.obj();
266
267            for reaction_item in QUICK_REACTIONS {
268                if let Some(reaction) = reactions
269                    .as_ref()
270                    .and_then(|reactions| reactions.reaction_group_by_key(reaction_item.key))
271                {
272                    if reaction_bindings.get(reaction_item.key).is_none() {
273                        let button = self
274                            .reaction_grid
275                            .child_at(reaction_item.column, reaction_item.row)
276                            .unwrap();
277                        let binding = reaction
278                            .bind_property("has-own-user", &button, "active")
279                            .sync_create()
280                            .build();
281                        reaction_bindings.insert(reaction_item.key.to_string(), binding);
282                    }
283                } else if let Some(binding) = reaction_bindings.remove(reaction_item.key) {
284                    if let Some(button) = self
285                        .reaction_grid
286                        .child_at(reaction_item.column, reaction_item.row)
287                        .and_downcast::<gtk::ToggleButton>()
288                    {
289                        button.set_active(false);
290                    }
291
292                    binding.unbind();
293                }
294            }
295        }
296
297        /// Handle when the "More reactions" button is activated.
298        #[template_callback]
299        fn more_reactions_activated(&self) {
300            self.obj()
301                .emit_by_name::<()>("more-reactions-activated", &[]);
302        }
303    }
304}
305
306glib::wrapper! {
307    /// A widget displaying quick reactions and taking its state from a [`ReactionList`].
308    pub struct QuickReactionChooser(ObjectSubclass<imp::QuickReactionChooser>)
309        @extends gtk::Widget, adw::Bin, @implements gtk::Accessible;
310}
311
312impl QuickReactionChooser {
313    pub fn new() -> Self {
314        glib::Object::new()
315    }
316
317    /// Connect to the signal emitted when the "More reactions" button is
318    /// activated.
319    pub fn connect_more_reactions_activated<F: Fn(&Self) + 'static>(
320        &self,
321        f: F,
322    ) -> glib::SignalHandlerId {
323        self.connect_closure(
324            "more-reactions-activated",
325            true,
326            closure_local!(move |obj: Self| {
327                f(&obj);
328            }),
329        )
330    }
331}
332
333impl Default for QuickReactionChooser {
334    fn default() -> Self {
335        Self::new()
336    }
337}