matrix_ui_serializable/room/room_screen.rs
1use std::{
2 collections::{BTreeMap, HashSet},
3 sync::Arc,
4};
5
6use matrix_sdk::ruma::{OwnedEventId, OwnedRoomId, OwnedUserId};
7use matrix_sdk_ui::{eyeball_im::Vector, timeline::TimelineItem};
8use serde::Serialize;
9
10use crate::{
11 events::timeline::{
12 PaginationDirection, TIMELINE_STATES, TimelineUiState, TimelineUpdate,
13 take_timeline_endpoints,
14 },
15 models::{
16 async_requests::{MatrixRequest, submit_async_request},
17 events::{ToastNotificationRequest, ToastNotificationVariant},
18 state_updater::StateUpdater,
19 },
20 room::notifications::enqueue_toast_notification,
21 user::user_power_level::UserPowerLevels,
22 utils::room_name_or_id,
23};
24
25/// A serializable struct representing the state of a given Matrix Room.
26/// Fields are not exposed to the adapter directly, the adapter can only serialize this struct.
27#[derive(Debug, Serialize)]
28#[serde(rename_all = "camelCase")]
29pub struct RoomScreen {
30 /// The room ID of the currently-shown room.
31 room_id: OwnedRoomId,
32 /// The display name of the currently-shown room.
33 room_name: String,
34 /// The persistent UI-relevant states for the room that this widget is currently displaying.
35 tl_state: Option<TimelineUiState>,
36 /// Known members of this room
37 members: BTreeMap<OwnedUserId, FrontendRoomMember>,
38 /// The users of this room that are currently typing a message
39 typing_users: HashSet<String>,
40 /// Wether this room is still loading or not
41 done_loading: bool,
42 /// The state updater passed by the adapter
43 #[serde(skip)]
44 state_updaters: Arc<Box<dyn StateUpdater>>,
45}
46impl Drop for RoomScreen {
47 fn drop(&mut self) {
48 // This ensures that the `TimelineUiState` instance owned by this room is *always* returned
49 // back to to `TIMELINE_STATES`, which ensures that its UI state(s) are not lost
50 // and that other RoomScreen instances can show this room in the future.
51 // RoomScreen will be dropped whenever its widget instance is destroyed, e.g.,
52 // when a Tab is closed or the app is resized to a different AdaptiveView layout.
53 self.hide_timeline();
54 }
55}
56
57impl RoomScreen {
58 pub fn new(
59 updaters: Arc<Box<dyn StateUpdater>>,
60 room_id: OwnedRoomId,
61 room_name: String,
62 ) -> Self {
63 Self {
64 room_id,
65 room_name,
66 tl_state: None,
67 members: BTreeMap::new(),
68 typing_users: HashSet::new(),
69 done_loading: false,
70 state_updaters: updaters,
71 }
72 }
73
74 fn update_frontend_state(&self) {
75 self.state_updaters
76 .update_room(self, self.room_id.as_str())
77 .expect(&format!(
78 "Couldn't update frontend store for room {:?}",
79 self.room_name,
80 ))
81 }
82
83 /// Processes all pending background updates to the currently-shown timeline.
84 pub fn process_timeline_updates(&mut self) {
85 let curr_first_id: usize = 0; // TODO: replace this dummy value
86
87 let Some(tl) = self.tl_state.as_mut() else {
88 return;
89 };
90
91 let mut should_continue_backwards_pagination = false;
92 let mut num_updates = 0;
93 while let Ok(update) = tl.update_receiver.try_recv() {
94 num_updates += 1;
95 match update {
96 TimelineUpdate::FirstUpdate { initial_items } => {
97 tl.fully_paginated = false;
98
99 tl.items = initial_items;
100 self.done_loading = true;
101 }
102 TimelineUpdate::NewItems {
103 new_items,
104 clear_cache,
105 } => {
106 if new_items.is_empty() {
107 if !tl.items.is_empty() {
108 println!(
109 "Timeline::handle_event(): timeline (had {} items) was cleared for room {}",
110 tl.items.len(),
111 tl.room_id
112 );
113 // For now, we paginate a cleared timeline in order to be able to show something at least.
114 // A proper solution would be what's described below, which would be to save a few event IDs
115 // and then either focus on them (if we're not close to the end of the timeline)
116 // or paginate backwards until we find them (only if we are close the end of the timeline).
117 should_continue_backwards_pagination = true;
118 }
119 }
120 if new_items.len() == tl.items.len() {
121 // println!("Timeline::handle_event(): no jump necessary for updated timeline of same length: {}", items.len());
122 } else if curr_first_id > new_items.len() {
123 println!(
124 "Timeline::handle_event(): jumping to bottom: curr_first_id {} is out of bounds for {} new items",
125 curr_first_id,
126 new_items.len()
127 );
128 } else if let Some((curr_item_idx, new_item_idx, new_item_scroll, _event_id)) =
129 find_new_item_matching_current_item(
130 0,
131 Some(0.0), // TODO replace
132 curr_first_id,
133 &tl.items,
134 &new_items,
135 )
136 {
137 if curr_item_idx != new_item_idx {
138 println!(
139 "Timeline::handle_event(): jumping view from event index {curr_item_idx} to new index {new_item_idx}, scroll {new_item_scroll}, event ID {_event_id}"
140 );
141 // Set scrolled_past_read_marker false when we jump to a new event
142 tl.scrolled_past_read_marker = false;
143 }
144 }
145 //
146 // TODO: after an (un)ignore user event, all timelines are cleared. Handle that here.
147 //
148 else {
149 // eprintln!("!!! Couldn't find new event with matching ID for ANY event currently visible in the portal list");
150 }
151
152 if clear_cache {
153 tl.fully_paginated = false;
154 }
155 tl.items = new_items;
156 self.done_loading = true;
157 }
158 TimelineUpdate::NewUnreadMessagesCount(_unread_messages_count) => {
159 // jump_to_bottom.show_unread_message_badge(unread_messages_count);
160 }
161 TimelineUpdate::TargetEventFound {
162 target_event_id,
163 index,
164 } => {
165 // println!("Target event found in room {}: {target_event_id}, index: {index}", tl.room_id);
166 tl.request_sender.send_if_modified(|requests| {
167 requests.retain(|r| r.room_id != tl.room_id);
168 // no need to notify/wake-up all receivers for a completed request
169 false
170 });
171
172 // sanity check: ensure the target event is in the timeline at the given `index`.
173 let item = tl.items.get(index);
174 let is_valid = item.is_some_and(|item| {
175 item.as_event()
176 .is_some_and(|ev| ev.event_id() == Some(&target_event_id))
177 });
178
179 // println!("TargetEventFound: is_valid? {is_valid}. room {}, event {target_event_id}, index {index} of {}\n --> item: {item:?}", tl.room_id, tl.items.len());
180 if is_valid {
181 } else {
182 // Here, the target event was not found in the current timeline,
183 // or we found it previously but it is no longer in the timeline (or has moved),
184 // which means we encountered an error and are unable to jump to the target event.
185 eprintln!(
186 "Target event index {index} of {} is out of bounds for room {}",
187 tl.items.len(),
188 tl.room_id
189 );
190 }
191
192 should_continue_backwards_pagination = false;
193 }
194 TimelineUpdate::PaginationRunning(direction) => {
195 println!(
196 "Pagination running in room {} in {direction} direction",
197 tl.room_id
198 );
199 if direction == PaginationDirection::Backwards {
200 self.done_loading = false;
201 } else {
202 eprintln!("Unexpected PaginationRunning update in the Forwards direction");
203 }
204 }
205 TimelineUpdate::PaginationError { error, direction } => {
206 eprintln!(
207 "Pagination error ({direction}) in room {}: {error:?}",
208 tl.room_id
209 );
210 self.done_loading = true;
211 }
212 TimelineUpdate::PaginationIdle {
213 fully_paginated,
214 direction,
215 } => {
216 if direction == PaginationDirection::Backwards {
217 // Don't set `done_loading` to `true`` here, because we want to keep the top space visible
218 // (with the "loading" message) until the corresponding `NewItems` update is received.
219 tl.fully_paginated = fully_paginated;
220 if fully_paginated {
221 self.done_loading = true;
222 }
223 } else {
224 eprintln!("Unexpected PaginationIdle update in the Forwards direction");
225 }
226 }
227 TimelineUpdate::EventDetailsFetched { event_id, result } => {
228 if let Err(_e) = result {
229 eprintln!(
230 "Failed to fetch details fetched for event {event_id} in room {}. Error: {_e:?}",
231 tl.room_id
232 );
233 }
234 // Here, to be most efficient, we could redraw only the updated event,
235 // but for now we just fall through and let the final `redraw()` call re-draw the whole timeline view.
236 }
237 TimelineUpdate::RoomMembersSynced => {
238 // println!("Timeline::handle_event(): room members fetched for room {}", tl.room_id);
239 // Here, to be most efficient, we could redraw only the user avatars and names in the timeline,
240 // but for now we just fall through and let the final `redraw()` call re-draw the whole timeline view.
241 }
242 TimelineUpdate::RoomMembersListFetched { members } => {
243 println!("RoomMembers list fetched !");
244 members.iter().for_each(|member| {
245 self.members.insert(
246 member.user_id().to_owned(),
247 FrontendRoomMember {
248 name: member.name().to_string(),
249 display_name_ambiguous: member.name_ambiguous(),
250 is_ignored: member.is_ignored(),
251 max_power_level: member.normalized_power_level(),
252 },
253 );
254 });
255 println!("{:?}", self.members);
256 }
257 TimelineUpdate::_MediaFetched => {
258 println!(
259 "Timeline::handle_event(): media fetched for room {}",
260 tl.room_id
261 );
262 // Here, to be most efficient, we could redraw only the media items in the timeline,
263 // but for now we just fall through and let the final `redraw()` call re-draw the whole timeline view.
264 }
265 TimelineUpdate::MessageEdited {
266 timeline_event_id,
267 result,
268 } => {
269 if result.is_ok() {
270 enqueue_toast_notification(ToastNotificationRequest::new(
271 format!("Successfully edited message."),
272 None,
273 ToastNotificationVariant::Success,
274 ));
275 } else {
276 eprintln!("Error editing event with id {timeline_event_id:?}");
277 enqueue_toast_notification(ToastNotificationRequest::new(
278 format!("Error while editing event."),
279 None,
280 ToastNotificationVariant::Error,
281 ));
282 }
283 }
284 TimelineUpdate::TypingUsers { users } => {
285 self.typing_users.extend(users);
286 }
287
288 TimelineUpdate::UserPowerLevels(user_power_level) => {
289 tl.user_power = user_power_level;
290 }
291
292 TimelineUpdate::OwnUserReadReceipt(receipt) => {
293 tl.latest_own_user_receipt = Some(receipt);
294 }
295 }
296 }
297
298 if should_continue_backwards_pagination {
299 println!("Continuing backwards pagination...");
300 submit_async_request(MatrixRequest::PaginateRoomTimeline {
301 room_id: tl.room_id.clone(),
302 num_events: 50,
303 direction: PaginationDirection::Backwards,
304 });
305 }
306
307 if num_updates > 0 {
308 // println!("Applied {} timeline updates for room {}, redrawing with {} items...", num_updates, tl.room_id, tl.items.len());
309 self.update_frontend_state();
310 }
311 }
312
313 /// Invoke this when this timeline is being shown,
314 /// e.g., when the user navigates to this timeline.
315 pub fn show_timeline(&mut self) {
316 let room_id = self.room_id.clone();
317 // just an optional sanity check
318 assert!(
319 self.tl_state.is_none(),
320 "BUG: tried to show_timeline() into a timeline with existing state. \
321 Did you forget to save the timeline state back to the global map of states?",
322 );
323
324 // Obtain the current user's power levels for this room.
325 submit_async_request(MatrixRequest::GetRoomPowerLevels {
326 room_id: room_id.clone(),
327 });
328
329 let state_opt = TIMELINE_STATES.lock().unwrap().remove(&room_id);
330 let (tl_state, first_time_showing_room) = if let Some(existing) = state_opt {
331 (existing, false)
332 } else {
333 let (_update_sender, update_receiver, request_sender) =
334 take_timeline_endpoints(&room_id)
335 .expect("BUG: couldn't get timeline state for first-viewed room.");
336 let new_tl_state = TimelineUiState {
337 room_id: room_id.clone(),
338 // We assume the user has all power levels by default, just to avoid
339 // unexpectedly hiding any UI elements that should be visible to the user.
340 // This doesn't mean that the user can actually perform all actions.
341 user_power: UserPowerLevels::all(),
342 // We assume timelines being viewed for the first time haven't been fully paginated.
343 fully_paginated: false,
344 items: Vector::new(),
345 update_receiver,
346 request_sender,
347 scrolled_past_read_marker: false,
348 latest_own_user_receipt: None,
349 };
350 (new_tl_state, true)
351 };
352
353 // Subscribe to typing notices, but hide the typing notice view initially.
354 submit_async_request(MatrixRequest::SubscribeToTypingNotices {
355 room_id: room_id.clone(),
356 subscribe: true,
357 });
358
359 submit_async_request(MatrixRequest::SubscribeToOwnUserReadReceiptsChanged {
360 room_id: room_id.clone(),
361 subscribe: true,
362 });
363 // Kick off a back pagination request for this room. This is "urgent",
364 // because we want to show the user some messages as soon as possible
365 // when they first open the room, and there might not be any messages yet.
366 if first_time_showing_room && !tl_state.fully_paginated {
367 println!(
368 "Sending a first-time backwards pagination request for room {}",
369 room_id
370 );
371 submit_async_request(MatrixRequest::PaginateRoomTimeline {
372 room_id: room_id.clone(),
373 num_events: 50,
374 direction: PaginationDirection::Backwards,
375 });
376 }
377
378 // This fetches the room members of the displayed timeline.
379 submit_async_request(MatrixRequest::SyncRoomMemberList {
380 room_id: room_id.clone(),
381 });
382
383 // As the final step, store the tl_state for this room into this RoomScreen widget,
384 // such that it can be accessed in future event/draw handlers.
385 self.tl_state = Some(tl_state);
386
387 // Now that we have restored the TimelineUiState into this RoomScreen widget,
388 // we can proceed to processing pending background updates, and if any were processed,
389 // the timeline will also be redrawn.
390 if first_time_showing_room {
391 self.process_timeline_updates();
392 }
393
394 self.update_frontend_state();
395 }
396
397 /// Invoke this when this RoomScreen/timeline is being hidden or no longer being shown.
398 fn hide_timeline(&mut self) {
399 self.save_state();
400
401 // When closing a room view, we do the following with non-persistent states:
402 // * Unsubscribe from typing notices, since we don't care about them
403 // when a given room isn't visible.
404 // * Clear the location preview. We don't save this to the TimelineUiState
405 // because the location might change by the next time the user opens this same room.
406 // self.location_preview(id!(location_preview)).clear();
407 submit_async_request(MatrixRequest::SubscribeToTypingNotices {
408 room_id: self.room_id.clone(),
409 subscribe: false,
410 });
411 submit_async_request(MatrixRequest::SubscribeToOwnUserReadReceiptsChanged {
412 room_id: self.room_id.clone(),
413 subscribe: false,
414 });
415 }
416
417 /// Removes the current room's visual UI state from this widget
418 /// and saves it to the map of `TIMELINE_STATES` such that it can be restored later.
419 ///
420 /// Note: after calling this function, the widget's `tl_state` will be `None`.
421 fn save_state(&mut self) {
422 let Some(tl) = self.tl_state.take() else {
423 eprintln!(
424 "Timeline::save_state(): skipping due to missing state, room {:?}",
425 self.room_id
426 );
427 return;
428 };
429 // Store this Timeline's `TimelineUiState` in the global map of states.
430 TIMELINE_STATES
431 .lock()
432 .unwrap()
433 .insert(tl.room_id.clone(), tl);
434 }
435
436 /// Sets this `RoomScreen` widget to display the timeline for the given room.
437 pub fn set_displayed_room<S: Into<Option<String>>>(
438 &mut self,
439 room_id: OwnedRoomId,
440 room_name: S,
441 ) {
442 // If the room is already being displayed, then do nothing.
443 // if self.room_id.as_ref().is_some_and(|id| id == &room_id) {
444 // return;
445 // }
446
447 self.hide_timeline();
448 self.room_name = room_name_or_id(room_name.into(), &room_id);
449 self.room_id = room_id.clone();
450
451 self.show_timeline();
452 }
453}
454
455/// Returns info about the item in the list of `new_items` that matches the event ID
456/// of a visible item in the given `curr_items` list.
457///
458/// This info includes a tuple of:
459/// 1. the index of the item in the current items list,
460/// 2. the index of the item in the new items list,
461/// 3. the positional "scroll" offset of the corresponding current item in the portal list,
462/// 4. the unique event ID of the item.
463fn find_new_item_matching_current_item(
464 visible_items: usize, // DUMMY PARAM TODO CHANGE THIS
465 position_of_item: Option<f64>, // DUMMY PARAM TODO CHANGE THIS
466 starting_at_curr_idx: usize,
467 curr_items: &Vector<Arc<TimelineItem>>,
468 new_items: &Vector<Arc<TimelineItem>>,
469) -> Option<(usize, usize, f64, OwnedEventId)> {
470 let mut curr_item_focus = curr_items.focus();
471 let mut idx_curr = starting_at_curr_idx;
472 let mut curr_items_with_ids: Vec<(usize, OwnedEventId)> = Vec::with_capacity(visible_items);
473
474 // Find all items with real event IDs that are currently visible in the portal list.
475 // TODO: if this is slow, we could limit it to 3-5 events at the most.
476 if curr_items_with_ids.len() <= visible_items {
477 while let Some(curr_item) = curr_item_focus.get(idx_curr) {
478 if let Some(event_id) = curr_item.as_event().and_then(|ev| ev.event_id()) {
479 curr_items_with_ids.push((idx_curr, event_id.to_owned()));
480 }
481 if curr_items_with_ids.len() >= visible_items {
482 break;
483 }
484 idx_curr += 1;
485 }
486 }
487
488 // Find a new item that has the same real event ID as any of the current items.
489 for (idx_new, new_item) in new_items.iter().enumerate() {
490 let Some(event_id) = new_item.as_event().and_then(|ev| ev.event_id()) else {
491 continue;
492 };
493 if let Some((idx_curr, _)) = curr_items_with_ids
494 .iter()
495 .find(|(_, ev_id)| ev_id == event_id)
496 {
497 // Not all items in the portal list are guaranteed to have a position offset,
498 // some may be zeroed-out, so we need to account for that possibility by only
499 // using events that have a real non-zero area
500 if let Some(pos_offset) = position_of_item {
501 println!(
502 "Found matching event ID {event_id} at index {idx_new} in new items list, corresponding to current item index {idx_curr} at pos offset {pos_offset}"
503 );
504 return Some((*idx_curr, idx_new, pos_offset, event_id.to_owned()));
505 }
506 }
507 }
508
509 None
510}
511
512#[derive(Debug, Clone, Serialize)]
513pub struct FrontendRoomMember {
514 name: String,
515 max_power_level: i64,
516 display_name_ambiguous: bool,
517 is_ignored: bool,
518}