[fractal/fractal-next] content: Implement reactions
- From: Julian Sparber <jsparber src gnome org>
- To: commits-list gnome org
- Cc:
- Subject: [fractal/fractal-next] content: Implement reactions
- Date: Wed, 19 Jan 2022 17:48:42 +0000 (UTC)
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]