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}