Skip to main content

fractal/session/room/
join_rule.rs

1use gettextrs::gettext;
2use gtk::{
3    glib,
4    glib::{clone, closure_local},
5    prelude::*,
6    subclass::prelude::*,
7};
8use ruma::{
9    OwnedRoomId,
10    events::room::join_rules::{
11        AllowRule, JoinRule as MatrixJoinRule, Restricted, RoomJoinRulesEventContent,
12    },
13};
14use tracing::error;
15
16use super::{Membership, Room};
17use crate::{components::PillSource, gettext_f, spawn_tokio, utils::BoundObject};
18
19/// Simplified join rules.
20#[derive(Debug, Default, Hash, Eq, PartialEq, Clone, Copy, glib::Enum)]
21#[enum_type(name = "JoinRuleValue")]
22pub enum JoinRuleValue {
23    /// Only invited users can join.
24    #[default]
25    Invite,
26    /// Anyone can join.
27    Public,
28    /// Members of a room can join.
29    RoomMembership,
30    /// The rule is unsupported.
31    Unsupported,
32}
33
34impl JoinRuleValue {
35    /// Whether we support editing this join rule.
36    pub(crate) fn can_be_edited(self) -> bool {
37        matches!(self, Self::Invite | Self::Public)
38    }
39}
40
41impl From<&MatrixJoinRule> for JoinRuleValue {
42    fn from(value: &MatrixJoinRule) -> Self {
43        match value {
44            MatrixJoinRule::Invite | MatrixJoinRule::Knock => Self::Invite,
45            MatrixJoinRule::Restricted(restricted)
46            | MatrixJoinRule::KnockRestricted(restricted) => {
47                if has_restricted_membership_room(restricted) {
48                    Self::RoomMembership
49                } else {
50                    Self::Unsupported
51                }
52            }
53            MatrixJoinRule::Public => Self::Public,
54            _ => Self::Unsupported,
55        }
56    }
57}
58
59mod imp {
60    use std::{
61        cell::{Cell, RefCell},
62        sync::LazyLock,
63    };
64
65    use glib::subclass::Signal;
66
67    use super::*;
68
69    #[derive(Debug, Default, glib::Properties)]
70    #[properties(wrapper_type = super::JoinRule)]
71    pub struct JoinRule {
72        /// The room where this join rule apply.
73        #[property(get)]
74        room: glib::WeakRef<Room>,
75        /// The current join rule from the SDK.
76        matrix_join_rule: RefCell<Option<MatrixJoinRule>>,
77        /// The value of the join rule.
78        #[property(get, builder(JoinRuleValue::default()))]
79        value: Cell<JoinRuleValue>,
80        /// Whether users can knock.
81        #[property(get)]
82        can_knock: Cell<bool>,
83        /// The string to use to display this join rule.
84        ///
85        /// This string can contain markup.
86        #[property(get)]
87        display_name: RefCell<String>,
88        /// The room we need to be a member of to match this join rule, if any.
89        ///
90        /// This can be a `Room` or a `RemoteRoom`.
91        // TODO: Support multiple rooms.
92        #[property(get)]
93        membership_room: BoundObject<PillSource>,
94        /// Whether our own user can join this room on their own.
95        #[property(get)]
96        we_can_join: Cell<bool>,
97        /// Whether anyone can join this room on their own.
98        #[property(get)]
99        anyone_can_join: Cell<bool>,
100        own_membership_handler: RefCell<Option<glib::SignalHandlerId>>,
101    }
102
103    #[glib::object_subclass]
104    impl ObjectSubclass for JoinRule {
105        const NAME: &'static str = "RoomJoinRule";
106        type Type = super::JoinRule;
107    }
108
109    #[glib::derived_properties]
110    impl ObjectImpl for JoinRule {
111        fn signals() -> &'static [Signal] {
112            static SIGNALS: LazyLock<Vec<Signal>> =
113                LazyLock::new(|| vec![Signal::builder("changed").build()]);
114            SIGNALS.as_ref()
115        }
116
117        fn dispose(&self) {
118            if let Some(room) = self.room.upgrade()
119                && let Some(handler) = self.own_membership_handler.take()
120            {
121                room.own_member().disconnect(handler);
122            }
123        }
124    }
125
126    impl JoinRule {
127        /// Set the room where this join rule applies.
128        pub(super) fn set_room(&self, room: &Room) {
129            self.room.set(Some(room));
130
131            let own_membership_handler = room.own_member().connect_membership_notify(clone!(
132                #[weak(rename_to = imp)]
133                self,
134                move |_| {
135                    imp.update_we_can_join();
136                }
137            ));
138            self.own_membership_handler
139                .replace(Some(own_membership_handler));
140        }
141
142        /// The current join rule from the SDK.
143        pub(super) fn matrix_join_rule(&self) -> Option<MatrixJoinRule> {
144            self.matrix_join_rule.borrow().clone()
145        }
146
147        /// Update the join rule.
148        pub(super) fn update_join_rule(&self, join_rule: Option<&MatrixJoinRule>) {
149            if self.matrix_join_rule.borrow().as_ref() == join_rule {
150                return;
151            }
152
153            self.matrix_join_rule.replace(join_rule.cloned());
154
155            self.update_value();
156            self.update_can_knock();
157            self.update_membership_room();
158            self.update_display_name();
159
160            self.update_we_can_join();
161            self.update_anyone_can_join();
162
163            self.obj().emit_by_name::<()>("changed", &[]);
164        }
165
166        /// Update the value of the join rule.
167        fn update_value(&self) {
168            let value = self
169                .matrix_join_rule
170                .borrow()
171                .as_ref()
172                .map(Into::into)
173                .unwrap_or_default();
174
175            if self.value.get() == value {
176                return;
177            }
178
179            self.value.set(value);
180            self.obj().notify_value();
181        }
182
183        /// Update whether users can knock.
184        fn update_can_knock(&self) {
185            let can_knock = self.matrix_join_rule.borrow().as_ref().is_some_and(|r| {
186                matches!(
187                    r,
188                    MatrixJoinRule::Knock | MatrixJoinRule::KnockRestricted(_)
189                )
190            });
191
192            if self.can_knock.get() == can_knock {
193                return;
194            }
195
196            self.can_knock.set(can_knock);
197            self.obj().notify_can_knock();
198        }
199
200        /// Set the room we need to be a member of to match this join rule.
201        fn update_membership_room(&self) {
202            let room_id = self
203                .matrix_join_rule
204                .borrow()
205                .as_ref()
206                .and_then(|r| match r {
207                    MatrixJoinRule::Restricted(restricted)
208                    | MatrixJoinRule::KnockRestricted(restricted) => {
209                        restricted_membership_room(restricted)
210                    }
211                    _ => None,
212                });
213
214            if self
215                .membership_room
216                .obj()
217                .map(|d| d.identifier())
218                .as_deref()
219                == room_id.as_ref().map(|id| id.as_str())
220            {
221                return;
222            }
223
224            self.membership_room.disconnect_signals();
225
226            if let Some(room_id) = room_id {
227                let Some(session) = self.room.upgrade().and_then(|r| r.session()) else {
228                    return;
229                };
230
231                let room: PillSource = if let Some(room) = session.room_list().get(&room_id) {
232                    room.upcast()
233                } else {
234                    session.remote_cache().room(room_id.into()).upcast()
235                };
236
237                let display_name_handler = room.connect_display_name_notify(clone!(
238                    #[weak(rename_to = imp)]
239                    self,
240                    move |_| {
241                        imp.update_display_name();
242                    }
243                ));
244
245                self.membership_room.set(room, vec![display_name_handler]);
246            }
247
248            self.obj().notify_membership_room();
249        }
250
251        /// Update the display name of the join rule.
252        fn update_display_name(&self) {
253            let value = self.value.get();
254            let can_knock = self.can_knock.get();
255
256            let name = match value {
257                JoinRuleValue::Invite => {
258                    if can_knock {
259                        gettext("Only invited users, and users can request an invite")
260                    } else {
261                        gettext("Only invited users")
262                    }
263                }
264                JoinRuleValue::RoomMembership => {
265                    let room_name = self
266                        .membership_room
267                        .obj()
268                        .map(|r| r.display_name())
269                        .unwrap_or_default();
270
271                    if can_knock {
272                        gettext_f(
273                            // Translators: Do NOT translate the content between '{' and '}',
274                            // this is a variable name.
275                            "Members of {room}, and users can request an invite",
276                            &[("room", &format!("<b>{room_name}</b>"))],
277                        )
278                    } else {
279                        gettext_f(
280                            // Translators: Do NOT translate the content between '{' and '}',
281                            // this is a variable name.
282                            "Members of {room}",
283                            &[("room", &format!("<b>{room_name}</b>"))],
284                        )
285                    }
286                }
287                JoinRuleValue::Public => gettext("Any registered user"),
288                JoinRuleValue::Unsupported => gettext("Unsupported rule"),
289            };
290
291            if *self.display_name.borrow() == name {
292                return;
293            }
294
295            self.display_name.replace(name);
296            self.obj().notify_display_name();
297        }
298
299        /// Update whether our own user can join this room on their own.
300        fn update_we_can_join(&self) {
301            let we_can_join = self.we_can_join();
302
303            if self.we_can_join.get() == we_can_join {
304                return;
305            }
306
307            self.we_can_join.set(we_can_join);
308            self.obj().notify_we_can_join();
309        }
310
311        /// Whether our own user can join this room on their own.
312        fn we_can_join(&self) -> bool {
313            let Some(matrix_join_rule) = self.matrix_join_rule() else {
314                return false;
315            };
316            let Some(room) = self.room.upgrade() else {
317                return false;
318            };
319
320            if room.own_member().membership() == Membership::Ban {
321                return false;
322            }
323
324            match matrix_join_rule {
325                MatrixJoinRule::Public => true,
326                MatrixJoinRule::Restricted(rules) | MatrixJoinRule::KnockRestricted(rules) => rules
327                    .allow
328                    .into_iter()
329                    .any(|rule| we_pass_restricted_allow_rule(&room, rule)),
330                _ => false,
331            }
332        }
333
334        /// Update whether our own user can join this room on their own.
335        fn update_anyone_can_join(&self) {
336            let anyone_can_join = self
337                .matrix_join_rule
338                .borrow()
339                .as_ref()
340                .is_some_and(|r| *r == MatrixJoinRule::Public);
341
342            if self.anyone_can_join.get() == anyone_can_join {
343                return;
344            }
345
346            self.anyone_can_join.set(anyone_can_join);
347            self.obj().notify_anyone_can_join();
348        }
349    }
350}
351
352glib::wrapper! {
353    /// The join rule of a room.
354    pub struct JoinRule(ObjectSubclass<imp::JoinRule>);
355}
356
357impl JoinRule {
358    pub fn new() -> Self {
359        glib::Object::new()
360    }
361
362    /// Initialize the join rule with the room where it applies.
363    pub(super) fn init(&self, room: &Room) {
364        self.imp().set_room(room);
365    }
366
367    /// Update the join rule with the given value from the SDK.
368    pub(super) fn update(&self, join_rule: Option<&MatrixJoinRule>) {
369        self.imp().update_join_rule(join_rule);
370    }
371
372    /// Get the current join rule from the SDK.
373    pub(crate) fn matrix_join_rule(&self) -> Option<MatrixJoinRule> {
374        self.imp().matrix_join_rule()
375    }
376
377    /// Change the join rule.
378    pub(crate) async fn set_matrix_join_rule(&self, rule: MatrixJoinRule) -> Result<(), ()> {
379        let Some(room) = self.room() else {
380            return Err(());
381        };
382
383        let content = RoomJoinRulesEventContent::new(rule);
384
385        let matrix_room = room.matrix_room().clone();
386        let handle = spawn_tokio!(async move { matrix_room.send_state_event(content).await });
387
388        match handle.await.expect("task was not aborted") {
389            Ok(_) => Ok(()),
390            Err(error) => {
391                error!("Could not change join rule: {error}");
392                Err(())
393            }
394        }
395    }
396
397    /// Connect to the signal emitted when the join rule changed.
398    pub(crate) fn connect_changed<F: Fn(&Self) + 'static>(&self, f: F) -> glib::SignalHandlerId {
399        self.connect_closure(
400            "changed",
401            true,
402            closure_local!(move |obj: Self| {
403                f(&obj);
404            }),
405        )
406    }
407}
408
409impl Default for JoinRule {
410    fn default() -> Self {
411        Self::new()
412    }
413}
414
415/// Whether the given restricted rule allows a room membership.
416fn has_restricted_membership_room(restricted: &Restricted) -> bool {
417    restricted
418        .allow
419        .iter()
420        .any(|a| matches!(a, AllowRule::RoomMembership(_)))
421}
422
423/// The ID of the first room, if the given restricted rule allows a room
424/// membership.
425fn restricted_membership_room(restricted: &Restricted) -> Option<OwnedRoomId> {
426    restricted.allow.iter().find_map(|a| match a {
427        AllowRule::RoomMembership(m) => Some(m.room_id.clone()),
428        _ => None,
429    })
430}
431
432/// Whether our account passes the given restricted allow rule.
433fn we_pass_restricted_allow_rule(room: &Room, rule: AllowRule) -> bool {
434    match rule {
435        AllowRule::RoomMembership(room_membership) => room.session().is_some_and(|s| {
436            s.room_list()
437                .get_by_identifier((&*room_membership.room_id).into())
438                .is_some_and(|room| room.is_joined())
439        }),
440        _ => false,
441    }
442}