Skip to main content

fractal/session_view/sidebar/
mod.rs

1use adw::{prelude::*, subclass::prelude::*};
2use gettextrs::gettext;
3use gtk::{
4    gio, glib,
5    glib::{clone, closure_local},
6};
7use tracing::error;
8
9mod icon_item_row;
10mod room_row;
11mod row;
12mod section_row;
13mod verification_row;
14
15use self::{
16    icon_item_row::SidebarIconItemRow, room_row::SidebarRoomRow, row::SidebarRow,
17    section_row::SidebarSectionRow, verification_row::SidebarVerificationRow,
18};
19use crate::{
20    account_settings::{AccountSettings, AccountSettingsSubpage},
21    account_switcher::AccountSwitcherButton,
22    components::OfflineBanner,
23    session::{
24        CryptoIdentityState, RecoveryState, RoomCategory, Session, SessionVerificationState,
25        SidebarListModel, SidebarSection, TargetRoomCategory, User,
26    },
27    utils::{FixedSelection, expression},
28};
29
30mod imp {
31    use std::{
32        cell::{Cell, OnceCell, RefCell},
33        sync::LazyLock,
34    };
35
36    use glib::subclass::{InitializingObject, Signal};
37
38    use super::*;
39
40    #[derive(Debug, Default, gtk::CompositeTemplate, glib::Properties)]
41    #[template(resource = "/org/gnome/Fractal/ui/session_view/sidebar/mod.ui")]
42    #[properties(wrapper_type = super::Sidebar)]
43    pub struct Sidebar {
44        #[template_child]
45        pub(super) header_bar: TemplateChild<adw::HeaderBar>,
46        #[template_child]
47        account_switcher_button: TemplateChild<AccountSwitcherButton>,
48        #[template_child]
49        security_banner: TemplateChild<adw::Banner>,
50        #[template_child]
51        scrolled_window: TemplateChild<gtk::ScrolledWindow>,
52        #[template_child]
53        listview: TemplateChild<gtk::ListView>,
54        #[template_child]
55        room_search_entry: TemplateChild<gtk::SearchEntry>,
56        #[template_child]
57        pub(super) room_search: TemplateChild<gtk::SearchBar>,
58        #[template_child]
59        room_row_menu: TemplateChild<gio::MenuModel>,
60        room_row_popover: OnceCell<gtk::PopoverMenu>,
61        /// The logged-in user.
62        #[property(get, set = Self::set_user, explicit_notify, nullable)]
63        user: RefCell<Option<User>>,
64        /// The category of the source that activated drop mode.
65        pub(super) drop_source_category: Cell<Option<RoomCategory>>,
66        /// The category of the drop target that is currently hovered.
67        pub(super) drop_active_target_category: Cell<Option<TargetRoomCategory>>,
68        /// The list model of this sidebar.
69        #[property(get, set = Self::set_list_model, explicit_notify, nullable)]
70        list_model: glib::WeakRef<SidebarListModel>,
71        expr_watch: RefCell<Option<gtk::ExpressionWatch>>,
72        session_handler: RefCell<Option<glib::SignalHandlerId>>,
73        security_handlers: RefCell<Vec<glib::SignalHandlerId>>,
74    }
75
76    #[glib::object_subclass]
77    impl ObjectSubclass for Sidebar {
78        const NAME: &'static str = "Sidebar";
79        type Type = super::Sidebar;
80        type ParentType = adw::NavigationPage;
81
82        fn class_init(klass: &mut Self::Class) {
83            OfflineBanner::ensure_type();
84
85            Self::bind_template(klass);
86            Self::bind_template_callbacks(klass);
87
88            klass.set_css_name("sidebar");
89        }
90
91        fn instance_init(obj: &InitializingObject<Self>) {
92            obj.init_template();
93        }
94    }
95
96    #[glib::derived_properties]
97    impl ObjectImpl for Sidebar {
98        fn signals() -> &'static [Signal] {
99            static SIGNALS: LazyLock<Vec<Signal>> = LazyLock::new(|| {
100                vec![
101                    Signal::builder("drop-source-category-changed").build(),
102                    Signal::builder("drop-active-target-category-changed").build(),
103                ]
104            });
105            SIGNALS.as_ref()
106        }
107
108        fn constructed(&self) {
109            self.parent_constructed();
110            let obj = self.obj();
111
112            let factory = gtk::SignalListItemFactory::new();
113            factory.connect_setup(clone!(
114                #[weak]
115                obj,
116                move |_, item| {
117                    let Some(item) = item.downcast_ref::<gtk::ListItem>() else {
118                        error!("List item factory did not receive a list item: {item:?}");
119                        return;
120                    };
121                    let row = SidebarRow::new(&obj);
122                    item.set_child(Some(&row));
123                    item.bind_property("item", &row, "item").build();
124                }
125            ));
126            self.listview.set_factory(Some(&factory));
127
128            self.listview.connect_activate(move |listview, pos| {
129                let Some(model) = listview.model().and_downcast::<FixedSelection>() else {
130                    return;
131                };
132                let Some(item) = model.item(pos) else {
133                    return;
134                };
135
136                if let Some(section) = item.downcast_ref::<SidebarSection>() {
137                    section.set_is_expanded(!section.is_expanded());
138                } else {
139                    model.set_selected(pos);
140                }
141            });
142
143            obj.property_expression("list-model")
144                .chain_property::<SidebarListModel>("selection-model")
145                .bind(&*self.listview, "model", None::<&glib::Object>);
146
147            // FIXME: Remove this hack once https://gitlab.gnome.org/GNOME/gtk/-/issues/4938 is resolved
148            self.scrolled_window
149                .vscrollbar()
150                .first_child()
151                .unwrap()
152                .set_overflow(gtk::Overflow::Hidden);
153        }
154
155        fn dispose(&self) {
156            if let Some(expr_watch) = self.expr_watch.take() {
157                expr_watch.unwatch();
158            }
159
160            if let Some(user) = self.user.take() {
161                let session = user.session();
162                if let Some(handler) = self.session_handler.take() {
163                    session.disconnect(handler);
164                }
165
166                let security = session.security();
167                for handler in self.security_handlers.take() {
168                    security.disconnect(handler);
169                }
170            }
171        }
172    }
173
174    impl WidgetImpl for Sidebar {
175        fn grab_focus(&self) -> bool {
176            if self.listview.grab_focus() {
177                true
178            } else {
179                self.account_switcher_button.grab_focus()
180            }
181        }
182    }
183
184    impl NavigationPageImpl for Sidebar {}
185
186    #[gtk::template_callbacks]
187    impl Sidebar {
188        /// Set the logged-in user.
189        fn set_user(&self, user: Option<User>) {
190            let prev_user = self.user.borrow().clone();
191            if prev_user == user {
192                return;
193            }
194
195            if let Some(user) = prev_user {
196                let session = user.session();
197                if let Some(handler) = self.session_handler.take() {
198                    session.disconnect(handler);
199                }
200
201                let security = session.security();
202                for handler in self.security_handlers.take() {
203                    security.disconnect(handler);
204                }
205            }
206
207            if let Some(user) = &user {
208                let session = user.session();
209
210                let offline_handler = session.connect_is_offline_notify(clone!(
211                    #[weak(rename_to = imp)]
212                    self,
213                    move |_| {
214                        imp.update_security_banner();
215                    }
216                ));
217                self.session_handler.replace(Some(offline_handler));
218
219                let security = session.security();
220                let crypto_identity_handler =
221                    security.connect_crypto_identity_state_notify(clone!(
222                        #[weak(rename_to = imp)]
223                        self,
224                        move |_| {
225                            imp.update_security_banner();
226                        }
227                    ));
228                let verification_handler = security.connect_verification_state_notify(clone!(
229                    #[weak(rename_to = imp)]
230                    self,
231                    move |_| {
232                        imp.update_security_banner();
233                    }
234                ));
235                let recovery_handler = security.connect_recovery_state_notify(clone!(
236                    #[weak(rename_to = imp)]
237                    self,
238                    move |_| {
239                        imp.update_security_banner();
240                    }
241                ));
242
243                self.security_handlers.replace(vec![
244                    crypto_identity_handler,
245                    verification_handler,
246                    recovery_handler,
247                ]);
248            }
249
250            self.user.replace(user);
251
252            self.update_security_banner();
253            self.obj().notify_user();
254        }
255
256        /// Set the list model of the sidebar.
257        fn set_list_model(&self, list_model: Option<&SidebarListModel>) {
258            if self.list_model.upgrade().as_ref() == list_model {
259                return;
260            }
261            let obj = self.obj();
262
263            if let Some(expr_watch) = self.expr_watch.take() {
264                expr_watch.unwatch();
265            }
266
267            if let Some(list_model) = list_model {
268                let expr_watch = expression::normalize_string(
269                    self.room_search_entry.property_expression("text"),
270                )
271                .bind(&list_model.string_filter(), "search", None::<&glib::Object>);
272                self.expr_watch.replace(Some(expr_watch));
273            }
274
275            self.list_model.set(list_model);
276            obj.notify_list_model();
277        }
278
279        /// The current session, if any.
280        fn session(&self) -> Option<Session> {
281            self.user.borrow().as_ref().map(User::session)
282        }
283
284        /// Update the security banner.
285        fn update_security_banner(&self) {
286            let Some(session) = self.session() else {
287                return;
288            };
289
290            if session.is_offline() {
291                // Only show one banner at a time.
292                // The user will not be able to solve security issues while offline anyway.
293                self.security_banner.set_revealed(false);
294                return;
295            }
296
297            let security = session.security();
298            let crypto_identity_state = security.crypto_identity_state();
299            let verification_state = security.verification_state();
300            let recovery_state = security.recovery_state();
301
302            if crypto_identity_state == CryptoIdentityState::Unknown
303                || verification_state == SessionVerificationState::Unknown
304                || recovery_state == RecoveryState::Unknown
305            {
306                // Do not show the banner prematurely, unknown states should solve themselves.
307                self.security_banner.set_revealed(false);
308                return;
309            }
310
311            if verification_state == SessionVerificationState::Verified
312                && recovery_state == RecoveryState::Enabled
313            {
314                // No need for the banner.
315                self.security_banner.set_revealed(false);
316                return;
317            }
318
319            let (title, button) = if crypto_identity_state == CryptoIdentityState::Missing {
320                (gettext("No crypto identity"), gettext("Enable"))
321            } else if verification_state == SessionVerificationState::Unverified {
322                (gettext("Crypto identity incomplete"), gettext("Verify"))
323            } else {
324                match recovery_state {
325                    RecoveryState::Disabled => {
326                        (gettext("Account recovery disabled"), gettext("Enable"))
327                    }
328                    RecoveryState::Incomplete => {
329                        (gettext("Account recovery incomplete"), gettext("Recover"))
330                    }
331                    _ => unreachable!(),
332                }
333            };
334
335            self.security_banner.set_title(&title);
336            self.security_banner.set_button_label(Some(&button));
337            self.security_banner.set_revealed(true);
338        }
339
340        /// Set the category of the source that activated drop mode.
341        pub(super) fn set_drop_source_category(&self, source_category: Option<RoomCategory>) {
342            if self.drop_source_category.get() == source_category {
343                return;
344            }
345
346            self.drop_source_category.set(source_category);
347
348            if source_category.is_some() {
349                self.listview.add_css_class("drop-mode");
350            } else {
351                self.listview.remove_css_class("drop-mode");
352            }
353
354            let Some(item_list) = self.list_model.upgrade().map(|model| model.item_list()) else {
355                return;
356            };
357
358            item_list.set_show_all_for_room_category(source_category);
359            self.obj()
360                .emit_by_name::<()>("drop-source-category-changed", &[]);
361        }
362
363        /// The shared popover for a room row in the sidebar.
364        pub(super) fn room_row_popover(&self) -> &gtk::PopoverMenu {
365            self.room_row_popover.get_or_init(|| {
366                let popover = gtk::PopoverMenu::builder()
367                    .menu_model(&*self.room_row_menu)
368                    .has_arrow(false)
369                    .halign(gtk::Align::Start)
370                    .build();
371                popover
372                    .update_property(&[gtk::accessible::Property::Label(&gettext("Context Menu"))]);
373
374                popover
375            })
376        }
377
378        /// Scroll to the currently selected item of the sidebar.
379        pub(super) fn scroll_to_selection(&self) {
380            let Some(list_model) = self.list_model.upgrade() else {
381                return;
382            };
383
384            let selected = list_model.selection_model().selected();
385
386            if selected != gtk::INVALID_LIST_POSITION {
387                self.listview
388                    .scroll_to(selected, gtk::ListScrollFlags::FOCUS, None);
389            }
390        }
391
392        /// Open the proper security flow to fix the current issue.
393        #[template_callback]
394        fn fix_security_issue(&self) {
395            let Some(session) = self.session() else {
396                return;
397            };
398
399            let dialog = AccountSettings::new(&session);
400
401            // Show the encryption tab if the user uses the back button.
402            dialog.show_encryption_tab();
403
404            let security = session.security();
405            let crypto_identity_state = security.crypto_identity_state();
406            let verification_state = security.verification_state();
407
408            let subpage = if crypto_identity_state == CryptoIdentityState::Missing
409                || verification_state == SessionVerificationState::Unverified
410            {
411                AccountSettingsSubpage::CryptoIdentitySetup
412            } else {
413                AccountSettingsSubpage::RecoverySetup
414            };
415            dialog.show_subpage(subpage);
416
417            dialog.present(Some(&*self.obj()));
418        }
419    }
420}
421
422glib::wrapper! {
423    /// The sidebar of the session view, displaying the list of rooms
424    /// available for the current session, among other things.
425    pub struct Sidebar(ObjectSubclass<imp::Sidebar>)
426        @extends gtk::Widget, adw::NavigationPage,
427        @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget;
428}
429
430#[gtk::template_callbacks]
431impl Sidebar {
432    pub fn new() -> Self {
433        glib::Object::new()
434    }
435
436    /// The search bar allowing to filter rooms in the sidebar.
437    pub(crate) fn room_search_bar(&self) -> gtk::SearchBar {
438        self.imp().room_search.clone()
439    }
440
441    /// The category of the source that activated drop mode.
442    fn drop_source_category(&self) -> Option<RoomCategory> {
443        self.imp().drop_source_category.get()
444    }
445
446    /// Set the category of the source that activated drop mode.
447    fn set_drop_source_category(&self, source_category: Option<RoomCategory>) {
448        self.imp().set_drop_source_category(source_category);
449    }
450
451    /// The category of the drop target that is currently hovered.
452    fn drop_active_target_category(&self) -> Option<TargetRoomCategory> {
453        self.imp().drop_active_target_category.get()
454    }
455
456    /// Set the category of the drop target that is currently hovered.
457    fn set_drop_active_target_category(&self, target_category: Option<TargetRoomCategory>) {
458        if self.drop_active_target_category() == target_category {
459            return;
460        }
461
462        self.imp().drop_active_target_category.set(target_category);
463        self.emit_by_name::<()>("drop-active-target-category-changed", &[]);
464    }
465
466    /// The shared popover for a room row in the sidebar.
467    fn room_row_popover(&self) -> &gtk::PopoverMenu {
468        self.imp().room_row_popover()
469    }
470
471    /// The `AdwHeaderBar` of the sidebar.
472    pub(crate) fn header_bar(&self) -> &adw::HeaderBar {
473        &self.imp().header_bar
474    }
475
476    /// Scroll to the currently selected item of the sidebar.
477    pub(crate) fn scroll_to_selection(&self) {
478        self.imp().scroll_to_selection();
479    }
480
481    /// Connect to the signal emitted when the drop source category changed.
482    pub fn connect_drop_source_category_changed<F: Fn(&Self) + 'static>(
483        &self,
484        f: F,
485    ) -> glib::SignalHandlerId {
486        self.connect_closure(
487            "drop-source-category-changed",
488            true,
489            closure_local!(move |obj: Self| {
490                f(&obj);
491            }),
492        )
493    }
494
495    /// Connect to the signal emitted when the drop active target category
496    /// changed.
497    pub fn connect_drop_active_target_category_changed<F: Fn(&Self) + 'static>(
498        &self,
499        f: F,
500    ) -> glib::SignalHandlerId {
501        self.connect_closure(
502            "drop-active-target-category-changed",
503            true,
504            closure_local!(move |obj: Self| {
505                f(&obj);
506            }),
507        )
508    }
509}