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
35const MAX_BODY_LINES: usize = 6;
40const 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 #[property(get, set = Self::set_session, explicit_notify, nullable)]
58 session: glib::WeakRef<Session>,
59 pub(super) push: RefCell<HashMap<OwnedRoomId, HashSet<String>>>,
63 pub(super) identity_verifications: RefCell<HashMap<VerificationKey, String>>,
67 #[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 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 pub struct Notifications(ObjectSubclass<imp::Notifications>);
99}
100
101impl Notifications {
102 pub fn new() -> Self {
103 glib::Object::new()
104 }
105
106 pub(crate) fn enabled(&self) -> bool {
108 let settings = self.settings();
109 settings.account_enabled() && settings.session_enabled()
110 }
111
112 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 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), ¬ification);
147 }
148
149 #[allow(clippy::too_many_lines)]
154 pub(crate) async fn show_push(
155 &self,
156 matrix_notification: Notification,
157 matrix_room: MatrixRoom,
158 ) {
159 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 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 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 pub(crate) async fn show_in_room_identity_verification(
302 &self,
303 verification: &IdentityVerification,
304 ) {
305 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 let user = verification.user();
323 let user_id = user.user_id();
324
325 let title = gettext("Verification Request");
326 let body = gettext_f(
327 "{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 pub(crate) async fn show_to_device_identity_verification(
353 &self,
354 verification: &IdentityVerification,
355 ) {
356 if !self.enabled() {
358 return;
359 }
360
361 let Some(session) = self.session() else {
362 return;
363 };
364 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 "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 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 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 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
458pub(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
528pub(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 Some(gettext_f("{user} invited you", &[("user", sender_name)]))
571 } else {
572 None
573 }
574}