Skip to main content

fractal/session/notifications/
mod.rs

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