fractal/session/model/notifications/
mod.rs

1use std::borrow::Cow;
2
3use gettextrs::gettext;
4use gtk::{gdk, gio, glib, prelude::*, subclass::prelude::*};
5use matrix_sdk::{sync::Notification, Room as MatrixRoom};
6use ruma::{api::client::device::get_device, OwnedRoomId, RoomId};
7use tracing::{debug, warn};
8
9mod notifications_settings;
10
11pub(crate) use self::notifications_settings::{
12    NotificationsGlobalSetting, NotificationsRoomSetting, NotificationsSettings,
13};
14use super::{IdentityVerification, Session, VerificationKey};
15use crate::{
16    gettext_f,
17    intent::SessionIntent,
18    prelude::*,
19    spawn_tokio,
20    utils::matrix::{
21        get_event_body, AnySyncOrStrippedTimelineEvent, MatrixEventIdUri, MatrixIdUri,
22        MatrixRoomIdUri,
23    },
24    Application, Window,
25};
26
27/// The maximum number of lines we want to display for the body of a
28/// notification.
29// This is taken from GNOME Shell's behavior:
30// <https://gitlab.gnome.org/GNOME/gnome-shell/-/blob/c7778e536b094fae4d0694af6103cf4ad75050d3/js/ui/messageList.js#L24>
31const MAX_BODY_LINES: usize = 6;
32/// The maximum number of characters that we want to display for the body of a
33/// notification. We assume that the system shows at most 100 characters per
34/// line, so this is `MAX_BODY_LINES * 100`.
35const MAX_BODY_CHARS: usize = MAX_BODY_LINES * 100;
36
37mod imp {
38    use std::{
39        cell::RefCell,
40        collections::{HashMap, HashSet},
41    };
42
43    use super::*;
44
45    #[derive(Debug, Default, glib::Properties)]
46    #[properties(wrapper_type = super::Notifications)]
47    pub struct Notifications {
48        /// The current session.
49        #[property(get, set = Self::set_session, explicit_notify, nullable)]
50        session: glib::WeakRef<Session>,
51        /// The push notifications that were presented.
52        ///
53        /// A map of room ID to list of notification IDs.
54        pub(super) push: RefCell<HashMap<OwnedRoomId, HashSet<String>>>,
55        /// The identity verification notifications that were presented.
56        ///
57        /// A map of verification key to notification ID.
58        pub(super) identity_verifications: RefCell<HashMap<VerificationKey, String>>,
59        /// The notifications settings for this session.
60        #[property(get)]
61        settings: NotificationsSettings,
62    }
63
64    #[glib::object_subclass]
65    impl ObjectSubclass for Notifications {
66        const NAME: &'static str = "Notifications";
67        type Type = super::Notifications;
68    }
69
70    #[glib::derived_properties]
71    impl ObjectImpl for Notifications {}
72
73    impl Notifications {
74        /// Set the current session.
75        fn set_session(&self, session: Option<&Session>) {
76            if self.session.upgrade().as_ref() == session {
77                return;
78            }
79
80            self.session.set(session);
81            self.obj().notify_session();
82
83            self.settings.set_session(session);
84        }
85    }
86}
87
88glib::wrapper! {
89    /// The notifications of a `Session`.
90    pub struct Notifications(ObjectSubclass<imp::Notifications>);
91}
92
93impl Notifications {
94    pub fn new() -> Self {
95        glib::Object::new()
96    }
97
98    /// Whether notifications are enabled for the current session.
99    pub(crate) fn enabled(&self) -> bool {
100        let settings = self.settings();
101        settings.account_enabled() && settings.session_enabled()
102    }
103
104    /// Helper method to create notification
105    fn send_notification(
106        id: &str,
107        title: &str,
108        body: &str,
109        session_id: &str,
110        intent: &SessionIntent,
111        icon: Option<&gdk::Texture>,
112    ) {
113        let notification = gio::Notification::new(title);
114        notification.set_category(Some("im.received"));
115        notification.set_priority(gio::NotificationPriority::High);
116
117        // Truncate the body if necessary.
118        let body = if let Some((end, _)) = body.char_indices().nth(MAX_BODY_CHARS) {
119            let mut body = body[..end].trim_end().to_owned();
120            if !body.ends_with('…') {
121                body.push('…');
122            }
123            Cow::Owned(body)
124        } else {
125            Cow::Borrowed(body)
126        };
127
128        notification.set_body(Some(&body));
129
130        let action = intent.app_action_name();
131        let target_value = intent.to_variant_with_session_id(session_id);
132        notification.set_default_action_and_target_value(action, Some(&target_value));
133
134        if let Some(notification_icon) = icon {
135            notification.set_icon(notification_icon);
136        }
137
138        Application::default().send_notification(Some(id), &notification);
139    }
140
141    /// Ask the system to show the given push notification, if applicable.
142    ///
143    /// The notification will not be shown if the application is active and the
144    /// room of the event is displayed.
145    pub(crate) async fn show_push(
146        &self,
147        matrix_notification: Notification,
148        matrix_room: MatrixRoom,
149    ) {
150        // Do not show notifications if they are disabled.
151        if !self.enabled() {
152            return;
153        }
154
155        let Some(session) = self.session() else {
156            return;
157        };
158
159        let app = Application::default();
160        let window = app.active_window().and_downcast::<Window>();
161        let session_id = session.session_id();
162        let room_id = matrix_room.room_id();
163
164        // Do not show notifications for the current room in the current session if the
165        // window is active.
166        if window.is_some_and(|w| {
167            w.is_active()
168                && w.current_session_id().as_deref() == Some(session_id)
169                && w.session_view()
170                    .selected_room()
171                    .is_some_and(|r| r.room_id() == room_id)
172        }) {
173            return;
174        }
175
176        let Some(room) = session.room_list().get(room_id) else {
177            warn!("Could not display notification for missing room {room_id}",);
178            return;
179        };
180
181        let event = match AnySyncOrStrippedTimelineEvent::from_raw(&matrix_notification.event) {
182            Ok(event) => event,
183            Err(error) => {
184                warn!(
185                    "Could not display notification for unrecognized event in room {room_id}: {error}",
186                );
187                return;
188            }
189        };
190
191        let is_direct = room.direct_member().is_some();
192        let sender_id = event.sender();
193        let owned_sender_id = sender_id.to_owned();
194        let handle =
195            spawn_tokio!(async move { matrix_room.get_member_no_sync(&owned_sender_id).await });
196
197        let sender = match handle.await.expect("task was not aborted") {
198            Ok(member) => member,
199            Err(error) => {
200                warn!("Could not get member for notification: {error}");
201                None
202            }
203        };
204
205        let sender_name = sender.as_ref().map_or_else(
206            || sender_id.localpart().to_owned(),
207            |member| {
208                let name = member.name();
209
210                if member.name_ambiguous() {
211                    format!("{name} ({})", member.user_id())
212                } else {
213                    name.to_owned()
214                }
215            },
216        );
217
218        let Some(body) = get_event_body(&event, &sender_name, session.user_id(), !is_direct) else {
219            debug!("Received notification for event of unexpected type {event:?}",);
220            return;
221        };
222
223        let room_id = room.room_id().to_owned();
224        let event_id = event.event_id();
225
226        let room_uri = MatrixRoomIdUri {
227            id: room_id.clone().into(),
228            via: vec![],
229        };
230        let matrix_uri = if let Some(event_id) = event_id {
231            MatrixIdUri::Event(MatrixEventIdUri {
232                event_id: event_id.to_owned(),
233                room_uri,
234            })
235        } else {
236            MatrixIdUri::Room(room_uri)
237        };
238
239        let id = if event_id.is_some() {
240            format!("{session_id}//{matrix_uri}")
241        } else {
242            let random_id = glib::uuid_string_random();
243            format!("{session_id}//{matrix_uri}//{random_id}")
244        };
245        let icon = room.avatar_data().as_notification_icon().await;
246
247        Self::send_notification(
248            &id,
249            &room.display_name(),
250            &body,
251            session_id,
252            &SessionIntent::ShowMatrixId(matrix_uri),
253            icon.as_ref(),
254        );
255
256        self.imp()
257            .push
258            .borrow_mut()
259            .entry(room_id)
260            .or_default()
261            .insert(id);
262    }
263
264    /// Show a notification for the given in-room identity verification.
265    pub(crate) async fn show_in_room_identity_verification(
266        &self,
267        verification: &IdentityVerification,
268    ) {
269        // Do not show notifications if they are disabled.
270        if !self.enabled() {
271            return;
272        }
273
274        let Some(session) = self.session() else {
275            return;
276        };
277        let Some(room) = verification.room() else {
278            return;
279        };
280
281        let room_id = room.room_id().to_owned();
282        let session_id = session.session_id();
283        let flow_id = verification.flow_id();
284
285        // In-room verifications should only happen for other users.
286        let user = verification.user();
287        let user_id = user.user_id();
288
289        let title = gettext("Verification Request");
290        let body = gettext_f(
291            // Translators: Do NOT translate the content between '{' and '}', this is a
292            // variable name.
293            "{user} sent a verification request",
294            &[("user", &user.display_name())],
295        );
296
297        let icon = user.avatar_data().as_notification_icon().await;
298
299        let id = format!("{session_id}//{room_id}//{user_id}//{flow_id}");
300        Self::send_notification(
301            &id,
302            &title,
303            &body,
304            session_id,
305            &SessionIntent::ShowIdentityVerification(verification.key()),
306            icon.as_ref(),
307        );
308
309        self.imp()
310            .identity_verifications
311            .borrow_mut()
312            .insert(verification.key(), id);
313    }
314
315    /// Show a notification for the given to-device identity verification.
316    pub(crate) async fn show_to_device_identity_verification(
317        &self,
318        verification: &IdentityVerification,
319    ) {
320        // Do not show notifications if they are disabled.
321        if !self.enabled() {
322            return;
323        }
324
325        let Some(session) = self.session() else {
326            return;
327        };
328        // To-device verifications should only happen for other sessions.
329        let Some(other_device_id) = verification.other_device_id() else {
330            return;
331        };
332
333        let session_id = session.session_id();
334        let flow_id = verification.flow_id();
335
336        let client = session.client();
337        let request = get_device::v3::Request::new(other_device_id.clone());
338        let handle = spawn_tokio!(async move { client.send(request).await });
339
340        let display_name = match handle.await.expect("task was not aborted") {
341            Ok(res) => res.device.display_name,
342            Err(error) => {
343                warn!("Could not get device for notification: {error}");
344                None
345            }
346        };
347        let display_name = display_name
348            .as_deref()
349            .unwrap_or_else(|| other_device_id.as_str());
350
351        let title = gettext("Login Request From Another Session");
352        let body = gettext_f(
353            // Translators: Do NOT translate the content between '{' and '}', this is a
354            // variable name.
355            "Verify your new session “{name}”",
356            &[("name", display_name)],
357        );
358
359        let id = format!("{session_id}//{other_device_id}//{flow_id}");
360
361        Self::send_notification(
362            &id,
363            &title,
364            &body,
365            session_id,
366            &SessionIntent::ShowIdentityVerification(verification.key()),
367            None,
368        );
369
370        self.imp()
371            .identity_verifications
372            .borrow_mut()
373            .insert(verification.key(), id);
374    }
375
376    /// Ask the system to remove the known notifications for the room with the
377    /// given ID.
378    ///
379    /// Only the notifications that were shown since the application's startup
380    /// are known, older ones might still be present.
381    pub(crate) fn withdraw_all_for_room(&self, room_id: &RoomId) {
382        if let Some(notifications) = self.imp().push.borrow_mut().remove(room_id) {
383            let app = Application::default();
384
385            for id in notifications {
386                app.withdraw_notification(&id);
387            }
388        }
389    }
390
391    /// Ask the system to remove the known notification for the identity
392    /// verification with the given key.
393    pub(crate) fn withdraw_identity_verification(&self, key: &VerificationKey) {
394        if let Some(id) = self.imp().identity_verifications.borrow_mut().remove(key) {
395            let app = Application::default();
396            app.withdraw_notification(&id);
397        }
398    }
399
400    /// Ask the system to remove all the known notifications for this session.
401    ///
402    /// Only the notifications that were shown since the application's startup
403    /// are known, older ones might still be present.
404    pub(crate) fn clear(&self) {
405        let app = Application::default();
406
407        for id in self.imp().push.take().values().flatten() {
408            app.withdraw_notification(id);
409        }
410        for id in self.imp().identity_verifications.take().values() {
411            app.withdraw_notification(id);
412        }
413    }
414}
415
416impl Default for Notifications {
417    fn default() -> Self {
418        Self::new()
419    }
420}