[fractal/fractal-next] content: Implement reactions



commit 2582b0d9b11cca0f156b32854e67e64fe1d6cf5c
Author: Kévin Commaille <zecakeh tedomum fr>
Date:   Wed Jan 19 17:47:36 2022 +0100

    content: Implement reactions
    
    Closes #530

 data/resources/resources.gresource.xml             |   2 +
 data/resources/style-dark.css                      |   8 +
 data/resources/style.css                           |  23 ++-
 data/resources/ui/content-message-reaction-list.ui |  10 ++
 data/resources/ui/content-message-reaction.ui      |  32 ++++
 data/resources/ui/content-message-row.ui           |   4 +-
 .../content/room_history/message_row/mod.rs        |  11 +-
 .../content/room_history/message_row/reaction.rs   | 111 +++++++++++++
 .../room_history/message_row/reaction_list.rs      |  66 ++++++++
 src/session/room/event.rs                          |  26 +++-
 src/session/room/event_actions.rs                  |  19 +++
 src/session/room/mod.rs                            |  90 ++++++++++-
 src/session/room/reaction_group.rs                 | 172 +++++++++++++++++++++
 src/session/room/reaction_list.rs                  | 132 ++++++++++++++++
 src/session/room/timeline.rs                       |  83 ++++++----
 src/utils.rs                                       |  17 +-
 16 files changed, 761 insertions(+), 45 deletions(-)
---
diff --git a/data/resources/resources.gresource.xml b/data/resources/resources.gresource.xml
index f754a62c..6911d699 100644
--- a/data/resources/resources.gresource.xml
+++ b/data/resources/resources.gresource.xml
@@ -18,6 +18,8 @@
     <file compressed="true" preprocess="xml-stripblanks" 
alias="content-invitee-item.ui">ui/content-invitee-item.ui</file>
     <file compressed="true" preprocess="xml-stripblanks" 
alias="content-invitee-row.ui">ui/content-invitee-row.ui</file>
     <file compressed="true" preprocess="xml-stripblanks" 
alias="content-message-media.ui">ui/content-message-media.ui</file>
+    <file compressed="true" preprocess="xml-stripblanks" 
alias="content-message-reaction-list.ui">ui/content-message-reaction-list.ui</file>
+    <file compressed="true" preprocess="xml-stripblanks" 
alias="content-message-reaction.ui">ui/content-message-reaction.ui</file>
     <file compressed="true" preprocess="xml-stripblanks" 
alias="content-message-reply.ui">ui/content-message-reply.ui</file>
     <file compressed="true" preprocess="xml-stripblanks" 
alias="content-message-row.ui">ui/content-message-row.ui</file>
     <file compressed="true" preprocess="xml-stripblanks" 
alias="content-divider-row.ui">ui/content-divider-row.ui</file>
diff --git a/data/resources/style-dark.css b/data/resources/style-dark.css
index 0b141bbe..21ee2a1b 100644
--- a/data/resources/style-dark.css
+++ b/data/resources/style-dark.css
@@ -10,3 +10,11 @@
 button.cutout {
   color: alpha(black, 0.8);
 }
+
+message-reactions .toggle {
+  border-color: @dark_2;
+}
+
+message-reactions .toggle:checked {
+  border-color: @blue_5;
+}
\ No newline at end of file
diff --git a/data/resources/style.css b/data/resources/style.css
index 812c53e3..18f62257 100644
--- a/data/resources/style.css
+++ b/data/resources/style.css
@@ -273,7 +273,8 @@ headerbar.flat {
   margin-right: 46px;
 }
 
-.room-history row:not(.has-header) .event-content {
+.room-history row:not(.has-header) .event-content,
+.room-history row:not(.has-header) message-reactions {
   margin-left: 46px;
 }
 
@@ -303,6 +304,26 @@ headerbar.flat {
   opacity: 0.7;
 }
 
+message-reactions .toggle {
+  padding: 1px 4px 0 5px;
+  background-color: @view_bg_color;
+  border: 1px solid @light_4;
+}
+
+message-reactions .toggle:checked {
+  background-color: alpha(@blue_1, 0.4);
+  border-color: @blue_2;
+}
+
+message-reactions .reaction-key {
+  font-size: 1.1em;
+}
+
+message-reactions .reaction-count {
+  font-size: 0.8em;
+  padding-left: 5px;
+}
+
 .divider-row {
   font-size: 0.9em;
   font-weight: bold;
diff --git a/data/resources/ui/content-message-reaction-list.ui 
b/data/resources/ui/content-message-reaction-list.ui
new file mode 100644
index 00000000..9aa22a40
--- /dev/null
+++ b/data/resources/ui/content-message-reaction-list.ui
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<interface>
+  <template class="ContentMessageReactionList" parent="AdwBin">
+    <child>
+      <object class="GtkFlowBox" id="flow_box">
+        <property name="max-children-per-line">100</property>
+      </object>
+    </child>
+  </template>
+</interface>
diff --git a/data/resources/ui/content-message-reaction.ui b/data/resources/ui/content-message-reaction.ui
new file mode 100644
index 00000000..ed36d551
--- /dev/null
+++ b/data/resources/ui/content-message-reaction.ui
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<interface>
+  <template class="ContentMessageReaction" parent="GtkFlowBoxChild">
+    <property name="focusable">false</property>
+    <child>
+      <object class="GtkToggleButton" id="button">
+        <style>
+          <class name="pill"/>
+        </style>
+        <property name="action-name">event.toggle-reaction</property>
+        <child>
+          <object class="GtkBox">
+            <child>
+              <object class="GtkLabel" id="reaction_key">
+                <style>
+                  <class name="reaction-key"/>
+                </style>
+              </object>
+            </child>
+            <child>
+              <object class="GtkLabel" id="reaction_count">
+                <style>
+                  <class name="reaction-count"/>
+                </style>
+              </object>
+            </child>
+          </object>
+        </child>
+      </object>
+    </child>
+  </template>
+</interface>
diff --git a/data/resources/ui/content-message-row.ui b/data/resources/ui/content-message-row.ui
index 2a639673..63e6c2ce 100644
--- a/data/resources/ui/content-message-row.ui
+++ b/data/resources/ui/content-message-row.ui
@@ -49,10 +49,12 @@
                 </style>
               </object>
             </child>
+            <child>
+              <object class="ContentMessageReactionList" id="reactions"/>
+            </child>
           </object>
         </child>
       </object>
     </child>
   </template>
 </interface>
-
diff --git a/src/session/content/room_history/message_row/mod.rs 
b/src/session/content/room_history/message_row/mod.rs
index 8aa07981..7bf1f212 100644
--- a/src/session/content/room_history/message_row/mod.rs
+++ b/src/session/content/room_history/message_row/mod.rs
@@ -1,5 +1,7 @@
 mod file;
 mod media;
+mod reaction;
+mod reaction_list;
 mod reply;
 mod text;
 
@@ -15,7 +17,10 @@ use matrix_sdk::ruma::events::{
     AnyMessageEventContent,
 };
 
-use self::{file::MessageFile, media::MessageMedia, reply::MessageReply, text::MessageText};
+use self::{
+    file::MessageFile, media::MessageMedia, reaction_list::MessageReactionList,
+    reply::MessageReply, text::MessageText,
+};
 use crate::prelude::*;
 use crate::session::room::Event;
 
@@ -38,6 +43,8 @@ mod imp {
         pub timestamp: TemplateChild<gtk::Label>,
         #[template_child]
         pub content: TemplateChild<adw::Bin>,
+        #[template_child]
+        pub reactions: TemplateChild<MessageReactionList>,
         pub source_changed_handler: RefCell<Option<SignalHandlerId>>,
         pub bindings: RefCell<Vec<glib::Binding>>,
         pub event: RefCell<Option<Event>>,
@@ -185,6 +192,8 @@ impl MessageRow {
                 }),
             )));
         self.update_content(&event);
+
+        priv_.reactions.set_reaction_list(event.reactions());
         priv_.event.replace(Some(event));
     }
 
diff --git a/src/session/content/room_history/message_row/reaction.rs 
b/src/session/content/room_history/message_row/reaction.rs
new file mode 100644
index 00000000..ef4d9145
--- /dev/null
+++ b/src/session/content/room_history/message_row/reaction.rs
@@ -0,0 +1,111 @@
+use adw::subclass::prelude::*;
+use gtk::{glib, prelude::*, subclass::prelude::*, CompositeTemplate};
+
+use crate::session::room::ReactionGroup;
+
+mod imp {
+    use super::*;
+    use glib::subclass::InitializingObject;
+    use once_cell::{sync::Lazy, unsync::OnceCell};
+
+    #[derive(Debug, Default, CompositeTemplate)]
+    #[template(resource = "/org/gnome/FractalNext/content-message-reaction.ui")]
+    pub struct MessageReaction {
+        /// The reaction group to display.
+        pub group: OnceCell<ReactionGroup>,
+        #[template_child]
+        pub button: TemplateChild<gtk::ToggleButton>,
+        #[template_child]
+        pub reaction_key: TemplateChild<gtk::Label>,
+        #[template_child]
+        pub reaction_count: TemplateChild<gtk::Label>,
+    }
+
+    #[glib::object_subclass]
+    impl ObjectSubclass for MessageReaction {
+        const NAME: &'static str = "ContentMessageReaction";
+        type Type = super::MessageReaction;
+        type ParentType = gtk::FlowBoxChild;
+
+        fn class_init(klass: &mut Self::Class) {
+            Self::bind_template(klass);
+        }
+
+        fn instance_init(obj: &InitializingObject<Self>) {
+            obj.init_template();
+        }
+    }
+
+    impl ObjectImpl for MessageReaction {
+        fn properties() -> &'static [glib::ParamSpec] {
+            static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
+                vec![glib::ParamSpec::new_object(
+                    "group",
+                    "Group",
+                    "The reaction group to display",
+                    ReactionGroup::static_type(),
+                    glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT_ONLY,
+                )]
+            });
+
+            PROPERTIES.as_ref()
+        }
+
+        fn set_property(
+            &self,
+            obj: &Self::Type,
+            _id: usize,
+            value: &glib::Value,
+            pspec: &glib::ParamSpec,
+        ) {
+            match pspec.name() {
+                "group" => {
+                    obj.set_group(value.get().unwrap());
+                }
+                _ => unimplemented!(),
+            }
+        }
+
+        fn property(&self, _obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
+            match pspec.name() {
+                "group" => self.group.get().to_value(),
+                _ => unimplemented!(),
+            }
+        }
+    }
+
+    impl WidgetImpl for MessageReaction {}
+
+    impl FlowBoxChildImpl for MessageReaction {}
+}
+
+glib::wrapper! {
+    /// A widget displaying the reactions of a message.
+    pub struct MessageReaction(ObjectSubclass<imp::MessageReaction>)
+        @extends gtk::Widget, gtk::FlowBoxChild, @implements gtk::Accessible;
+}
+
+impl MessageReaction {
+    pub fn new(reaction_group: ReactionGroup) -> Self {
+        glib::Object::new(&[("group", &reaction_group)]).expect("Failed to create MessageReaction")
+    }
+
+    fn set_group(&self, group: ReactionGroup) {
+        let priv_ = imp::MessageReaction::from_instance(self);
+        let key = group.key();
+        priv_.reaction_key.set_label(key);
+        priv_
+            .button
+            .set_action_target_value(Some(&key.to_variant()));
+        group
+            .bind_property("has-user", &*priv_.button, "active")
+            .flags(glib::BindingFlags::SYNC_CREATE)
+            .build();
+        group
+            .bind_property("count", &*priv_.reaction_count, "label")
+            .flags(glib::BindingFlags::SYNC_CREATE)
+            .build();
+
+        priv_.group.set(group).unwrap();
+    }
+}
diff --git a/src/session/content/room_history/message_row/reaction_list.rs 
b/src/session/content/room_history/message_row/reaction_list.rs
new file mode 100644
index 00000000..1a7dc492
--- /dev/null
+++ b/src/session/content/room_history/message_row/reaction_list.rs
@@ -0,0 +1,66 @@
+use adw::subclass::prelude::*;
+use gtk::{glib, prelude::*, subclass::prelude::*, CompositeTemplate};
+
+use crate::session::room::ReactionList;
+
+use super::reaction::MessageReaction;
+
+mod imp {
+    use super::*;
+    use glib::subclass::InitializingObject;
+
+    #[derive(Debug, Default, CompositeTemplate)]
+    #[template(resource = "/org/gnome/FractalNext/content-message-reaction-list.ui")]
+    pub struct MessageReactionList {
+        #[template_child]
+        pub flow_box: TemplateChild<gtk::FlowBox>,
+    }
+
+    #[glib::object_subclass]
+    impl ObjectSubclass for MessageReactionList {
+        const NAME: &'static str = "ContentMessageReactionList";
+        type Type = super::MessageReactionList;
+        type ParentType = adw::Bin;
+
+        fn class_init(klass: &mut Self::Class) {
+            Self::bind_template(klass);
+            klass.set_css_name("message-reactions");
+        }
+
+        fn instance_init(obj: &InitializingObject<Self>) {
+            obj.init_template();
+        }
+    }
+
+    impl ObjectImpl for MessageReactionList {}
+
+    impl WidgetImpl for MessageReactionList {}
+
+    impl BinImpl for MessageReactionList {}
+}
+
+glib::wrapper! {
+    /// A widget displaying the reactions of a message.
+    pub struct MessageReactionList(ObjectSubclass<imp::MessageReactionList>)
+        @extends gtk::Widget, adw::Bin, @implements gtk::Accessible;
+}
+
+impl MessageReactionList {
+    pub fn new() -> Self {
+        glib::Object::new(&[]).expect("Failed to create MessageReactionList")
+    }
+
+    pub fn set_reaction_list(&self, reaction_list: &ReactionList) {
+        let priv_ = imp::MessageReactionList::from_instance(self);
+
+        priv_.flow_box.bind_model(Some(reaction_list), |obj| {
+            MessageReaction::new(obj.clone().downcast().unwrap()).upcast()
+        });
+    }
+}
+
+impl Default for MessageReactionList {
+    fn default() -> Self {
+        Self::new()
+    }
+}
diff --git a/src/session/room/event.rs b/src/session/room/event.rs
index b428dc05..820c2b6d 100644
--- a/src/session/room/event.rs
+++ b/src/session/room/event.rs
@@ -18,7 +18,10 @@ use matrix_sdk::{
 use std::sync::Arc;
 
 use crate::{
-    session::{room::Member, Room},
+    session::{
+        room::{Member, ReactionList},
+        Room,
+    },
     spawn_tokio,
     utils::{filename_for_mime, media_type_uid},
 };
@@ -42,6 +45,7 @@ mod imp {
         pub pure_event: RefCell<Option<SyncRoomEvent>>,
         /// Events that replace this one, in the order they arrive.
         pub replacing_events: RefCell<Vec<super::Event>>,
+        pub reactions: ReactionList,
         pub source_changed_handler: RefCell<Option<SignalHandlerId>>,
         pub show_header: Cell<bool>,
         pub room: OnceCell<WeakRef<Room>>,
@@ -576,6 +580,26 @@ impl Event {
             .is_some()
     }
 
+    /// Whether this is a reaction.
+    pub fn is_reaction(&self) -> bool {
+        matches!(
+            self.message_content(),
+            Some(AnyMessageEventContent::Reaction(_))
+        )
+    }
+
+    /// The reactions for this event.
+    pub fn reactions(&self) -> &ReactionList {
+        let priv_ = imp::Event::from_instance(self);
+        &priv_.reactions
+    }
+
+    /// Add reactions to this event.
+    pub fn add_reactions(&self, reactions: Vec<Event>) {
+        let priv_ = imp::Event::from_instance(self);
+        priv_.reactions.add_reactions(reactions);
+    }
+
     /// The content of this matrix event.
     pub fn original_content(&self) -> Option<AnyMessageEventContent> {
         match self.matrix_event()? {
diff --git a/src/session/room/event_actions.rs b/src/session/room/event_actions.rs
index bf543b02..2fc9a625 100644
--- a/src/session/room/event_actions.rs
+++ b/src/session/room/event_actions.rs
@@ -72,6 +72,25 @@ where
         action_group.add_action(&view_source);
 
         if let Some(AnyMessageEventContent::RoomMessage(message)) = event.message_content() {
+            // Send/redact a reaction
+            let toggle_reaction =
+                gio::SimpleAction::new("toggle-reaction", Some(&String::static_variant_type()));
+            toggle_reaction.connect_activate(clone!(@weak event => move |_, variant| {
+                let key: String = variant.unwrap().get().unwrap();
+                let room = event.room();
+
+                    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());
+                    }
+            }));
+            action_group.add_action(&toggle_reaction);
+
             if let MessageType::File(_) = message.msgtype {
                 // Save message's file
                 let file_save = gio::SimpleAction::new("file-save", None);
diff --git a/src/session/room/mod.rs b/src/session/room/mod.rs
index 71b6bace..451b2df0 100644
--- a/src/session/room/mod.rs
+++ b/src/session/room/mod.rs
@@ -6,6 +6,8 @@ mod member;
 mod member_list;
 mod member_role;
 mod power_levels;
+mod reaction_group;
+mod reaction_list;
 mod room_type;
 mod timeline;
 
@@ -19,9 +21,12 @@ pub use self::member_role::MemberRole;
 pub use self::power_levels::{
     PowerLevel, PowerLevels, RoomAction, POWER_LEVEL_MAX, POWER_LEVEL_MIN,
 };
+pub use self::reaction_group::ReactionGroup;
+pub use self::reaction_list::ReactionList;
 pub use self::room_type::RoomType;
 pub use self::timeline::Timeline;
 use crate::session::User;
+use crate::utils::pending_event_ids;
 
 use gettextrs::gettext;
 use gtk::{glib, glib::clone, prelude::*, subclass::prelude::*};
@@ -32,9 +37,13 @@ use matrix_sdk::{
     ruma::{
         api::client::r0::sync::sync_events::InvitedRoom,
         events::{
+            reaction::{Relation, SyncReactionEvent},
             room::{
-                member::MembershipState, message::RoomMessageEventContent,
-                name::RoomNameEventContent, topic::RoomTopicEventContent,
+                member::MembershipState,
+                message::RoomMessageEventContent,
+                name::RoomNameEventContent,
+                redaction::{RoomRedactionEventContent, SyncRoomRedactionEvent},
+                topic::RoomTopicEventContent,
             },
             tag::{TagInfo, TagName},
             AnyRoomAccountDataEvent, AnyStateEventContent, AnyStrippedStateEvent,
@@ -915,18 +924,79 @@ impl Room {
         );
     }
 
-    pub fn send_message(&self, content: RoomMessageEventContent) {
+    /// Send the given `event` in this room, with the temporary ID `txn_id`.
+    fn send_room_message_event(&self, event: AnySyncMessageEvent, txn_id: Uuid) {
         let priv_ = imp::Room::from_instance(self);
 
-        let txn_id = Uuid::new_v4();
+        if let MatrixRoom::Joined(matrix_room) = self.matrix_room() {
+            let content = event.content();
+            let json = serde_json::to_string(&AnySyncRoomEvent::Message(event)).unwrap();
+            let raw_event: Raw<AnySyncRoomEvent> =
+                Raw::from_json(RawValue::from_string(json).unwrap());
+            let event = Event::new(raw_event.into(), self);
+            priv_.timeline.get().unwrap().append_pending(txn_id, event);
+
+            let handle = spawn_tokio!(async move { matrix_room.send(content, Some(txn_id)).await });
+
+            spawn!(
+                glib::PRIORITY_DEFAULT_IDLE,
+                clone!(@weak self as obj => async move {
+                    // FIXME: We should retry the request if it fails
+                    match handle.await.unwrap() {
+                            Ok(_) => {},
+                            Err(error) => error!("Couldn’t send room message event: {}", error),
+                    };
+                })
+            );
+        }
+    }
+
+    /// Send a message with the given `content` in this room.
+    pub fn send_message(&self, content: RoomMessageEventContent) {
+        let (txn_id, event_id) = pending_event_ids();
         let event = AnySyncMessageEvent::RoomMessage(SyncMessageEvent {
-            content: content.clone(),
-            event_id: EventId::parse(format!("${}:fractal.gnome.org", txn_id).as_str()).unwrap(),
+            content,
+            event_id,
             sender: self.session().user().unwrap().user_id().as_ref().to_owned(),
             origin_server_ts: MilliSecondsSinceUnixEpoch::now(),
             unsigned: Unsigned::default(),
         });
 
+        self.send_room_message_event(event, txn_id);
+    }
+
+    /// Send a `key` reaction for the `relates_to` event ID in this room.
+    pub fn send_reaction(&self, key: String, relates_to: Box<EventId>) {
+        let (txn_id, event_id) = pending_event_ids();
+        let event = AnySyncMessageEvent::Reaction(SyncReactionEvent {
+            content: Relation::new(relates_to, key).into(),
+            event_id,
+            sender: self.session().user().unwrap().user_id().as_ref().to_owned(),
+            origin_server_ts: MilliSecondsSinceUnixEpoch::now(),
+            unsigned: Unsigned::default(),
+        });
+
+        self.send_room_message_event(event, txn_id);
+    }
+
+    /// Redact `redacted_event_id` in this room because of `reason`.
+    pub fn redact(&self, redacted_event_id: Box<EventId>, reason: Option<String>) {
+        let (txn_id, event_id) = pending_event_ids();
+        let content = if let Some(reason) = reason.as_ref() {
+            RoomRedactionEventContent::with_reason(reason.clone())
+        } else {
+            RoomRedactionEventContent::new()
+        };
+        let event = AnySyncMessageEvent::RoomRedaction(SyncRoomRedactionEvent {
+            content,
+            redacts: redacted_event_id.clone(),
+            event_id,
+            sender: self.session().user().unwrap().user_id().as_ref().to_owned(),
+            origin_server_ts: MilliSecondsSinceUnixEpoch::now(),
+            unsigned: Unsigned::default(),
+        });
+
+        let priv_ = imp::Room::from_instance(self);
         if let MatrixRoom::Joined(matrix_room) = self.matrix_room() {
             let json = serde_json::to_string(&AnySyncRoomEvent::Message(event)).unwrap();
             let raw_event: Raw<AnySyncRoomEvent> =
@@ -934,7 +1004,11 @@ impl Room {
             let event = Event::new(raw_event.into(), self);
             priv_.timeline.get().unwrap().append_pending(txn_id, event);
 
-            let handle = spawn_tokio!(async move { matrix_room.send(content, Some(txn_id)).await });
+            let handle = spawn_tokio!(async move {
+                matrix_room
+                    .redact(&redacted_event_id, reason.as_deref(), Some(txn_id))
+                    .await
+            });
 
             spawn!(
                 glib::PRIORITY_DEFAULT_IDLE,
@@ -942,7 +1016,7 @@ impl Room {
                     // FIXME: We should retry the request if it fails
                     match handle.await.unwrap() {
                             Ok(_) => {},
-                            Err(error) => error!("Couldn’t send message: {}", error),
+                            Err(error) => error!("Couldn’t redadct event: {}", error),
                     };
                 })
             );
diff --git a/src/session/room/reaction_group.rs b/src/session/room/reaction_group.rs
new file mode 100644
index 00000000..2da25cee
--- /dev/null
+++ b/src/session/room/reaction_group.rs
@@ -0,0 +1,172 @@
+use gtk::{glib, glib::clone, prelude::*, subclass::prelude::*};
+
+use crate::session::UserExt;
+
+use super::Event;
+
+mod imp {
+    use super::*;
+    use indexmap::IndexSet;
+    use once_cell::{sync::Lazy, unsync::OnceCell};
+    use std::cell::RefCell;
+
+    #[derive(Debug, Default)]
+    pub struct ReactionGroup {
+        /// The key of the group.
+        pub key: OnceCell<String>,
+        /// The reactions in the group.
+        pub reactions: RefCell<IndexSet<Event>>,
+    }
+
+    #[glib::object_subclass]
+    impl ObjectSubclass for ReactionGroup {
+        const NAME: &'static str = "ReactionGroup";
+        type Type = super::ReactionGroup;
+        type ParentType = glib::Object;
+    }
+
+    impl ObjectImpl for ReactionGroup {
+        fn properties() -> &'static [glib::ParamSpec] {
+            static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
+                vec![
+                    glib::ParamSpec::new_string(
+                        "key",
+                        "Key",
+                        "The key of the group",
+                        None,
+                        glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT_ONLY,
+                    ),
+                    glib::ParamSpec::new_uint(
+                        "count",
+                        "Count",
+                        "The number of reactions in this group",
+                        u32::MIN,
+                        u32::MAX,
+                        0,
+                        glib::ParamFlags::READABLE,
+                    ),
+                    glib::ParamSpec::new_boolean(
+                        "has-user",
+                        "Has User",
+                        "Whether this group has a reaction from this user",
+                        false,
+                        glib::ParamFlags::READABLE,
+                    ),
+                ]
+            });
+
+            PROPERTIES.as_ref()
+        }
+
+        fn set_property(
+            &self,
+            _obj: &Self::Type,
+            _id: usize,
+            value: &glib::Value,
+            pspec: &glib::ParamSpec,
+        ) {
+            match pspec.name() {
+                "key" => {
+                    self.key.set(value.get::<String>().unwrap()).unwrap();
+                }
+                _ => unimplemented!(),
+            }
+        }
+
+        fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
+            match pspec.name() {
+                "key" => obj.key().to_value(),
+                "count" => obj.count().to_value(),
+                "has-user" => obj.has_user().to_value(),
+                _ => unimplemented!(),
+            }
+        }
+    }
+}
+
+glib::wrapper! {
+    /// Reactions groupped by a given key.
+    pub struct ReactionGroup(ObjectSubclass<imp::ReactionGroup>);
+}
+
+impl ReactionGroup {
+    pub fn new(key: &str) -> Self {
+        glib::Object::new(&[("key", &key)]).expect("Failed to create ReactionGroup")
+    }
+
+    pub fn key(&self) -> &str {
+        let priv_ = imp::ReactionGroup::from_instance(self);
+        priv_.key.get().unwrap()
+    }
+
+    pub fn count(&self) -> u32 {
+        let priv_ = imp::ReactionGroup::from_instance(self);
+        priv_
+            .reactions
+            .borrow()
+            .iter()
+            .filter(|event| !event.redacted())
+            .count() as u32
+    }
+
+    /// The reaction in this group sent by this user, if any.
+    pub fn user_reaction(&self) -> Option<Event> {
+        let priv_ = imp::ReactionGroup::from_instance(self);
+        let reactions = priv_.reactions.borrow();
+        if let Some(user) = reactions
+            .first()
+            .and_then(|event| event.room().session().user().cloned())
+        {
+            for reaction in reactions.iter().filter(|event| !event.redacted()) {
+                if reaction.matrix_sender() == user.user_id() {
+                    return Some(reaction.clone());
+                }
+            }
+        }
+        None
+    }
+
+    /// Whether this group has a reaction from this user.
+    pub fn has_user(&self) -> bool {
+        self.user_reaction().is_some()
+    }
+
+    /// Add new reactions to this group.
+    pub fn add_reactions(&self, new_reactions: Vec<Event>) {
+        let prev_has_user = self.has_user();
+        let mut added_reactions = Vec::with_capacity(new_reactions.len());
+
+        {
+            let mut reactions = imp::ReactionGroup::from_instance(self)
+                .reactions
+                .borrow_mut();
+
+            reactions.reserve(new_reactions.len());
+
+            for reaction in new_reactions {
+                if reactions.insert(reaction.clone()) {
+                    added_reactions.push(reaction);
+                }
+            }
+        }
+
+        for reaction in added_reactions.iter() {
+            // Reaction's source should only change when it is redacted.
+            reaction.connect_notify_local(
+                Some("source"),
+                clone!(@weak self as obj => move |_, _| {
+                    obj.notify("count");
+                    obj.notify("has-user");
+                }),
+            );
+        }
+
+        if !added_reactions.is_empty() {
+            self.notify("count");
+        }
+
+        if self.has_user() != prev_has_user {
+            self.notify("has-user");
+        }
+    }
+}
diff --git a/src/session/room/reaction_list.rs b/src/session/room/reaction_list.rs
new file mode 100644
index 00000000..cef6806b
--- /dev/null
+++ b/src/session/room/reaction_list.rs
@@ -0,0 +1,132 @@
+use std::collections::HashMap;
+
+use gtk::{gio, glib, glib::clone, prelude::*, subclass::prelude::*};
+use matrix_sdk::ruma::events::AnyMessageEventContent;
+
+use super::{Event, ReactionGroup};
+
+mod imp {
+    use super::*;
+    use indexmap::IndexMap;
+    use std::cell::RefCell;
+
+    #[derive(Debug, Default)]
+    pub struct ReactionList {
+        /// The list of reactions grouped by key.
+        pub reactions: RefCell<IndexMap<String, ReactionGroup>>,
+    }
+
+    #[glib::object_subclass]
+    impl ObjectSubclass for ReactionList {
+        const NAME: &'static str = "ReactionList";
+        type Type = super::ReactionList;
+        type ParentType = glib::Object;
+        type Interfaces = (gio::ListModel,);
+    }
+
+    impl ObjectImpl for ReactionList {}
+
+    impl ListModelImpl for ReactionList {
+        fn item_type(&self, _list_model: &Self::Type) -> glib::Type {
+            ReactionGroup::static_type()
+        }
+        fn n_items(&self, _list_model: &Self::Type) -> u32 {
+            self.reactions.borrow().len() as u32
+        }
+        fn item(&self, _list_model: &Self::Type, position: u32) -> Option<glib::Object> {
+            let reactions = self.reactions.borrow();
+
+            reactions
+                .get_index(position as usize)
+                .map(|(_key, reaction_group)| reaction_group.clone().upcast::<glib::Object>())
+        }
+    }
+}
+
+glib::wrapper! {
+    /// List of all `ReactionGroup`s for an `Event`. Implements `ListModel`.
+    ///
+    /// `ReactionGroup`s are sorted in "insertion order".
+    pub struct ReactionList(ObjectSubclass<imp::ReactionList>)
+        @implements gio::ListModel;
+}
+
+impl ReactionList {
+    pub fn new() -> Self {
+        glib::Object::new(&[]).expect("Failed to create ReactionList")
+    }
+
+    /// Add reactions with the given reaction `Event`s.
+    ///
+    /// Ignores `Event`s that are not reactions.
+    pub fn add_reactions(&self, new_reactions: Vec<Event>) {
+        let mut reactions = imp::ReactionList::from_instance(self)
+            .reactions
+            .borrow_mut();
+        let prev_len = reactions.len();
+
+        // Group reactions by key
+        let mut grouped_reactions: HashMap<String, Vec<Event>> = HashMap::new();
+        for event in new_reactions {
+            if let Some(AnyMessageEventContent::Reaction(reaction)) = event.message_content() {
+                let relation = reaction.relates_to;
+                grouped_reactions
+                    .entry(relation.emoji)
+                    .or_default()
+                    .push(event);
+            }
+        }
+
+        // Add groups to the list
+        for (key, reactions_list) in grouped_reactions {
+            reactions
+                .entry(key)
+                .or_insert_with_key(|key| {
+                    let group = ReactionGroup::new(key);
+                    group.connect_notify_local(
+                        Some("count"),
+                        clone!(@weak self as obj => move |group, _| {
+                            if group.count() == 0 {
+                                obj.remove_reaction_group(group.key());
+                            }
+                        }),
+                    );
+                    group
+                })
+                .add_reactions(reactions_list);
+        }
+
+        let num_reactions_added = reactions.len().saturating_sub(prev_len);
+
+        // We can't have the borrow active when items_changed is emitted because that will probably
+        // cause reads of the reactions field.
+        std::mem::drop(reactions);
+
+        if num_reactions_added > 0 {
+            // IndexMap preserves insertion order, so all the new items will be at the end.
+            self.items_changed(prev_len as u32, 0, num_reactions_added as u32);
+        }
+    }
+
+    /// Get a reaction group by its key.
+    ///
+    /// Returns `None` if no action group was found with this key.
+    pub fn reaction_group_by_key(&self, key: &str) -> Option<ReactionGroup> {
+        let priv_ = imp::ReactionList::from_instance(self);
+        priv_.reactions.borrow().get(key).cloned()
+    }
+
+    /// 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);
+        }
+    }
+}
+
+impl Default for ReactionList {
+    fn default() -> Self {
+        Self::new()
+    }
+}
diff --git a/src/session/room/timeline.rs b/src/session/room/timeline.rs
index b38543a1..ea21dace 100644
--- a/src/session/room/timeline.rs
+++ b/src/session/room/timeline.rs
@@ -281,20 +281,27 @@ impl Timeline {
                 .filter_map(|item| item.event())
             {
                 if let Some(relates_to) = relates_to_events.remove(&event.matrix_event_id()) {
-                    let replacing_events = relates_to
-                        .into_iter()
-                        .map(|event_id| {
-                            self.event_by_id(&event_id)
-                                .expect("Previously known event has disappeared")
-                        })
-                        .filter(|related_event| related_event.is_replacing_event())
-                        .collect();
+                    let mut replacing_events: Vec<Event> = vec![];
+                    let mut reactions: Vec<Event> = vec![];
+
+                    for relation_event_id in relates_to {
+                        let relation = self
+                            .event_by_id(&relation_event_id)
+                            .expect("Previously known event has disappeared");
+
+                        if relation.is_replacing_event() {
+                            replacing_events.push(relation);
+                        } else if relation.is_reaction() {
+                            reactions.push(relation);
+                        }
+                    }
 
                     if position != 0 || event.replacing_events().is_empty() {
                         event.append_replacing_events(replacing_events);
                     } else {
                         event.prepend_replacing_events(replacing_events);
                     }
+                    event.add_reactions(reactions);
                 }
             }
         }
@@ -314,20 +321,27 @@ impl Timeline {
         let mut new_relations: HashMap<Box<EventId>, Vec<Event>> = HashMap::new();
         for event in events {
             if let Some(relates_to) = relates_to_events.remove(&event.matrix_event_id()) {
-                let replacing_events = relates_to
-                    .into_iter()
-                    .map(|event_id| {
-                        self.event_by_id(&event_id)
-                            .expect("Previously known event has disappeared")
-                    })
-                    .filter(|related_event| related_event.is_replacing_event())
-                    .collect();
+                let mut replacing_events: Vec<Event> = vec![];
+                let mut reactions: Vec<Event> = vec![];
+
+                for relation_event_id in relates_to {
+                    let relation = self
+                        .event_by_id(&relation_event_id)
+                        .expect("Previously known event has disappeared");
+
+                    if relation.is_replacing_event() {
+                        replacing_events.push(relation);
+                    } else if relation.is_reaction() {
+                        reactions.push(relation);
+                    }
+                }
 
                 if !at_front || event.replacing_events().is_empty() {
                     event.append_replacing_events(replacing_events);
                 } else {
                     event.prepend_replacing_events(replacing_events);
                 }
+                event.add_reactions(reactions);
             }
 
             if let Some(relates_to_event) = event.related_matrix_event() {
@@ -337,11 +351,11 @@ impl Timeline {
         }
 
         // Handle new relations
-        for (relates_to_event_id, relations) in new_relations {
+        for (relates_to_event_id, new_relations) in new_relations {
             if let Some(relates_to_event) = self.event_by_id(&relates_to_event_id) {
                 // Get the relations in relates_to_event otherwise they will be added in
                 // in items_changed and they might not be added at the right place.
-                let mut all_replacing_events: Vec<Event> = relates_to_events
+                let mut relations: Vec<Event> = relates_to_events
                     .remove(&relates_to_event.matrix_event_id())
                     .unwrap_or_default()
                     .into_iter()
@@ -349,37 +363,44 @@ impl Timeline {
                         self.event_by_id(&event_id)
                             .expect("Previously known event has disappeared")
                     })
-                    .filter(|related_event| related_event.is_replacing_event())
-                    .collect();
-                let new_replacing_events: Vec<Event> = relations
-                    .into_iter()
-                    .filter(|event| event.is_replacing_event())
                     .collect();
 
                 if at_front {
-                    all_replacing_events.splice(..0, new_replacing_events);
+                    relations.splice(..0, new_relations);
                 } else {
-                    all_replacing_events.extend(new_replacing_events);
+                    relations.extend(new_relations);
+                }
+
+                let mut replacing_events: Vec<Event> = vec![];
+                let mut reactions: Vec<Event> = vec![];
+
+                for relation in relations {
+                    if relation.is_replacing_event() {
+                        replacing_events.push(relation);
+                    } else if relation.is_reaction() {
+                        reactions.push(relation);
+                    }
                 }
 
                 if !at_front || relates_to_event.replacing_events().is_empty() {
-                    relates_to_event.append_replacing_events(all_replacing_events);
+                    relates_to_event.append_replacing_events(replacing_events);
                 } else {
-                    relates_to_event.prepend_replacing_events(all_replacing_events);
+                    relates_to_event.prepend_replacing_events(replacing_events);
                 }
+                relates_to_event.add_reactions(reactions);
             } else {
                 // Store the new event if the `related_to` event isn't known, we will update the 
`relates_to` once
                 // the `related_to` event is added to the list
                 let relates_to_event = relates_to_events.entry(relates_to_event_id).or_default();
-                let replacing_events_ids: Vec<Box<EventId>> = relations
+
+                let relations_ids: Vec<Box<EventId>> = new_relations
                     .iter()
-                    .filter(|event| event.is_replacing_event())
                     .map(|event| event.matrix_event_id())
                     .collect();
                 if at_front {
-                    relates_to_event.splice(..0, replacing_events_ids);
+                    relates_to_event.splice(..0, relations_ids);
                 } else {
-                    relates_to_event.extend(replacing_events_ids);
+                    relates_to_event.extend(relations_ids);
                 }
             }
         }
diff --git a/src/utils.rs b/src/utils.rs
index 5406ce4a..be967414 100644
--- a/src/utils.rs
+++ b/src/utils.rs
@@ -67,8 +67,11 @@ use std::str::FromStr;
 use gettextrs::gettext;
 use gtk::gio::{self, prelude::*};
 use gtk::glib::{self, Object};
-use matrix_sdk::media::MediaType;
-use matrix_sdk::ruma::UInt;
+use matrix_sdk::{
+    media::MediaType,
+    ruma::{EventId, UInt},
+    uuid::Uuid,
+};
 use mime::Mime;
 
 /// Returns an expression looking up the given property on `object`.
@@ -217,3 +220,13 @@ pub fn filename_for_mime(mime_type: Option<&str>, fallback: Option<mime::Name>)
         .map(|extension| format!("{}.{}", name, extension))
         .unwrap_or(name)
 }
+
+/// Generate temporary IDs for pending events.
+///
+/// Returns a `(transaction_id, event_id)` tuple. The `event_id` is derived from
+/// the `transaction_id`.
+pub fn pending_event_ids() -> (Uuid, Box<EventId>) {
+    let txn_id = Uuid::new_v4();
+    let event_id = EventId::parse(format!("${}:fractal.gnome.org", txn_id)).unwrap();
+    (txn_id, event_id)
+}


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]