1use adw::{prelude::*, subclass::prelude::*};
2use gettextrs::gettext;
3use gtk::{accessible::Relation, gdk, gio, glib, glib::clone};
4use ruma::api::client::receipt::create_receipt::v3::ReceiptType;
5use tracing::error;
6
7use super::{
8 Sidebar, SidebarIconItemRow, SidebarRoomRow, SidebarSectionRow, SidebarVerificationRow,
9};
10use crate::{
11 components::{confirm_leave_room_dialog, ContextMenuBin},
12 prelude::*,
13 session::model::{
14 IdentityVerification, ReceiptPosition, Room, RoomCategory, SidebarIconItem,
15 SidebarIconItemType, SidebarSection, TargetRoomCategory, User,
16 },
17 spawn, spawn_tokio, toast,
18 utils::BoundObjectWeakRef,
19};
20
21mod imp {
22 use std::cell::RefCell;
23
24 use super::*;
25
26 #[derive(Debug, Default, glib::Properties)]
27 #[properties(wrapper_type = super::SidebarRow)]
28 pub struct SidebarRow {
29 #[property(get, set = Self::set_sidebar, construct_only)]
31 sidebar: BoundObjectWeakRef<Sidebar>,
32 #[property(get, set = Self::set_item, explicit_notify, nullable)]
34 item: RefCell<Option<glib::Object>>,
35 room_handler: RefCell<Option<glib::SignalHandlerId>>,
36 room_join_rule_handler: RefCell<Option<glib::SignalHandlerId>>,
37 room_is_read_handler: RefCell<Option<glib::SignalHandlerId>>,
38 }
39
40 #[glib::object_subclass]
41 impl ObjectSubclass for SidebarRow {
42 const NAME: &'static str = "SidebarRow";
43 type Type = super::SidebarRow;
44 type ParentType = ContextMenuBin;
45
46 fn class_init(klass: &mut Self::Class) {
47 klass.set_css_name("sidebar-row");
48 klass.set_accessible_role(gtk::AccessibleRole::ListItem);
49 }
50 }
51
52 #[glib::derived_properties]
53 impl ObjectImpl for SidebarRow {
54 fn constructed(&self) {
55 self.parent_constructed();
56
57 let drop = gtk::DropTarget::builder()
59 .actions(gdk::DragAction::MOVE)
60 .formats(&gdk::ContentFormats::for_type(Room::static_type()))
61 .build();
62 drop.connect_accept(clone!(
63 #[weak(rename_to = imp)]
64 self,
65 #[upgrade_or]
66 false,
67 move |_, drop| imp.drop_accept(drop)
68 ));
69 drop.connect_leave(clone!(
70 #[weak(rename_to = imp)]
71 self,
72 move |_| {
73 imp.drop_leave();
74 }
75 ));
76 drop.connect_drop(clone!(
77 #[weak(rename_to = imp)]
78 self,
79 #[upgrade_or]
80 false,
81 move |_, v, _, _| imp.drop_end(v)
82 ));
83 self.obj().add_controller(drop);
84 }
85
86 fn dispose(&self) {
87 if let Some(room) = self.room() {
88 if let Some(handler) = self.room_join_rule_handler.take() {
89 room.join_rule().disconnect(handler);
90 }
91 if let Some(handler) = self.room_is_read_handler.take() {
92 room.disconnect(handler);
93 }
94 }
95 }
96 }
97
98 impl WidgetImpl for SidebarRow {}
99
100 impl ContextMenuBinImpl for SidebarRow {
101 fn menu_opened(&self) {
102 if !self
103 .item
104 .borrow()
105 .as_ref()
106 .is_some_and(glib::Object::is::<Room>)
107 {
108 return;
110 }
111
112 let obj = self.obj();
113 if let Some(sidebar) = obj.sidebar() {
114 let popover = sidebar.room_row_popover();
115 obj.set_popover(Some(popover.clone()));
116 }
117 }
118 }
119
120 impl SidebarRow {
121 fn set_sidebar(&self, sidebar: &Sidebar) {
123 let drop_source_category_handler =
124 sidebar.connect_drop_source_category_changed(clone!(
125 #[weak(rename_to = imp)]
126 self,
127 move |_| {
128 imp.update_for_drop_source_category();
129 }
130 ));
131
132 let drop_active_target_category_handler = sidebar
133 .connect_drop_active_target_category_changed(clone!(
134 #[weak(rename_to = imp)]
135 self,
136 move |_| {
137 imp.update_for_drop_active_target_category();
138 }
139 ));
140
141 self.sidebar.set(
142 sidebar,
143 vec![
144 drop_source_category_handler,
145 drop_active_target_category_handler,
146 ],
147 );
148 }
149
150 fn set_item(&self, item: Option<glib::Object>) {
152 if *self.item.borrow() == item {
153 return;
154 }
155 let obj = self.obj();
156
157 if let Some(room) = self.room() {
158 if let Some(handler) = self.room_handler.take() {
159 room.disconnect(handler);
160 }
161 if let Some(handler) = self.room_join_rule_handler.take() {
162 room.join_rule().disconnect(handler);
163 }
164 if let Some(handler) = self.room_is_read_handler.take() {
165 room.disconnect(handler);
166 }
167 }
168
169 self.item.replace(item.clone());
170
171 self.update_context_menu();
172
173 if let Some(item) = item {
174 if let Some(section) = item.downcast_ref::<SidebarSection>() {
175 let child = obj.child_or_else::<SidebarSectionRow>(|| {
176 let child = SidebarSectionRow::new();
177 obj.update_relation(&[Relation::LabelledBy(&[child.labelled_by()])]);
178 child
179 });
180 child.set_section(Some(section.clone()));
181 } else if let Some(room) = item.downcast_ref::<Room>() {
182 let child = obj.child_or_default::<SidebarRoomRow>();
183
184 let room_is_direct_handler = room.connect_is_direct_notify(clone!(
185 #[weak(rename_to = imp)]
186 self,
187 move |_| {
188 imp.update_context_menu();
189 }
190 ));
191 self.room_handler.replace(Some(room_is_direct_handler));
192 let room_join_rule_handler =
193 room.join_rule().connect_we_can_join_notify(clone!(
194 #[weak(rename_to = imp)]
195 self,
196 move |_| {
197 imp.update_context_menu();
198 }
199 ));
200 self.room_join_rule_handler
201 .replace(Some(room_join_rule_handler));
202
203 let room_is_read_handler = room.connect_is_read_notify(clone!(
204 #[weak(rename_to = imp)]
205 self,
206 move |_| {
207 imp.update_context_menu();
208 }
209 ));
210 self.room_is_read_handler
211 .replace(Some(room_is_read_handler));
212
213 child.set_room(Some(room.clone()));
214 } else if let Some(icon_item) = item.downcast_ref::<SidebarIconItem>() {
215 let child = obj.child_or_default::<SidebarIconItemRow>();
216 child.set_icon_item(Some(icon_item.clone()));
217 } else if let Some(verification) = item.downcast_ref::<IdentityVerification>() {
218 let child = obj.child_or_default::<SidebarVerificationRow>();
219 child.set_identity_verification(Some(verification.clone()));
220 } else {
221 panic!("Wrong row item: {item:?}");
222 }
223
224 self.update_for_drop_source_category();
225 }
226
227 self.update_context_menu();
228 obj.notify_item();
229 }
230
231 pub(super) fn room(&self) -> Option<Room> {
233 self.item.borrow().clone().and_downcast()
234 }
235
236 pub(super) fn room_category(&self) -> Option<RoomCategory> {
241 let borrowed_item = self.item.borrow();
242 let item = borrowed_item.as_ref()?;
243
244 if let Some(room) = item.downcast_ref::<Room>() {
245 Some(room.category())
246 } else {
247 item.downcast_ref::<SidebarSection>()
248 .and_then(|section| section.name().into_room_category())
249 }
250 }
251
252 pub(super) fn target_room_category(&self) -> Option<TargetRoomCategory> {
254 self.room_category()
255 .and_then(RoomCategory::to_target_room_category)
256 }
257
258 pub(super) fn item_type(&self) -> Option<SidebarIconItemType> {
261 let borrowed_item = self.item.borrow();
262 borrowed_item
263 .as_ref()?
264 .downcast_ref::<SidebarIconItem>()
265 .map(SidebarIconItem::item_type)
266 }
267
268 fn has_room_context_menu(&self) -> bool {
270 self.room().is_some_and(|r| {
271 matches!(
272 r.category(),
273 RoomCategory::Invited
274 | RoomCategory::Favorite
275 | RoomCategory::Normal
276 | RoomCategory::LowPriority
277 | RoomCategory::Left
278 )
279 })
280 }
281
282 fn update_context_menu(&self) {
284 let obj = self.obj();
285
286 if !self.has_room_context_menu() {
287 obj.insert_action_group("room-row", None::<&gio::ActionGroup>);
288 obj.set_has_context_menu(false);
289 return;
290 }
291
292 obj.insert_action_group("room-row", self.room_actions().as_ref());
293 obj.set_has_context_menu(true);
294 }
295
296 #[allow(clippy::too_many_lines)]
298 fn room_actions(&self) -> Option<gio::SimpleActionGroup> {
299 let room = self.room()?;
300
301 let action_group = gio::SimpleActionGroup::new();
302 let category = room.category();
303
304 match category {
305 RoomCategory::Invited => {
306 action_group.add_action_entries([
307 gio::ActionEntry::builder("accept-invite")
308 .activate(clone!(
309 #[weak(rename_to = imp)]
310 self,
311 move |_, _, _| {
312 if let Some(room) = imp.room() {
313 spawn!(async move {
314 imp.set_room_category(
315 &room,
316 TargetRoomCategory::Normal,
317 )
318 .await;
319 });
320 }
321 }
322 ))
323 .build(),
324 gio::ActionEntry::builder("decline-invite")
325 .activate(clone!(
326 #[weak(rename_to = imp)]
327 self,
328 move |_, _, _| {
329 if let Some(room) = imp.room() {
330 spawn!(async move {
331 imp.set_room_category(&room, TargetRoomCategory::Left)
332 .await;
333 });
334 }
335 }
336 ))
337 .build(),
338 ]);
339 }
340 RoomCategory::Favorite | RoomCategory::Normal | RoomCategory::LowPriority => {
341 if matches!(category, RoomCategory::Favorite | RoomCategory::LowPriority) {
342 action_group.add_action_entries([gio::ActionEntry::builder("set-normal")
343 .activate(clone!(
344 #[weak(rename_to = imp)]
345 self,
346 move |_, _, _| {
347 if let Some(room) = imp.room() {
348 spawn!(async move {
349 imp.set_room_category(
350 &room,
351 TargetRoomCategory::Normal,
352 )
353 .await;
354 });
355 }
356 }
357 ))
358 .build()]);
359 }
360
361 if matches!(category, RoomCategory::Normal | RoomCategory::LowPriority) {
362 action_group.add_action_entries([gio::ActionEntry::builder(
363 "set-favorite",
364 )
365 .activate(clone!(
366 #[weak(rename_to = imp)]
367 self,
368 move |_, _, _| {
369 if let Some(room) = imp.room() {
370 spawn!(async move {
371 imp.set_room_category(&room, TargetRoomCategory::Favorite)
372 .await;
373 });
374 }
375 }
376 ))
377 .build()]);
378 }
379
380 if matches!(category, RoomCategory::Favorite | RoomCategory::Normal) {
381 action_group.add_action_entries([gio::ActionEntry::builder(
382 "set-lowpriority",
383 )
384 .activate(clone!(
385 #[weak(rename_to = imp)]
386 self,
387 move |_, _, _| {
388 if let Some(room) = imp.room() {
389 spawn!(async move {
390 imp.set_room_category(
391 &room,
392 TargetRoomCategory::LowPriority,
393 )
394 .await;
395 });
396 }
397 }
398 ))
399 .build()]);
400 }
401
402 action_group.add_action_entries([gio::ActionEntry::builder("leave")
403 .activate(clone!(
404 #[weak(rename_to = imp)]
405 self,
406 move |_, _, _| {
407 if let Some(room) = imp.room() {
408 spawn!(async move {
409 imp.set_room_category(&room, TargetRoomCategory::Left)
410 .await;
411 });
412 }
413 }
414 ))
415 .build()]);
416
417 if room.is_read() {
418 action_group.add_action_entries([gio::ActionEntry::builder(
419 "mark-as-unread",
420 )
421 .activate(clone!(
422 #[weak]
423 room,
424 move |_, _, _| {
425 spawn!(async move {
426 room.mark_as_unread().await;
427 });
428 }
429 ))
430 .build()]);
431 } else {
432 action_group.add_action_entries([gio::ActionEntry::builder(
433 "mark-as-read",
434 )
435 .activate(clone!(
436 #[weak]
437 room,
438 move |_, _, _| {
439 spawn!(async move {
440 room.send_receipt(ReceiptType::Read, ReceiptPosition::End)
441 .await;
442 });
443 }
444 ))
445 .build()]);
446 }
447 }
448 RoomCategory::Left => {
449 if room.join_rule().we_can_join() {
450 action_group.add_action_entries([gio::ActionEntry::builder("join")
451 .activate(clone!(
452 #[weak(rename_to = imp)]
453 self,
454 move |_, _, _| {
455 if let Some(room) = imp.room() {
456 spawn!(async move {
457 imp.set_room_category(
458 &room,
459 TargetRoomCategory::Normal,
460 )
461 .await;
462 });
463 }
464 }
465 ))
466 .build()]);
467 }
468
469 action_group.add_action_entries([gio::ActionEntry::builder("forget")
470 .activate(clone!(
471 #[weak(rename_to = imp)]
472 self,
473 move |_, _, _| {
474 if let Some(room) = imp.room() {
475 spawn!(async move {
476 imp.forget_room(&room).await;
477 });
478 }
479 }
480 ))
481 .build()]);
482 }
483 RoomCategory::Outdated | RoomCategory::Space | RoomCategory::Ignored => {}
484 }
485
486 if matches!(
487 category,
488 RoomCategory::Favorite
489 | RoomCategory::Normal
490 | RoomCategory::LowPriority
491 | RoomCategory::Left
492 ) {
493 if room.is_direct() {
494 action_group.add_action_entries([gio::ActionEntry::builder(
495 "unset-direct-chat",
496 )
497 .activate(clone!(
498 #[weak(rename_to = imp)]
499 self,
500 move |_, _, _| {
501 if let Some(room) = imp.room() {
502 spawn!(async move {
503 imp.set_room_is_direct(&room, false).await;
504 });
505 }
506 }
507 ))
508 .build()]);
509 } else {
510 action_group.add_action_entries([gio::ActionEntry::builder("set-direct-chat")
511 .activate(clone!(
512 #[weak(rename_to = imp)]
513 self,
514 move |_, _, _| {
515 if let Some(room) = imp.room() {
516 spawn!(async move {
517 imp.set_room_is_direct(&room, true).await;
518 });
519 }
520 }
521 ))
522 .build()]);
523 }
524 }
525
526 Some(action_group)
527 }
528
529 fn update_for_drop_source_category(&self) {
531 let obj = self.obj();
532 let source_category = self.sidebar.obj().and_then(|s| s.drop_source_category());
533
534 if let Some(source_category) = source_category {
535 if self
536 .target_room_category()
537 .is_some_and(|row_category| source_category.can_change_to(row_category))
538 {
539 obj.remove_css_class("drop-disabled");
540
541 if self
542 .item
543 .borrow()
544 .as_ref()
545 .and_then(glib::Object::downcast_ref)
546 .is_some_and(SidebarSection::is_empty)
547 {
548 obj.add_css_class("drop-empty");
549 } else {
550 obj.remove_css_class("drop-empty");
551 }
552 } else {
553 let is_forget_item = self
554 .item_type()
555 .is_some_and(|item_type| item_type == SidebarIconItemType::Forget);
556 if is_forget_item && source_category == RoomCategory::Left {
557 obj.remove_css_class("drop-disabled");
558 } else {
559 obj.add_css_class("drop-disabled");
560 obj.remove_css_class("drop-empty");
561 }
562 }
563 } else {
564 obj.remove_css_class("drop-disabled");
566 obj.remove_css_class("drop-empty");
567 obj.remove_css_class("drop-active");
568 }
569
570 if let Some(section_row) = obj.child().and_downcast::<SidebarSectionRow>() {
571 section_row.set_show_label_for_room_category(source_category);
572 }
573 }
574
575 fn update_for_drop_active_target_category(&self) {
577 let obj = self.obj();
578
579 let Some(room_category) = self.room_category() else {
580 obj.remove_css_class("drop-active");
581 return;
582 };
583
584 let target_category = self
585 .sidebar
586 .obj()
587 .and_then(|s| s.drop_active_target_category());
588
589 if target_category.is_some_and(|target_category| target_category == room_category) {
590 obj.add_css_class("drop-active");
591 } else {
592 obj.remove_css_class("drop-active");
593 }
594 }
595
596 fn drop_accept(&self, drop: &gdk::Drop) -> bool {
598 let Some(sidebar) = self.sidebar.obj() else {
599 return false;
600 };
601
602 let room = drop
603 .drag()
604 .map(|drag| drag.content())
605 .and_then(|content| content.value(Room::static_type()).ok())
606 .and_then(|value| value.get::<Room>().ok());
607 if let Some(room) = room {
608 if let Some(target_category) = self.target_room_category() {
609 if room.category().can_change_to(target_category) {
610 sidebar.set_drop_active_target_category(Some(target_category));
611 return true;
612 }
613 } else if let Some(item_type) = self.item_type() {
614 if room.category() == RoomCategory::Left
615 && item_type == SidebarIconItemType::Forget
616 {
617 self.obj().add_css_class("drop-active");
618 sidebar.set_drop_active_target_category(None);
619 return true;
620 }
621 }
622 }
623 false
624 }
625
626 fn drop_leave(&self) {
628 self.obj().remove_css_class("drop-active");
629 if let Some(sidebar) = self.sidebar.obj() {
630 sidebar.set_drop_active_target_category(None);
631 }
632 }
633
634 fn drop_end(&self, value: &glib::Value) -> bool {
636 let mut ret = false;
637 if let Ok(room) = value.get::<Room>() {
638 if let Some(target_category) = self.target_room_category() {
639 if room.category().can_change_to(target_category) {
640 spawn!(clone!(
641 #[weak(rename_to = imp)]
642 self,
643 async move {
644 imp.set_room_category(&room, target_category).await;
645 }
646 ));
647 ret = true;
648 }
649 } else if let Some(item_type) = self.item_type() {
650 if room.category() == RoomCategory::Left
651 && item_type == SidebarIconItemType::Forget
652 {
653 spawn!(clone!(
654 #[weak(rename_to = imp)]
655 self,
656 async move {
657 imp.forget_room(&room).await;
658 }
659 ));
660 ret = true;
661 }
662 }
663 }
664 if let Some(sidebar) = self.sidebar.obj() {
665 sidebar.set_drop_source_category(None);
666 }
667 ret
668 }
669
670 async fn set_room_category(&self, room: &Room, category: TargetRoomCategory) {
672 let obj = self.obj();
673
674 let ignored_inviter = if category == TargetRoomCategory::Left {
675 let Some(response) = confirm_leave_room_dialog(room, &*obj).await else {
676 return;
677 };
678
679 response.ignore_inviter.then(|| room.inviter()).flatten()
680 } else {
681 None
682 };
683
684 let previous_category = room.category();
685 if room.change_category(category).await.is_err() {
686 match previous_category {
687 RoomCategory::Invited => {
688 if category == RoomCategory::Left {
689 toast!(
690 obj,
691 gettext(
692 "Could not decline invitation for {room}",
695 ),
696 @room,
697 );
698 } else {
699 toast!(
700 obj,
701 gettext(
702 "Could not accept invitation for {room}",
705 ),
706 @room,
707 );
708 }
709 }
710 RoomCategory::Left => {
711 toast!(
712 obj,
713 gettext(
714 "Could not join {room}",
717 ),
718 @room,
719 );
720 }
721 _ => {
722 if category == RoomCategory::Left {
723 toast!(
724 obj,
725 gettext(
726 "Could not leave {room}",
728 ),
729 @room,
730 );
731 } else {
732 toast!(
733 obj,
734 gettext(
735 "Could not move {room} from {previous_category} to {new_category}",
737 ),
738 @room,
739 previous_category = previous_category.to_string(),
740 new_category = RoomCategory::from(category).to_string(),
741 );
742 }
743 }
744 }
745 }
746
747 if let Some(inviter) = ignored_inviter {
748 if inviter.upcast::<User>().ignore().await.is_err() {
749 toast!(obj, gettext("Could not ignore user"));
750 }
751 }
752 }
753
754 async fn forget_room(&self, room: &Room) {
756 if room.forget().await.is_err() {
757 toast!(
758 self.obj(),
759 gettext("Could not forget {room}"),
761 @room,
762 );
763 }
764 }
765
766 async fn set_room_is_direct(&self, room: &Room, is_direct: bool) {
768 let matrix_room = room.matrix_room().clone();
769 let handle = spawn_tokio!(async move { matrix_room.set_is_direct(is_direct).await });
770
771 if let Err(error) = handle.await.unwrap() {
772 let obj = self.obj();
773
774 if is_direct {
775 error!("Could not mark room as direct chat: {error}");
776 toast!(obj, gettext("Could not mark {room} as direct chat"), @room);
779 } else {
780 error!("Could not unmark room as direct chat: {error}");
781 toast!(obj, gettext("Could not unmark {room} as direct chat"), @room);
784 }
785 }
786 }
787 }
788}
789
790glib::wrapper! {
791 pub struct SidebarRow(ObjectSubclass<imp::SidebarRow>)
793 @extends gtk::Widget, ContextMenuBin, @implements gtk::Accessible;
794}
795
796impl SidebarRow {
797 pub fn new(sidebar: &Sidebar) -> Self {
798 glib::Object::builder().property("sidebar", sidebar).build()
799 }
800}
801
802impl ChildPropertyExt for SidebarRow {
803 fn child_property(&self) -> Option<gtk::Widget> {
804 self.child()
805 }
806
807 fn set_child_property(&self, child: Option<&impl IsA<gtk::Widget>>) {
808 self.set_child(child);
809 }
810}