mod divider_row;
mod item_row;
mod member_timestamp;
mod message_row;
mod message_toolbar;
mod read_receipts_list;
mod sender_avatar;
mod state_row;
mod title;
mod typing_row;
mod verification_info_bar;
use std::time::Duration;
use adw::{prelude::*, subclass::prelude::*};
use gettextrs::gettext;
use gtk::{gdk, gio, glib, glib::clone, graphene::Point, CompositeTemplate};
use matrix_sdk::ruma::EventId;
use ruma::{
api::client::receipt::create_receipt::v3::ReceiptType, events::receipt::ReceiptThread,
OwnedEventId,
};
use tracing::{error, warn};
use self::{
divider_row::DividerRow, item_row::ItemRow, message_row::MessageRow,
message_toolbar::MessageToolbar, read_receipts_list::ReadReceiptsList,
sender_avatar::SenderAvatar, state_row::StateRow, title::RoomHistoryTitle,
typing_row::TypingRow, verification_info_bar::VerificationInfoBar,
};
use super::{room_details, RoomDetails};
use crate::{
components::{confirm_leave_room_dialog, DragOverlay, ReactionChooser, Spinner},
i18n::gettext_f,
prelude::*,
session::model::{
Event, EventKey, MemberList, Membership, Room, RoomType, Timeline, TimelineState,
},
spawn, spawn_tokio, toast,
utils::{template_callbacks::TemplateCallbacks, BoundObject},
Window,
};
const SCROLL_TIMEOUT: Duration = Duration::from_millis(500);
const READ_TIMEOUT: Duration = Duration::from_secs(5);
mod imp {
use std::{
cell::{Cell, OnceCell, RefCell},
marker::PhantomData,
};
use glib::subclass::InitializingObject;
use super::*;
#[derive(Debug, Default, CompositeTemplate, glib::Properties)]
#[template(resource = "/org/gnome/Fractal/ui/session/view/content/room_history/mod.ui")]
#[properties(wrapper_type = super::RoomHistory)]
pub struct RoomHistory {
#[property(get, set = Self::set_room, explicit_notify, nullable)]
pub room: BoundObject<Room>,
#[property(get, set)]
pub only_view: Cell<bool>,
#[property(get = Self::empty)]
empty: PhantomData<bool>,
pub room_members: RefCell<Option<MemberList>>,
pub timeline_handlers: RefCell<Vec<glib::SignalHandlerId>>,
pub is_auto_scrolling: Cell<bool>,
#[property(get, set = Self::set_sticky, explicit_notify)]
pub sticky: Cell<bool>,
pub item_context_menu: OnceCell<gtk::PopoverMenu>,
pub item_reaction_chooser: ReactionChooser,
pub sender_context_menu: OnceCell<gtk::PopoverMenu>,
#[template_child]
pub sender_menu_model: TemplateChild<gio::Menu>,
#[template_child]
pub header_bar: TemplateChild<adw::HeaderBar>,
#[template_child]
pub room_menu: TemplateChild<gtk::MenuButton>,
#[template_child]
pub listview: TemplateChild<gtk::ListView>,
#[template_child]
pub content: TemplateChild<gtk::Widget>,
#[template_child]
pub scrolled_window: TemplateChild<gtk::ScrolledWindow>,
#[template_child]
pub scroll_btn: TemplateChild<gtk::Button>,
#[template_child]
pub scroll_btn_revealer: TemplateChild<gtk::Revealer>,
#[template_child]
pub message_toolbar: TemplateChild<MessageToolbar>,
#[template_child]
pub loading: TemplateChild<Spinner>,
#[template_child]
pub error: TemplateChild<adw::StatusPage>,
#[template_child]
pub stack: TemplateChild<gtk::Stack>,
#[template_child]
pub tombstoned_banner: TemplateChild<adw::Banner>,
pub is_loading: Cell<bool>,
#[template_child]
pub drag_overlay: TemplateChild<DragOverlay>,
pub scroll_timeout: RefCell<Option<glib::SourceId>>,
pub read_timeout: RefCell<Option<glib::SourceId>>,
pub selection_model: OnceCell<gtk::NoSelection>,
pub can_invite_handler: RefCell<Option<glib::SignalHandlerId>>,
pub membership_handler: RefCell<Option<glib::SignalHandlerId>>,
pub join_rule_handler: RefCell<Option<glib::SignalHandlerId>>,
}
#[glib::object_subclass]
impl ObjectSubclass for RoomHistory {
const NAME: &'static str = "ContentRoomHistory";
type Type = super::RoomHistory;
type ParentType = adw::Bin;
fn class_init(klass: &mut Self::Class) {
RoomHistoryTitle::ensure_type();
ItemRow::ensure_type();
VerificationInfoBar::ensure_type();
Timeline::ensure_type();
Self::bind_template(klass);
Self::Type::bind_template_callbacks(klass);
TemplateCallbacks::bind_template_callbacks(klass);
klass.set_accessible_role(gtk::AccessibleRole::Group);
klass.install_action_async("room-history.leave", None, |obj, _, _| async move {
obj.leave().await;
});
klass.install_action_async("room-history.join", None, |obj, _, _| async move {
obj.join().await;
});
klass.install_action_async("room-history.forget", None, |obj, _, _| async move {
obj.forget().await;
});
klass.install_action("room-history.try-again", None, |obj, _, _| {
obj.try_again();
});
klass.install_action("room-history.details", None, |obj, _, _| {
obj.open_room_details(None);
});
klass.install_action("room-history.invite-members", None, |obj, _, _| {
obj.open_room_details(Some(room_details::SubpageName::Invite));
});
klass.install_action("room-history.scroll-down", None, |obj, _, _| {
obj.scroll_down();
});
klass.install_action(
"room-history.scroll-to-event",
Some(&EventKey::static_variant_type()),
|obj, _, v| {
if let Some(event_key) = v.and_then(EventKey::from_variant) {
obj.scroll_to_event(&event_key);
}
},
);
klass.install_action(
"room-history.reply",
Some(&String::static_variant_type()),
|obj, _, v| {
if let Some(event_id) = v
.and_then(String::from_variant)
.and_then(|s| EventId::parse(s).ok())
{
if let Some(event) = obj
.room()
.and_then(|room| {
room.timeline().event_by_key(&EventKey::EventId(event_id))
})
.and_downcast()
{
obj.message_toolbar().set_reply_to(event);
}
}
},
);
klass.install_action(
"room-history.edit",
Some(&String::static_variant_type()),
|obj, _, v| {
if let Some(event_id) = v
.and_then(String::from_variant)
.and_then(|s| EventId::parse(s).ok())
{
if let Some(event) = obj
.room()
.and_then(|room| {
room.timeline().event_by_key(&EventKey::EventId(event_id))
})
.and_downcast()
{
obj.message_toolbar().set_edit(event);
}
}
},
);
}
fn instance_init(obj: &InitializingObject<Self>) {
obj.init_template();
}
}
#[glib::derived_properties]
impl ObjectImpl for RoomHistory {
fn constructed(&self) {
self.setup_listview();
self.setup_drop_target();
self.scroll_btn_revealer
.connect_child_revealed_notify(|revealer| {
if !revealer.reveals_child() && !revealer.is_child_revealed() {
revealer.set_visible(false);
}
});
self.parent_constructed();
}
fn dispose(&self) {
self.disconnect_all();
}
}
impl WidgetImpl for RoomHistory {}
impl BinImpl for RoomHistory {}
impl RoomHistory {
fn setup_listview(&self) {
let obj = self.obj();
let factory = gtk::SignalListItemFactory::new();
factory.connect_setup(clone!(
#[weak]
obj,
move |_, item| {
let Some(item) = item.downcast_ref::<gtk::ListItem>() else {
error!("List item factory did not receive a list item: {item:?}");
return;
};
let row = ItemRow::new(&obj);
item.set_child(Some(&row));
item.bind_property("item", &row, "item").build();
item.set_activatable(false);
item.set_selectable(false);
}
));
self.listview.set_factory(Some(&factory));
self.listview
.set_vscroll_policy(gtk::ScrollablePolicy::Natural);
self.listview.set_model(Some(obj.selection_model()));
obj.set_sticky(true);
let adj = self.listview.vadjustment().unwrap();
adj.connect_value_changed(clone!(
#[weak]
obj,
move |_| {
tracing::trace!("Scroll value changed");
let imp = obj.imp();
obj.trigger_read_receipts_update();
let is_at_bottom = obj.is_at_bottom();
if imp.is_auto_scrolling.get() {
tracing::trace!("Automatically scrolled");
if is_at_bottom {
imp.set_is_auto_scrolling(false);
obj.set_sticky(true);
} else {
obj.scroll_down();
}
} else {
tracing::trace!("User scrolled");
obj.set_sticky(is_at_bottom);
}
if !is_at_bottom {
if let Some(room) = obj.room() {
room.timeline().remove_empty_typing_row();
}
}
obj.start_loading();
}
));
adj.connect_upper_notify(clone!(
#[weak]
obj,
move |_| {
tracing::trace!("Scroll upper changed");
if obj.sticky() {
obj.scroll_down();
}
obj.start_loading();
}
));
adj.connect_page_size_notify(clone!(
#[weak]
obj,
move |_| {
tracing::trace!("Scroll page size changed");
if obj.sticky() {
obj.scroll_down();
}
obj.start_loading();
}
));
}
fn setup_drop_target(&self) {
let obj = self.obj();
let target = gtk::DropTarget::new(
gio::File::static_type(),
gdk::DragAction::COPY | gdk::DragAction::MOVE,
);
target.connect_drop(clone!(
#[weak]
obj,
#[upgrade_or]
false,
move |_, value, _, _| {
match value.get::<gio::File>() {
Ok(file) => {
spawn!(async move {
obj.message_toolbar().send_file(file).await;
});
true
}
Err(error) => {
warn!("Could not get file from drop: {error:?}");
toast!(obj, gettext("Error getting file from drop"));
false
}
}
}
));
self.drag_overlay.set_drop_target(target);
}
}
impl RoomHistory {
fn disconnect_all(&self) {
if let Some(room) = self.room.obj() {
for handler in self.timeline_handlers.take() {
room.timeline().disconnect(handler);
}
if let Some(handler) = self.can_invite_handler.take() {
room.permissions().disconnect(handler);
}
if let Some(handler) = self.membership_handler.take() {
room.own_member().disconnect(handler);
}
if let Some(handler) = self.join_rule_handler.take() {
room.join_rule().disconnect(handler);
}
}
self.room.disconnect_signals();
}
fn set_room(&self, room: Option<Room>) {
if self.room.obj() == room {
return;
}
let obj = self.obj();
self.disconnect_all();
if let Some(source_id) = self.scroll_timeout.take() {
source_id.remove();
}
if let Some(source_id) = self.read_timeout.take() {
source_id.remove();
}
if let Some(room) = room {
let timeline = room.timeline();
self.room_members
.replace(Some(room.get_or_create_members()));
let membership_handler = room.own_member().connect_membership_notify(clone!(
#[weak]
obj,
move |_| {
obj.update_room_menu();
}
));
self.membership_handler.replace(Some(membership_handler));
let join_rule_handler = room.join_rule().connect_we_can_join_notify(clone!(
#[weak]
obj,
move |_| {
obj.update_room_menu();
}
));
self.join_rule_handler.replace(Some(join_rule_handler));
let tombstoned_handler = room.connect_is_tombstoned_notify(clone!(
#[weak]
obj,
move |_| {
obj.update_tombstoned_banner();
}
));
let successor_handler = room.connect_successor_id_string_notify(clone!(
#[weak]
obj,
move |_| {
obj.update_tombstoned_banner();
}
));
let successor_room_handler = room.connect_successor_notify(clone!(
#[weak]
obj,
move |_| {
obj.update_tombstoned_banner();
}
));
self.room.set(
room,
vec![
tombstoned_handler,
successor_handler,
successor_room_handler,
],
);
let empty_handler = timeline.connect_empty_notify(clone!(
#[weak]
obj,
move |_| {
obj.update_view();
}
));
let state_handler = timeline.connect_state_notify(clone!(
#[weak]
obj,
move |timeline| {
obj.update_view();
if timeline.state() == TimelineState::Ready {
obj.start_loading();
}
}
));
self.timeline_handlers
.replace(vec![empty_handler, state_handler]);
timeline.remove_empty_typing_row();
obj.selection_model().set_model(Some(&timeline.items()));
obj.trigger_read_receipts_update();
obj.init_invite_action();
obj.scroll_down();
} else {
obj.selection_model().set_model(None::<&gio::ListModel>);
}
self.is_loading.set(false);
obj.update_view();
obj.start_loading();
obj.update_room_menu();
obj.update_tombstoned_banner();
obj.notify_room();
obj.notify_empty();
}
fn empty(&self) -> bool {
self.room.obj().is_none()
}
fn set_sticky(&self, sticky: bool) {
tracing::trace!("set_sticky: {sticky:?}");
if self.sticky.get() == sticky {
return;
}
tracing::trace!("sticky changed");
if !sticky {
self.scroll_btn_revealer.set_visible(true);
}
self.scroll_btn_revealer.set_reveal_child(!sticky);
self.sticky.set(sticky);
self.obj().notify_sticky();
}
pub(super) fn set_is_auto_scrolling(&self, is_auto: bool) {
tracing::trace!("set_is_auto_scrolling: {is_auto:?}");
if self.is_auto_scrolling.get() == is_auto {
return;
}
tracing::trace!("is_auto_scrolling changed");
self.is_auto_scrolling.set(is_auto);
}
}
}
glib::wrapper! {
pub struct RoomHistory(ObjectSubclass<imp::RoomHistory>)
@extends gtk::Widget, adw::Bin, @implements gtk::Accessible;
}
#[gtk::template_callbacks]
impl RoomHistory {
pub fn new() -> Self {
glib::Object::new()
}
pub fn header_bar(&self) -> &adw::HeaderBar {
&self.imp().header_bar
}
fn message_toolbar(&self) -> &MessageToolbar {
&self.imp().message_toolbar
}
pub fn room_members(&self) -> Option<MemberList> {
self.imp().room_members.borrow().clone()
}
fn selection_model(&self) -> >k::NoSelection {
self.imp()
.selection_model
.get_or_init(|| gtk::NoSelection::new(gio::ListModel::NONE.cloned()))
}
async fn leave(&self) {
let Some(room) = self.room() else {
return;
};
if confirm_leave_room_dialog(&room, self).await.is_none() {
return;
}
if room.set_category(RoomType::Left).await.is_err() {
toast!(
self,
gettext(
"Could not leave {room}",
),
@room,
);
}
}
async fn join(&self) {
let Some(room) = self.room() else {
return;
};
if room.set_category(RoomType::Normal).await.is_err() {
toast!(
self,
gettext_f(
"Could not join room {room_name}. Try again later.",
&[("room_name", &room.display_name())],
)
);
}
}
async fn forget(&self) {
let Some(room) = self.room() else {
return;
};
if room.forget().await.is_err() {
toast!(
self,
gettext("Could not forget {room}"),
@room,
);
}
}
fn init_invite_action(&self) {
let Some(room) = self.room() else {
return;
};
let permissions = room.permissions();
let can_invite_handler = permissions.connect_can_invite_notify(clone!(
#[weak(rename_to = obj)]
self,
move |permissions| {
obj.action_set_enabled("room-history.invite-members", permissions.can_invite());
}
));
self.imp()
.can_invite_handler
.replace(Some(can_invite_handler));
self.action_set_enabled("room-history.invite-members", permissions.can_invite());
}
pub fn open_room_details(&self, subpage_name: Option<room_details::SubpageName>) {
let Some(room) = self.room() else {
return;
};
let window = RoomDetails::new(self.root().and_downcast_ref(), &room);
if let Some(subpage_name) = subpage_name {
window.show_initial_subpage(subpage_name);
}
window.present();
}
fn update_room_menu(&self) {
let imp = self.imp();
let Some(room) = self.room() else {
imp.room_menu.set_visible(false);
return;
};
let membership = room.own_member().membership();
self.action_set_enabled("room-history.leave", membership == Membership::Join);
self.action_set_enabled(
"room-history.join",
membership == Membership::Leave && room.join_rule().we_can_join(),
);
self.action_set_enabled(
"room-history.forget",
matches!(membership, Membership::Leave | Membership::Ban),
);
imp.room_menu.set_visible(true);
}
fn update_view(&self) {
let imp = self.imp();
if let Some(room) = self.room() {
if room.timeline().empty() {
if room.timeline().state() == TimelineState::Error {
imp.stack.set_visible_child(&*imp.error);
} else {
imp.stack.set_visible_child(&*imp.loading);
}
} else {
imp.stack.set_visible_child(&*imp.content);
}
}
}
fn need_messages(&self) -> bool {
let Some(room) = self.room() else {
return false;
};
let timeline = room.timeline();
if !timeline.can_load() {
return false;
}
if timeline.empty() {
return true;
};
let adj = self.imp().listview.vadjustment().unwrap();
adj.value() < adj.page_size() * 2.0 || adj.upper() <= adj.page_size() / 2.0
}
fn start_loading(&self) {
let imp = self.imp();
if imp.is_loading.get() {
return;
}
if !self.need_messages() {
return;
}
let Some(room) = self.room() else {
return;
};
imp.is_loading.set(true);
let obj_weak = self.downgrade();
spawn!(glib::Priority::DEFAULT_IDLE, async move {
room.timeline().load().await;
if let Some(obj) = obj_weak.upgrade() {
obj.imp().is_loading.set(false);
}
});
}
pub fn scroll_down(&self) {
tracing::trace!("scroll_down");
let imp = self.imp();
imp.set_is_auto_scrolling(true);
let n_items = self.selection_model().n_items();
if n_items > 0 {
imp.listview
.scroll_to(n_items - 1, gtk::ListScrollFlags::FOCUS, None);
}
}
fn is_at_bottom(&self) -> bool {
let adj = self.imp().listview.vadjustment().unwrap();
adj.value() + adj.page_size() == adj.upper()
}
pub fn enable_sticky_mode(&self) {
self.set_sticky(self.is_at_bottom());
}
fn try_again(&self) {
self.start_loading();
}
pub fn handle_paste_action(&self) {
self.message_toolbar().handle_paste_action();
}
pub fn item_context_menu(&self) -> >k::PopoverMenu {
self.imp().item_context_menu.get_or_init(|| {
let popover = gtk::PopoverMenu::builder()
.has_arrow(false)
.halign(gtk::Align::Start)
.build();
popover.update_property(&[gtk::accessible::Property::Label(&gettext("Context Menu"))]);
popover
})
}
pub fn item_reaction_chooser(&self) -> &ReactionChooser {
&self.imp().item_reaction_chooser
}
pub fn sender_context_menu(&self) -> >k::PopoverMenu {
let imp = self.imp();
imp.sender_context_menu.get_or_init(|| {
let popover = gtk::PopoverMenu::builder()
.has_arrow(false)
.halign(gtk::Align::Start)
.menu_model(&*imp.sender_menu_model)
.build();
popover.update_property(&[gtk::accessible::Property::Label(&gettext(
"Sender Context Menu",
))]);
popover
})
}
fn scroll_to_event(&self, key: &EventKey) {
let room = match self.room() {
Some(room) => room,
None => return,
};
if let Some(pos) = room.timeline().find_event_position(key) {
let pos = pos as u32;
self.imp()
.listview
.scroll_to(pos, gtk::ListScrollFlags::FOCUS, None);
}
}
fn trigger_read_receipts_update(&self) {
let Some(room) = self.room() else {
return;
};
let timeline = room.timeline();
if !timeline.empty() {
let imp = self.imp();
if let Some(source_id) = imp.scroll_timeout.take() {
source_id.remove();
}
if let Some(source_id) = imp.read_timeout.take() {
source_id.remove();
}
imp.scroll_timeout
.replace(Some(glib::timeout_add_local_once(
SCROLL_TIMEOUT,
clone!(
#[weak(rename_to = obj)]
self,
move || {
obj.update_read_receipts();
}
),
)));
}
}
fn update_read_receipts(&self) {
let imp = self.imp();
imp.scroll_timeout.take();
if let Some(source_id) = imp.read_timeout.take() {
source_id.remove();
}
imp.read_timeout.replace(Some(glib::timeout_add_local_once(
READ_TIMEOUT,
clone!(
#[weak(rename_to = obj)]
self,
move || {
obj.update_read_marker();
}
),
)));
let Some(position) = self.receipt_position() else {
return;
};
spawn!(clone!(
#[weak(rename_to = obj)]
self,
async move {
obj.send_receipt(ReceiptType::Read, position).await;
}
));
}
fn update_read_marker(&self) {
let imp = self.imp();
imp.read_timeout.take();
let Some(position) = self.receipt_position() else {
return;
};
spawn!(clone!(
#[weak(rename_to = obj)]
self,
async move {
obj.send_receipt(ReceiptType::FullyRead, position).await;
}
));
}
fn receipt_position(&self) -> Option<ReceiptPosition> {
let position = if self.is_at_bottom() {
ReceiptPosition::End
} else {
ReceiptPosition::Event(self.last_visible_event_id()?)
};
Some(position)
}
fn last_visible_event_id(&self) -> Option<OwnedEventId> {
let listview = &*self.imp().listview;
let mut child = listview.last_child();
let max = listview.height() as f32;
while let Some(item) = child {
let top_pos = item
.compute_point(listview, &Point::new(0.0, 0.0))
.unwrap()
.y();
let bottom_pos = item
.compute_point(listview, &Point::new(0.0, item.height() as f32))
.unwrap()
.y();
let top_in_view = top_pos > 0.0 && top_pos <= max;
let bottom_in_view = bottom_pos > 0.0 && bottom_pos <= max;
let content_in_view = top_pos <= max && bottom_pos > 0.0;
if top_in_view || bottom_in_view || content_in_view {
if let Some(event_id) = item
.first_child()
.and_downcast::<ItemRow>()
.and_then(|row| row.item())
.and_downcast::<Event>()
.and_then(|event| event.event_id())
{
return Some(event_id);
}
}
child = item.prev_sibling();
}
None
}
async fn send_receipt(&self, receipt_type: ReceiptType, position: ReceiptPosition) {
let Some(room) = self.room() else {
return;
};
let Some(session) = room.session() else {
return;
};
tracing::trace!(
"{}::send_receipt: {receipt_type:?} at {position:?}",
room.human_readable_id()
);
let send_public_receipt = session.settings().public_read_receipts_enabled();
let receipt_type = match receipt_type {
ReceiptType::Read if !send_public_receipt => ReceiptType::ReadPrivate,
t => t,
};
let matrix_timeline = room.timeline().matrix_timeline();
let handle = spawn_tokio!(async move {
match position {
ReceiptPosition::End => matrix_timeline.mark_as_read(receipt_type).await,
ReceiptPosition::Event(event_id) => {
matrix_timeline
.send_single_receipt(receipt_type, ReceiptThread::Unthreaded, event_id)
.await
}
}
});
if let Err(error) = handle.await.unwrap() {
error!("Could not send read receipt: {error}");
}
}
fn update_tombstoned_banner(&self) {
let banner = &self.imp().tombstoned_banner;
let Some(room) = self.room() else {
banner.set_revealed(false);
return;
};
if !room.is_tombstoned() {
banner.set_revealed(false);
return;
}
if room.successor().is_some() {
banner.set_title(&gettext("There is a newer version of this room"));
banner.set_button_label(Some(&gettext("View")));
} else if room.successor_id().is_some() {
banner.set_title(&gettext("There is a newer version of this room"));
banner.set_button_label(Some(&gettext("Join")));
} else {
banner.set_title(&gettext("This room was closed"));
banner.set_button_label(None);
}
banner.set_revealed(true);
}
#[template_callback]
async fn join_or_view_successor(&self) {
let Some(room) = self.room() else {
return;
};
let Some(session) = room.session() else {
return;
};
if !room.is_joined() || !room.is_tombstoned() {
return;
}
if let Some(successor) = room.successor() {
let Some(window) = self.root().and_downcast::<Window>() else {
return;
};
window.show_room(session.session_id(), successor.room_id());
} else if let Some(successor_id) = room.successor_id().map(ToOwned::to_owned) {
let via = successor_id
.server_name()
.map(ToOwned::to_owned)
.into_iter()
.collect();
if let Err(error) = session
.room_list()
.join_by_id_or_alias(successor_id.into(), via)
.await
{
toast!(self, error);
}
}
}
}
#[derive(Debug, Clone)]
enum ReceiptPosition {
End,
Event(OwnedEventId),
}