fractal/src/session/view/content/room_history/read_receipts_list/mod.rs

300 lines
9.4 KiB
Rust

use adw::subclass::prelude::*;
use gtk::{gdk, gio, glib, glib::clone, prelude::*, CompositeTemplate};
mod read_receipts_popover;
use self::read_receipts_popover::ReadReceiptsPopover;
use super::member_timestamp::MemberTimestamp;
use crate::{
components::OverlappingAvatars,
i18n::{gettext_f, ngettext_f},
prelude::*,
session::model::{Member, MemberList, UserReadReceipt},
utils::BoundObjectWeakRef,
};
// Keep in sync with the `max-avatars` property of the `avatar_list` in the
// UI file.
const MAX_RECEIPTS_SHOWN: u32 = 10;
mod imp {
use std::cell::{Cell, RefCell};
use glib::subclass::InitializingObject;
use super::*;
#[derive(Debug, CompositeTemplate, glib::Properties)]
#[template(
resource = "/org/gnome/Fractal/ui/session/view/content/room_history/read_receipts_list/mod.ui"
)]
#[properties(wrapper_type = super::ReadReceiptsList)]
pub struct ReadReceiptsList {
#[template_child]
pub label: TemplateChild<gtk::Label>,
#[template_child]
pub avatar_list: TemplateChild<OverlappingAvatars>,
/// Whether this list is active.
///
/// This list is active when the popover is displayed.
#[property(get)]
pub active: Cell<bool>,
/// The list of room members.
#[property(get, set = Self::set_members, explicit_notify, nullable)]
pub members: RefCell<Option<MemberList>>,
/// The list of read receipts.
#[property(get)]
pub list: gio::ListStore,
/// The read receipts used as a source.
pub source: BoundObjectWeakRef<gio::ListStore>,
/// The displayed member if there is only one receipt.
pub receipt_member: BoundObjectWeakRef<Member>,
}
impl Default for ReadReceiptsList {
fn default() -> Self {
Self {
label: Default::default(),
avatar_list: Default::default(),
active: Default::default(),
members: Default::default(),
list: gio::ListStore::new::<MemberTimestamp>(),
source: Default::default(),
receipt_member: 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);
Self::Type::bind_template_callbacks(klass);
klass.set_css_name("read-receipts-list");
klass.set_accessible_role(gtk::AccessibleRole::ToggleButton);
}
fn instance_init(obj: &InitializingObject<Self>) {
obj.init_template();
}
}
#[glib::derived_properties]
impl ObjectImpl for ReadReceiptsList {
fn constructed(&self) {
self.parent_constructed();
let obj = self.obj();
self.avatar_list
.bind_model(Some(self.list.clone()), |item| {
item.downcast_ref::<MemberTimestamp>()
.and_then(|m| m.member())
.map(|m| m.avatar_data().clone())
.unwrap()
});
self.list
.connect_items_changed(clone!(@weak obj => move |_, _,_,_| {
obj.update_tooltip();
obj.update_label();
}));
obj.set_pressed_state(false);
}
}
impl WidgetImpl for ReadReceiptsList {}
impl BinImpl for ReadReceiptsList {}
impl AccessibleImpl for ReadReceiptsList {
fn first_accessible_child(&self) -> Option<gtk::Accessible> {
// Hide the children in the a11y tree.
None
}
}
impl ReadReceiptsList {
/// Set the list of room members.
fn set_members(&self, members: Option<MemberList>) {
if *self.members.borrow() == members {
return;
}
let obj = self.obj();
self.members.replace(members);
obj.notify_members();
if let Some(source) = self.source.obj() {
obj.items_changed(&source, 0, self.list.n_items(), source.n_items());
}
}
}
}
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;
}
#[gtk::template_callbacks]
impl ReadReceiptsList {
pub fn new(members: &MemberList) -> Self {
glib::Object::builder().property("members", members).build()
}
/// Set whether this list is active.
fn set_active(&self, active: bool) {
if self.active() == active {
return;
}
self.imp().active.set(active);
self.notify_active();
self.set_pressed_state(active);
}
/// Set the CSS and a11 states.
fn set_pressed_state(&self, pressed: bool) {
if pressed {
self.set_state_flags(gtk::StateFlags::CHECKED, false);
} else {
self.unset_state_flags(gtk::StateFlags::CHECKED);
}
let tristate = if pressed {
gtk::AccessibleTristate::True
} else {
gtk::AccessibleTristate::False
};
self.update_state(&[gtk::accessible::State::Pressed(tristate)]);
}
/// 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 Some(members) = &*self.imp().members.borrow() else {
return;
};
let mut new_receipts = Vec::with_capacity(added as usize);
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 = MemberTimestamp::new(
&member,
source_receipt.receipt.ts.map(|ts| ts.as_secs().into()),
);
new_receipts.push(receipt);
}
self.list().splice(pos, removed, &new_receipts);
}
fn update_tooltip(&self) {
let imp = self.imp();
imp.receipt_member.disconnect_signals();
let n_items = self.list().n_items();
if n_items == 1 {
if let Some(member) = self
.list()
.item(0)
.and_downcast::<MemberTimestamp>()
.and_then(|r| r.member())
{
// Listen to changes of the display name.
let handler_id = member.connect_notify_local(
Some("display-name"),
clone!(@weak self as obj => move |member, _| {
obj.update_member_tooltip(member);
}),
);
imp.receipt_member.set(&member, vec![handler_id]);
self.update_member_tooltip(&member);
return;
}
}
let text = (n_items > 0).then(|| {
ngettext_f(
// Translators: Do NOT translate the content between '{' and '}', this is a
// variable name.
"Seen by 1 member",
"Seen by {n} members",
n_items,
&[("n", &n_items.to_string())],
)
});
self.set_tooltip_text(text.as_deref())
}
fn update_member_tooltip(&self, member: &Member) {
// Translators: Do NOT translate the content between '{' and '}', this is a
// variable name.
let text = gettext_f("Seen by {name}", &[("name", &member.display_name())]);
self.set_tooltip_text(Some(&text));
}
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));
label.set_visible(true);
} else {
label.set_visible(false);
}
}
/// Handle a click on the container.
///
/// Shows a popover with the list of receipts if there are any.
#[template_callback]
fn show_popover(&self, _n_press: i32, x: f64, y: f64) {
let list = self.list();
if list.n_items() == 0 {
// No popover.
return;
}
self.set_active(true);
let popover = ReadReceiptsPopover::new(&list);
popover.set_parent(self);
popover.set_pointing_to(Some(&gdk::Rectangle::new(x as i32, y as i32, 0, 0)));
popover.connect_closed(clone!(@weak self as obj => move |popover| {
popover.unparent();
obj.set_active(false);
}));
popover.popup();
}
}