fractal/session/view/sidebar/
mod.rs1use 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 #[property(get, set = Self::set_user, explicit_notify, nullable)]
62 user: RefCell<Option<User>>,
63 pub(super) drop_source_category: Cell<Option<RoomCategory>>,
65 pub(super) drop_active_target_category: Cell<Option<TargetRoomCategory>>,
67 #[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 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 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 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 fn session(&self) -> Option<Session> {
272 self.user.borrow().as_ref().map(User::session)
273 }
274
275 fn update_security_banner(&self) {
277 let Some(session) = self.session() else {
278 return;
279 };
280
281 if session.is_offline() {
282 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 self.security_banner.set_revealed(false);
299 return;
300 }
301
302 if verification_state == SessionVerificationState::Verified
303 && recovery_state == RecoveryState::Enabled
304 {
305 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 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 pub(super) fn room_row_popover(&self) -> >k::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 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 #[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 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 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 pub(crate) fn room_search_bar(&self) -> gtk::SearchBar {
428 self.imp().room_search.clone()
429 }
430
431 fn drop_source_category(&self) -> Option<RoomCategory> {
433 self.imp().drop_source_category.get()
434 }
435
436 fn set_drop_source_category(&self, source_category: Option<RoomCategory>) {
438 self.imp().set_drop_source_category(source_category);
439 }
440
441 fn drop_active_target_category(&self) -> Option<TargetRoomCategory> {
443 self.imp().drop_active_target_category.get()
444 }
445
446 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 fn room_row_popover(&self) -> >k::PopoverMenu {
458 self.imp().room_row_popover()
459 }
460
461 pub(crate) fn header_bar(&self) -> &adw::HeaderBar {
463 &self.imp().header_bar
464 }
465
466 pub(crate) fn scroll_to_selection(&self) {
468 self.imp().scroll_to_selection();
469 }
470
471 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 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}