fractal/session/view/content/room_details/
general_page.rs

1use adw::{prelude::*, subclass::prelude::*};
2use gettextrs::{gettext, ngettext};
3use gtk::{
4    gio,
5    glib::{self, clone},
6    pango, CompositeTemplate,
7};
8use ruma::{
9    api::client::{
10        directory::{get_room_visibility, set_room_visibility},
11        discovery::get_capabilities::Capabilities,
12        room::{upgrade_room, Visibility},
13    },
14    events::{
15        room::{
16            guest_access::{GuestAccess, RoomGuestAccessEventContent},
17            history_visibility::RoomHistoryVisibilityEventContent,
18            power_levels::PowerLevelAction,
19        },
20        StateEventType,
21    },
22};
23use tracing::error;
24
25use super::{room_upgrade_dialog::confirm_room_upgrade, MemberRow, MembershipLists, RoomDetails};
26use crate::{
27    components::{
28        Avatar, ButtonCountRow, CheckLoadingRow, ComboLoadingRow, CopyableRow, LoadingButton,
29        SwitchLoadingRow,
30    },
31    gettext_f,
32    prelude::*,
33    session::model::{
34        HistoryVisibilityValue, JoinRuleValue, Member, NotificationsRoomSetting, Room, RoomCategory,
35    },
36    spawn, spawn_tokio, toast,
37    utils::{expression, matrix::MatrixIdUri, BoundObjectWeakRef, TemplateCallbacks},
38    Window,
39};
40
41mod imp {
42    use std::{
43        cell::{Cell, RefCell},
44        marker::PhantomData,
45    };
46
47    use glib::subclass::InitializingObject;
48
49    use super::*;
50
51    #[derive(Debug, Default, CompositeTemplate, glib::Properties)]
52    #[template(
53        resource = "/org/gnome/Fractal/ui/session/view/content/room_details/general_page.ui"
54    )]
55    #[properties(wrapper_type = super::GeneralPage)]
56    pub struct GeneralPage {
57        #[template_child]
58        avatar: TemplateChild<Avatar>,
59        #[template_child]
60        room_topic: TemplateChild<gtk::Label>,
61        #[template_child]
62        edit_details_btn: TemplateChild<gtk::Button>,
63        #[template_child]
64        direct_members_group: TemplateChild<adw::PreferencesGroup>,
65        #[template_child]
66        direct_members_list: TemplateChild<gtk::ListBox>,
67        #[template_child]
68        no_direct_members_label: TemplateChild<gtk::Label>,
69        #[template_child]
70        members_row_group: TemplateChild<adw::PreferencesGroup>,
71        #[template_child]
72        members_row: TemplateChild<ButtonCountRow>,
73        #[template_child]
74        notifications: TemplateChild<adw::PreferencesGroup>,
75        #[template_child]
76        notifications_global_row: TemplateChild<CheckLoadingRow>,
77        #[template_child]
78        notifications_all_row: TemplateChild<CheckLoadingRow>,
79        #[template_child]
80        notifications_mentions_row: TemplateChild<CheckLoadingRow>,
81        #[template_child]
82        notifications_mute_row: TemplateChild<CheckLoadingRow>,
83        #[template_child]
84        addresses_group: TemplateChild<adw::PreferencesGroup>,
85        #[template_child]
86        edit_addresses_button: TemplateChild<gtk::Button>,
87        #[template_child]
88        no_addresses_label: TemplateChild<gtk::Label>,
89        canonical_alias_row: RefCell<Option<CopyableRow>>,
90        alt_aliases_rows: RefCell<Vec<CopyableRow>>,
91        #[template_child]
92        join_rule: TemplateChild<ComboLoadingRow>,
93        #[template_child]
94        guest_access: TemplateChild<SwitchLoadingRow>,
95        #[template_child]
96        publish: TemplateChild<SwitchLoadingRow>,
97        #[template_child]
98        history_visibility: TemplateChild<ComboLoadingRow>,
99        #[template_child]
100        encryption: TemplateChild<SwitchLoadingRow>,
101        #[template_child]
102        upgrade_button: TemplateChild<LoadingButton>,
103        #[template_child]
104        room_federated: TemplateChild<adw::ActionRow>,
105        /// The presented room.
106        #[property(get, set = Self::set_room, construct_only)]
107        room: BoundObjectWeakRef<Room>,
108        /// The lists of members filtered by membership for the room.
109        #[property(get, set = Self::set_membership_lists, construct_only)]
110        membership_lists: glib::WeakRef<MembershipLists>,
111        /// The notifications setting for the room.
112        #[property(get = Self::notifications_setting, set = Self::set_notifications_setting, explicit_notify, builder(NotificationsRoomSetting::default()))]
113        notifications_setting: PhantomData<NotificationsRoomSetting>,
114        /// Whether the notifications section is busy.
115        #[property(get)]
116        notifications_loading: Cell<bool>,
117        /// Whether the room is published in the directory.
118        #[property(get)]
119        is_published: Cell<bool>,
120        expr_watch: RefCell<Option<gtk::ExpressionWatch>>,
121        notifications_settings_handlers: RefCell<Vec<glib::SignalHandlerId>>,
122        membership_handler: RefCell<Option<glib::SignalHandlerId>>,
123        permissions_handler: RefCell<Option<glib::SignalHandlerId>>,
124        canonical_alias_handler: RefCell<Option<glib::SignalHandlerId>>,
125        alt_aliases_handler: RefCell<Option<glib::SignalHandlerId>>,
126        join_rule_handler: RefCell<Option<glib::SignalHandlerId>>,
127        capabilities: RefCell<Capabilities>,
128        direct_members_list_has_bound_model: Cell<bool>,
129    }
130
131    #[glib::object_subclass]
132    impl ObjectSubclass for GeneralPage {
133        const NAME: &'static str = "RoomDetailsGeneralPage";
134        type Type = super::GeneralPage;
135        type ParentType = adw::PreferencesPage;
136
137        fn class_init(klass: &mut Self::Class) {
138            CopyableRow::ensure_type();
139
140            Self::bind_template(klass);
141            Self::bind_template_callbacks(klass);
142            TemplateCallbacks::bind_template_callbacks(klass);
143
144            klass
145                .install_property_action("room.set-notifications-setting", "notifications-setting");
146        }
147
148        fn instance_init(obj: &InitializingObject<Self>) {
149            obj.init_template();
150        }
151    }
152
153    #[glib::derived_properties]
154    impl ObjectImpl for GeneralPage {
155        fn constructed(&self) {
156            self.parent_constructed();
157            let obj = self.obj();
158
159            self.room_topic.connect_activate_link(clone!(
160                #[weak]
161                obj,
162                #[upgrade_or]
163                glib::Propagation::Proceed,
164                move |_, uri| {
165                    let Ok(uri) = MatrixIdUri::parse(uri) else {
166                        return glib::Propagation::Proceed;
167                    };
168                    let Some(room_details) = obj
169                        .ancestor(RoomDetails::static_type())
170                        .and_downcast::<RoomDetails>()
171                    else {
172                        return glib::Propagation::Proceed;
173                    };
174                    let Some(parent_window) = room_details.transient_for().and_downcast::<Window>()
175                    else {
176                        return glib::Propagation::Proceed;
177                    };
178
179                    parent_window.session_view().show_matrix_uri(uri);
180                    room_details.close();
181
182                    glib::Propagation::Stop
183                }
184            ));
185        }
186
187        fn dispose(&self) {
188            self.disconnect_all();
189        }
190    }
191
192    impl WidgetImpl for GeneralPage {}
193    impl PreferencesPageImpl for GeneralPage {}
194
195    #[gtk::template_callbacks]
196    impl GeneralPage {
197        /// Set the presented room.
198        #[allow(clippy::too_many_lines)]
199        fn set_room(&self, room: &Room) {
200            let obj = self.obj();
201
202            let membership_handler = room.own_member().connect_membership_notify(clone!(
203                #[weak(rename_to = imp)]
204                self,
205                move |_| {
206                    imp.update_notifications();
207                }
208            ));
209            self.membership_handler.replace(Some(membership_handler));
210
211            let permissions_handler = room.permissions().connect_changed(clone!(
212                #[weak(rename_to = imp)]
213                self,
214                move |_| {
215                    imp.update_upgrade_button();
216                    imp.update_edit_addresses_button();
217                    imp.update_join_rule();
218                    imp.update_guest_access();
219                    imp.update_history_visibility();
220                    imp.update_encryption();
221
222                    spawn!(async move {
223                        imp.update_publish().await;
224                    });
225                }
226            ));
227            self.permissions_handler.replace(Some(permissions_handler));
228
229            let aliases = room.aliases();
230            let canonical_alias_handler = aliases.connect_canonical_alias_string_notify(clone!(
231                #[weak(rename_to = imp)]
232                self,
233                move |_| {
234                    imp.update_addresses();
235                }
236            ));
237            self.canonical_alias_handler
238                .replace(Some(canonical_alias_handler));
239
240            let alt_aliases_handler = aliases.alt_aliases_model().connect_items_changed(clone!(
241                #[weak(rename_to = imp)]
242                self,
243                move |_, _, _, _| {
244                    imp.update_addresses();
245                }
246            ));
247            self.alt_aliases_handler.replace(Some(alt_aliases_handler));
248
249            let join_rule_handler = room.join_rule().connect_changed(clone!(
250                #[weak(rename_to = imp)]
251                self,
252                move |_| {
253                    imp.update_join_rule();
254                }
255            ));
256            self.join_rule_handler.replace(Some(join_rule_handler));
257
258            let room_handler_ids = vec![
259                room.connect_joined_members_count_notify(clone!(
260                    #[weak(rename_to = imp)]
261                    self,
262                    move |_| {
263                        imp.update_members();
264                    }
265                )),
266                room.connect_is_direct_notify(clone!(
267                    #[weak(rename_to = imp)]
268                    self,
269                    move |_| {
270                        imp.update_members();
271                    }
272                )),
273                room.connect_category_notify(clone!(
274                    #[weak(rename_to = imp)]
275                    self,
276                    move |_| {
277                        imp.update_members();
278                    }
279                )),
280                room.connect_notifications_setting_notify(clone!(
281                    #[weak(rename_to = imp)]
282                    self,
283                    move |_| {
284                        imp.update_notifications();
285                    }
286                )),
287                room.connect_is_tombstoned_notify(clone!(
288                    #[weak(rename_to = imp)]
289                    self,
290                    move |_| {
291                        imp.update_upgrade_button();
292                    }
293                )),
294                room.connect_guests_allowed_notify(clone!(
295                    #[weak(rename_to = imp)]
296                    self,
297                    move |_| {
298                        imp.update_guest_access();
299                    }
300                )),
301                room.connect_history_visibility_notify(clone!(
302                    #[weak(rename_to = imp)]
303                    self,
304                    move |_| {
305                        imp.update_history_visibility();
306                    }
307                )),
308                room.connect_is_encrypted_notify(clone!(
309                    #[weak(rename_to = imp)]
310                    self,
311                    move |_| {
312                        imp.update_encryption();
313                    }
314                )),
315            ];
316
317            self.room.set(room, room_handler_ids);
318            obj.notify_room();
319
320            if let Some(session) = room.session() {
321                let notifications_settings = session.notifications().settings();
322                let notifications_settings_handlers = vec![
323                    notifications_settings.connect_account_enabled_notify(clone!(
324                        #[weak(rename_to = imp)]
325                        self,
326                        move |_| {
327                            imp.update_notifications();
328                        }
329                    )),
330                    notifications_settings.connect_session_enabled_notify(clone!(
331                        #[weak(rename_to = imp)]
332                        self,
333                        move |_| {
334                            imp.update_notifications();
335                        }
336                    )),
337                ];
338
339                self.notifications_settings_handlers
340                    .replace(notifications_settings_handlers);
341            }
342
343            self.init_edit_details();
344            self.update_members();
345            self.update_notifications();
346            self.update_edit_addresses_button();
347            self.update_addresses();
348            self.update_federated();
349            self.update_join_rule();
350            self.update_guest_access();
351            self.update_publish_title();
352            self.update_history_visibility();
353            self.update_encryption();
354            self.update_upgrade_button();
355
356            spawn!(clone!(
357                #[weak(rename_to = imp)]
358                self,
359                async move {
360                    imp.update_publish().await;
361                }
362            ));
363
364            self.load_capabilities();
365        }
366
367        /// Set the lists of members filtered by membership for the room.
368        fn set_membership_lists(&self, membership_lists: &MembershipLists) {
369            self.membership_lists.set(Some(membership_lists));
370            self.update_members();
371        }
372
373        /// The notifications setting for the room.
374        fn notifications_setting(&self) -> NotificationsRoomSetting {
375            self.room
376                .obj()
377                .map(|r| r.notifications_setting())
378                .unwrap_or_default()
379        }
380
381        /// Set the notifications setting for the room.
382        fn set_notifications_setting(&self, setting: NotificationsRoomSetting) {
383            if self.notifications_setting() == setting {
384                return;
385            }
386
387            self.notifications_setting_changed(setting);
388        }
389
390        /// Fetch the capabilities of the homeserver.
391        fn load_capabilities(&self) {
392            let Some(room) = self.room.obj() else {
393                return;
394            };
395            let client = room.matrix_room().client();
396
397            spawn!(
398                glib::Priority::LOW,
399                clone!(
400                    #[weak(rename_to = imp)]
401                    self,
402                    async move {
403                        let handle = spawn_tokio!(async move { client.get_capabilities().await });
404                        match handle.await.unwrap() {
405                            Ok(capabilities) => {
406                                imp.capabilities.replace(capabilities);
407                            }
408                            Err(error) => {
409                                error!("Could not get server capabilities: {error}");
410                                imp.capabilities.take();
411                            }
412                        }
413                    }
414                )
415            );
416        }
417
418        /// Initialize the button to edit details.
419        fn init_edit_details(&self) {
420            let Some(room) = self.room.obj() else {
421                return;
422            };
423
424            // Hide edit button when the user cannot edit any detail or when the room is
425            // direct.
426            let permissions = room.permissions();
427            let can_change_avatar = permissions.property_expression("can-change-avatar");
428            let can_change_name = permissions.property_expression("can-change-name");
429            let can_change_topic = permissions.property_expression("can-change-topic");
430
431            let can_change_name_or_topic = expression::or(can_change_name, can_change_topic);
432            let can_edit_at_least_one_detail =
433                expression::or(can_change_name_or_topic, can_change_avatar);
434
435            let is_direct_expr = room.property_expression("is-direct");
436
437            let expr_watch = expression::and(
438                expression::not(is_direct_expr),
439                can_edit_at_least_one_detail,
440            )
441            .bind(&*self.edit_details_btn, "visible", gtk::Widget::NONE);
442            self.expr_watch.replace(Some(expr_watch));
443        }
444
445        /// Update the members section.
446        fn update_members(&self) {
447            let Some(room) = self.room.obj() else {
448                return;
449            };
450            let Some(membership_lists) = self.membership_lists.upgrade() else {
451                return;
452            };
453
454            let joined_members_count = membership_lists.joined().n_items();
455
456            // When the room is direct there should only be 2 members in most cases, but use
457            // the members count to make sure we do not show a list that is too long.
458            let is_direct_with_few_members = room.is_direct() && joined_members_count < 5;
459            if is_direct_with_few_members {
460                let title = ngettext("Member", "Members", joined_members_count);
461                self.direct_members_group.set_title(&title);
462
463                // Set model of direct members list dynamically to avoid creating unnecessary
464                // widgets in the background.
465                if !self.direct_members_list_has_bound_model.get() {
466                    self.direct_members_list
467                        .bind_model(Some(&membership_lists.joined()), |item| {
468                            let member = item
469                                .downcast_ref::<Member>()
470                                .expect("joined members list contains members");
471                            let member_row = MemberRow::new(false);
472                            member_row.set_member(Some(member));
473
474                            gtk::ListBoxRow::builder()
475                                .selectable(false)
476                                .child(&member_row)
477                                .action_name("details.show-member")
478                                .action_target(&member.user_id().as_str().to_variant())
479                                .build()
480                                .upcast()
481                        });
482                    self.direct_members_list_has_bound_model.set(true);
483                }
484
485                let has_members = joined_members_count > 0;
486                self.direct_members_list.set_visible(has_members);
487                self.no_direct_members_label.set_visible(!has_members);
488            } else {
489                let mut server_joined_members_count = room.joined_members_count();
490
491                if room.category() == RoomCategory::Left {
492                    // The number of joined members count from the homeserver is only updated when
493                    // we are joined, so we must at least remove ourself from the count after we
494                    // left.
495                    server_joined_members_count = server_joined_members_count.saturating_sub(1);
496                }
497
498                // Use the maximum between the count of joined members in the local list, and
499                // the one provided by the homeserver. The homeserver is usually right, except
500                // when we just joined a room, where it will be 0 for a while.
501                let joined_members_count =
502                    server_joined_members_count.max(joined_members_count.into());
503                self.members_row.set_count(joined_members_count.to_string());
504
505                let n = joined_members_count.try_into().unwrap_or(u32::MAX);
506                let title = ngettext("Member", "Members", n);
507                self.members_row.set_title(&title);
508
509                if self.direct_members_list_has_bound_model.get() {
510                    self.direct_members_list
511                        .bind_model(None::<&gio::ListModel>, |_item| {
512                            gtk::ListBoxRow::new().upcast()
513                        });
514                    self.direct_members_list_has_bound_model.set(false);
515                }
516            }
517
518            self.direct_members_group
519                .set_visible(is_direct_with_few_members);
520            self.members_row_group
521                .set_visible(!is_direct_with_few_members);
522        }
523
524        /// Disconnect all the signals.
525        fn disconnect_all(&self) {
526            if let Some(room) = self.room.obj() {
527                if let Some(session) = room.session() {
528                    for handler in self.notifications_settings_handlers.take() {
529                        session.notifications().settings().disconnect(handler);
530                    }
531                }
532
533                if let Some(handler) = self.membership_handler.take() {
534                    room.own_member().disconnect(handler);
535                }
536
537                if let Some(handler) = self.permissions_handler.take() {
538                    room.permissions().disconnect(handler);
539                }
540
541                let aliases = room.aliases();
542                if let Some(handler) = self.canonical_alias_handler.take() {
543                    aliases.disconnect(handler);
544                }
545                if let Some(handler) = self.alt_aliases_handler.take() {
546                    aliases.alt_aliases_model().disconnect(handler);
547                }
548
549                if let Some(handler) = self.join_rule_handler.take() {
550                    room.join_rule().disconnect(handler);
551                }
552            }
553
554            self.room.disconnect_signals();
555
556            if let Some(watch) = self.expr_watch.take() {
557                watch.unwatch();
558            }
559        }
560
561        /// Update the section about notifications.
562        fn update_notifications(&self) {
563            let Some(room) = self.room.obj() else {
564                return;
565            };
566
567            if !room.is_joined() {
568                self.notifications.set_visible(false);
569                return;
570            }
571
572            let Some(session) = room.session() else {
573                return;
574            };
575
576            // Updates the active radio button.
577            self.obj().notify_notifications_setting();
578
579            let settings = session.notifications().settings();
580            let sensitive = settings.account_enabled()
581                && settings.session_enabled()
582                && !self.notifications_loading.get();
583            self.notifications.set_sensitive(sensitive);
584            self.notifications.set_visible(true);
585        }
586
587        /// Update the loading state in the notifications section.
588        fn set_notifications_loading(&self, loading: bool, setting: NotificationsRoomSetting) {
589            // Only show the spinner on the selected one.
590            self.notifications_global_row
591                .set_is_loading(loading && setting == NotificationsRoomSetting::Global);
592            self.notifications_all_row
593                .set_is_loading(loading && setting == NotificationsRoomSetting::All);
594            self.notifications_mentions_row
595                .set_is_loading(loading && setting == NotificationsRoomSetting::MentionsOnly);
596            self.notifications_mute_row
597                .set_is_loading(loading && setting == NotificationsRoomSetting::Mute);
598
599            self.notifications_loading.set(loading);
600            self.obj().notify_notifications_loading();
601        }
602
603        /// Handle a change of the notifications setting.
604        fn notifications_setting_changed(&self, setting: NotificationsRoomSetting) {
605            let Some(room) = self.room.obj() else {
606                return;
607            };
608            let Some(session) = room.session() else {
609                return;
610            };
611
612            if setting == room.notifications_setting() {
613                // Nothing to do.
614                return;
615            }
616
617            self.notifications.set_sensitive(false);
618            self.set_notifications_loading(true, setting);
619
620            let settings = session.notifications().settings();
621            spawn!(clone!(
622                #[weak(rename_to = imp)]
623                self,
624                async move {
625                    if settings
626                        .set_per_room_setting(room.room_id().to_owned(), setting)
627                        .await
628                        .is_err()
629                    {
630                        toast!(imp.obj(), gettext("Could not change notifications setting"));
631                    }
632
633                    imp.set_notifications_loading(false, setting);
634                    imp.update_notifications();
635                }
636            ));
637        }
638
639        /// Update the button to edit addresses.
640        fn update_edit_addresses_button(&self) {
641            let Some(room) = self.room.obj() else {
642                return;
643            };
644
645            let can_edit = room.is_joined()
646                && room
647                    .permissions()
648                    .is_allowed_to(PowerLevelAction::SendState(StateEventType::RoomPowerLevels));
649            self.edit_addresses_button.set_visible(can_edit);
650        }
651
652        /// Update the addresses group.
653        fn update_addresses(&self) {
654            let Some(room) = self.room.obj() else {
655                return;
656            };
657            let aliases = room.aliases();
658
659            let canonical_alias_string = aliases.canonical_alias_string();
660            let has_canonical_alias = canonical_alias_string.is_some();
661
662            if let Some(canonical_alias_string) = canonical_alias_string {
663                let mut row_borrow = self.canonical_alias_row.borrow_mut();
664                let row = row_borrow.get_or_insert_with(|| {
665                    // We want the main alias always at the top but cannot add a row at the top so
666                    // we have to remove the other rows first.
667                    self.remove_alt_aliases_rows();
668
669                    let row = CopyableRow::new();
670                    row.set_copy_button_tooltip_text(Some(gettext("Copy address")));
671                    row.set_toast_text(Some(gettext("Address copied to clipboard")));
672
673                    // Mark the main alias with a tag.
674                    let label = gtk::Label::builder()
675                        .label(gettext("Main Address"))
676                        .ellipsize(pango::EllipsizeMode::End)
677                        .css_classes(["public-address-tag"])
678                        .valign(gtk::Align::Center)
679                        .build();
680                    row.update_relation(&[gtk::accessible::Relation::DescribedBy(&[
681                        label.upcast_ref()
682                    ])]);
683                    row.set_extra_suffix(Some(label));
684
685                    self.addresses_group.add(&row);
686
687                    row
688                });
689
690                row.set_title(&canonical_alias_string);
691            } else if let Some(row) = self.canonical_alias_row.take() {
692                self.addresses_group.remove(&row);
693            }
694
695            let alt_aliases = aliases.alt_aliases_model();
696            let alt_aliases_count = alt_aliases.n_items() as usize;
697            if alt_aliases_count == 0 {
698                self.remove_alt_aliases_rows();
699            } else {
700                let mut rows = self.alt_aliases_rows.borrow_mut();
701
702                for (pos, alt_alias) in alt_aliases.iter::<glib::Object>().enumerate() {
703                    let Some(alt_alias) = alt_alias.ok().and_downcast::<gtk::StringObject>() else {
704                        break;
705                    };
706
707                    let row = rows.get(pos).cloned().unwrap_or_else(|| {
708                        let row = CopyableRow::new();
709                        row.set_copy_button_tooltip_text(Some(gettext("Copy address")));
710                        row.set_toast_text(Some(gettext("Address copied to clipboard")));
711
712                        self.addresses_group.add(&row);
713                        rows.push(row.clone());
714
715                        row
716                    });
717
718                    row.set_title(&alt_alias.string());
719                }
720
721                let rows_count = rows.len();
722                if alt_aliases_count < rows_count {
723                    for _ in alt_aliases_count..rows_count {
724                        if let Some(row) = rows.pop() {
725                            self.addresses_group.remove(&row);
726                        }
727                    }
728                }
729            }
730
731            self.no_addresses_label
732                .set_visible(!has_canonical_alias && alt_aliases_count == 0);
733        }
734
735        fn remove_alt_aliases_rows(&self) {
736            for row in self.alt_aliases_rows.take() {
737                self.addresses_group.remove(&row);
738            }
739        }
740
741        /// Copy the room's permalink to the clipboard.
742        #[template_callback]
743        async fn copy_permalink(&self) {
744            let Some(room) = self.room.obj() else {
745                return;
746            };
747
748            let permalink = room.matrix_to_uri().await;
749
750            let obj = self.obj();
751            obj.clipboard().set_text(&permalink.to_string());
752            toast!(obj, gettext("Room link copied to clipboard"));
753        }
754
755        /// Update the join rule row.
756        fn update_join_rule(&self) {
757            let Some(room) = self.room.obj() else {
758                return;
759            };
760
761            let row = &self.join_rule;
762            row.set_is_loading(false);
763
764            let permissions = room.permissions();
765            let join_rule = room.join_rule();
766
767            let is_supported_join_rule = matches!(
768                join_rule.value(),
769                JoinRuleValue::Public | JoinRuleValue::Invite
770            ) && !join_rule.can_knock();
771            let can_change = permissions
772                .is_allowed_to(PowerLevelAction::SendState(StateEventType::RoomJoinRules));
773
774            row.set_read_only(!is_supported_join_rule || !can_change);
775            row.set_selected_string(Some(join_rule.display_name()));
776        }
777
778        /// Set the join rule of the room.
779        #[template_callback]
780        async fn set_join_rule(&self) {
781            let Some(room) = self.room.obj() else {
782                return;
783            };
784            let join_rule = room.join_rule();
785
786            let row = &self.join_rule;
787
788            let value = match row.selected() {
789                0 => JoinRuleValue::Invite,
790                1 => JoinRuleValue::Public,
791                _ => {
792                    return;
793                }
794            };
795
796            if join_rule.value() == value {
797                // Nothing to do.
798                return;
799            }
800
801            row.set_is_loading(true);
802            row.set_read_only(true);
803
804            if join_rule.set_value(value).await.is_err() {
805                toast!(self.obj(), gettext("Could not change who can join"));
806                self.update_join_rule();
807            }
808        }
809
810        /// Update the guest access row.
811        fn update_guest_access(&self) {
812            let Some(room) = self.room.obj() else {
813                return;
814            };
815
816            let row = &self.guest_access;
817            row.set_is_active(room.guests_allowed());
818            row.set_is_loading(false);
819
820            let can_change = room
821                .permissions()
822                .is_allowed_to(PowerLevelAction::SendState(StateEventType::RoomGuestAccess));
823            row.set_read_only(!can_change);
824        }
825
826        /// Toggle the guest access.
827        #[template_callback]
828        async fn toggle_guest_access(&self) {
829            let Some(room) = self.room.obj() else { return };
830
831            let row = &self.guest_access;
832            let guests_allowed = row.is_active();
833
834            if room.guests_allowed() == guests_allowed {
835                return;
836            }
837
838            row.set_is_loading(true);
839            row.set_read_only(true);
840
841            let guest_access = if guests_allowed {
842                GuestAccess::CanJoin
843            } else {
844                GuestAccess::Forbidden
845            };
846            let content = RoomGuestAccessEventContent::new(guest_access);
847
848            let matrix_room = room.matrix_room().clone();
849            let handle = spawn_tokio!(async move { matrix_room.send_state_event(content).await });
850
851            if let Err(error) = handle.await.unwrap() {
852                error!("Could not change guest access: {error}");
853                toast!(self.obj(), gettext("Could not change guest access"));
854                self.update_guest_access();
855            }
856        }
857
858        /// Update the title of the publish row.
859        fn update_publish_title(&self) {
860            let Some(room) = self.room.obj() else {
861                return;
862            };
863
864            let own_member = room.own_member();
865            let server_name = own_member.user_id().server_name();
866
867            let title = gettext_f(
868                // Translators: Do NOT translate the content between '{' and '}',
869                // this is a variable name.
870                "Publish in the {homeserver} directory",
871                &[("homeserver", server_name.as_str())],
872            );
873            self.publish.set_title(&title);
874        }
875
876        /// Update the publish row.
877        async fn update_publish(&self) {
878            let Some(room) = self.room.obj() else {
879                return;
880            };
881
882            let row = &self.publish;
883
884            // There is no clear definition of who is allowed to publish a room to the
885            // directory in the Matrix spec. Let's assume it doesn't make sense unless the
886            // user can change the public addresses.
887            let can_change = room
888                .permissions()
889                .is_allowed_to(PowerLevelAction::SendState(
890                    StateEventType::RoomCanonicalAlias,
891                ));
892            row.set_read_only(!can_change);
893
894            let matrix_room = room.matrix_room();
895            let client = matrix_room.client();
896            let request = get_room_visibility::v3::Request::new(matrix_room.room_id().to_owned());
897
898            let handle = spawn_tokio!(async move { client.send(request).await });
899
900            match handle.await.unwrap() {
901                Ok(response) => {
902                    let is_published = response.visibility == Visibility::Public;
903                    self.is_published.set(is_published);
904                    row.set_is_active(is_published);
905                }
906                Err(error) => {
907                    error!("Could not get directory visibility of room: {error}");
908                }
909            }
910
911            row.set_is_loading(false);
912        }
913
914        /// Toggle whether the room is published in the room directory.
915        #[template_callback]
916        async fn toggle_publish(&self) {
917            let Some(room) = self.room.obj() else { return };
918
919            let row = &self.publish;
920            let publish = row.is_active();
921
922            if self.is_published.get() == publish {
923                return;
924            }
925
926            row.set_is_loading(true);
927            row.set_read_only(true);
928
929            let visibility = if publish {
930                Visibility::Public
931            } else {
932                Visibility::Private
933            };
934
935            let matrix_room = room.matrix_room();
936            let client = matrix_room.client();
937            let request =
938                set_room_visibility::v3::Request::new(matrix_room.room_id().to_owned(), visibility);
939
940            let handle = spawn_tokio!(async move { client.send(request).await });
941
942            if let Err(error) = handle.await.unwrap() {
943                error!("Could not change directory visibility of room: {error}");
944                let text = if publish {
945                    gettext("Could not publish room in directory")
946                } else {
947                    gettext("Could not unpublish room from directory")
948                };
949                toast!(self.obj(), text);
950            }
951
952            self.update_publish().await;
953        }
954
955        /// Update the history visibility edit button.
956        fn update_history_visibility(&self) {
957            let Some(room) = self.room.obj() else {
958                return;
959            };
960
961            let row = &self.history_visibility;
962            row.set_is_loading(false);
963
964            let visibility = room.history_visibility();
965
966            let text = match visibility {
967                HistoryVisibilityValue::WorldReadable => {
968                    gettext("Anyone, even if they are not in the room")
969                }
970                HistoryVisibilityValue::Shared => {
971                    gettext("Members only, since this option was selected")
972                }
973                HistoryVisibilityValue::Invited => gettext("Members only, since they were invited"),
974                HistoryVisibilityValue::Joined => {
975                    gettext("Members only, since they joined the room")
976                }
977                HistoryVisibilityValue::Unsupported => gettext("Unsupported rule"),
978            };
979            row.set_selected_string(Some(text));
980
981            let is_supported = visibility != HistoryVisibilityValue::Unsupported;
982            let can_change = room
983                .permissions()
984                .is_allowed_to(PowerLevelAction::SendState(
985                    StateEventType::RoomHistoryVisibility,
986                ));
987
988            row.set_read_only(!is_supported || !can_change);
989        }
990
991        /// Set the history_visibility of the room.
992        #[template_callback]
993        async fn set_history_visibility(&self) {
994            let Some(room) = self.room.obj() else {
995                return;
996            };
997            let row = &self.history_visibility;
998
999            let visibility = match row.selected() {
1000                0 => HistoryVisibilityValue::WorldReadable,
1001                1 => HistoryVisibilityValue::Shared,
1002                2 => HistoryVisibilityValue::Joined,
1003                3 => HistoryVisibilityValue::Invited,
1004                _ => {
1005                    return;
1006                }
1007            };
1008
1009            if room.history_visibility() == visibility {
1010                // Nothing to do.
1011                return;
1012            }
1013
1014            row.set_is_loading(true);
1015            row.set_read_only(true);
1016
1017            let content = RoomHistoryVisibilityEventContent::new(visibility.into());
1018
1019            let matrix_room = room.matrix_room().clone();
1020            let handle = spawn_tokio!(async move { matrix_room.send_state_event(content).await });
1021
1022            if let Err(error) = handle.await.unwrap() {
1023                error!("Could not change room history visibility: {error}");
1024                toast!(self.obj(), gettext("Could not change who can read history"));
1025
1026                self.update_history_visibility();
1027            }
1028        }
1029
1030        /// Update the encryption row.
1031        fn update_encryption(&self) {
1032            let Some(room) = self.room.obj() else {
1033                return;
1034            };
1035
1036            let row = &self.encryption;
1037            row.set_is_loading(false);
1038
1039            let is_encrypted = room.is_encrypted();
1040            row.set_is_active(is_encrypted);
1041
1042            let can_change = !is_encrypted
1043                && room
1044                    .permissions()
1045                    .is_allowed_to(PowerLevelAction::SendState(StateEventType::RoomEncryption));
1046            row.set_read_only(!can_change);
1047        }
1048
1049        /// Enable encryption in the room.
1050        #[template_callback]
1051        async fn enable_encryption(&self) {
1052            let Some(room) = self.room.obj() else { return };
1053
1054            let row = &self.encryption;
1055
1056            if room.is_encrypted() || !row.is_active() {
1057                // Nothing to do.
1058                return;
1059            }
1060
1061            row.set_is_loading(true);
1062            row.set_read_only(true);
1063
1064            // Ask for confirmation.
1065            let dialog = adw::AlertDialog::builder()
1066                        .heading(gettext("Enable Encryption?"))
1067                        .body(gettext("Enabling encryption will prevent new members to read the history before they arrived. This cannot be disabled later."))
1068                        .default_response("cancel")
1069                        .build();
1070            dialog.add_responses(&[
1071                ("cancel", &gettext("Cancel")),
1072                ("enable", &gettext("Enable")),
1073            ]);
1074            dialog.set_response_appearance("enable", adw::ResponseAppearance::Destructive);
1075
1076            let obj = self.obj();
1077            if dialog.choose_future(&*obj).await != "enable" {
1078                self.update_encryption();
1079                return;
1080            }
1081
1082            if room.enable_encryption().await.is_err() {
1083                toast!(obj, gettext("Could not enable encryption"));
1084                self.update_encryption();
1085            }
1086        }
1087
1088        /// Update the room upgrade button.
1089        fn update_upgrade_button(&self) {
1090            let Some(room) = self.room.obj() else {
1091                return;
1092            };
1093
1094            let can_upgrade = !room.is_tombstoned()
1095                && room
1096                    .permissions()
1097                    .is_allowed_to(PowerLevelAction::SendState(StateEventType::RoomTombstone));
1098            self.upgrade_button.set_visible(can_upgrade);
1099        }
1100
1101        /// Update the room federation row.
1102        fn update_federated(&self) {
1103            let Some(room) = self.room.obj() else {
1104                return;
1105            };
1106
1107            let subtitle = if room.federated() {
1108                // Translators: As in, 'Room federated'.
1109                gettext("Federated")
1110            } else {
1111                // Translators: As in, 'Room not federated'.
1112                gettext("Not federated")
1113            };
1114
1115            self.room_federated.set_subtitle(&subtitle);
1116        }
1117
1118        /// Upgrade the room to a new version.
1119        #[template_callback]
1120        async fn upgrade(&self) {
1121            let Some(room) = self.room.obj() else {
1122                return;
1123            };
1124
1125            let obj = self.obj();
1126            // TODO: Hide upgrade button if room already upgraded?
1127            self.upgrade_button.set_is_loading(true);
1128            let room_versions_capability = self.capabilities.borrow().room_versions.clone();
1129
1130            let Some(new_version) = confirm_room_upgrade(room_versions_capability, &*obj).await
1131            else {
1132                self.upgrade_button.set_is_loading(false);
1133                return;
1134            };
1135
1136            let client = room.matrix_room().client();
1137            let request = upgrade_room::v3::Request::new(room.room_id().to_owned(), new_version);
1138
1139            let handle = spawn_tokio!(async move { client.send(request).await });
1140
1141            match handle.await.unwrap() {
1142                Ok(_) => {
1143                    toast!(obj, gettext("Room upgraded successfully"));
1144                }
1145                Err(error) => {
1146                    error!("Could not upgrade room: {error}");
1147                    toast!(obj, gettext("Could not upgrade room"));
1148                    self.upgrade_button.set_is_loading(false);
1149                }
1150            }
1151        }
1152
1153        /// Unselect the topic of the room.
1154        ///
1155        /// This is to circumvent the default GTK behavior to select all the
1156        /// text when opening the details.
1157        pub(super) fn unselect_topic(&self) {
1158            // Put the cursor at the beginning of the title instead of having the title
1159            // selected, if it is visible.
1160            if self.room_topic.is_visible() {
1161                self.room_topic.select_region(0, 0);
1162            }
1163        }
1164    }
1165}
1166
1167glib::wrapper! {
1168    /// Preference Window to display and update room details.
1169    pub struct GeneralPage(ObjectSubclass<imp::GeneralPage>)
1170        @extends gtk::Widget, adw::PreferencesPage, @implements gtk::Accessible;
1171}
1172
1173impl GeneralPage {
1174    pub fn new(room: &Room, membership_lists: &MembershipLists) -> Self {
1175        glib::Object::builder()
1176            .property("room", room)
1177            .property("membership-lists", membership_lists)
1178            .build()
1179    }
1180
1181    /// Unselect the topic of the room.
1182    ///
1183    /// This is to circumvent the default GTK behavior to select all the text
1184    /// when opening the details.
1185    pub(crate) fn unselect_topic(&self) {
1186        let imp = self.imp();
1187
1188        glib::idle_add_local_once(clone!(
1189            #[weak]
1190            imp,
1191            move || {
1192                imp.unselect_topic();
1193            }
1194        ));
1195    }
1196}