fractal/session/view/sidebar/
mod.rs1use adw::{prelude::*, subclass::prelude::*};
2use gettextrs::gettext;
3use gtk::{
4 CompositeTemplate, ListScrollFlags, gio,
5 glib::{self, 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 super::{AccountSettings, account_settings::AccountSettingsSubpage};
20use crate::{
21 account_switcher::AccountSwitcherButton,
22 components::OfflineBanner,
23 session::model::{
24 CryptoIdentityState, RecoveryState, RoomCategory, Selection, Session,
25 SessionVerificationState, SidebarListModel, SidebarSection, TargetRoomCategory, User,
26 },
27 utils::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, 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 #[property(get, set = Self::set_user, explicit_notify, nullable)]
63 user: RefCell<Option<User>>,
64 pub(super) drop_source_category: Cell<Option<RoomCategory>>,
66 pub(super) drop_active_target_category: Cell<Option<TargetRoomCategory>>,
68 #[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::<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 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 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 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 fn session(&self) -> Option<Session> {
281 self.user.borrow().as_ref().map(User::session)
282 }
283
284 fn update_security_banner(&self) {
286 let Some(session) = self.session() else {
287 return;
288 };
289
290 if session.is_offline() {
291 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 self.security_banner.set_revealed(false);
308 return;
309 }
310
311 if verification_state == SessionVerificationState::Verified
312 && recovery_state == RecoveryState::Enabled
313 {
314 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 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 pub(super) fn room_row_popover(&self) -> >k::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 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, ListScrollFlags::FOCUS, None);
389 }
390 }
391
392 #[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 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 pub struct Sidebar(ObjectSubclass<imp::Sidebar>)
426 @extends gtk::Widget, adw::NavigationPage, @implements gtk::Accessible;
427}
428
429#[gtk::template_callbacks]
430impl Sidebar {
431 pub fn new() -> Self {
432 glib::Object::new()
433 }
434
435 pub(crate) fn room_search_bar(&self) -> gtk::SearchBar {
437 self.imp().room_search.clone()
438 }
439
440 fn drop_source_category(&self) -> Option<RoomCategory> {
442 self.imp().drop_source_category.get()
443 }
444
445 fn set_drop_source_category(&self, source_category: Option<RoomCategory>) {
447 self.imp().set_drop_source_category(source_category);
448 }
449
450 fn drop_active_target_category(&self) -> Option<TargetRoomCategory> {
452 self.imp().drop_active_target_category.get()
453 }
454
455 fn set_drop_active_target_category(&self, target_category: Option<TargetRoomCategory>) {
457 if self.drop_active_target_category() == target_category {
458 return;
459 }
460
461 self.imp().drop_active_target_category.set(target_category);
462 self.emit_by_name::<()>("drop-active-target-category-changed", &[]);
463 }
464
465 fn room_row_popover(&self) -> >k::PopoverMenu {
467 self.imp().room_row_popover()
468 }
469
470 pub(crate) fn header_bar(&self) -> &adw::HeaderBar {
472 &self.imp().header_bar
473 }
474
475 pub(crate) fn scroll_to_selection(&self) {
477 self.imp().scroll_to_selection();
478 }
479
480 pub fn connect_drop_source_category_changed<F: Fn(&Self) + 'static>(
482 &self,
483 f: F,
484 ) -> glib::SignalHandlerId {
485 self.connect_closure(
486 "drop-source-category-changed",
487 true,
488 closure_local!(move |obj: Self| {
489 f(&obj);
490 }),
491 )
492 }
493
494 pub fn connect_drop_active_target_category_changed<F: Fn(&Self) + 'static>(
497 &self,
498 f: F,
499 ) -> glib::SignalHandlerId {
500 self.connect_closure(
501 "drop-active-target-category-changed",
502 true,
503 closure_local!(move |obj: Self| {
504 f(&obj);
505 }),
506 )
507 }
508}