fractal/session/model/notifications/
mod.rs

1use std::{borrow::Cow, cell::Cell, time::Duration};
2
3use gettextrs::gettext;
4use gtk::{gdk, gio, glib, prelude::*, subclass::prelude::*};
5use matrix_sdk::{Room as MatrixRoom, sync::Notification};
6use ruma::{
7    OwnedRoomId, RoomId, UserId,
8    api::client::device::get_device,
9    events::{
10        AnyMessageLikeEventContent, AnyStrippedStateEvent, AnySyncStateEvent, AnySyncTimelineEvent,
11        SyncStateEvent,
12        room::{member::MembershipState, message::MessageType},
13    },
14    html::{HtmlSanitizerMode, RemoveReplyFallback},
15};
16use tracing::{debug, warn};
17
18mod notifications_settings;
19
20pub(crate) use self::notifications_settings::{
21    NotificationsGlobalSetting, NotificationsRoomSetting, NotificationsSettings,
22};
23use super::{IdentityVerification, Session, VerificationKey};
24use crate::{
25    Application, Window, gettext_f,
26    intent::SessionIntent,
27    prelude::*,
28    spawn_tokio,
29    utils::matrix::{
30        AnySyncOrStrippedTimelineEvent, MatrixEventIdUri, MatrixIdUri, MatrixRoomIdUri,
31    },
32};
33
34/// The maximum number of lines we want to display for the body of a
35/// notification.
36// This is taken from GNOME Shell's behavior:
37// <https://gitlab.gnome.org/GNOME/gnome-shell/-/blob/c7778e536b094fae4d0694af6103cf4ad75050d3/js/ui/messageList.js#L24>
38const MAX_BODY_LINES: usize = 6;
39/// The maximum number of characters that we want to display for the body of a
40/// notification. We assume that the system shows at most 100 characters per
41/// line, so this is `MAX_BODY_LINES * 100`.
42const MAX_BODY_CHARS: usize = MAX_BODY_LINES * 100;
43
44mod imp {
45    use std::{
46        cell::RefCell,
47        collections::{HashMap, HashSet},
48    };
49
50    use super::*;
51
52    #[derive(Debug, Default, glib::Properties)]
53    #[properties(wrapper_type = super::Notifications)]
54    pub struct Notifications {
55        /// The current session.
56        #[property(get, set = Self::set_session, explicit_notify, nullable)]
57        session: glib::WeakRef<Session>,
58        /// The push notifications that were presented.
59        ///
60        /// A map of room ID to list of notification IDs.
61        pub(super) push: RefCell<HashMap<OwnedRoomId, HashSet<String>>>,
62        /// The identity verification notifications that were presented.
63        ///
64        /// A map of verification key to notification ID.
65        pub(super) identity_verifications: RefCell<HashMap<VerificationKey, String>>,
66        /// The notifications settings for this session.
67        #[property(get)]
68        settings: NotificationsSettings,
69    }
70
71    #[glib::object_subclass]
72    impl ObjectSubclass for Notifications {
73        const NAME: &'static str = "Notifications";
74        type Type = super::Notifications;
75    }
76
77    #[glib::derived_properties]
78    impl ObjectImpl for Notifications {}
79
80    impl Notifications {
81        /// Set the current session.
82        fn set_session(&self, session: Option<&Session>) {
83            if self.session.upgrade().as_ref() == session {
84                return;
85            }
86
87            self.session.set(session);
88            self.obj().notify_session();
89
90            self.settings.set_session(session);
91        }
92    }
93}
94
95glib::wrapper! {
96    /// The notifications of a `Session`.
97    pub struct Notifications(ObjectSubclass<imp::Notifications>);
98}
99
100impl Notifications {
101    pub fn new() -> Self {
102        glib::Object::new()
103    }
104
105    /// Whether notifications are enabled for the current session.
106    pub(crate) fn enabled(&self) -> bool {
107        let settings = self.settings();
108        settings.account_enabled() && settings.session_enabled()
109    }
110
111    /// Helper method to create notification
112    fn send_notification(
113        id: &str,
114        title: &str,
115        body: &str,
116        session_id: &str,
117        intent: &SessionIntent,
118        icon: Option<&gdk::Texture>,
119    ) {
120        let notification = gio::Notification::new(title);
121        notification.set_category(Some("im.received"));
122        notification.set_priority(gio::NotificationPriority::High);
123
124        // Truncate the body if necessary.
125        let body = if let Some((end, _)) = body.char_indices().nth(MAX_BODY_CHARS) {
126            let mut body = body[..end].trim_end().to_owned();
127            if !body.ends_with('…') {
128                body.push('…');
129            }
130            Cow::Owned(body)
131        } else {
132            Cow::Borrowed(body)
133        };
134
135        notification.set_body(Some(&body));
136
137        let action = intent.app_action_name();
138        let target_value = intent.to_variant_with_session_id(session_id);
139        notification.set_default_action_and_target_value(action, Some(&target_value));
140
141        if let Some(notification_icon) = icon {
142            notification.set_icon(notification_icon);
143        }
144
145        Application::default().send_notification(Some(id), &notification);
146    }
147
148    /// Ask the system to show the given push notification, if applicable.
149    ///
150    /// The notification will not be shown if the application is active and the
151    /// room of the event is displayed.
152    #[allow(clippy::too_many_lines)]
153    pub(crate) async fn show_push(
154        &self,
155        matrix_notification: Notification,
156        matrix_room: MatrixRoom,
157    ) {
158        // Do not show notifications if they are disabled.
159        if !self.enabled() {
160            return;
161        }
162
163        let Some(session) = self.session() else {
164            return;
165        };
166
167        let app = Application::default();
168        let window = app.active_window().and_downcast::<Window>();
169        let session_id = session.session_id();
170        let room_id = matrix_room.room_id();
171
172        // Do not show notifications for the current room in the current session if the
173        // window is active.
174        if window.is_some_and(|w| {
175            w.is_active()
176                && w.current_session_id().as_deref() == Some(session_id)
177                && w.session_view()
178                    .selected_room()
179                    .is_some_and(|r| r.room_id() == room_id)
180        }) {
181            return;
182        }
183
184        let Some(room) = session
185            .room_list()
186            .get_wait(room_id, Some(Duration::from_secs(10)))
187            .await
188        else {
189            warn!("Could not display notification for missing room {room_id}",);
190            return;
191        };
192
193        if !room.is_room_info_initialized() {
194            // Wait for the room to finish initializing, otherwise we will not have the
195            // display name or the avatar.
196            let (sender, receiver) = futures_channel::oneshot::channel();
197
198            let sender_cell = Cell::new(Some(sender));
199            let handler_id = room.connect_is_room_info_initialized_notify(move |_| {
200                if let Some(sender) = sender_cell.take() {
201                    let _ = sender.send(());
202                }
203            });
204
205            let _ = receiver.await;
206            room.disconnect(handler_id);
207        }
208
209        let event = match AnySyncOrStrippedTimelineEvent::from_raw(&matrix_notification.event) {
210            Ok(event) => event,
211            Err(error) => {
212                warn!(
213                    "Could not display notification for unrecognized event in room {room_id}: {error}",
214                );
215                return;
216            }
217        };
218
219        let is_direct = room.direct_member().is_some();
220        let sender_id = event.sender();
221        let owned_sender_id = sender_id.to_owned();
222        let handle =
223            spawn_tokio!(async move { matrix_room.get_member_no_sync(&owned_sender_id).await });
224
225        let sender = match handle.await.expect("task was not aborted") {
226            Ok(member) => member,
227            Err(error) => {
228                warn!("Could not get member for notification: {error}");
229                None
230            }
231        };
232
233        let sender_name = sender.as_ref().map_or_else(
234            || sender_id.localpart().to_owned(),
235            |member| {
236                let name = member.name();
237
238                if member.name_ambiguous() {
239                    format!("{name} ({})", member.user_id())
240                } else {
241                    name.to_owned()
242                }
243            },
244        );
245
246        let (body, is_invite) =
247            if let Some(body) = message_notification_body(&event, &sender_name, !is_direct) {
248                (body, false)
249            } else if let Some(body) =
250                own_invite_notification_body(&event, &sender_name, session.user_id())
251            {
252                (body, true)
253            } else {
254                debug!("Received notification for event of unexpected type {event:?}",);
255                return;
256            };
257
258        let room_id = room.room_id().to_owned();
259        let event_id = event.event_id();
260
261        let room_uri = MatrixRoomIdUri {
262            id: room_id.clone().into(),
263            via: vec![],
264        };
265        let matrix_uri = if let Some(event_id) = event_id {
266            MatrixIdUri::Event(MatrixEventIdUri {
267                event_id: event_id.to_owned(),
268                room_uri,
269            })
270        } else {
271            MatrixIdUri::Room(room_uri)
272        };
273
274        let id = if event_id.is_some() {
275            format!("{session_id}//{matrix_uri}")
276        } else {
277            let random_id = glib::uuid_string_random();
278            format!("{session_id}//{matrix_uri}//{random_id}")
279        };
280
281        let inhibit_image = is_invite && !session.global_account_data().invite_avatars_enabled();
282        let icon = room.avatar_data().as_notification_icon(inhibit_image).await;
283
284        Self::send_notification(
285            &id,
286            &room.display_name(),
287            &body,
288            session_id,
289            &SessionIntent::ShowMatrixId(matrix_uri),
290            icon.as_ref(),
291        );
292
293        self.imp()
294            .push
295            .borrow_mut()
296            .entry(room_id)
297            .or_default()
298            .insert(id);
299    }
300
301    /// Show a notification for the given in-room identity verification.
302    pub(crate) async fn show_in_room_identity_verification(
303        &self,
304        verification: &IdentityVerification,
305    ) {
306        // Do not show notifications if they are disabled.
307        if !self.enabled() {
308            return;
309        }
310
311        let Some(session) = self.session() else {
312            return;
313        };
314        let Some(room) = verification.room() else {
315            return;
316        };
317
318        let room_id = room.room_id().to_owned();
319        let session_id = session.session_id();
320        let flow_id = verification.flow_id();
321
322        // In-room verifications should only happen for other users.
323        let user = verification.user();
324        let user_id = user.user_id();
325
326        let title = gettext("Verification Request");
327        let body = gettext_f(
328            // Translators: Do NOT translate the content between '{' and '}', this is a
329            // variable name.
330            "{user} sent a verification request",
331            &[("user", &user.display_name())],
332        );
333
334        let icon = user.avatar_data().as_notification_icon(false).await;
335
336        let id = format!("{session_id}//{room_id}//{user_id}//{flow_id}");
337        Self::send_notification(
338            &id,
339            &title,
340            &body,
341            session_id,
342            &SessionIntent::ShowIdentityVerification(verification.key()),
343            icon.as_ref(),
344        );
345
346        self.imp()
347            .identity_verifications
348            .borrow_mut()
349            .insert(verification.key(), id);
350    }
351
352    /// Show a notification for the given to-device identity verification.
353    pub(crate) async fn show_to_device_identity_verification(
354        &self,
355        verification: &IdentityVerification,
356    ) {
357        // Do not show notifications if they are disabled.
358        if !self.enabled() {
359            return;
360        }
361
362        let Some(session) = self.session() else {
363            return;
364        };
365        // To-device verifications should only happen for other sessions.
366        let Some(other_device_id) = verification.other_device_id() else {
367            return;
368        };
369
370        let session_id = session.session_id();
371        let flow_id = verification.flow_id();
372
373        let client = session.client();
374        let request = get_device::v3::Request::new(other_device_id.clone());
375        let handle = spawn_tokio!(async move { client.send(request).await });
376
377        let display_name = match handle.await.expect("task was not aborted") {
378            Ok(res) => res.device.display_name,
379            Err(error) => {
380                warn!("Could not get device for notification: {error}");
381                None
382            }
383        };
384        let display_name = display_name
385            .as_deref()
386            .unwrap_or_else(|| other_device_id.as_str());
387
388        let title = gettext("Login Request From Another Session");
389        let body = gettext_f(
390            // Translators: Do NOT translate the content between '{' and '}', this is a
391            // variable name.
392            "Verify your new session “{name}”",
393            &[("name", display_name)],
394        );
395
396        let id = format!("{session_id}//{other_device_id}//{flow_id}");
397
398        Self::send_notification(
399            &id,
400            &title,
401            &body,
402            session_id,
403            &SessionIntent::ShowIdentityVerification(verification.key()),
404            None,
405        );
406
407        self.imp()
408            .identity_verifications
409            .borrow_mut()
410            .insert(verification.key(), id);
411    }
412
413    /// Ask the system to remove the known notifications for the room with the
414    /// given ID.
415    ///
416    /// Only the notifications that were shown since the application's startup
417    /// are known, older ones might still be present.
418    pub(crate) fn withdraw_all_for_room(&self, room_id: &RoomId) {
419        if let Some(notifications) = self.imp().push.borrow_mut().remove(room_id) {
420            let app = Application::default();
421
422            for id in notifications {
423                app.withdraw_notification(&id);
424            }
425        }
426    }
427
428    /// Ask the system to remove the known notification for the identity
429    /// verification with the given key.
430    pub(crate) fn withdraw_identity_verification(&self, key: &VerificationKey) {
431        if let Some(id) = self.imp().identity_verifications.borrow_mut().remove(key) {
432            let app = Application::default();
433            app.withdraw_notification(&id);
434        }
435    }
436
437    /// Ask the system to remove all the known notifications for this session.
438    ///
439    /// Only the notifications that were shown since the application's startup
440    /// are known, older ones might still be present.
441    pub(crate) fn clear(&self) {
442        let app = Application::default();
443
444        for id in self.imp().push.take().values().flatten() {
445            app.withdraw_notification(id);
446        }
447        for id in self.imp().identity_verifications.take().values() {
448            app.withdraw_notification(id);
449        }
450    }
451}
452
453impl Default for Notifications {
454    fn default() -> Self {
455        Self::new()
456    }
457}
458
459/// Generate the notification body for the given event, if it is a message-like
460/// event.
461///
462/// If it's a media message, this will return a localized body.
463///
464/// Returns `None` if it is not a message-like event or if the message type is
465/// not supported.
466pub(crate) fn message_notification_body(
467    event: &AnySyncOrStrippedTimelineEvent,
468    sender_name: &str,
469    show_sender: bool,
470) -> Option<String> {
471    let AnySyncOrStrippedTimelineEvent::Sync(sync_event) = event else {
472        return None;
473    };
474    let AnySyncTimelineEvent::MessageLike(message_event) = &**sync_event else {
475        return None;
476    };
477
478    match message_event.original_content()? {
479        AnyMessageLikeEventContent::RoomMessage(mut message) => {
480            message.sanitize(HtmlSanitizerMode::Compat, RemoveReplyFallback::Yes);
481
482            let body = match message.msgtype {
483                MessageType::Audio(_) => {
484                    gettext_f("{user} sent an audio file.", &[("user", sender_name)])
485                }
486                MessageType::Emote(content) => format!("{sender_name} {}", content.body),
487                MessageType::File(_) => gettext_f("{user} sent a file.", &[("user", sender_name)]),
488                MessageType::Image(_) => {
489                    gettext_f("{user} sent an image.", &[("user", sender_name)])
490                }
491                MessageType::Location(_) => {
492                    gettext_f("{user} sent their location.", &[("user", sender_name)])
493                }
494                MessageType::Notice(content) => {
495                    text_event_body(content.body, sender_name, show_sender)
496                }
497                MessageType::ServerNotice(content) => {
498                    text_event_body(content.body, sender_name, show_sender)
499                }
500                MessageType::Text(content) => {
501                    text_event_body(content.body, sender_name, show_sender)
502                }
503                MessageType::Video(_) => {
504                    gettext_f("{user} sent a video.", &[("user", sender_name)])
505                }
506                _ => return None,
507            };
508            Some(body)
509        }
510        AnyMessageLikeEventContent::Sticker(_) => Some(gettext_f(
511            "{user} sent a sticker.",
512            &[("user", sender_name)],
513        )),
514        _ => None,
515    }
516}
517
518fn text_event_body(message: String, sender_name: &str, show_sender: bool) -> String {
519    if show_sender {
520        gettext_f(
521            "{user}: {message}",
522            &[("user", sender_name), ("message", &message)],
523        )
524    } else {
525        message
526    }
527}
528
529/// Generate the notification body for the given event, if it is an invite for
530/// our own user.
531///
532/// This will return a localized body.
533///
534/// Returns `None` if it is not an invite for our own user.
535pub(crate) fn own_invite_notification_body(
536    event: &AnySyncOrStrippedTimelineEvent,
537    sender_name: &str,
538    own_user_id: &UserId,
539) -> Option<String> {
540    let (membership, state_key) = match event {
541        AnySyncOrStrippedTimelineEvent::Sync(sync_event) => {
542            if let AnySyncTimelineEvent::State(AnySyncStateEvent::RoomMember(member_event)) =
543                &**sync_event
544            {
545                match member_event {
546                    SyncStateEvent::Original(original_event) => (
547                        &original_event.content.membership,
548                        &original_event.state_key,
549                    ),
550                    SyncStateEvent::Redacted(redacted_event) => (
551                        &redacted_event.content.membership,
552                        &redacted_event.state_key,
553                    ),
554                }
555            } else {
556                return None;
557            }
558        }
559        AnySyncOrStrippedTimelineEvent::Stripped(stripped_event) => {
560            if let AnyStrippedStateEvent::RoomMember(member_event) = &**stripped_event {
561                (&member_event.content.membership, &member_event.state_key)
562            } else {
563                return None;
564            }
565        }
566    };
567
568    if *membership == MembershipState::Invite && state_key == own_user_id {
569        // Translators: Do NOT translate the content between '{' and '}', this is a
570        // variable name.
571        Some(gettext_f("{user} invited you", &[("user", sender_name)]))
572    } else {
573        None
574    }
575}