fractal/session/model/notifications/
notifications_settings.rs

1use std::collections::HashMap;
2
3use futures_util::StreamExt;
4use gtk::{glib, glib::clone, prelude::*, subclass::prelude::*};
5use matrix_sdk::{
6    notification_settings::{
7        IsEncrypted, NotificationSettings as MatrixNotificationSettings, RoomNotificationMode,
8    },
9    NotificationSettingsError,
10};
11use ruma::{
12    push::{PredefinedOverrideRuleId, RuleKind},
13    OwnedRoomId, RoomId,
14};
15use tokio::task::AbortHandle;
16use tokio_stream::wrappers::BroadcastStream;
17use tracing::error;
18
19use crate::{
20    session::model::{Room, Session, SessionState},
21    spawn, spawn_tokio,
22};
23
24/// The possible values for the global notifications setting.
25#[derive(
26    Debug, Default, Hash, Eq, PartialEq, Clone, Copy, glib::Enum, strum::Display, strum::EnumString,
27)]
28#[enum_type(name = "NotificationsGlobalSetting")]
29#[strum(serialize_all = "kebab-case")]
30pub enum NotificationsGlobalSetting {
31    /// Every message in every room.
32    #[default]
33    All,
34    /// Every message in 1-to-1 rooms, and mentions and keywords in every room.
35    DirectAndMentions,
36    /// Only mentions and keywords in every room.
37    MentionsOnly,
38}
39
40/// The possible values for a room notifications setting.
41#[derive(Debug, Default, Hash, Eq, PartialEq, Clone, Copy, glib::Enum, strum::EnumString)]
42#[enum_type(name = "NotificationsRoomSetting")]
43#[strum(serialize_all = "kebab-case")]
44pub enum NotificationsRoomSetting {
45    /// Use the global setting.
46    #[default]
47    Global,
48    /// All messages.
49    All,
50    /// Only mentions and keywords.
51    MentionsOnly,
52    /// No notifications.
53    Mute,
54}
55
56impl NotificationsRoomSetting {
57    /// Convert to a [`RoomNotificationMode`].
58    fn to_notification_mode(self) -> Option<RoomNotificationMode> {
59        match self {
60            Self::Global => None,
61            Self::All => Some(RoomNotificationMode::AllMessages),
62            Self::MentionsOnly => Some(RoomNotificationMode::MentionsAndKeywordsOnly),
63            Self::Mute => Some(RoomNotificationMode::Mute),
64        }
65    }
66}
67
68impl From<RoomNotificationMode> for NotificationsRoomSetting {
69    fn from(value: RoomNotificationMode) -> Self {
70        match value {
71            RoomNotificationMode::AllMessages => Self::All,
72            RoomNotificationMode::MentionsAndKeywordsOnly => Self::MentionsOnly,
73            RoomNotificationMode::Mute => Self::Mute,
74        }
75    }
76}
77
78mod imp {
79    use std::cell::{Cell, RefCell};
80
81    use super::*;
82
83    #[derive(Debug, Default, glib::Properties)]
84    #[properties(wrapper_type = super::NotificationsSettings)]
85    pub struct NotificationsSettings {
86        /// The parent `Session`.
87        #[property(get, set = Self::set_session, explicit_notify, nullable)]
88        session: glib::WeakRef<Session>,
89        /// The SDK notification settings API.
90        api: RefCell<Option<MatrixNotificationSettings>>,
91        /// Whether notifications are enabled for this Matrix account.
92        #[property(get)]
93        account_enabled: Cell<bool>,
94        /// Whether notifications are enabled for this session.
95        #[property(get, set = Self::set_session_enabled, explicit_notify)]
96        session_enabled: Cell<bool>,
97        /// The global setting about which messages trigger notifications.
98        #[property(get, builder(NotificationsGlobalSetting::default()))]
99        global_setting: Cell<NotificationsGlobalSetting>,
100        /// The list of keywords that trigger notifications.
101        #[property(get)]
102        keywords_list: gtk::StringList,
103        /// The map of room ID to per-room notification setting.
104        ///
105        /// Any room not in this map uses the global setting.
106        per_room_settings: RefCell<HashMap<OwnedRoomId, NotificationsRoomSetting>>,
107        abort_handle: RefCell<Option<AbortHandle>>,
108    }
109
110    #[glib::object_subclass]
111    impl ObjectSubclass for NotificationsSettings {
112        const NAME: &'static str = "NotificationsSettings";
113        type Type = super::NotificationsSettings;
114    }
115
116    #[glib::derived_properties]
117    impl ObjectImpl for NotificationsSettings {
118        fn dispose(&self) {
119            if let Some(handle) = self.abort_handle.take() {
120                handle.abort();
121            }
122        }
123    }
124
125    impl NotificationsSettings {
126        /// Set the parent `Session`.
127        fn set_session(&self, session: Option<&Session>) {
128            if self.session.upgrade().as_ref() == session {
129                return;
130            }
131
132            let obj = self.obj();
133
134            if let Some(session) = session {
135                session
136                    .settings()
137                    .bind_property("notifications-enabled", &*obj, "session-enabled")
138                    .sync_create()
139                    .bidirectional()
140                    .build();
141            }
142
143            self.session.set(session);
144            obj.notify_session();
145
146            spawn!(clone!(
147                #[weak(rename_to = imp)]
148                self,
149                async move {
150                    imp.init_api().await;
151                }
152            ));
153        }
154
155        /// Set whether notifications are enabled for this session.
156        fn set_session_enabled(&self, enabled: bool) {
157            if self.session_enabled.get() == enabled {
158                return;
159            }
160
161            if !enabled {
162                if let Some(session) = self.session.upgrade() {
163                    session.notifications().clear();
164                }
165            }
166
167            self.session_enabled.set(enabled);
168            self.obj().notify_session_enabled();
169        }
170
171        /// The SDK notification settings API.
172        pub(super) fn api(&self) -> Option<MatrixNotificationSettings> {
173            self.api.borrow().clone()
174        }
175
176        /// Initialize the SDK notification settings API.
177        async fn init_api(&self) {
178            let Some(session) = self.session.upgrade() else {
179                self.api.take();
180                return;
181            };
182
183            // If the session is not ready, there is no client so let's wait to initialize
184            // the API.
185            if session.state() != SessionState::Ready {
186                self.api.take();
187
188                session.connect_ready(clone!(
189                    #[weak(rename_to = imp)]
190                    self,
191                    move |_| {
192                        spawn!(async move {
193                            imp.init_api().await;
194                        });
195                    }
196                ));
197
198                return;
199            }
200
201            let client = session.client();
202            let api = spawn_tokio!(async move { client.notification_settings().await })
203                .await
204                .expect("task was not aborted");
205            let stream = BroadcastStream::new(api.subscribe_to_changes());
206
207            self.api.replace(Some(api.clone()));
208
209            let obj_weak = glib::SendWeakRef::from(self.obj().downgrade());
210            let fut = stream.for_each(move |res| {
211                let obj_weak = obj_weak.clone();
212                async move {
213                    if res.is_err() {
214                        return;
215                    }
216
217                    let ctx = glib::MainContext::default();
218                    ctx.spawn(async move {
219                        spawn!(async move {
220                            if let Some(obj) = obj_weak.upgrade() {
221                                obj.imp().update().await;
222                            }
223                        });
224                    });
225                }
226            });
227
228            self.abort_handle
229                .replace(Some(spawn_tokio!(fut).abort_handle()));
230
231            spawn!(clone!(
232                #[weak(rename_to = imp)]
233                self,
234                async move {
235                    imp.update().await;
236                }
237            ));
238        }
239
240        /// Update the notification settings from the SDK API.
241        async fn update(&self) {
242            let Some(api) = self.api() else {
243                return;
244            };
245
246            let api_clone = api.clone();
247            let handle = spawn_tokio!(async move {
248                api_clone
249                    .is_push_rule_enabled(RuleKind::Override, PredefinedOverrideRuleId::Master)
250                    .await
251            });
252
253            let account_enabled = match handle.await.expect("task was not aborted") {
254                // The rule disables notifications, so we need to invert the boolean.
255                Ok(enabled) => !enabled,
256                Err(error) => {
257                    error!("Could not get account notifications setting: {error}");
258                    true
259                }
260            };
261            self.set_account_enabled(account_enabled);
262
263            if default_rooms_notifications_is_all(api.clone(), false).await {
264                self.set_global_setting(NotificationsGlobalSetting::All);
265            } else if default_rooms_notifications_is_all(api, true).await {
266                self.set_global_setting(NotificationsGlobalSetting::DirectAndMentions);
267            } else {
268                self.set_global_setting(NotificationsGlobalSetting::MentionsOnly);
269            }
270
271            self.update_keywords_list().await;
272            self.update_per_room_settings().await;
273        }
274
275        /// Set whether notifications are enabled for this session.
276        pub(super) fn set_account_enabled(&self, enabled: bool) {
277            if self.account_enabled.get() == enabled {
278                return;
279            }
280
281            self.account_enabled.set(enabled);
282            self.obj().notify_account_enabled();
283        }
284
285        /// Set the global setting about which messages trigger notifications.
286        pub(super) fn set_global_setting(&self, setting: NotificationsGlobalSetting) {
287            if self.global_setting.get() == setting {
288                return;
289            }
290
291            self.global_setting.set(setting);
292            self.obj().notify_global_setting();
293        }
294
295        /// Update the local list of keywords with the remote one.
296        pub(super) async fn update_keywords_list(&self) {
297            let Some(api) = self.api() else {
298                return;
299            };
300
301            let keywords = spawn_tokio!(async move { api.enabled_keywords().await })
302                .await
303                .expect("task was not aborted");
304
305            let list = &self.keywords_list;
306            let mut diverges_at = None;
307
308            let keywords = keywords.iter().map(String::as_str).collect::<Vec<_>>();
309            let new_len = keywords.len() as u32;
310            let old_len = list.n_items();
311
312            // Check if there is any keyword that changed, was moved or was added.
313            for (pos, keyword) in keywords.iter().enumerate() {
314                if Some(*keyword)
315                    != list
316                        .item(pos as u32)
317                        .and_downcast::<gtk::StringObject>()
318                        .map(|o| o.string())
319                        .as_deref()
320                {
321                    diverges_at = Some(pos as u32);
322                    break;
323                }
324            }
325
326            // Check if keywords were removed.
327            if diverges_at.is_none() && old_len > new_len {
328                diverges_at = Some(new_len);
329            }
330
331            let Some(pos) = diverges_at else {
332                // Nothing to do.
333                return;
334            };
335
336            let additions = &keywords[pos as usize..];
337            list.splice(pos, old_len.saturating_sub(pos), additions);
338        }
339
340        /// Update the local list of per-room settings with the remote one.
341        pub(super) async fn update_per_room_settings(&self) {
342            let Some(api) = self.api() else {
343                return;
344            };
345
346            let api_clone = api.clone();
347            let room_ids = spawn_tokio!(async move {
348                api_clone
349                    .get_rooms_with_user_defined_rules(Some(true))
350                    .await
351            })
352            .await
353            .expect("task was not aborted");
354
355            // Update the local map.
356            let mut per_room_settings = HashMap::with_capacity(room_ids.len());
357            for room_id in room_ids {
358                let Ok(room_id) = RoomId::parse(room_id) else {
359                    continue;
360                };
361
362                let room_id_clone = room_id.clone();
363                let api_clone = api.clone();
364                let handle = spawn_tokio!(async move {
365                    api_clone
366                        .get_user_defined_room_notification_mode(&room_id_clone)
367                        .await
368                });
369
370                if let Some(setting) = handle.await.expect("task was not aborted") {
371                    per_room_settings.insert(room_id, setting.into());
372                }
373            }
374
375            self.per_room_settings.replace(per_room_settings.clone());
376
377            // Update the setting in the rooms.
378            // Since we don't know when a room was added or removed, we have to update every
379            // room.
380            let Some(session) = self.session.upgrade() else {
381                return;
382            };
383            let room_list = session.room_list();
384
385            for room in room_list.iter::<Room>() {
386                let Ok(room) = room else {
387                    // Returns an error when the list changed, just stop.
388                    break;
389                };
390
391                if let Some(setting) = per_room_settings.get(room.room_id()) {
392                    room.set_notifications_setting(*setting);
393                } else {
394                    room.set_notifications_setting(NotificationsRoomSetting::Global);
395                }
396            }
397        }
398    }
399}
400
401glib::wrapper! {
402    /// The notifications settings of a `Session`.
403    pub struct NotificationsSettings(ObjectSubclass<imp::NotificationsSettings>);
404}
405
406impl NotificationsSettings {
407    /// Create a new `NotificationsSettings`.
408    pub fn new() -> Self {
409        glib::Object::new()
410    }
411
412    /// Set whether notifications are enabled for this session.
413    pub(crate) async fn set_account_enabled(
414        &self,
415        enabled: bool,
416    ) -> Result<(), NotificationSettingsError> {
417        let imp = self.imp();
418
419        let Some(api) = imp.api() else {
420            error!("Cannot update notifications settings when API is not initialized");
421            return Err(NotificationSettingsError::UnableToUpdatePushRule);
422        };
423
424        let handle = spawn_tokio!(async move {
425            api.set_push_rule_enabled(
426                RuleKind::Override,
427                PredefinedOverrideRuleId::Master,
428                // The rule disables notifications, so we need to invert the boolean.
429                !enabled,
430            )
431            .await
432        });
433
434        match handle.await.expect("task was not aborted") {
435            Ok(()) => {
436                imp.set_account_enabled(enabled);
437                Ok(())
438            }
439            Err(error) => {
440                error!("Could not change account notifications setting: {error}");
441                Err(error)
442            }
443        }
444    }
445
446    /// Set the global setting about which messages trigger notifications.
447    pub(crate) async fn set_global_setting(
448        &self,
449        setting: NotificationsGlobalSetting,
450    ) -> Result<(), NotificationSettingsError> {
451        let imp = self.imp();
452
453        let Some(api) = imp.api() else {
454            error!("Cannot update notifications settings when API is not initialized");
455            return Err(NotificationSettingsError::UnableToUpdatePushRule);
456        };
457
458        let (group_all, one_to_one_all) = match setting {
459            NotificationsGlobalSetting::All => (true, true),
460            NotificationsGlobalSetting::DirectAndMentions => (false, true),
461            NotificationsGlobalSetting::MentionsOnly => (false, false),
462        };
463
464        if let Err(error) = set_default_rooms_notifications_all(api.clone(), false, group_all).await
465        {
466            error!("Could not change global group chats notifications setting: {error}");
467            return Err(error);
468        }
469        if let Err(error) = set_default_rooms_notifications_all(api, true, one_to_one_all).await {
470            error!("Could not change global 1-to-1 chats notifications setting: {error}");
471            return Err(error);
472        }
473
474        imp.set_global_setting(setting);
475
476        Ok(())
477    }
478
479    /// Remove a keyword from the list.
480    pub(crate) async fn remove_keyword(
481        &self,
482        keyword: String,
483    ) -> Result<(), NotificationSettingsError> {
484        let imp = self.imp();
485
486        let Some(api) = imp.api() else {
487            error!("Cannot update notifications settings when API is not initialized");
488            return Err(NotificationSettingsError::UnableToUpdatePushRule);
489        };
490
491        let keyword_clone = keyword.clone();
492        let handle = spawn_tokio!(async move { api.remove_keyword(&keyword_clone).await });
493
494        if let Err(error) = handle.await.expect("task was not aborted") {
495            error!("Could not remove notification keyword `{keyword}`: {error}");
496            return Err(error);
497        }
498
499        imp.update_keywords_list().await;
500
501        Ok(())
502    }
503
504    /// Add a keyword to the list.
505    pub(crate) async fn add_keyword(
506        &self,
507        keyword: String,
508    ) -> Result<(), NotificationSettingsError> {
509        let imp = self.imp();
510
511        let Some(api) = imp.api() else {
512            error!("Cannot update notifications settings when API is not initialized");
513            return Err(NotificationSettingsError::UnableToUpdatePushRule);
514        };
515
516        let keyword_clone = keyword.clone();
517        let handle = spawn_tokio!(async move { api.add_keyword(keyword_clone).await });
518
519        if let Err(error) = handle.await.expect("task was not aborted") {
520            error!("Could not add notification keyword `{keyword}`: {error}");
521            return Err(error);
522        }
523
524        imp.update_keywords_list().await;
525
526        Ok(())
527    }
528
529    /// Set the notification setting for the room with the given ID.
530    pub(crate) async fn set_per_room_setting(
531        &self,
532        room_id: OwnedRoomId,
533        setting: NotificationsRoomSetting,
534    ) -> Result<(), NotificationSettingsError> {
535        let imp = self.imp();
536
537        let Some(api) = imp.api() else {
538            error!("Cannot update notifications settings when API is not initialized");
539            return Err(NotificationSettingsError::UnableToUpdatePushRule);
540        };
541
542        let room_id_clone = room_id.clone();
543        let handle = if let Some(mode) = setting.to_notification_mode() {
544            spawn_tokio!(async move { api.set_room_notification_mode(&room_id_clone, mode).await })
545        } else {
546            spawn_tokio!(async move { api.delete_user_defined_room_rules(&room_id_clone).await })
547        };
548
549        if let Err(error) = handle.await.expect("task was not aborted") {
550            error!("Could not update notifications setting for room `{room_id}`: {error}");
551            return Err(error);
552        }
553
554        imp.update_per_room_settings().await;
555
556        Ok(())
557    }
558}
559
560impl Default for NotificationsSettings {
561    fn default() -> Self {
562        Self::new()
563    }
564}
565
566async fn default_rooms_notifications_is_all(
567    api: MatrixNotificationSettings,
568    is_one_to_one: bool,
569) -> bool {
570    let mode = spawn_tokio!(async move {
571        api.get_default_room_notification_mode(IsEncrypted::No, is_one_to_one.into())
572            .await
573    })
574    .await
575    .expect("task was not aborted");
576
577    mode == RoomNotificationMode::AllMessages
578}
579
580async fn set_default_rooms_notifications_all(
581    api: MatrixNotificationSettings,
582    is_one_to_one: bool,
583    all: bool,
584) -> Result<(), NotificationSettingsError> {
585    let mode = if all {
586        RoomNotificationMode::AllMessages
587    } else {
588        RoomNotificationMode::MentionsAndKeywordsOnly
589    };
590
591    spawn_tokio!(async move {
592        api.set_default_room_notification_mode(IsEncrypted::No, is_one_to_one.into(), mode)
593            .await
594    })
595    .await
596    .expect("task was not aborted")
597}