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}