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