Browse Source

content: Add reaction chooser to context menu

merge-requests/1327/merge
Kévin Commaille 4 years ago
parent
commit
0a47fb71a6
No known key found for this signature in database
GPG Key ID: DD507DAE96E8245C
  1. 1
      data/resources/resources.gresource.xml
  2. 16
      data/resources/style.css
  3. 27
      data/resources/ui/components-reaction-chooser.ui
  4. 46
      data/resources/ui/event-menu.ui
  5. 18
      src/components/context_menu_bin.rs
  6. 2
      src/components/mod.rs
  7. 184
      src/components/reaction_chooser.rs
  8. 82
      src/session/content/room_history/item_row.rs
  9. 11
      src/session/content/room_history/message_row/text.rs
  10. 35
      src/session/room/event_actions.rs
  11. 5
      src/session/room/reaction_list.rs

1
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>

16
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;

27
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>

46
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>

18
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) -> &gtk::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) -> &gtk::PopoverMenu {
let priv_ = imp::ContextMenuBin::from_instance(self.upcast_ref());
&priv_.popover
}
}
pub trait ContextMenuBinImpl: BinImpl {}

2
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;

184
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()
}
}

82
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 {

11
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: &gtk::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 {

35
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`.

5
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);
}
}

Loading…
Cancel
Save