event: Provide more data for read receipts

This commit is contained in:
Kévin Commaille 2023-10-28 12:35:51 +02:00
parent 33d06a93e8
commit 24d6a7ce32
No known key found for this signature in database
GPG key ID: 29A48C1F03620416
12 changed files with 405 additions and 163 deletions

View file

@ -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},

View file

@ -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<Option<EventTimelineItem>>,
@ -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::BoxedAnyObject>(),
}
}
}
#[glib::object_subclass]
@ -122,6 +140,9 @@ mod imp {
glib::ParamSpecBoolean::builder("is-highlighted")
.read_only()
.build(),
glib::ParamSpecObject::builder::<gio::ListStore>("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) -> &gtk::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::<Vec<_>>();
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::<glib::BoxedAnyObject>()
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::<UserReadReceipt>().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::<Vec<_>>();
read_receipts.splice(0, old_count, &new_read_receipts);
let had_read_receipts = old_count > 0;
let has_read_receipts = new_count > 0;

View file

@ -825,6 +825,7 @@ impl Room {
} else {
let list = MemberList::new(self);
members.set(Some(&list));
self.notify("members");
list
}
}

View file

@ -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");
}

View file

@ -99,6 +99,13 @@
<lookup name="event">ContentMessageRow</lookup>
</lookup>
</binding>
<binding name="members">
<lookup name="members" type="Room">
<lookup name="room" type="RoomEvent">
<lookup name="event">ContentMessageRow</lookup>
</lookup>
</lookup>
</binding>
<layout>
<property name="column">1</property>
<property name="row">3</property>

View file

@ -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<gtk::Label>,
#[template_child]
pub overlapping_box: TemplateChild<OverlappingBox>,
/// The read receipts that are bound, if any.
pub bound_receipts: BoundObjectWeakRef<gtk::StringList>,
}
#[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<Self>) {
obj.init_template();
}
}
impl ObjectImpl for ReadReceiptsList {
fn properties() -> &'static [glib::ParamSpec] {
static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
vec![glib::ParamSpecObject::builder::<gtk::StringList>("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<imp::ReadReceiptsList>)
@extends gtk::Widget, adw::Bin, @implements gtk::Accessible;
}
impl ReadReceiptsList {
pub fn new() -> Self {
glib::Object::new()
}
pub fn list(&self) -> Option<gtk::StringList> {
self.imp().bound_receipts.obj()
}
pub fn set_list(&self, room: &Room, read_receipts: &gtk::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::<gtk::StringObject>()
.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: &gtk::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("");
}
}
}

View file

@ -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<Member>,
/// The timestamp of this read receipt, in milliseconds since Unix
/// Epoch, if any.
///
/// A value of 0 means no timestamp.
pub timestamp: Cell<u64>,
}
#[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<Vec<glib::ParamSpec>> = Lazy::new(|| {
vec![
glib::ParamSpecObject::builder::<Member>("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::<Option<Member>>().unwrap().as_ref()),
"timestamp" => obj.set_timestamp(value.get().unwrap()),
_ => unimplemented!(),
}
}
}
}
glib::wrapper! {
/// A room member's read receipt.
pub struct MemberReadReceipt(ObjectSubclass<imp::MemberReadReceipt>);
}
impl MemberReadReceipt {
/// Constructs a new `MemberReadReceipt` with the given member and
/// timestamp.
pub fn new(member: &Member, timestamp: Option<u64>) -> 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<Member> {
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");
}
}

View file

@ -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<gtk::Label>,
#[template_child]
pub overlapping_box: TemplateChild<OverlappingBox>,
/// The list of room members.
pub members: RefCell<Option<MemberList>>,
/// The list of read receipts.
pub list: gio::ListStore,
/// The read receipts used as a source.
pub source: BoundObjectWeakRef<gio::ListStore>,
}
impl Default for ReadReceiptsList {
fn default() -> Self {
Self {
label: Default::default(),
overlapping_box: Default::default(),
members: Default::default(),
list: gio::ListStore::new::<MemberReadReceipt>(),
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<Self>) {
obj.init_template();
}
}
impl ObjectImpl for ReadReceiptsList {
fn properties() -> &'static [glib::ParamSpec] {
static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
vec![
glib::ParamSpecObject::builder::<MemberList>("members").build(),
glib::ParamSpecObject::builder::<gio::ListStore>("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::<MemberReadReceipt>().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<imp::ReadReceiptsList>)
@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<MemberList> {
self.imp().members.borrow().clone()
}
/// Set the list of room members.
pub fn set_members(&self, members: Option<MemberList>) {
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::<glib::BoxedAnyObject>() else {
break;
};
let source_receipt = boxed.borrow::<UserReadReceipt>();
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("");
}
}
}

View file

@ -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");
}

View file

@ -15,6 +15,13 @@
<lookup name="event">ContentStateRow</lookup>
</lookup>
</binding>
<binding name="members">
<lookup name="members" type="Room">
<lookup name="room" type="RoomEvent">
<lookup name="event">ContentStateRow</lookup>
</lookup>
</lookup>
</binding>
</object>
</child>
</object>

View file

@ -77,7 +77,7 @@
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_history/message_toolbar/completion/completion_row.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_history/message_toolbar/mod.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_history/mod.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_history/read_receipts_list.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_history/read_receipts_list/mod.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_history/state_row/creation.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_history/state_row/mod.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_history/state_row/tombstone.ui</file>