fractal/session/view/sidebar/
mod.rs

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