1use adw::{prelude::*, subclass::prelude::*};
2use gettextrs::gettext;
3use gtk::{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::{ContextMenuBin, confirm_leave_room_dialog},
12 prelude::*,
13 session::{
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(&[gtk::accessible::Relation::LabelledBy(&[
178 child.labelled_by()
179 ])]);
180 child
181 });
182 child.set_section(Some(section.clone()));
183 } else if let Some(room) = item.downcast_ref::<Room>() {
184 let child = obj.child_or_default::<SidebarRoomRow>();
185
186 let room_is_direct_handler = room.connect_is_direct_notify(clone!(
187 #[weak(rename_to = imp)]
188 self,
189 move |_| {
190 imp.update_context_menu();
191 }
192 ));
193 self.room_handler.replace(Some(room_is_direct_handler));
194 let room_join_rule_handler =
195 room.join_rule().connect_we_can_join_notify(clone!(
196 #[weak(rename_to = imp)]
197 self,
198 move |_| {
199 imp.update_context_menu();
200 }
201 ));
202 self.room_join_rule_handler
203 .replace(Some(room_join_rule_handler));
204
205 let room_is_read_handler = room.connect_is_read_notify(clone!(
206 #[weak(rename_to = imp)]
207 self,
208 move |_| {
209 imp.update_context_menu();
210 }
211 ));
212 self.room_is_read_handler
213 .replace(Some(room_is_read_handler));
214
215 child.set_room(Some(room.clone()));
216 } else if let Some(icon_item) = item.downcast_ref::<SidebarIconItem>() {
217 let child = obj.child_or_default::<SidebarIconItemRow>();
218 child.set_icon_item(Some(icon_item.clone()));
219 } else if let Some(verification) = item.downcast_ref::<IdentityVerification>() {
220 let child = obj.child_or_default::<SidebarVerificationRow>();
221 child.set_identity_verification(Some(verification.clone()));
222 } else {
223 panic!("Wrong row item: {item:?}");
224 }
225
226 self.update_for_drop_source_category();
227 }
228
229 self.update_context_menu();
230 obj.notify_item();
231 }
232
233 pub(super) fn room(&self) -> Option<Room> {
235 self.item.borrow().clone().and_downcast()
236 }
237
238 pub(super) fn room_category(&self) -> Option<RoomCategory> {
243 let borrowed_item = self.item.borrow();
244 let item = borrowed_item.as_ref()?;
245
246 if let Some(room) = item.downcast_ref::<Room>() {
247 Some(room.category())
248 } else {
249 item.downcast_ref::<SidebarSection>()
250 .and_then(|section| section.name().into_room_category())
251 }
252 }
253
254 pub(super) fn target_room_category(&self) -> Option<TargetRoomCategory> {
256 self.room_category()
257 .and_then(RoomCategory::to_target_room_category)
258 }
259
260 pub(super) fn item_type(&self) -> Option<SidebarIconItemType> {
263 let borrowed_item = self.item.borrow();
264 borrowed_item
265 .as_ref()?
266 .downcast_ref::<SidebarIconItem>()
267 .map(SidebarIconItem::item_type)
268 }
269
270 fn has_room_context_menu(&self) -> bool {
272 self.room().is_some_and(|r| {
273 matches!(
274 r.category(),
275 RoomCategory::Invited
276 | RoomCategory::Favorite
277 | RoomCategory::Normal
278 | RoomCategory::LowPriority
279 | RoomCategory::Left
280 )
281 })
282 }
283
284 fn update_context_menu(&self) {
286 let obj = self.obj();
287
288 if !self.has_room_context_menu() {
289 obj.insert_action_group("room-row", None::<&gio::ActionGroup>);
290 obj.set_has_context_menu(false);
291 return;
292 }
293
294 obj.insert_action_group("room-row", self.room_actions().as_ref());
295 obj.set_has_context_menu(true);
296 }
297
298 #[allow(clippy::too_many_lines)]
300 fn room_actions(&self) -> Option<gio::SimpleActionGroup> {
301 let room = self.room()?;
302
303 let action_group = gio::SimpleActionGroup::new();
304 let category = room.category();
305
306 match category {
307 RoomCategory::Knocked => {
308 action_group.add_action_entries([gio::ActionEntry::builder(
309 "retract-invite-request",
310 )
311 .activate(clone!(
312 #[weak(rename_to = imp)]
313 self,
314 move |_, _, _| {
315 if let Some(room) = imp.room() {
316 spawn!(async move {
317 imp.set_room_category(&room, TargetRoomCategory::Left).await;
318 });
319 }
320 }
321 ))
322 .build()]);
323 }
324 RoomCategory::Invited => {
325 action_group.add_action_entries([
326 gio::ActionEntry::builder("accept-invite")
327 .activate(clone!(
328 #[weak(rename_to = imp)]
329 self,
330 move |_, _, _| {
331 if let Some(room) = imp.room() {
332 spawn!(async move {
333 imp.set_room_category(
334 &room,
335 TargetRoomCategory::Normal,
336 )
337 .await;
338 });
339 }
340 }
341 ))
342 .build(),
343 gio::ActionEntry::builder("decline-invite")
344 .activate(clone!(
345 #[weak(rename_to = imp)]
346 self,
347 move |_, _, _| {
348 if let Some(room) = imp.room() {
349 spawn!(async move {
350 imp.set_room_category(&room, TargetRoomCategory::Left)
351 .await;
352 });
353 }
354 }
355 ))
356 .build(),
357 ]);
358 }
359 RoomCategory::Favorite | RoomCategory::Normal | RoomCategory::LowPriority => {
360 if matches!(category, RoomCategory::Favorite | RoomCategory::LowPriority) {
361 action_group.add_action_entries([gio::ActionEntry::builder("set-normal")
362 .activate(clone!(
363 #[weak(rename_to = imp)]
364 self,
365 move |_, _, _| {
366 if let Some(room) = imp.room() {
367 spawn!(async move {
368 imp.set_room_category(
369 &room,
370 TargetRoomCategory::Normal,
371 )
372 .await;
373 });
374 }
375 }
376 ))
377 .build()]);
378 }
379
380 if matches!(category, RoomCategory::Normal | RoomCategory::LowPriority) {
381 action_group.add_action_entries([gio::ActionEntry::builder(
382 "set-favorite",
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(&room, TargetRoomCategory::Favorite)
391 .await;
392 });
393 }
394 }
395 ))
396 .build()]);
397 }
398
399 if matches!(category, RoomCategory::Favorite | RoomCategory::Normal) {
400 action_group.add_action_entries([gio::ActionEntry::builder(
401 "set-lowpriority",
402 )
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(
410 &room,
411 TargetRoomCategory::LowPriority,
412 )
413 .await;
414 });
415 }
416 }
417 ))
418 .build()]);
419 }
420
421 action_group.add_action_entries([gio::ActionEntry::builder("leave")
422 .activate(clone!(
423 #[weak(rename_to = imp)]
424 self,
425 move |_, _, _| {
426 if let Some(room) = imp.room() {
427 spawn!(async move {
428 imp.set_room_category(&room, TargetRoomCategory::Left)
429 .await;
430 });
431 }
432 }
433 ))
434 .build()]);
435
436 if room.is_read() {
437 action_group.add_action_entries([gio::ActionEntry::builder(
438 "mark-as-unread",
439 )
440 .activate(clone!(
441 #[weak]
442 room,
443 move |_, _, _| {
444 spawn!(async move {
445 room.mark_as_unread().await;
446 });
447 }
448 ))
449 .build()]);
450 } else {
451 action_group.add_action_entries([gio::ActionEntry::builder(
452 "mark-as-read",
453 )
454 .activate(clone!(
455 #[weak]
456 room,
457 move |_, _, _| {
458 spawn!(async move {
459 room.send_receipt(ReceiptType::Read, ReceiptPosition::End)
460 .await;
461 });
462 }
463 ))
464 .build()]);
465 }
466 }
467 RoomCategory::Left => {
468 if room.join_rule().we_can_join() {
469 action_group.add_action_entries([gio::ActionEntry::builder("join")
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.set_room_category(
477 &room,
478 TargetRoomCategory::Normal,
479 )
480 .await;
481 });
482 }
483 }
484 ))
485 .build()]);
486 }
487
488 action_group.add_action_entries([gio::ActionEntry::builder("forget")
489 .activate(clone!(
490 #[weak(rename_to = imp)]
491 self,
492 move |_, _, _| {
493 if let Some(room) = imp.room() {
494 spawn!(async move {
495 imp.forget_room(&room).await;
496 });
497 }
498 }
499 ))
500 .build()]);
501 }
502 RoomCategory::Outdated | RoomCategory::Space | RoomCategory::Ignored => {}
503 }
504
505 if matches!(
506 category,
507 RoomCategory::Favorite
508 | RoomCategory::Normal
509 | RoomCategory::LowPriority
510 | RoomCategory::Left
511 ) {
512 if room.is_direct() {
513 action_group.add_action_entries([gio::ActionEntry::builder(
514 "unset-direct-chat",
515 )
516 .activate(clone!(
517 #[weak(rename_to = imp)]
518 self,
519 move |_, _, _| {
520 if let Some(room) = imp.room() {
521 spawn!(async move {
522 imp.set_room_is_direct(&room, false).await;
523 });
524 }
525 }
526 ))
527 .build()]);
528 } else {
529 action_group.add_action_entries([gio::ActionEntry::builder("set-direct-chat")
530 .activate(clone!(
531 #[weak(rename_to = imp)]
532 self,
533 move |_, _, _| {
534 if let Some(room) = imp.room() {
535 spawn!(async move {
536 imp.set_room_is_direct(&room, true).await;
537 });
538 }
539 }
540 ))
541 .build()]);
542 }
543 }
544
545 Some(action_group)
546 }
547
548 fn update_for_drop_source_category(&self) {
550 let obj = self.obj();
551 let source_category = self.sidebar.obj().and_then(|s| s.drop_source_category());
552
553 if let Some(source_category) = source_category {
554 if self
555 .target_room_category()
556 .is_some_and(|row_category| source_category.can_change_to(row_category))
557 {
558 obj.remove_css_class("drop-disabled");
559
560 if self
561 .item
562 .borrow()
563 .as_ref()
564 .and_then(glib::Object::downcast_ref)
565 .is_some_and(SidebarSection::is_empty)
566 {
567 obj.add_css_class("drop-empty");
568 } else {
569 obj.remove_css_class("drop-empty");
570 }
571 } else {
572 let is_forget_item = self
573 .item_type()
574 .is_some_and(|item_type| item_type == SidebarIconItemType::Forget);
575 if is_forget_item && source_category == RoomCategory::Left {
576 obj.remove_css_class("drop-disabled");
577 } else {
578 obj.add_css_class("drop-disabled");
579 obj.remove_css_class("drop-empty");
580 }
581 }
582 } else {
583 obj.remove_css_class("drop-disabled");
585 obj.remove_css_class("drop-empty");
586 obj.remove_css_class("drop-active");
587 }
588
589 if let Some(section_row) = obj.child().and_downcast::<SidebarSectionRow>() {
590 section_row.set_show_label_for_room_category(source_category);
591 }
592 }
593
594 fn update_for_drop_active_target_category(&self) {
596 let obj = self.obj();
597
598 let Some(room_category) = self.room_category() else {
599 obj.remove_css_class("drop-active");
600 return;
601 };
602
603 let target_category = self
604 .sidebar
605 .obj()
606 .and_then(|s| s.drop_active_target_category());
607
608 if target_category.is_some_and(|target_category| target_category == room_category) {
609 obj.add_css_class("drop-active");
610 } else {
611 obj.remove_css_class("drop-active");
612 }
613 }
614
615 fn drop_accept(&self, drop: &gdk::Drop) -> bool {
617 let Some(sidebar) = self.sidebar.obj() else {
618 return false;
619 };
620
621 let room = drop
622 .drag()
623 .map(|drag| drag.content())
624 .and_then(|content| content.value(Room::static_type()).ok())
625 .and_then(|value| value.get::<Room>().ok());
626 if let Some(room) = room {
627 if let Some(target_category) = self.target_room_category() {
628 if room.category().can_change_to(target_category) {
629 sidebar.set_drop_active_target_category(Some(target_category));
630 return true;
631 }
632 } else if self
633 .item_type()
634 .is_some_and(|item_type| item_type == SidebarIconItemType::Forget)
635 && room.category() == RoomCategory::Left
636 {
637 self.obj().add_css_class("drop-active");
638 sidebar.set_drop_active_target_category(None);
639 return true;
640 }
641 }
642
643 false
644 }
645
646 fn drop_leave(&self) {
648 self.obj().remove_css_class("drop-active");
649 if let Some(sidebar) = self.sidebar.obj() {
650 sidebar.set_drop_active_target_category(None);
651 }
652 }
653
654 fn drop_end(&self, value: &glib::Value) -> bool {
656 let mut ret = false;
657 if let Ok(room) = value.get::<Room>() {
658 if let Some(target_category) = self.target_room_category() {
659 if room.category().can_change_to(target_category) {
660 spawn!(clone!(
661 #[weak(rename_to = imp)]
662 self,
663 async move {
664 imp.set_room_category(&room, target_category).await;
665 }
666 ));
667 ret = true;
668 }
669 } else if self
670 .item_type()
671 .is_some_and(|item_type| item_type == SidebarIconItemType::Forget)
672 && room.category() == RoomCategory::Left
673 {
674 spawn!(clone!(
675 #[weak(rename_to = imp)]
676 self,
677 async move {
678 imp.forget_room(&room).await;
679 }
680 ));
681 ret = true;
682 }
683 }
684 if let Some(sidebar) = self.sidebar.obj() {
685 sidebar.set_drop_source_category(None);
686 }
687 ret
688 }
689
690 async fn set_room_category(&self, room: &Room, category: TargetRoomCategory) {
692 let obj = self.obj();
693
694 let ignored_inviter = if category == TargetRoomCategory::Left {
695 let Some(response) = confirm_leave_room_dialog(room, &*obj).await else {
696 return;
697 };
698
699 response.ignore_inviter.then(|| room.inviter()).flatten()
700 } else {
701 None
702 };
703
704 let previous_category = room.category();
705 if room.change_category(category).await.is_err() {
706 match previous_category {
707 RoomCategory::Invited => {
708 if category == RoomCategory::Left {
709 toast!(
710 obj,
711 gettext(
712 "Could not decline invitation for {room}",
715 ),
716 @room,
717 );
718 } else {
719 toast!(
720 obj,
721 gettext(
722 "Could not accept invitation for {room}",
725 ),
726 @room,
727 );
728 }
729 }
730 RoomCategory::Left => {
731 toast!(
732 obj,
733 gettext(
734 "Could not join {room}",
737 ),
738 @room,
739 );
740 }
741 _ => {
742 if category == RoomCategory::Left {
743 toast!(
744 obj,
745 gettext(
746 "Could not leave {room}",
748 ),
749 @room,
750 );
751 } else {
752 toast!(
753 obj,
754 gettext(
755 "Could not move {room} from {previous_category} to {new_category}",
757 ),
758 @room,
759 previous_category = previous_category.to_string(),
760 new_category = RoomCategory::from(category).to_string(),
761 );
762 }
763 }
764 }
765 }
766
767 if let Some(inviter) = ignored_inviter
768 && inviter.upcast::<User>().ignore().await.is_err()
769 {
770 toast!(obj, gettext("Could not ignore user"));
771 }
772 }
773
774 async fn forget_room(&self, room: &Room) {
776 if room.forget().await.is_err() {
777 toast!(
778 self.obj(),
779 gettext("Could not forget {room}"),
781 @room,
782 );
783 }
784 }
785
786 async fn set_room_is_direct(&self, room: &Room, is_direct: bool) {
788 let matrix_room = room.matrix_room().clone();
789 let handle = spawn_tokio!(async move { matrix_room.set_is_direct(is_direct).await });
790
791 if let Err(error) = handle.await.unwrap() {
792 let obj = self.obj();
793
794 if is_direct {
795 error!("Could not mark room as direct chat: {error}");
796 toast!(obj, gettext("Could not mark {room} as direct chat"), @room);
799 } else {
800 error!("Could not unmark room as direct chat: {error}");
801 toast!(obj, gettext("Could not unmark {room} as direct chat"), @room);
804 }
805 }
806 }
807 }
808}
809
810glib::wrapper! {
811 pub struct SidebarRow(ObjectSubclass<imp::SidebarRow>)
813 @extends gtk::Widget, ContextMenuBin,
814 @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget;
815}
816
817impl SidebarRow {
818 pub fn new(sidebar: &Sidebar) -> Self {
819 glib::Object::builder().property("sidebar", sidebar).build()
820 }
821}
822
823impl ChildPropertyExt for SidebarRow {
824 fn child_property(&self) -> Option<gtk::Widget> {
825 self.child()
826 }
827
828 fn set_child_property(&self, child: Option<&impl IsA<gtk::Widget>>) {
829 self.set_child(child);
830 }
831}