From 24d6a7ce322574c6dd05629cf4d4c40c994995b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= Date: Sat, 28 Oct 2023 12:35:51 +0200 Subject: [PATCH] event: Provide more data for read receipts --- src/session/model/mod.rs | 3 +- src/session/model/room/event/mod.rs | 56 +++-- src/session/model/room/mod.rs | 1 + .../content/room_history/message_row/mod.rs | 3 +- .../content/room_history/message_row/mod.ui | 7 + .../room_history/read_receipts_list.rs | 143 ----------- .../read_receipts_list/member_read_receipt.rs | 116 +++++++++ .../room_history/read_receipts_list/mod.rs | 227 ++++++++++++++++++ .../mod.ui} | 0 .../content/room_history/state_row/mod.rs | 3 +- .../content/room_history/state_row/mod.ui | 7 + src/ui-resources.gresource.xml | 2 +- 12 files changed, 405 insertions(+), 163 deletions(-) delete mode 100644 src/session/view/content/room_history/read_receipts_list.rs create mode 100644 src/session/view/content/room_history/read_receipts_list/member_read_receipt.rs create mode 100644 src/session/view/content/room_history/read_receipts_list/mod.rs rename src/session/view/content/room_history/{read_receipts_list.ui => read_receipts_list/mod.ui} (100%) diff --git a/src/session/model/mod.rs b/src/session/model/mod.rs index b8ad561f..8075bf9a 100644 --- a/src/session/model/mod.rs +++ b/src/session/model/mod.rs @@ -14,7 +14,8 @@ pub use self::{ room::{ Event, EventKey, HighlightFlags, Member, MemberList, MemberRole, Membership, PowerLevel, ReactionGroup, ReactionList, Room, RoomType, Timeline, TimelineItem, TimelineItemExt, - TimelineState, TypingList, VirtualItem, VirtualItemKind, POWER_LEVEL_MAX, POWER_LEVEL_MIN, + TimelineState, TypingList, UserReadReceipt, VirtualItem, VirtualItemKind, POWER_LEVEL_MAX, + POWER_LEVEL_MIN, }, room_list::RoomList, session::{Session, SessionState}, diff --git a/src/session/model/room/event/mod.rs b/src/session/model/room/event/mod.rs index 2c7e2f45..29ad71b9 100644 --- a/src/session/model/room/event/mod.rs +++ b/src/session/model/room/event/mod.rs @@ -1,6 +1,6 @@ use std::{borrow::Cow, fmt}; -use gtk::{glib, prelude::*, subclass::prelude::*}; +use gtk::{gio, glib, prelude::*, subclass::prelude::*}; use indexmap::IndexMap; use matrix_sdk_ui::timeline::{ AnyOtherFullStateEventContent, Error as TimelineError, EventTimelineItem, RepliedToEvent, @@ -71,6 +71,13 @@ impl glib::FromVariant for EventKey { #[boxed_type(name = "BoxedEventTimelineItem")] pub struct BoxedEventTimelineItem(EventTimelineItem); +/// A user's read receipt. +#[derive(Clone, Debug)] +pub struct UserReadReceipt { + pub user_id: OwnedUserId, + pub receipt: Receipt, +} + mod imp { use std::cell::RefCell; @@ -79,7 +86,7 @@ mod imp { use super::*; - #[derive(Debug, Default)] + #[derive(Debug)] pub struct Event { /// The underlying SDK timeline item. pub item: RefCell>, @@ -91,7 +98,18 @@ mod imp { pub reactions: ReactionList, /// The read receipts on this event. - pub read_receipts: gtk::StringList, + pub read_receipts: gio::ListStore, + } + + impl Default for Event { + fn default() -> Self { + Self { + item: Default::default(), + room: Default::default(), + reactions: Default::default(), + read_receipts: gio::ListStore::new::(), + } + } } #[glib::object_subclass] @@ -122,6 +140,9 @@ mod imp { glib::ParamSpecBoolean::builder("is-highlighted") .read_only() .build(), + glib::ParamSpecObject::builder::("read-receipts") + .read_only() + .build(), glib::ParamSpecBoolean::builder("has-read-receipts") .read_only() .build(), @@ -156,6 +177,7 @@ mod imp { "reactions" => obj.reactions().to_value(), "is-edited" => obj.is_edited().to_value(), "is-highlighted" => obj.is_highlighted().to_value(), + "read-receipts" => obj.read_receipts().to_value(), "has-read-receipts" => obj.has_read_receipts().to_value(), _ => unimplemented!(), } @@ -441,7 +463,7 @@ impl Event { } /// The read receipts on this event. - pub fn read_receipts(&self) -> >k::StringList { + pub fn read_receipts(&self) -> &gio::ListStore { &self.imp().read_receipts } @@ -456,21 +478,18 @@ impl Event { let old_count = read_receipts.n_items(); let new_count = new_read_receipts.len() as u32; - let new_user_ids = new_read_receipts - .keys() - .map(|u| u.as_str()) - .collect::>(); - if old_count == new_count { let mut is_all_same = true; - for i in 0..old_count { - let Some(old_user_id) = read_receipts.string(i) else { + for (i, new_user_id) in new_read_receipts.keys().enumerate() { + let Some(old_receipt) = read_receipts + .item(i as u32) + .and_downcast::() + else { is_all_same = false; break; }; - let new_user_id = new_user_ids[i as usize]; - if old_user_id != new_user_id { + if old_receipt.borrow::().user_id != *new_user_id { is_all_same = false; break; } @@ -481,7 +500,16 @@ impl Event { } } - read_receipts.splice(0, old_count, &new_user_ids); + let new_read_receipts = new_read_receipts + .into_iter() + .map(|(user_id, receipt)| { + glib::BoxedAnyObject::new(UserReadReceipt { + user_id: user_id.clone(), + receipt: receipt.clone(), + }) + }) + .collect::>(); + read_receipts.splice(0, old_count, &new_read_receipts); let had_read_receipts = old_count > 0; let has_read_receipts = new_count > 0; diff --git a/src/session/model/room/mod.rs b/src/session/model/room/mod.rs index 98825db2..4528225d 100644 --- a/src/session/model/room/mod.rs +++ b/src/session/model/room/mod.rs @@ -825,6 +825,7 @@ impl Room { } else { let list = MemberList::new(self); members.set(Some(&list)); + self.notify("members"); list } } diff --git a/src/session/view/content/room_history/message_row/mod.rs b/src/session/view/content/room_history/message_row/mod.rs index a255e7da..2c97a228 100644 --- a/src/session/view/content/room_history/message_row/mod.rs +++ b/src/session/view/content/room_history/message_row/mod.rs @@ -216,8 +216,7 @@ impl MessageRow { imp.reactions .set_reaction_list(&event.room().get_or_create_members(), event.reactions()); - imp.read_receipts - .set_list(&event.room(), event.read_receipts()); + imp.read_receipts.set_source(event.read_receipts()); imp.event.replace(Some(event)); self.notify("event"); } diff --git a/src/session/view/content/room_history/message_row/mod.ui b/src/session/view/content/room_history/message_row/mod.ui index 0808293a..a59ab81f 100644 --- a/src/session/view/content/room_history/message_row/mod.ui +++ b/src/session/view/content/room_history/message_row/mod.ui @@ -99,6 +99,13 @@ ContentMessageRow + + + + ContentMessageRow + + + 1 3 diff --git a/src/session/view/content/room_history/read_receipts_list.rs b/src/session/view/content/room_history/read_receipts_list.rs deleted file mode 100644 index c3df0067..00000000 --- a/src/session/view/content/room_history/read_receipts_list.rs +++ /dev/null @@ -1,143 +0,0 @@ -use adw::subclass::prelude::*; -use gtk::{glib, glib::clone, prelude::*, CompositeTemplate}; -use ruma::UserId; - -use crate::{ - components::{Avatar, OverlappingBox}, - prelude::*, - session::model::Room, - utils::BoundObjectWeakRef, -}; - -// Keep in sync with the `max-children` property of the `overlapping_box` in the -// UI file. -const MAX_RECEIPTS_SHOWN: u32 = 10; - -mod imp { - use glib::subclass::InitializingObject; - use once_cell::sync::Lazy; - - use super::*; - - #[derive(Debug, Default, CompositeTemplate)] - #[template( - resource = "/org/gnome/Fractal/ui/session/view/content/room_history/read_receipts_list.ui" - )] - pub struct ReadReceiptsList { - #[template_child] - pub label: TemplateChild, - #[template_child] - pub overlapping_box: TemplateChild, - - /// The read receipts that are bound, if any. - pub bound_receipts: BoundObjectWeakRef, - } - - #[glib::object_subclass] - impl ObjectSubclass for ReadReceiptsList { - const NAME: &'static str = "ContentReadReceiptsList"; - type Type = super::ReadReceiptsList; - type ParentType = adw::Bin; - - fn class_init(klass: &mut Self::Class) { - Self::bind_template(klass); - klass.set_css_name("read-receipts-list"); - } - - fn instance_init(obj: &InitializingObject) { - obj.init_template(); - } - } - - impl ObjectImpl for ReadReceiptsList { - fn properties() -> &'static [glib::ParamSpec] { - static PROPERTIES: Lazy> = Lazy::new(|| { - vec![glib::ParamSpecObject::builder::("list") - .read_only() - .build()] - }); - - PROPERTIES.as_ref() - } - - fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { - let obj = self.obj(); - - match pspec.name() { - "list" => obj.list().to_value(), - _ => unimplemented!(), - } - } - - fn dispose(&self) { - self.bound_receipts.disconnect_signals(); - } - } - - impl WidgetImpl for ReadReceiptsList {} - - impl BinImpl for ReadReceiptsList {} -} - -glib::wrapper! { - /// A widget displaying the read receipts on a message. - pub struct ReadReceiptsList(ObjectSubclass) - @extends gtk::Widget, adw::Bin, @implements gtk::Accessible; -} - -impl ReadReceiptsList { - pub fn new() -> Self { - glib::Object::new() - } - - pub fn list(&self) -> Option { - self.imp().bound_receipts.obj() - } - - pub fn set_list(&self, room: &Room, read_receipts: >k::StringList) { - let imp = self.imp(); - - imp.overlapping_box.bind_model( - Some(read_receipts), - clone!(@weak room => @default-return { Avatar::new().upcast() }, move |item| { - let user_id = UserId::parse( - item.downcast_ref::() - .unwrap() - .string() - ) - .expect("Strings in read receipts list are valid UserIds"); - // We should have a strong reference to the list in the RoomHistory so we can use `get_or_create_members()`. - let member = room.get_or_create_members().get_or_create(user_id); - - let avatar_data = member.avatar_data(); - let avatar = Avatar::new(); - avatar.set_size(20); - avatar.set_data(Some(avatar_data.clone())); - - let cutout = adw::Bin::builder().child(&avatar).css_classes(["cutout"]).build(); - cutout.upcast() - }), - ); - - let items_changed_handler_id = read_receipts.connect_items_changed( - clone!(@weak self as obj => move |read_receipts, _, _, _| { - obj.update_label(read_receipts); - }), - ); - - imp.bound_receipts - .set(read_receipts, vec![items_changed_handler_id]); - self.update_label(read_receipts); - self.notify("list"); - } - - fn update_label(&self, read_receipts: >k::StringList) { - let label = &self.imp().label; - let n_items = read_receipts.n_items(); - if n_items > MAX_RECEIPTS_SHOWN { - label.set_text(&format!("{} +", n_items - MAX_RECEIPTS_SHOWN)); - } else { - label.set_text(""); - } - } -} diff --git a/src/session/view/content/room_history/read_receipts_list/member_read_receipt.rs b/src/session/view/content/room_history/read_receipts_list/member_read_receipt.rs new file mode 100644 index 00000000..cb39d4a8 --- /dev/null +++ b/src/session/view/content/room_history/read_receipts_list/member_read_receipt.rs @@ -0,0 +1,116 @@ +use adw::subclass::prelude::*; +use gtk::{glib, prelude::*}; + +use crate::session::model::Member; + +mod imp { + use std::cell::Cell; + + use once_cell::sync::Lazy; + + use super::*; + + #[derive(Debug, Default)] + pub struct MemberReadReceipt { + /// The room member of this read receipt. + pub member: glib::WeakRef, + /// The timestamp of this read receipt, in milliseconds since Unix + /// Epoch, if any. + /// + /// A value of 0 means no timestamp. + pub timestamp: Cell, + } + + #[glib::object_subclass] + impl ObjectSubclass for MemberReadReceipt { + const NAME: &'static str = "ContentMemberReadReceipt"; + type Type = super::MemberReadReceipt; + } + + impl ObjectImpl for MemberReadReceipt { + fn properties() -> &'static [glib::ParamSpec] { + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![ + glib::ParamSpecObject::builder::("member") + .construct_only() + .build(), + glib::ParamSpecUInt64::builder("timestamp") + .construct_only() + .build(), + ] + }); + + PROPERTIES.as_ref() + } + + fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + let obj = self.obj(); + + match pspec.name() { + "member" => obj.member().to_value(), + "timestamp" => obj.timestamp().to_value(), + _ => unimplemented!(), + } + } + + fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) { + let obj = self.obj(); + + match pspec.name() { + "member" => obj.set_member(value.get::>().unwrap().as_ref()), + "timestamp" => obj.set_timestamp(value.get().unwrap()), + _ => unimplemented!(), + } + } + } +} + +glib::wrapper! { + /// A room member's read receipt. + pub struct MemberReadReceipt(ObjectSubclass); +} + +impl MemberReadReceipt { + /// Constructs a new `MemberReadReceipt` with the given member and + /// timestamp. + pub fn new(member: &Member, timestamp: Option) -> Self { + glib::Object::builder() + .property("member", member) + .property("timestamp", timestamp.unwrap_or_default()) + .build() + } + + /// The room member of this read receipt. + pub fn member(&self) -> Option { + self.imp().member.upgrade() + } + + /// Set the room member of this read receipt. + fn set_member(&self, member: Option<&Member>) { + let Some(member) = member else { + // Ignore if there is no member. + return; + }; + + self.imp().member.set(Some(member)); + self.notify("member"); + } + + /// The timestamp of this read receipt, in milliseconds since Unix Epoch, if + /// any. + /// + /// A value of 0 means no timestamp. + pub fn timestamp(&self) -> u64 { + self.imp().timestamp.get() + } + + /// Set the timestamp of this read receipt. + pub fn set_timestamp(&self, ts: u64) { + if self.timestamp() == ts { + return; + } + + self.imp().timestamp.set(ts); + self.notify("timestamp"); + } +} diff --git a/src/session/view/content/room_history/read_receipts_list/mod.rs b/src/session/view/content/room_history/read_receipts_list/mod.rs new file mode 100644 index 00000000..252acd88 --- /dev/null +++ b/src/session/view/content/room_history/read_receipts_list/mod.rs @@ -0,0 +1,227 @@ +use adw::subclass::prelude::*; +use gtk::{gio, glib, glib::clone, prelude::*, CompositeTemplate}; + +mod member_read_receipt; + +use self::member_read_receipt::MemberReadReceipt; +use crate::{ + components::{Avatar, OverlappingBox}, + prelude::*, + session::model::{MemberList, UserReadReceipt}, + utils::BoundObjectWeakRef, +}; + +// Keep in sync with the `max-children` property of the `overlapping_box` in the +// UI file. +const MAX_RECEIPTS_SHOWN: u32 = 10; + +mod imp { + use std::cell::RefCell; + + use glib::subclass::InitializingObject; + use once_cell::sync::Lazy; + + use super::*; + + #[derive(Debug, CompositeTemplate)] + #[template( + resource = "/org/gnome/Fractal/ui/session/view/content/room_history/read_receipts_list/mod.ui" + )] + pub struct ReadReceiptsList { + #[template_child] + pub label: TemplateChild, + #[template_child] + pub overlapping_box: TemplateChild, + /// The list of room members. + pub members: RefCell>, + /// The list of read receipts. + pub list: gio::ListStore, + /// The read receipts used as a source. + pub source: BoundObjectWeakRef, + } + + impl Default for ReadReceiptsList { + fn default() -> Self { + Self { + label: Default::default(), + overlapping_box: Default::default(), + members: Default::default(), + list: gio::ListStore::new::(), + source: Default::default(), + } + } + } + + #[glib::object_subclass] + impl ObjectSubclass for ReadReceiptsList { + const NAME: &'static str = "ContentReadReceiptsList"; + type Type = super::ReadReceiptsList; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + Self::bind_template(klass); + klass.set_css_name("read-receipts-list"); + } + + fn instance_init(obj: &InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for ReadReceiptsList { + fn properties() -> &'static [glib::ParamSpec] { + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![ + glib::ParamSpecObject::builder::("members").build(), + glib::ParamSpecObject::builder::("list") + .read_only() + .build(), + ] + }); + + PROPERTIES.as_ref() + } + + fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + let obj = self.obj(); + + match pspec.name() { + "members" => obj.members().to_value(), + "list" => obj.list().to_value(), + _ => unimplemented!(), + } + } + + fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) { + let obj = self.obj(); + + match pspec.name() { + "members" => obj.set_members(value.get().unwrap()), + _ => unimplemented!(), + } + } + + fn constructed(&self) { + self.parent_constructed(); + let obj = self.obj(); + + self.overlapping_box.bind_model( + Some(&self.list), + clone!(@weak obj => @default-return { Avatar::new().upcast() }, move |item| { + let avatar = Avatar::new(); + avatar.set_size(20); + + if let Some(member) = item.downcast_ref::().and_then(|r| r.member()) { + avatar.set_data(Some(member.avatar_data().clone())); + } + + let cutout = adw::Bin::builder().child(&avatar).css_classes(["cutout"]).build(); + cutout.upcast() + }), + ); + + self.list + .connect_items_changed(clone!(@weak obj => move |_, _,_,_| { + obj.update_label(); + })); + } + + fn dispose(&self) { + self.source.disconnect_signals(); + } + } + + impl WidgetImpl for ReadReceiptsList {} + + impl BinImpl for ReadReceiptsList {} +} + +glib::wrapper! { + /// A widget displaying the read receipts on a message. + pub struct ReadReceiptsList(ObjectSubclass) + @extends gtk::Widget, adw::Bin, @implements gtk::Accessible; +} + +impl ReadReceiptsList { + pub fn new(members: &MemberList) -> Self { + glib::Object::builder().property("members", members).build() + } + + /// The list of room members. + pub fn members(&self) -> Option { + self.imp().members.borrow().clone() + } + + /// Set the list of room members. + pub fn set_members(&self, members: Option) { + let imp = self.imp(); + + if imp.members.borrow().as_ref() == members.as_ref() { + return; + } + + imp.members.replace(members); + self.notify("members"); + + if let Some(source) = imp.source.obj() { + self.items_changed(&source, 0, self.list().n_items(), source.n_items()); + } + } + + /// The list of read receipts to present. + pub fn list(&self) -> &gio::ListStore { + &self.imp().list + } + + /// Set the read receipts that are used as a source of data. + pub fn set_source(&self, source: &gio::ListStore) { + let imp = self.imp(); + + let items_changed_handler_id = source.connect_items_changed( + clone!(@weak self as obj => move |source, pos, removed, added| { + obj.items_changed(source, pos, removed, added); + }), + ); + self.items_changed(source, 0, self.list().n_items(), source.n_items()); + + imp.source.set(source, vec![items_changed_handler_id]); + } + + fn items_changed(&self, source: &gio::ListStore, pos: u32, removed: u32, added: u32) { + let mut new_receipts = Vec::with_capacity(added as usize); + + { + let Some(members) = &*self.imp().members.borrow() else { + return; + }; + + for i in pos..pos + added { + let Some(boxed) = source.item(i).and_downcast::() else { + break; + }; + + let source_receipt = boxed.borrow::(); + let member = members.get_or_create(source_receipt.user_id.clone()); + let receipt = MemberReadReceipt::new( + &member, + source_receipt.receipt.ts.map(|ts| ts.0.into()), + ); + + new_receipts.push(receipt); + } + } + + self.list().splice(pos, removed, &new_receipts); + } + + fn update_label(&self) { + let label = &self.imp().label; + let n_items = self.list().n_items(); + + if n_items > MAX_RECEIPTS_SHOWN { + label.set_text(&format!("{} +", n_items - MAX_RECEIPTS_SHOWN)); + } else { + label.set_text(""); + } + } +} diff --git a/src/session/view/content/room_history/read_receipts_list.ui b/src/session/view/content/room_history/read_receipts_list/mod.ui similarity index 100% rename from src/session/view/content/room_history/read_receipts_list.ui rename to src/session/view/content/room_history/read_receipts_list/mod.ui diff --git a/src/session/view/content/room_history/state_row/mod.rs b/src/session/view/content/room_history/state_row/mod.rs index 5c5f7aec..b53590dd 100644 --- a/src/session/view/content/room_history/state_row/mod.rs +++ b/src/session/view/content/room_history/state_row/mod.rs @@ -121,8 +121,7 @@ impl StateRow { } let imp = self.imp(); - imp.read_receipts - .set_list(&event.room(), event.read_receipts()); + imp.read_receipts.set_source(event.read_receipts()); imp.event.replace(Some(event)); self.notify("event"); } diff --git a/src/session/view/content/room_history/state_row/mod.ui b/src/session/view/content/room_history/state_row/mod.ui index 6081b7ea..da152c8c 100644 --- a/src/session/view/content/room_history/state_row/mod.ui +++ b/src/session/view/content/room_history/state_row/mod.ui @@ -15,6 +15,13 @@ ContentStateRow + + + + ContentStateRow + + + diff --git a/src/ui-resources.gresource.xml b/src/ui-resources.gresource.xml index 64021f8b..79906126 100644 --- a/src/ui-resources.gresource.xml +++ b/src/ui-resources.gresource.xml @@ -77,7 +77,7 @@ session/view/content/room_history/message_toolbar/completion/completion_row.ui session/view/content/room_history/message_toolbar/mod.ui session/view/content/room_history/mod.ui - session/view/content/room_history/read_receipts_list.ui + session/view/content/room_history/read_receipts_list/mod.ui session/view/content/room_history/state_row/creation.ui session/view/content/room_history/state_row/mod.ui session/view/content/room_history/state_row/tombstone.ui