diff --git a/src/session/content/room_history/mod.rs b/src/session/content/room_history/mod.rs index 7e4cdc1e..11fe86ab 100644 --- a/src/session/content/room_history/mod.rs +++ b/src/session/content/room_history/mod.rs @@ -8,7 +8,7 @@ mod state_row; mod typing_row; mod verification_info_bar; -use std::str::FromStr; +use std::{str::FromStr, time::Duration}; use adw::subclass::prelude::*; use ashpd::{ @@ -35,9 +35,14 @@ use matrix_sdk::{ EventId, }, }; -use ruma::events::{ - room::message::{ForwardThread, LocationMessageEventContent, RoomMessageEventContent}, - AnyMessageLikeEventContent, +use ruma::{ + api::client::receipt::create_receipt::v3::ReceiptType, + events::{ + receipt::ReceiptThread, + room::message::{ForwardThread, LocationMessageEventContent, RoomMessageEventContent}, + AnyMessageLikeEventContent, + }, + OwnedEventId, }; use sourceview::prelude::*; @@ -61,6 +66,11 @@ use crate::{ }, }; +/// The time to wait before considering that scrolling has ended. +const SCROLL_TIMEOUT: Duration = Duration::from_millis(500); +/// The time to wait before considering that messages on a screen where read. +const READ_TIMEOUT: Duration = Duration::from_secs(10); + #[derive(Debug, Default, Hash, Eq, PartialEq, Clone, Copy, glib::Enum)] #[repr(i32)] #[enum_type(name = "RelatedEventType")] @@ -127,6 +137,8 @@ mod imp { pub related_event_content: TemplateChild, pub related_event_type: Cell, pub related_event: RefCell>, + pub scroll_timeout: RefCell>, + pub read_timeout: RefCell>, } #[glib::object_subclass] @@ -316,6 +328,8 @@ mod imp { adj.connect_value_changed(clone!(@weak obj => move |adj| { let imp = obj.imp(); + obj.trigger_read_receipts_update(); + let is_at_bottom = adj.value() + adj.page_size() == adj.upper(); if imp.is_auto_scrolling.get() { if is_at_bottom { @@ -486,6 +500,13 @@ impl RoomHistory { self.clear_related_event(); } + if let Some(source_id) = imp.scroll_timeout.take() { + source_id.remove(); + } + if let Some(source_id) = imp.read_timeout.take() { + source_id.remove(); + } + if let Some(ref room) = room { let timeline = room.timeline(); @@ -514,6 +535,8 @@ impl RoomHistory { imp.state_timeline_handler.replace(Some(handler_id)); timeline.remove_empty_typing_row(); + self.trigger_read_receipts_update(); + room.load_members(); self.init_invite_action(room); self.scroll_down(); @@ -1239,6 +1262,128 @@ impl RoomHistory { room.send_typing_notification(typing); } } + + /// Trigger the process to update read receipts. + fn trigger_read_receipts_update(&self) { + let Some(room) = self.room() else { + return; + }; + + let timeline = room.timeline(); + if !timeline.is_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(); + } + + // Only send read receipt when scrolling stopped. + imp.scroll_timeout + .replace(Some(glib::timeout_add_local_once( + SCROLL_TIMEOUT, + clone!(@weak self as obj => move || { + obj.update_read_receipts(); + }), + ))); + } + } + + /// Update the 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 self as obj => move || { + obj.update_read_marker(); + }), + ))); + + let last_event_id = self.last_visible_event_id(); + + if let Some(event_id) = last_event_id { + spawn!(clone!(@weak self as obj => async move { + obj.send_receipt(ReceiptType::Read, event_id).await; + })); + } + } + + /// Update the read marker. + fn update_read_marker(&self) { + let imp = self.imp(); + imp.read_timeout.take(); + + let last_event_id = self.last_visible_event_id(); + + if let Some(event_id) = last_event_id { + spawn!(clone!(@weak self as obj => async move { + obj.send_receipt(ReceiptType::FullyRead, event_id).await; + })); + } + } + + /// Get the ID of the last visible event in the room history. + fn last_visible_event_id(&self) -> Option { + let listview = &*self.imp().listview; + let mut child = listview.last_child(); + // The visible part of the listview spans between 0 and max. + let max = listview.height() as f64; + + while let Some(item) = child { + // Vertical position of the top of the item. + let (_, top_pos) = item.translate_coordinates(listview, 0.0, 0.0).unwrap(); + // Vertical position of the bottom of the item. + let (_, bottom_pos) = item + .translate_coordinates(listview, 0.0, item.height() as f64) + .unwrap(); + + let top_in_view = top_pos > 0.0 && top_pos <= max; + let bottom_in_view = bottom_pos > 0.0 && bottom_pos <= max; + // If a message is too big and takes more space than the current view. + 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_then(|child| child.downcast::().ok()) + .and_then(|row| row.item()) + .and_then(|item| item.downcast::().ok()) + .and_then(|event| event.event_id()) + { + return Some(event_id); + } + } + + child = item.prev_sibling(); + } + + None + } + + /// Send the given receipt. + async fn send_receipt(&self, receipt_type: ReceiptType, event_id: OwnedEventId) { + let Some(room) = self.room() else { + return; + }; + + let matrix_timeline = room.timeline().matrix_timeline(); + let handle = spawn_tokio!(async move { + matrix_timeline + .send_single_receipt(receipt_type, ReceiptThread::Unthreaded, event_id) + .await + }); + + if let Err(error) = handle.await.unwrap() { + error!("Failed to send read receipt: {error}"); + } + } } enum MentionChunk {