From 1dc09cc2997ae6f13073dc9aa53ce4c349a6ca41 Mon Sep 17 00:00:00 2001 From: Kevin Boos Date: Wed, 8 Jul 2026 18:31:14 -0700 Subject: [PATCH 1/2] Pause sending timeline updates while a timeline is closed in the UI Addressed another stretch goal of #292, which was basically to not send and endlessly accumulate lots of TimelineUpdates to a room or thread when it wasn't being shown in the UI. Now the background timeline subscriber async task does still process those updates but doesn't tell the UI. Instead it just coalesces them locally and then informs the UI in one very fast & efficient cumulative update about the latest state of the timeline's events. --- src/home/loading_pane.rs | 16 +++--- src/home/room_screen.rs | 20 +++++-- src/home/rooms_list.rs | 3 +- src/sliding_sync.rs | 117 +++++++++++++++++++++++++++------------ 4 files changed, 107 insertions(+), 49 deletions(-) diff --git a/src/home/loading_pane.rs b/src/home/loading_pane.rs index baa975a3d..cea03385a 100644 --- a/src/home/loading_pane.rs +++ b/src/home/loading_pane.rs @@ -120,12 +120,12 @@ impl Drop for LoadingPane { fn drop(&mut self) { if let LoadingPaneState::BackwardsPaginateUntilEvent { target_event_id, request_sender, .. } = &self.state { warning!("Dropping LoadingPane with target_event_id: {}", target_event_id); - request_sender.send_if_modified(|requests| { - let initial_len = requests.len(); - requests.retain(|r| &r.target_event_id != target_event_id); + request_sender.send_if_modified(|req| { + let initial_len = req.backwards_paginate.len(); + req.backwards_paginate.retain(|r| &r.target_event_id != target_event_id); // if we actually cancelled this request, notify the receivers // such that they can stop looking for the target event. - requests.len() != initial_len + req.backwards_paginate.len() != initial_len }); } } @@ -176,12 +176,12 @@ impl Widget for LoadingPane { }; if close_pane { if let LoadingPaneState::BackwardsPaginateUntilEvent { target_event_id, request_sender, .. } = &self.state { - let _did_send = request_sender.send_if_modified(|requests| { - let initial_len = requests.len(); - requests.retain(|r| &r.target_event_id != target_event_id); + let _did_send = request_sender.send_if_modified(|req| { + let initial_len = req.backwards_paginate.len(); + req.backwards_paginate.retain(|r| &r.target_event_id != target_event_id); // if we actually cancelled this request, notify the receivers // such that they can stop looking for the target event. - requests.len() != initial_len + req.backwards_paginate.len() != initial_len }); log!("LoadingPane: {} cancel request for target_event_id: {target_event_id}", if _did_send { "Sent" } else { "Did not send" }, diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index 4eb7015f7..caf20bdab 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -1616,8 +1616,8 @@ impl RoomScreen { } TimelineUpdate::TargetEventFound { target_event_id, index } => { // log!("Target event found in room {}: {target_event_id}, index: {index}", tl.kind.room_id()); - tl.request_sender.send_if_modified(|requests| { - requests.retain(|r| &r.room_id != tl.kind.room_id()); + tl.request_sender.send_if_modified(|req| { + req.backwards_paginate.retain(|r| &r.room_id != tl.kind.room_id()); // no need to notify/wake-up all receivers for a completed request false }); @@ -2478,13 +2478,13 @@ impl RoomScreen { ); loading_pane.show(cx); - tl.request_sender.send_if_modified(|requests| { - if let Some(existing) = requests.iter_mut().find(|r| &r.room_id == tl.kind.room_id()) { + tl.request_sender.send_if_modified(|req| { + if let Some(existing) = req.backwards_paginate.iter_mut().find(|r| &r.room_id == tl.kind.room_id()) { warning!("Unexpected: room {} already had an existing timeline request in progress, event: {:?}", tl.kind.room_id(), existing.target_event_id); // We might as well re-use this existing request... existing.target_event_id = target_event_id.clone(); } else { - requests.push(BackwardsPaginateUntilEventRequest { + req.backwards_paginate.push(BackwardsPaginateUntilEventRequest { room_id: tl.kind.room_id().clone(), target_event_id: target_event_id.clone(), // avoid re-searching through items we already searched through. @@ -2685,6 +2685,11 @@ impl RoomScreen { // such that it can be accessed in future functions like event/draw handlers. self.tl_state = Some(tl_state); + // Tell the background subscriber that this timeline is now open (if it was previously closed). + if let Some(tl) = self.tl_state.as_ref() { + tl.request_sender.send_if_modified(|req| !std::mem::replace(&mut req.is_timeline_open, true)); + } + // Now that we have restored the TimelineUiState into this RoomScreen widget, // we can proceed to processing pending background updates. self.process_timeline_updates(cx, &self.portal_list(cx, ids!(list))); @@ -2699,6 +2704,11 @@ impl RoomScreen { return; } + // Tell the background subscriber that this timeline is now closed. + if let Some(tl) = self.tl_state.as_ref() { + tl.request_sender.send_if_modified(|req| std::mem::replace(&mut req.is_timeline_open, false)); + } + self.save_state(); // When closing a room view, we do the following with non-persistent states. diff --git a/src/home/rooms_list.rs b/src/home/rooms_list.rs index 85ea70e96..c3e1ad2e2 100644 --- a/src/home/rooms_list.rs +++ b/src/home/rooms_list.rs @@ -1498,8 +1498,7 @@ impl Widget for RoomsList { scope = Scope::with_props(&*direct_room); item.draw_all(cx, &mut scope); } else { - list.item(cx, portal_list_index, id!(empty)) - .draw_all(cx, &mut scope); + list.item(cx, portal_list_index, id!(empty)).draw_all(cx, &mut scope); } } else if self.regular_rooms_indexes.header_index == Some(portal_list_index) { diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 32dd655c5..8bfdeede6 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -1010,7 +1010,10 @@ async fn matrix_worker_task( log!("Successfully created thread-focused timeline for room {room_id}, thread {thread_root_event_id}."); let thread_timeline = Arc::new(thread_timeline); let (timeline_update_sender, timeline_update_receiver) = crossbeam_channel::unbounded(); - let (request_sender, request_receiver) = watch::channel(Vec::new()); + let (request_sender, request_receiver) = watch::channel(TimelineRequest { + backwards_paginate: Vec::new(), + is_timeline_open: true, + }); let timeline_subscriber_handler_task = Handle::current().spawn( timeline_subscriber_handler( thread_timeline.clone(), @@ -2410,7 +2413,18 @@ pub fn start_matrix_tokio() -> Result { /// A tokio::watch channel sender for sending requests from the RoomScreen UI widget /// to the corresponding background async task for that room (its `timeline_subscriber_handler`). -pub type TimelineRequestSender = watch::Sender>; +pub type TimelineRequestSender = watch::Sender; + +/// Details of current requests that the RoomScreen UI has made of the background timeline subscriber task. +pub struct TimelineRequest { + /// Pending backwards-pagination-until-event requests (jump to a specific event). + pub backwards_paginate: Vec, + /// Whether this timeline is currently open in the UI. + /// + /// The timeline subscriber stops sending updates while it's closed, + /// and when it gets re-opened, it sends one catch-up update. + pub is_timeline_open: bool, +} /// The return type for [`take_timeline_endpoints()`]. /// @@ -2431,7 +2445,7 @@ pub struct TimelineEndpoints { enum TimelineSubscriber { /// The timeline (room or thread) hasn't been opened yet, so its background subscriber task isn't running. NotStarted { - request_receiver: watch::Receiver>, + request_receiver: watch::Receiver, }, /// The timeline's background subscriber task is running, meaning the room has been opened at least once. Running(JoinHandle<()>), @@ -3734,7 +3748,10 @@ async fn add_new_room( // The `timeline_subscriber_handler` async task is spawned lazily when the room/thread is first opened. // All we do here is set up a channel between the UI and backend, for future use. - let (request_sender, request_receiver) = watch::channel(Vec::new()); + let (request_sender, request_receiver) = watch::channel(TimelineRequest { + backwards_paginate: Vec::new(), + is_timeline_open: true, + }); // We need to add the room to the `ALL_JOINED_ROOMS` list before we can send // an `AddJoinedRoom` update to the RoomsList widget, because that widget might @@ -4375,7 +4392,7 @@ const LOG_ROOM_LIST_DIFFS: bool = cfg!(feature = "log_room_list_diffs"); async fn timeline_subscriber_handler( timeline: Arc, timeline_update_sender: crossbeam_channel::Sender, - mut request_receiver: watch::Receiver>, + mut request_receiver: watch::Receiver, thread_root_event_id: Option, ) { /// An inner function that searches the given new timeline items for a target event. @@ -4419,6 +4436,13 @@ async fn timeline_subscriber_handler( // the timeline index and event ID of the target event, if it has been found. let mut found_target_event_id: Option<(usize, OwnedEventId)> = None; + // Whether this timeline is currently open in the UI. + // This starts as true because we only spawn this subscriber task lazily upon open. + let mut is_timeline_open = true; + // Whether any update changes have arrived since this timeline was last closed, + // meaning that we need to send an cumulative update when the timeline gets re-opened. + let mut has_unsent_changes = false; + loop { tokio::select! { // we should check for new requests before handling new timeline updates, // because the request might influence how we handle a timeline update. @@ -4427,13 +4451,31 @@ async fn timeline_subscriber_handler( // Handle updates to the current backwards pagination requests. Ok(()) = request_receiver.changed() => { let prev_target_event_id = target_event_id.clone(); - let new_request_details = request_receiver - .borrow_and_update() - .iter() - .find_map(|req| req.room_id - .eq(&room_id) - .then(|| (req.target_event_id.clone(), req.starting_index, req.current_tl_len)) - ); + let (now_open, new_request_details) = { + let req = request_receiver.borrow_and_update(); + let details = req.backwards_paginate.iter() + .find_map(|r| r.room_id + .eq(&room_id) + .then(|| (r.target_event_id.clone(), r.starting_index, r.current_tl_len)) + ); + (req.is_timeline_open, details) + }; + + // On reopen, send one catch-up snapshot, but only if something actually + // changed while closed (otherwise the UI already has the current items). + if now_open && !is_timeline_open && has_unsent_changes { + let len = timeline_items.len(); + if timeline_update_sender.send(TimelineUpdate::NewItems { + new_items: timeline_items.clone(), + changed_indices: 0..len, + clear_cache: true, + is_append: false, + }).is_ok() { + SignalToUI::set_ui_signal(); + } + has_unsent_changes = false; + } + is_timeline_open = now_open; target_event_id = new_request_details.as_ref().map(|(ev, ..)| ev.clone()); @@ -4643,29 +4685,36 @@ async fn timeline_subscriber_handler( if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: applied {num_updates} updates for room {room_id}, thread {thread_root_event_id:?}, timeline now has {} items. is_append? {is_append}, clear_cache? {clear_cache}. Changes: {changed_indices:?}.", timeline_items.len()); } - timeline_update_sender.send(TimelineUpdate::NewItems { - new_items: timeline_items.clone(), - changed_indices, - clear_cache, - is_append, - }).expect("Error: timeline update sender couldn't send update with new items!"); - - // We must send this update *after* the actual NewItems update, - // otherwise the UI thread (RoomScreen) won't be able to correctly locate the target event. - if let Some((index, found_event_id)) = found_target_event_id.take() { - target_event_id = None; - timeline_update_sender.send( - TimelineUpdate::TargetEventFound { - target_event_id: found_event_id.clone(), - index, - } - ).unwrap_or_else( - |_e| panic!("Error: timeline update sender couldn't send TargetEventFound({found_event_id}, {index}) to room {room_id}, thread {thread_root_event_id:?}!") - ); - } + // Only send updates to the UI while this timeline is open. + // While it's closed, we process the updates locally until it is re-opened agian. + if is_timeline_open { + timeline_update_sender.send(TimelineUpdate::NewItems { + new_items: timeline_items.clone(), + changed_indices, + clear_cache, + is_append, + }).expect("Error: timeline update sender couldn't send update with new items!"); + + // We must send this update *after* the actual NewItems update, + // otherwise the UI thread (RoomScreen) won't be able to correctly locate the target event. + if let Some((index, found_event_id)) = found_target_event_id.take() { + target_event_id = None; + timeline_update_sender.send( + TimelineUpdate::TargetEventFound { + target_event_id: found_event_id.clone(), + index, + } + ).unwrap_or_else( + |_e| panic!("Error: timeline update sender couldn't send TargetEventFound({found_event_id}, {index}) to room {room_id}, thread {thread_root_event_id:?}!") + ); + } - // Send a Makepad-level signal to update this room's timeline UI view. - SignalToUI::set_ui_signal(); + // Send a Makepad-level signal to update this room's timeline UI view. + SignalToUI::set_ui_signal(); + } else { + // Closed: our local items are updated above; remember to catch the UI up on reopen. + has_unsent_changes = true; + } } } From aad9da6e896abaa894e83184e6f49eed3d68f232 Mon Sep 17 00:00:00 2001 From: Kevin Boos Date: Wed, 8 Jul 2026 18:35:01 -0700 Subject: [PATCH 2/2] typos --- src/sliding_sync.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 8bfdeede6..80bc62bd5 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -4686,7 +4686,7 @@ async fn timeline_subscriber_handler( log!("timeline_subscriber: applied {num_updates} updates for room {room_id}, thread {thread_root_event_id:?}, timeline now has {} items. is_append? {is_append}, clear_cache? {clear_cache}. Changes: {changed_indices:?}.", timeline_items.len()); } // Only send updates to the UI while this timeline is open. - // While it's closed, we process the updates locally until it is re-opened agian. + // While it's closed, we process the updates locally until it is re-opened again. if is_timeline_open { timeline_update_sender.send(TimelineUpdate::NewItems { new_items: timeline_items.clone(),