fractal/session/model/notifications/
mod.rs1use 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
27const MAX_BODY_LINES: usize = 6;
32const 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 #[property(get, set = Self::set_session, explicit_notify, nullable)]
50 session: glib::WeakRef<Session>,
51 pub(super) push: RefCell<HashMap<OwnedRoomId, HashSet<String>>>,
55 pub(super) identity_verifications: RefCell<HashMap<VerificationKey, String>>,
59 #[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 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 pub struct Notifications(ObjectSubclass<imp::Notifications>);
91}
92
93impl Notifications {
94 pub fn new() -> Self {
95 glib::Object::new()
96 }
97
98 pub(crate) fn enabled(&self) -> bool {
100 let settings = self.settings();
101 settings.account_enabled() && settings.session_enabled()
102 }
103
104 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 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), ¬ification);
139 }
140
141 pub(crate) async fn show_push(
146 &self,
147 matrix_notification: Notification,
148 matrix_room: MatrixRoom,
149 ) {
150 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 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 pub(crate) async fn show_in_room_identity_verification(
266 &self,
267 verification: &IdentityVerification,
268 ) {
269 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 let user = verification.user();
287 let user_id = user.user_id();
288
289 let title = gettext("Verification Request");
290 let body = gettext_f(
291 "{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 pub(crate) async fn show_to_device_identity_verification(
317 &self,
318 verification: &IdentityVerification,
319 ) {
320 if !self.enabled() {
322 return;
323 }
324
325 let Some(session) = self.session() else {
326 return;
327 };
328 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 "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 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 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 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}