Skip to main content

matrix_ui_serializable/room/
room_screen.rs

1use std::{collections::BTreeMap, sync::Arc};
2
3use matrix_sdk::{
4    room::RoomMemberRole,
5    ruma::{
6        OwnedEventId, OwnedMxcUri, OwnedRoomId, OwnedUserId, events::room::member::MembershipState,
7    },
8};
9use matrix_sdk_ui::{eyeball_im::Vector, timeline::TimelineItem};
10use serde::Serialize;
11use tokio::sync::oneshot;
12use tracing::{debug, error, trace, warn};
13
14use crate::{
15    events::timeline::{
16        PaginationDirection, TIMELINE_STATES, TimelineEndpoints, TimelineKind, TimelineUiState,
17        TimelineUpdate, take_timeline_endpoints,
18    },
19    models::{
20        async_requests::{MatrixRequest, submit_async_request},
21        events::{ToastNotificationRequest, ToastNotificationVariant},
22        state_updater::StateUpdater,
23    },
24    room::notifications::enqueue_toast_notification,
25    user::user_power_level::{FrontendUserPowerLevel, UserPowerLevels},
26    utils::room_name_or_id,
27};
28
29/// A serializable struct representing the state of a given Matrix Room.
30/// Fields are not exposed to the adapter directly, the adapter can only serialize this struct.
31#[derive(Debug, Serialize)]
32#[serde(rename_all = "camelCase")]
33pub struct RoomScreen {
34    /// The timeline currently displayed by this RoomScreen, if any.
35    timeline_kind: Option<TimelineKind>,
36    /// The display name of the currently-shown room.
37    room_name: String,
38    /// The persistent UI-relevant states for the room that this widget is currently displaying.
39    tl_state: Option<TimelineUiState>,
40    /// Known members of this room
41    members: BTreeMap<OwnedUserId, FrontendRoomMember>,
42    /// The set of pinned events in this room.
43    pinned_events: Vec<OwnedEventId>,
44    /// Whether this room has been successfully loaded (received from the homeserver).
45    is_loaded: bool,
46    /// Whether or not all rooms have been loaded (received from the homeserver).
47    all_rooms_loaded: bool,
48    /// The state updater passed by the adapter
49    #[serde(skip)]
50    state_updaters: Arc<Box<dyn StateUpdater>>,
51}
52impl Drop for RoomScreen {
53    fn drop(&mut self) {
54        // This ensures that the `TimelineUiState` instance owned by this room is *always* returned
55        // back to to `TIMELINE_STATES`, which ensures that its UI state(s) are not lost
56        // and that other RoomScreen instances can show this room in the future.
57        // RoomScreen will be dropped whenever its widget instance is destroyed, e.g.,
58        // when a Tab is closed or the app is resized to a different AdaptiveView layout.
59        self.hide_timeline();
60    }
61}
62
63impl RoomScreen {
64    pub fn new(
65        updaters: Arc<Box<dyn StateUpdater>>,
66        timeline_kind: Option<TimelineKind>,
67        room_name: String,
68    ) -> Self {
69        Self {
70            timeline_kind,
71            room_name,
72            tl_state: None,
73            members: BTreeMap::new(),
74            all_rooms_loaded: false,
75            is_loaded: false,
76            pinned_events: Vec::new(),
77            state_updaters: updaters,
78        }
79    }
80
81    fn update_frontend_state(&self) {
82        if let Err(e) = self.state_updaters.update_room(self) {
83            enqueue_toast_notification(ToastNotificationRequest::new(
84                format!(
85                    "Cannot update room state for room {}. Error: {e}",
86                    self.room_name
87                ),
88                None,
89                ToastNotificationVariant::Error,
90            ))
91        }
92    }
93
94    /// Processes all pending background updates to the currently-shown timeline.
95    pub fn process_timeline_updates(&mut self) {
96        let curr_first_id: usize = 0; // TODO: replace this dummy value
97        let mut _typing_users = None;
98
99        let Some(tl) = self.tl_state.as_mut() else {
100            return;
101        };
102
103        let mut should_continue_backwards_pagination = false;
104        let mut num_updates = 0;
105        while let Ok(update) = tl.update_receiver.try_recv() {
106            num_updates += 1;
107            match update {
108                TimelineUpdate::FirstUpdate { initial_items } => {
109                    tl.fully_paginated = false;
110
111                    tl.items = initial_items;
112                    self.is_loaded = true;
113                }
114                TimelineUpdate::NewItems {
115                    new_items,
116                    clear_cache,
117                } => {
118                    if new_items.is_empty() && !tl.items.is_empty() {
119                        trace!(
120                            "Timeline::handle_event(): timeline (had {} items) was cleared for room {}",
121                            tl.items.len(),
122                            tl.kind.room_id()
123                        );
124                        // For now, we paginate a cleared timeline in order to be able to show something at least.
125                        // A proper solution would be what's described below, which would be to save a few event IDs
126                        // and then either focus on them (if we're not close to the end of the timeline)
127                        // or paginate backwards until we find them (only if we are close the end of the timeline).
128                        should_continue_backwards_pagination = true;
129                    }
130                    if new_items.len() == tl.items.len() {
131                        trace!(
132                            "Timeline::handle_event(): no jump necessary for updated timeline of same length: {}",
133                            tl.items.len()
134                        );
135                    } else if curr_first_id > new_items.len() {
136                        trace!(
137                            "Timeline::handle_event(): jumping to bottom: curr_first_id {} is out of bounds for {} new items",
138                            curr_first_id,
139                            new_items.len()
140                        );
141                    } else if let Some((curr_item_idx, new_item_idx, new_item_scroll, _event_id)) =
142                        find_new_item_matching_current_item(
143                            0,
144                            Some(0.0), // TODO replace
145                            curr_first_id,
146                            &tl.items,
147                            &new_items,
148                        )
149                    {
150                        if curr_item_idx != new_item_idx {
151                            trace!(
152                                "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}"
153                            );
154                            // Set scrolled_past_read_marker false when we jump to a new event
155                            tl.scrolled_past_read_marker = false;
156                        }
157                    }
158                    //
159                    // TODO: after an (un)ignore user event, all timelines are cleared. Handle that here.
160                    //
161                    else {
162                        // warn!("!!! Couldn't find new event with matching ID for ANY event currently visible in the portal list");
163                    }
164
165                    if clear_cache {
166                        tl.fully_paginated = false;
167                    }
168                    tl.items = new_items;
169                    self.is_loaded = true;
170                }
171                TimelineUpdate::NewUnreadMessagesCount(_unread_messages_count) => {
172                    // jump_to_bottom.show_unread_message_badge(unread_messages_count);
173                }
174                TimelineUpdate::TargetEventFound {
175                    target_event_id,
176                    index,
177                } => {
178                    // trace!("Target event found in room {}: {target_event_id}, index: {index}", tl.room_id);
179                    tl.request_sender.send_if_modified(|requests| {
180                        requests.retain(|r| r.room_id != *tl.kind.room_id());
181                        // no need to notify/wake-up all receivers for a completed request
182                        false
183                    });
184
185                    // sanity check: ensure the target event is in the timeline at the given `index`.
186                    let item = tl.items.get(index);
187                    let is_valid = item.is_some_and(|item| {
188                        item.as_event()
189                            .is_some_and(|ev| ev.event_id() == Some(&target_event_id))
190                    });
191
192                    trace!(
193                        "TargetEventFound: is_valid? {is_valid}. room {}, event {target_event_id}, index {index} of {}\n  --> item: {item:?}",
194                        tl.kind.room_id(),
195                        tl.items.len()
196                    );
197                    if is_valid {
198                    } else {
199                        // Here, the target event was not found in the current timeline,
200                        // or we found it previously but it is no longer in the timeline (or has moved),
201                        // which means we encountered an error and are unable to jump to the target event.
202                        warn!(
203                            "Target event index {index} of {} is out of bounds for room {}",
204                            tl.items.len(),
205                            tl.kind.room_id()
206                        );
207                    }
208
209                    should_continue_backwards_pagination = false;
210                }
211                TimelineUpdate::PaginationRunning(direction) => {
212                    trace!(
213                        "Pagination running in room {} in {direction} direction",
214                        tl.kind.room_id()
215                    );
216                    if direction == PaginationDirection::Backwards {
217                        self.is_loaded = false;
218                    } else {
219                        warn!("Unexpected PaginationRunning update in the Forwards direction");
220                    }
221                }
222                TimelineUpdate::PaginationError { error, direction } => {
223                    error!(
224                        "Pagination error ({direction}) in room {}: {error:?}",
225                        tl.kind.room_id()
226                    );
227                    self.is_loaded = true;
228                }
229                TimelineUpdate::PaginationIdle {
230                    fully_paginated,
231                    direction,
232                } => {
233                    if direction == PaginationDirection::Backwards {
234                        // Don't set `done_loading` to `true`` here, because we want to keep the top space visible
235                        // (with the "loading" message) until the corresponding `NewItems` update is received.
236                        tl.fully_paginated = fully_paginated;
237                        if fully_paginated {
238                            self.is_loaded = true;
239                        }
240                    } else {
241                        warn!("Unexpected PaginationIdle update in the Forwards direction");
242                    }
243                }
244                TimelineUpdate::EventDetailsFetched { event_id, result } => {
245                    if let Err(_e) = result {
246                        warn!(
247                            "Failed to fetch details fetched for event {event_id} in room {}. Error: {_e:?}",
248                            tl.kind.room_id()
249                        );
250                    }
251                    // Here, to be most efficient, we could redraw only the updated event,
252                    // but for now we just fall through and let the final `redraw()` call re-draw the whole timeline view.
253                }
254                TimelineUpdate::RoomMembersSynced => {
255                    trace!(
256                        "Timeline::handle_event(): room members fetched for room {}",
257                        tl.kind.room_id()
258                    );
259                    // Here, to be most efficient, we could redraw only the user avatars and names in the timeline,
260                    // but for now we just fall through and let the final `redraw()` call re-draw the whole timeline view.
261                }
262                TimelineUpdate::RoomMembersListFetched { members } => {
263                    debug!("RoomMembers list fetched !");
264                    // We clear the map before so we're sure there aren't
265                    // any members at previous membership state.
266                    self.members.clear();
267                    members.iter().for_each(|member| {
268                        self.members.insert(
269                            member.user_id().to_owned(),
270                            FrontendRoomMember {
271                                name: member.name().to_owned(),
272                                display_name_ambiguous: member.name_ambiguous(),
273                                is_ignored: member.is_ignored(),
274                                max_power_level: member.normalized_power_level().into(),
275                                avatar: member.avatar_url().map(|u| u.to_owned()),
276                                role: member.suggested_role_for_power_level().into(),
277                                membership: member.membership().to_owned(),
278                            },
279                        );
280                    });
281                    debug!("{:?}", self.members);
282                }
283                TimelineUpdate::_MediaFetched => {
284                    trace!(
285                        "Timeline::handle_event(): media fetched for room {}",
286                        tl.kind.room_id()
287                    );
288                    // Here, to be most efficient, we could redraw only the media items in the timeline,
289                    // but for now we just fall through and let the final `redraw()` call re-draw the whole timeline view.
290                }
291                TimelineUpdate::MessageEdited {
292                    timeline_event_item_id: timeline_event_id,
293                    result,
294                } => {
295                    if result.is_ok() {
296                        enqueue_toast_notification(ToastNotificationRequest::new(
297                            "Successfully edited message.".to_owned(),
298                            None,
299                            ToastNotificationVariant::Success,
300                        ));
301                    } else {
302                        error!("Error editing event with id {timeline_event_id:?}");
303                        enqueue_toast_notification(ToastNotificationRequest::new(
304                            "Error while editing event.".to_owned(),
305                            None,
306                            ToastNotificationVariant::Error,
307                        ));
308                    }
309                }
310                TimelineUpdate::TypingUsers { users } => {
311                    // TODO: USE THIS
312                    _typing_users = Some(users);
313                }
314
315                TimelineUpdate::UserPowerLevels(user_power_level) => {
316                    tl.user_power = user_power_level;
317                }
318
319                TimelineUpdate::OwnUserReadReceipt(receipt) => {
320                    tl.latest_own_user_receipt = Some(receipt);
321                }
322            }
323        }
324
325        if should_continue_backwards_pagination {
326            trace!("Continuing backwards pagination...");
327            submit_async_request(MatrixRequest::PaginateTimeline {
328                timeline_kind: tl.kind.clone(),
329                num_events: 50,
330                direction: PaginationDirection::Backwards,
331            });
332        }
333
334        if num_updates > 0 {
335            debug!(
336                "Applied {} timeline updates for room {}, redrawing with {} items...",
337                num_updates,
338                tl.kind.room_id(),
339                tl.items.len()
340            );
341            self.update_frontend_state();
342        }
343    }
344
345    /// Invoke this when this timeline is being shown,
346    /// e.g., when the user navigates to this timeline.
347    pub fn show_timeline(&mut self) {
348        let kind = self
349            .timeline_kind
350            .clone()
351            .expect("BUG: Timeline::show_timeline(): no timeline_kind was set.");
352        let room_id = kind.room_id().clone();
353        // just an optional sanity check
354        assert!(
355            self.tl_state.is_none(),
356            "BUG: tried to show_timeline() into a timeline with existing state. \
357            Did you forget to save the timeline state back to the global map of states?",
358        );
359
360        let state_opt = {
361            let mut lock = TIMELINE_STATES.lock().unwrap();
362            lock.remove(&kind)
363        };
364        let (tl_state, first_time_showing_room) = if let Some(existing) = state_opt {
365            (existing, false)
366        } else {
367            // This part differs a bit from robrix. Here we wait for
368            // the thread timeline to be built before proceeding.
369            let timeline_endpoints = match take_timeline_endpoints(&kind) {
370                Some(te) => te,
371                None if let Some(thread_root) = kind.thread_root_event_id() => {
372                    let (tx, rx) = oneshot::channel();
373                    submit_async_request(MatrixRequest::CreateThreadTimeline {
374                        room_id: room_id.clone(),
375                        thread_root_event_id: thread_root.clone(),
376                        sender: tx,
377                    });
378                    match futures::executor::block_on(rx) {
379                        Ok(_) => take_timeline_endpoints(&kind).expect("msg"),
380                        Err(e) => {
381                            warn!("Timeline hasn't been created. {e}");
382                            return;
383                        }
384                    }
385                }
386                None if !self.is_loaded && self.all_rooms_loaded => panic!(
387                    "BUG: timeline {kind} is not loaded, but its RoomScreen \
388                        was not waiting for its timeline to be loaded either."
389                ),
390                None => return,
391            };
392
393            let TimelineEndpoints {
394                update_receiver,
395                _update_sender: _,
396                request_sender,
397                _successor_room: _,
398            } = timeline_endpoints;
399
400            // Start with the basic tombstone info, and fetch the full details
401            // if the room has been tombstoned.
402            // let tombstone_info = if let Some(sr) = successor_room {
403            //     submit_async_request(MatrixRequest::GetSuccessorRoomDetails {
404            //         tombstoned_room_id: room_id.clone(),
405            //     });
406            //     Some(SuccessorRoomDetails::Basic(sr))
407            // } else {
408            //     None
409            // };
410
411            let tl_state = TimelineUiState {
412                kind,
413                // Initially, we assume the user has all power levels by default.
414                // This avoids unexpectedly hiding any UI elements that should be visible to the user.
415                // This doesn't mean that the user can actually perform all actions;
416                // the power levels will be updated from the homeserver once the room is opened.
417                user_power: UserPowerLevels::all(),
418                // Room members start as None and get populated when fetched from the server
419                // We assume timelines being viewed for the first time haven't been fully paginated.
420                fully_paginated: false,
421                items: Vector::new(),
422                update_receiver,
423                request_sender,
424                scrolled_past_read_marker: false,
425                latest_own_user_receipt: None,
426            };
427            (tl_state, true)
428        };
429
430        // TODO: support typing notices in frontend
431        // Subscribe to typing notices, but hide the typing notice view initially.
432        // submit_async_request(MatrixRequest::SubscribeToTypingNotices {
433        //     room_id: room_id.clone(),
434        //     subscribe: true,
435        // });
436
437        submit_async_request(MatrixRequest::SubscribeToOwnUserReadReceiptsChanged {
438            timeline_kind: tl_state.kind.clone(),
439            subscribe: true,
440        });
441        // Kick off a back pagination request for this room. This is "urgent",
442        // because we want to show the user some messages as soon as possible
443        // when they first open the room, and there might not be any messages yet.
444        if first_time_showing_room && !tl_state.fully_paginated {
445            debug!(
446                "Sending a first-time backwards pagination request for room {}",
447                room_id
448            );
449            submit_async_request(MatrixRequest::PaginateTimeline {
450                timeline_kind: tl_state.kind.clone(),
451                num_events: 50,
452                direction: PaginationDirection::Backwards,
453            });
454        }
455
456        // This fetches the room members of the displayed timeline.
457        submit_async_request(MatrixRequest::SyncRoomMemberList {
458            timeline_kind: tl_state.kind.clone(),
459        });
460
461        // As the final step, store the tl_state for this room into this RoomScreen widget,
462        // such that it can be accessed in future event/draw handlers.
463        self.tl_state = Some(tl_state);
464
465        // Now that we have restored the TimelineUiState into this RoomScreen widget,
466        // we can proceed to processing pending background updates, and if any were processed,
467        // the timeline will also be redrawn.
468        if first_time_showing_room {
469            self.process_timeline_updates();
470        }
471
472        self.update_frontend_state();
473    }
474
475    /// Invoke this when this RoomScreen/timeline is being hidden or no longer being shown.
476    fn hide_timeline(&mut self) {
477        let Some(timeline_kind) = self.timeline_kind.clone() else {
478            return;
479        };
480
481        self.save_state();
482
483        // When closing a room view, we do the following with non-persistent states.
484        // (This should be the inverse of what's done in `show_timeline()`.)
485        // * Unsubscribe from typing notices, since we don't care about them
486        //   when a given room isn't visible.
487        // * Unsubscribe from updates to this room's pinned events, for the same reason.
488        // * Unsubscribe from updates to our own user's read receipts, for the same reason.
489        if matches!(timeline_kind, TimelineKind::MainRoom { .. }) {
490            submit_async_request(MatrixRequest::SubscribeToTypingNotices {
491                room_id: timeline_kind.room_id().clone(),
492                subscribe: false,
493            });
494            // submit_async_request(MatrixRequest::SubscribeToPinnedEvents {
495            //     room_id: timeline_kind.room_id().clone(),
496            //     subscribe: false,
497            // });
498        }
499        submit_async_request(MatrixRequest::SubscribeToOwnUserReadReceiptsChanged {
500            timeline_kind,
501            subscribe: false,
502        });
503    }
504
505    /// Removes the current room's visual UI state from this widget
506    /// and saves it to the map of `TIMELINE_STATES` such that it can be restored later.
507    ///
508    /// Note: after calling this function, the widget's `tl_state` will be `None`.
509    fn save_state(&mut self) {
510        let Some(tl) = self.tl_state.take() else {
511            warn!(
512                "Timeline::save_state(): skipping due to missing state, room {:?}",
513                self.timeline_kind
514            );
515            return;
516        };
517        // Store this Timeline's `TimelineUiState` in the global map of states.
518        TIMELINE_STATES.lock().unwrap().insert(tl.kind.clone(), tl);
519    }
520
521    /// Sets this `RoomScreen` widget to display the timeline for the given room.
522    pub fn set_displayed_room(
523        &mut self,
524        room_id: OwnedRoomId,
525        room_name: String,
526        thread_root_event_id: Option<OwnedEventId>,
527    ) {
528        let timeline_kind = if let Some(thread_root_event_id) = thread_root_event_id {
529            TimelineKind::Thread {
530                room_id: room_id.clone(),
531                thread_root_event_id,
532            }
533        } else {
534            TimelineKind::MainRoom {
535                room_id: room_id.clone(),
536            }
537        };
538
539        // If this timeline is already displayed, we don't need to do anything major,
540        // but we do need update the `room_name_id` in case it has changed, or it has been cleared.
541        if self
542            .timeline_kind
543            .as_ref()
544            .is_some_and(|kind| kind == &timeline_kind)
545        {
546            self.room_name = room_name;
547            return;
548        }
549
550        self.hide_timeline();
551        self.timeline_kind = Some(timeline_kind.clone());
552        self.room_name = room_name_or_id(room_name.into(), &room_id);
553
554        self.show_timeline();
555    }
556}
557
558/// Returns info about the item in the list of `new_items` that matches the event ID
559/// of a visible item in the given `curr_items` list.
560///
561/// This info includes a tuple of:
562/// 1. the index of the item in the current items list,
563/// 2. the index of the item in the new items list,
564/// 3. the positional "scroll" offset of the corresponding current item in the portal list,
565/// 4. the unique event ID of the item.
566fn find_new_item_matching_current_item(
567    visible_items: usize,          // DUMMY PARAM TODO CHANGE THIS
568    position_of_item: Option<f64>, // DUMMY PARAM TODO CHANGE THIS
569    starting_at_curr_idx: usize,
570    curr_items: &Vector<Arc<TimelineItem>>,
571    new_items: &Vector<Arc<TimelineItem>>,
572) -> Option<(usize, usize, f64, OwnedEventId)> {
573    let mut curr_item_focus = curr_items.focus();
574    let mut idx_curr = starting_at_curr_idx;
575    let mut curr_items_with_ids: Vec<(usize, OwnedEventId)> = Vec::with_capacity(visible_items);
576
577    // Find all items with real event IDs that are currently visible in the portal list.
578    // TODO: if this is slow, we could limit it to 3-5 events at the most.
579    if curr_items_with_ids.len() <= visible_items {
580        while let Some(curr_item) = curr_item_focus.get(idx_curr) {
581            if let Some(event_id) = curr_item.as_event().and_then(|ev| ev.event_id()) {
582                curr_items_with_ids.push((idx_curr, event_id.to_owned()));
583            }
584            if curr_items_with_ids.len() >= visible_items {
585                break;
586            }
587            idx_curr += 1;
588        }
589    }
590
591    // Find a new item that has the same real event ID as any of the current items.
592    for (idx_new, new_item) in new_items.iter().enumerate() {
593        let Some(event_id) = new_item.as_event().and_then(|ev| ev.event_id()) else {
594            continue;
595        };
596        if let Some((idx_curr, _)) = curr_items_with_ids
597            .iter()
598            .find(|(_, ev_id)| ev_id == event_id)
599        {
600            // Not all items in the portal list are guaranteed to have a position offset,
601            // some may be zeroed-out, so we need to account for that possibility by only
602            // using events that have a real non-zero area
603            if let Some(pos_offset) = position_of_item {
604                trace!(
605                    "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}"
606                );
607                return Some((*idx_curr, idx_new, pos_offset, event_id.to_owned()));
608            }
609        }
610    }
611
612    None
613}
614
615#[derive(Debug, Clone, Serialize)]
616#[serde(rename_all = "camelCase")]
617pub struct FrontendRoomMember {
618    name: String,
619    // This looks shitty but we cannot use serde flatten and ts_rs type together
620    max_power_level: FrontendUserPowerLevel,
621    display_name_ambiguous: bool,
622    is_ignored: bool,
623    avatar: Option<OwnedMxcUri>,
624    role: FrontendRoomMemberRole,
625    membership: MembershipState,
626}
627
628#[derive(Clone, Debug, PartialEq, Serialize)]
629#[serde(rename_all_fields = "camelCase", rename_all = "camelCase")]
630/// Same as RoomMemberRole with Serialize
631pub enum FrontendRoomMemberRole {
632    /// The member is a creator.
633    ///
634    /// A creator has an infinite power level and cannot be demoted, so this
635    /// role is immutable. A room can have several creators.
636    ///
637    /// It is available in room versions where
638    /// `explicitly_privilege_room_creators` in [`AuthorizationRules`] is set to
639    /// `true`.
640    ///
641    /// [`AuthorizationRules`]: ruma::room_version_rules::AuthorizationRules
642    Creator,
643    /// The member is an administrator.
644    Administrator,
645    /// The member is a moderator.
646    Moderator,
647    /// The member is a regular user.
648    User,
649}
650
651impl From<RoomMemberRole> for FrontendRoomMemberRole {
652    fn from(value: RoomMemberRole) -> Self {
653        match value {
654            RoomMemberRole::Creator => Self::Creator,
655            RoomMemberRole::Administrator => Self::Administrator,
656            RoomMemberRole::Moderator => Self::Moderator,
657            RoomMemberRole::User => Self::User,
658        }
659    }
660}