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 #[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 if !show_content {
217 imp.sidebar.grab_focus();
218 }
219
220 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 imp.withdraw_selected_item_notifications();
243 }
244 ));
245 imp.window_active_handler_id.replace(Some(handler_id));
246 });
247
248 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 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 fn sidebar_list_model(&self) -> Option<SidebarListModel> {
283 self.session
284 .upgrade()
285 .map(|session| session.sidebar_list_model())
286 }
287
288 fn item_list(&self) -> Option<SidebarItemList> {
290 self.sidebar_list_model()
291 .map(|sidebar_list_model| sidebar_list_model.item_list())
292 }
293
294 fn room_list(&self) -> Option<RoomList> {
296 self.session.upgrade().map(|session| session.room_list())
297 }
298
299 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 pub(super) fn selected_item(&self) -> Option<glib::Object> {
310 self.content.item()
311 }
312
313 pub(super) fn select_room(&self, room: Room) {
315 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 self.sidebar.scroll_to_selection();
329 }
330
331 pub(super) fn selected_room(&self) -> Option<Room> {
333 self.selected_item().and_downcast()
334 }
335
336 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 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 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 pub(super) fn select_identity_verification(&self, verification: IdentityVerification) {
382 self.select_item(Some(verification.upcast()));
383 }
384
385 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 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 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 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 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 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 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 fn parent_window(&self) -> Option<Window> {
497 self.obj().root().and_downcast()
498 }
499
500 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 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 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 pub(super) fn handle_paste_action(&self) {
546 self.content.handle_paste_action();
547 }
548
549 pub(super) fn show_media_viewer(
551 &self,
552 source_widget: >k::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 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 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 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 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 pub fn new() -> Self {
609 glib::Object::new()
610 }
611
612 pub(crate) fn selected_room(&self) -> Option<Room> {
614 self.imp().selected_room()
615 }
616
617 pub(crate) fn select_room(&self, room: Room) {
619 self.imp().select_room(room);
620 }
621
622 pub(crate) fn select_room_if_exists(&self, identifier: &RoomOrAliasId) -> bool {
626 self.imp().select_room_if_exists(identifier)
627 }
628
629 pub(crate) fn select_identity_verification(&self, verification: IdentityVerification) {
631 self.imp().select_identity_verification(verification);
632 }
633
634 pub(crate) fn handle_paste_action(&self) {
636 self.imp().handle_paste_action();
637 }
638
639 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 pub(crate) fn show_matrix_uri(&self, uri: MatrixIdUri) {
653 self.imp().show_matrix_uri(uri);
654 }
655
656 pub(crate) fn process_intent(&self, intent: SessionIntent) {
658 self.imp().process_intent(intent);
659 }
660}
661
662#[derive(Eq, PartialEq, Copy, Clone)]
664enum ReadState {
665 Any,
667 Unread,
669}
670
671#[derive(Eq, PartialEq, Copy, Clone)]
673enum Direction {
674 Up,
676 Down,
678}