Skip to main content

fractal/session_view/
mod.rs

1use adw::{prelude::*, subclass::prelude::*};
2use gtk::{gdk, glib, glib::clone};
3use ruma::{OwnedEventId, OwnedUserId, RoomId, RoomOrAliasId};
4use tracing::{error, warn};
5
6mod content;
7mod create_direct_chat_dialog;
8mod create_room_dialog;
9mod explore;
10mod invite;
11mod invite_request;
12mod media_viewer;
13mod room_details;
14mod room_history;
15mod sidebar;
16
17use self::{
18    content::Content, create_direct_chat_dialog::CreateDirectChatDialog,
19    create_room_dialog::CreateRoomDialog, explore::Explore, invite::Invite,
20    invite_request::InviteRequest, media_viewer::MediaViewer, room_details::RoomDetails,
21    room_history::RoomHistory, sidebar::Sidebar,
22};
23use crate::{
24    Window,
25    components::{RoomPreviewDialog, UserProfileDialog},
26    intent::SessionIntent,
27    prelude::*,
28    session::{
29        IdentityVerification, Room, RoomCategory, RoomList, Session, SidebarItemList,
30        SidebarListModel, VerificationKey,
31    },
32    utils::matrix::{MatrixEventIdUri, MatrixIdUri, MatrixRoomIdUri, VisualMediaMessage},
33};
34
35mod imp {
36    use std::cell::RefCell;
37
38    use glib::subclass::InitializingObject;
39
40    use super::*;
41
42    #[derive(Debug, Default, gtk::CompositeTemplate, glib::Properties)]
43    #[template(resource = "/org/gnome/Fractal/ui/session_view/mod.ui")]
44    #[properties(wrapper_type = super::SessionView)]
45    pub struct SessionView {
46        #[template_child]
47        stack: TemplateChild<gtk::Stack>,
48        #[template_child]
49        overlay: TemplateChild<gtk::Overlay>,
50        #[template_child]
51        split_view: TemplateChild<adw::NavigationSplitView>,
52        #[template_child]
53        sidebar: TemplateChild<Sidebar>,
54        #[template_child]
55        content: TemplateChild<Content>,
56        #[template_child]
57        media_viewer: TemplateChild<MediaViewer>,
58        /// The current session.
59        #[property(get, set = Self::set_session, explicit_notify, nullable)]
60        session: glib::WeakRef<Session>,
61        window_active_handler_id: RefCell<Option<glib::SignalHandlerId>>,
62    }
63
64    #[glib::object_subclass]
65    impl ObjectSubclass for SessionView {
66        const NAME: &'static str = "SessionView";
67        type Type = super::SessionView;
68        type ParentType = adw::Bin;
69
70        #[allow(clippy::too_many_lines)]
71        fn class_init(klass: &mut Self::Class) {
72            Self::bind_template(klass);
73
74            klass.install_action("session.open-account-settings", None, |obj, _, _| {
75                let Some(session) = obj.session() else {
76                    return;
77                };
78
79                if obj
80                    .activate_action(
81                        "win.open-account-settings",
82                        Some(&session.session_id().to_variant()),
83                    )
84                    .is_err()
85                {
86                    error!("Could not activate action `win.open-account-settings`");
87                }
88            });
89            klass.add_binding_action(
90                gdk::Key::comma,
91                gdk::ModifierType::CONTROL_MASK,
92                "session.open-account-settings",
93            );
94
95            klass.install_action("session.close-room", None, |obj, _, _| {
96                obj.imp().select_item(None);
97            });
98            klass.add_binding_action(
99                gdk::Key::Escape,
100                gdk::ModifierType::empty(),
101                "session.close-room",
102            );
103
104            klass.install_action(
105                "session.show-room",
106                Some(&String::static_variant_type()),
107                |obj, _, parameter| {
108                    let Some(parameter) = parameter else {
109                        error!("Could not show room without an ID");
110                        return;
111                    };
112                    let Some(room_id_str) = parameter.get::<String>() else {
113                        error!("Could not show room with non-string ID");
114                        return;
115                    };
116                    let Ok(room_id) = <&RoomId>::try_from(room_id_str.as_str()) else {
117                        error!("Could not show room with invalid ID");
118                        return;
119                    };
120
121                    obj.imp().select_room_by_id(room_id);
122                },
123            );
124
125            klass.install_action("session.create-room", None, |obj, _, _| {
126                obj.imp().create_room();
127            });
128
129            klass.install_action("session.join-room", None, |obj, _, _| {
130                obj.imp().preview_room(None);
131            });
132            klass.add_binding_action(
133                gdk::Key::L,
134                gdk::ModifierType::CONTROL_MASK,
135                "session.join-room",
136            );
137
138            klass.install_action("session.create-direct-chat", None, |obj, _, _| {
139                obj.imp().create_direct_chat();
140            });
141
142            klass.install_action("session.toggle-room-search", None, |obj, _, _| {
143                obj.imp().toggle_room_search();
144            });
145            klass.add_binding_action(
146                gdk::Key::k,
147                gdk::ModifierType::CONTROL_MASK,
148                "session.toggle-room-search",
149            );
150
151            klass.install_action("session.select-unread-room", None, |obj, _, _| {
152                obj.imp().select_unread_room();
153            });
154            klass.add_binding_action(
155                gdk::Key::asterisk,
156                gdk::ModifierType::CONTROL_MASK,
157                "session.select-unread-room",
158            );
159
160            klass.install_action("session.select-prev-room", None, |obj, _, _| {
161                obj.imp().select_next_room(ReadState::Any, Direction::Up);
162            });
163
164            klass.install_action("session.select-prev-unread-room", None, |obj, _, _| {
165                obj.imp().select_next_room(ReadState::Unread, Direction::Up);
166            });
167
168            klass.install_action("session.select-next-room", None, |obj, _, _| {
169                obj.imp().select_next_room(ReadState::Any, Direction::Down);
170            });
171
172            klass.install_action("session.select-next-unread-room", None, |obj, _, _| {
173                obj.imp()
174                    .select_next_room(ReadState::Unread, Direction::Down);
175            });
176
177            klass.install_action(
178                "session.show-matrix-uri",
179                Some(&MatrixIdUri::static_variant_type()),
180                |obj, _, parameter| {
181                    let Some(parameter) = parameter else {
182                        error!("Could not show missing Matrix URI");
183                        return;
184                    };
185                    let Some(uri) = parameter.get::<MatrixIdUri>() else {
186                        error!("Could not show invalid Matrix URI");
187                        return;
188                    };
189
190                    obj.imp().show_matrix_uri(uri);
191                },
192            );
193        }
194
195        fn instance_init(obj: &InitializingObject<Self>) {
196            obj.init_template();
197        }
198    }
199
200    #[glib::derived_properties]
201    impl ObjectImpl for SessionView {
202        fn constructed(&self) {
203            self.parent_constructed();
204            let obj = self.obj();
205
206            self.content.connect_item_notify(clone!(
207                #[weak(rename_to = imp)]
208                self,
209                move |content| {
210                    let show_content = content.item().is_some();
211                    imp.split_view.set_show_content(show_content);
212
213                    // Only grab focus for the sidebar here. We handle the other case in
214                    // `Content::set_item()` directly, because we need to grab focus only
215                    // after the visible content changed.
216                    if !show_content {
217                        imp.sidebar.grab_focus();
218                    }
219
220                    // Withdraw the notifications of the newly selected item.
221                    imp.withdraw_selected_item_notifications();
222                }
223            ));
224
225            obj.connect_root_notify(|obj| {
226                let imp = obj.imp();
227
228                let Some(window) = imp.parent_window() else {
229                    return;
230                };
231
232                let handler_id = window.connect_is_active_notify(clone!(
233                    #[weak]
234                    imp,
235                    move |window| {
236                        if !window.is_active() {
237                            return;
238                        }
239
240                        // When the window becomes active, withdraw the notifications
241                        // of the selected item.
242                        imp.withdraw_selected_item_notifications();
243                    }
244                ));
245                imp.window_active_handler_id.replace(Some(handler_id));
246            });
247
248            // Make sure all header bars on the same screen have the same height.
249            // Necessary when the text scaling changes.
250            let size_group = gtk::SizeGroup::new(gtk::SizeGroupMode::Vertical);
251            size_group.add_widget(self.sidebar.header_bar());
252
253            for header_bar in self.content.header_bars() {
254                size_group.add_widget(header_bar);
255            }
256        }
257
258        fn dispose(&self) {
259            if let Some(handler_id) = self.window_active_handler_id.take()
260                && let Some(window) = self.parent_window()
261            {
262                window.disconnect(handler_id);
263            }
264        }
265    }
266
267    impl WidgetImpl for SessionView {}
268    impl BinImpl for SessionView {}
269
270    impl SessionView {
271        /// Set the current session.
272        fn set_session(&self, session: Option<&Session>) {
273            if self.session.upgrade().as_ref() == session {
274                return;
275            }
276
277            self.session.set(session);
278            self.obj().notify_session();
279        }
280
281        /// Get the [`SidebarListModel`] of the current session.
282        fn sidebar_list_model(&self) -> Option<SidebarListModel> {
283            self.session
284                .upgrade()
285                .map(|session| session.sidebar_list_model())
286        }
287
288        /// Get the [`SidebarItemList`] of the current session.
289        fn item_list(&self) -> Option<SidebarItemList> {
290            self.sidebar_list_model()
291                .map(|sidebar_list_model| sidebar_list_model.item_list())
292        }
293
294        /// Get the [`RoomList`] of the current session.
295        fn room_list(&self) -> Option<RoomList> {
296            self.session.upgrade().map(|session| session.room_list())
297        }
298
299        /// Select the given item.
300        pub(super) fn select_item(&self, item: Option<glib::Object>) {
301            let Some(sidebar_list_model) = self.sidebar_list_model() else {
302                return;
303            };
304
305            sidebar_list_model.selection_model().set_selected_item(item);
306        }
307
308        /// The currently selected item, if any.
309        pub(super) fn selected_item(&self) -> Option<glib::Object> {
310            self.content.item()
311        }
312
313        /// Select the given room.
314        pub(super) fn select_room(&self, room: Room) {
315            // Make sure the room is visible in the sidebar.
316            // First, ensure that the section containing the room is expanded.
317            if let Some(section) = self
318                .item_list()
319                .and_then(|item_list| item_list.section_from_room_category(room.category()))
320            {
321                section.set_is_expanded(true);
322            }
323
324            self.select_item(Some(room.upcast()));
325
326            // Now scroll to the room to make sure that it is in the viewport, and that it
327            // is focused in the list for users using keyboard navigation.
328            self.sidebar.scroll_to_selection();
329        }
330
331        /// The currently selected room, if any.
332        pub(super) fn selected_room(&self) -> Option<Room> {
333            self.selected_item().and_downcast()
334        }
335
336        /// Select the room with the given ID in this view.
337        pub(super) fn select_room_by_id(&self, room_id: &RoomId) {
338            if let Some(room) = self
339                .room_list()
340                .and_then(|room_list| room_list.get(room_id))
341            {
342                self.select_room(room);
343            } else {
344                warn!("The room with ID {room_id} could not be found");
345            }
346        }
347
348        /// Select the room with the given identifier in this view, if it
349        /// exists.
350        ///
351        /// Returns `true` if the room was found.
352        pub(super) fn select_room_if_exists(&self, identifier: &RoomOrAliasId) -> bool {
353            if let Some(room) = self
354                .room_list()
355                .and_then(|room_list| room_list.get_by_identifier(identifier))
356            {
357                self.select_room(room);
358                true
359            } else {
360                false
361            }
362        }
363
364        /// Select the identity verification with the given key in this view.
365        pub(super) fn select_identity_verification_by_id(&self, key: &VerificationKey) {
366            if let Some(verification) = self
367                .session
368                .upgrade()
369                .and_then(|s| s.verification_list().get(key))
370            {
371                self.select_identity_verification(verification);
372            } else {
373                warn!(
374                    "Identity verification for user {} with flow ID {} could not be found",
375                    key.user_id, key.flow_id
376                );
377            }
378        }
379
380        /// Select the given identity verification in this view.
381        pub(super) fn select_identity_verification(&self, verification: IdentityVerification) {
382            self.select_item(Some(verification.upcast()));
383        }
384
385        /// Withdraw the notifications for the currently selected item.
386        fn withdraw_selected_item_notifications(&self) {
387            let Some(session) = self.session.upgrade() else {
388                return;
389            };
390            let Some(item) = self.selected_item() else {
391                return;
392            };
393
394            let notifications = session.notifications();
395
396            if let Some(room) = item.downcast_ref::<Room>() {
397                notifications.withdraw_all_for_room(room.room_id());
398            } else if let Some(verification) = item.downcast_ref::<IdentityVerification>() {
399                notifications.withdraw_identity_verification(&verification.key());
400            }
401        }
402
403        /// Select the next room with the given read state in the given
404        /// direction.
405        ///
406        /// The search wraps: if no room matches below (for `direction == Down`)
407        /// then search continues in the down direction from the first room.
408        fn select_next_room(&self, read_state: ReadState, direction: Direction) {
409            let Some(sidebar_list_model) = self.sidebar_list_model() else {
410                return;
411            };
412
413            let selection_list = sidebar_list_model.selection_model();
414            let len = selection_list.n_items();
415            let current_index = selection_list.selected().min(len);
416
417            let search_order: Box<dyn Iterator<Item = u32>> = {
418                // Iterate over every item except the current one.
419                let order = ((current_index + 1)..len).chain(0..current_index);
420                match direction {
421                    Direction::Up => Box::new(order.rev()),
422                    Direction::Down => Box::new(order),
423                }
424            };
425
426            for index in search_order {
427                let Some(item) = selection_list.item(index) else {
428                    // The list of rooms was mutated: let's give up responding to the key binding.
429                    return;
430                };
431
432                if let Ok(room) = item.downcast::<Room>()
433                    && (read_state == ReadState::Any || !room.is_read())
434                {
435                    self.select_room(room);
436                    return;
437                }
438            }
439        }
440
441        /// Select a room with unread messages.
442        fn select_unread_room(&self) {
443            let Some(room_list) = self.room_list() else {
444                return;
445            };
446            let current_room = self.selected_room();
447
448            if let Some((unread_room, _score)) = room_list
449                .snapshot()
450                .into_iter()
451                .filter(|room| Some(room) != current_room.as_ref())
452                .filter_map(|room| Self::score_for_unread_room(&room).map(|score| (room, score)))
453                .max_by_key(|(_room, score)| *score)
454            {
455                self.select_room(unread_room);
456            }
457        }
458
459        /// The score to determine the order in which unread rooms are selected.
460        ///
461        /// First by category, then by notification count so DMs are selected
462        /// before group chats, and finally by recency.
463        ///
464        /// Returns `None` if the room should never be selected.
465        fn score_for_unread_room(room: &Room) -> Option<(u8, u64, u64)> {
466            if room.is_read() {
467                return None;
468            }
469
470            let category_score = match room.category() {
471                RoomCategory::Invited => 5,
472                RoomCategory::Favorite => 4,
473                RoomCategory::Normal => 3,
474                RoomCategory::LowPriority => 2,
475                RoomCategory::Left => 1,
476                RoomCategory::Knocked
477                | RoomCategory::Ignored
478                | RoomCategory::Outdated
479                | RoomCategory::Space => return None,
480            };
481
482            Some((
483                category_score,
484                room.notification_count(),
485                room.latest_activity(),
486            ))
487        }
488
489        /// Toggle the visibility of the room search bar.
490        fn toggle_room_search(&self) {
491            let room_search = self.sidebar.room_search_bar();
492            room_search.set_search_mode(!room_search.is_search_mode());
493        }
494
495        /// Returns the ancestor window containing this widget.
496        fn parent_window(&self) -> Option<Window> {
497            self.obj().root().and_downcast()
498        }
499
500        /// Show the dialog to create a room.
501        fn create_room(&self) {
502            let Some(session) = self.session.upgrade() else {
503                return;
504            };
505
506            let dialog = CreateRoomDialog::new(&session);
507            dialog.present(Some(&*self.obj()));
508        }
509
510        /// Show the dialog to create a direct chat.
511        fn create_direct_chat(&self) {
512            let Some(session) = self.session.upgrade() else {
513                return;
514            };
515
516            let dialog = CreateDirectChatDialog::new(&session);
517            dialog.present(Some(&*self.obj()));
518        }
519
520        /// Show the dialog to preview a room.
521        ///
522        /// If no room URI is provided, the user will have to enter one.
523        pub(super) fn preview_room(&self, room_uri: Option<MatrixRoomIdUri>) {
524            let Some(session) = self.session.upgrade() else {
525                return;
526            };
527
528            if room_uri
529                .as_ref()
530                .is_some_and(|room_uri| self.select_room_if_exists(&room_uri.id))
531            {
532                return;
533            }
534
535            let dialog = RoomPreviewDialog::new(&session);
536
537            if let Some(uri) = room_uri {
538                dialog.set_uri(uri);
539            }
540
541            dialog.present(Some(&*self.obj()));
542        }
543
544        /// Handle when the paste shortcut was activated.
545        pub(super) fn handle_paste_action(&self) {
546            self.content.handle_paste_action();
547        }
548
549        /// Show the given media event in the media viewer.
550        pub(super) fn show_media_viewer(
551            &self,
552            source_widget: &gtk::Widget,
553            room: &Room,
554            media_message: VisualMediaMessage,
555            event_id: Option<OwnedEventId>,
556        ) {
557            self.media_viewer.set_message(room, media_message, event_id);
558            self.media_viewer.reveal(source_widget);
559        }
560
561        /// Show the profile of the given user.
562        pub(super) fn show_user_profile_dialog(&self, user_id: OwnedUserId) {
563            let Some(session) = self.session.upgrade() else {
564                return;
565            };
566
567            let dialog = UserProfileDialog::new();
568            dialog.load_user(&session, user_id);
569            dialog.present(Some(&*self.obj()));
570        }
571
572        /// Process the given intent.
573        pub(super) fn process_intent(&self, intent: SessionIntent) {
574            match intent {
575                SessionIntent::ShowMatrixId(matrix_uri) => {
576                    self.show_matrix_uri(matrix_uri);
577                }
578                SessionIntent::ShowIdentityVerification(key) => {
579                    self.select_identity_verification_by_id(&key);
580                }
581            }
582        }
583
584        /// Show the given `MatrixIdUri`.
585        pub(super) fn show_matrix_uri(&self, uri: MatrixIdUri) {
586            match uri {
587                MatrixIdUri::Room(room_uri)
588                | MatrixIdUri::Event(MatrixEventIdUri { room_uri, .. }) => {
589                    self.preview_room(Some(room_uri));
590                }
591                MatrixIdUri::User(user_id) => {
592                    self.show_user_profile_dialog(user_id);
593                }
594            }
595        }
596    }
597}
598
599glib::wrapper! {
600    /// A view for a Matrix user session.
601    pub struct SessionView(ObjectSubclass<imp::SessionView>)
602        @extends gtk::Widget, adw::Bin,
603        @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget;
604}
605
606impl SessionView {
607    /// Create a new session view.
608    pub fn new() -> Self {
609        glib::Object::new()
610    }
611
612    /// The currently selected room, if any.
613    pub(crate) fn selected_room(&self) -> Option<Room> {
614        self.imp().selected_room()
615    }
616
617    /// Select the given room.
618    pub(crate) fn select_room(&self, room: Room) {
619        self.imp().select_room(room);
620    }
621
622    /// Select the room with the given identifier in this view, if it exists.
623    ///
624    /// Returns `true` if the room was found.
625    pub(crate) fn select_room_if_exists(&self, identifier: &RoomOrAliasId) -> bool {
626        self.imp().select_room_if_exists(identifier)
627    }
628
629    /// Select the given identity verification in this view.
630    pub(crate) fn select_identity_verification(&self, verification: IdentityVerification) {
631        self.imp().select_identity_verification(verification);
632    }
633
634    /// Handle when the paste action was activated.
635    pub(crate) fn handle_paste_action(&self) {
636        self.imp().handle_paste_action();
637    }
638
639    /// Show the given media event in the media viewer.
640    pub(crate) fn show_media_viewer(
641        &self,
642        source_widget: &impl IsA<gtk::Widget>,
643        room: &Room,
644        media_message: VisualMediaMessage,
645        event_id: Option<OwnedEventId>,
646    ) {
647        self.imp()
648            .show_media_viewer(source_widget.upcast_ref(), room, media_message, event_id);
649    }
650
651    /// Show the given `MatrixIdUri`.
652    pub(crate) fn show_matrix_uri(&self, uri: MatrixIdUri) {
653        self.imp().show_matrix_uri(uri);
654    }
655
656    /// Process the given intent.
657    pub(crate) fn process_intent(&self, intent: SessionIntent) {
658        self.imp().process_intent(intent);
659    }
660}
661
662/// A predicate to filter rooms depending on whether they have unread messages.
663#[derive(Eq, PartialEq, Copy, Clone)]
664enum ReadState {
665    /// Any room can be selected.
666    Any,
667    /// Only rooms with unread messages can be selected.
668    Unread,
669}
670
671/// A direction in the room list.
672#[derive(Eq, PartialEq, Copy, Clone)]
673enum Direction {
674    /// We are navigating from bottom to top.
675    Up,
676    /// We are navigating from top to bottom.
677    Down,
678}