BlueSky & more on desktop
lazurite.stormlightlabs.org/
tauri
rust
typescript
bluesky
appview
atproto
solid
1use super::auth::LazuriteOAuthSession;
2use super::error::{log_error_chain, log_warn_chain, AppError, Result};
3use super::settings::{self, AppSettings};
4use super::state::AppState;
5use jacquard::api::app_bsky::notification::get_unread_count::GetUnreadCount;
6use jacquard::api::app_bsky::notification::list_notifications::ListNotifications;
7use jacquard::api::app_bsky::notification::update_seen::UpdateSeen;
8use jacquard::types::datetime::Datetime;
9use jacquard::xrpc::XrpcClient;
10use std::collections::VecDeque;
11use std::sync::Arc;
12use std::time::Duration;
13use tauri::{AppHandle, Emitter, Manager};
14use tauri_plugin_log::log;
15use tauri_plugin_notification::NotificationExt;
16
17pub const NOTIFICATIONS_UNREAD_COUNT_EVENT: &str = "notifications:unread-count";
18
19const MAIN_WINDOW_LABEL: &str = "main";
20const POLL_INITIAL_DELAY: Duration = Duration::from_secs(5);
21const POLL_INTERVAL: Duration = Duration::from_secs(30);
22const MAX_SYSTEM_NOTIFICATIONS: usize = 3;
23const MAX_TRACKED_NOTIFICATION_URIS: usize = 128;
24
25async fn get_session(state: &AppState) -> Result<Arc<LazuriteOAuthSession>> {
26 let did = state
27 .active_session
28 .read()
29 .map_err(|error| {
30 log::error!("active_session poisoned: {error}");
31 AppError::StatePoisoned("active_session")
32 })?
33 .as_ref()
34 .ok_or_else(|| {
35 log::error!("no active account");
36 AppError::Validation("no active account".into())
37 })?
38 .did
39 .clone();
40
41 state
42 .sessions
43 .read()
44 .map_err(|error| {
45 log::error!("sessions poisoned: {error}");
46 AppError::StatePoisoned("sessions")
47 })?
48 .get(&did)
49 .cloned()
50 .ok_or_else(|| {
51 log::error!("session not found for active account");
52 AppError::Validation("session not found for active account".into())
53 })
54}
55
56fn active_session_did(state: &AppState) -> Result<Option<String>> {
57 Ok(state
58 .active_session
59 .read()
60 .map_err(|error| {
61 log::error!("active_session poisoned: {error}");
62 AppError::StatePoisoned("active_session")
63 })?
64 .as_ref()
65 .map(|session| session.did.clone()))
66}
67
68pub async fn list_notifications(cursor: Option<String>, state: &AppState) -> Result<serde_json::Value> {
69 let session = get_session(state).await?;
70 let mut req = ListNotifications::new().limit(50i64);
71 if let Some(c) = &cursor {
72 req = req.cursor(Some(c.as_str().into()));
73 }
74
75 let output = session
76 .send(req.build())
77 .await
78 .map_err(|error| {
79 log_error_chain("listNotifications error", &error);
80 AppError::validation("listNotifications error")
81 })?
82 .into_output()
83 .map_err(|error| {
84 log_error_chain("listNotifications output error", &error);
85 AppError::validation("listNotifications output error")
86 })?;
87
88 serde_json::to_value(&output).map_err(AppError::from)
89}
90
91pub async fn update_seen(state: &AppState) -> Result<()> {
92 let session = get_session(state).await?;
93
94 let response = session
95 .send(UpdateSeen::new().seen_at(Datetime::now()).build())
96 .await
97 .map_err(|error| {
98 log_error_chain("updateSeen error", &error);
99 AppError::validation("updateSeen error")
100 })?;
101
102 if response.status().is_success() {
103 return Ok(());
104 }
105
106 response.into_output().map_err(|error| {
107 log_error_chain("updateSeen output error", &error);
108 AppError::validation("updateSeen output error")
109 })?;
110
111 Ok(())
112}
113
114pub async fn get_unread_count(state: &AppState) -> Result<i64> {
115 let session = get_session(state).await?;
116
117 let output = session
118 .send(GetUnreadCount::new().build())
119 .await
120 .map_err(|error| {
121 log_warn_chain("getUnreadCount error", &error);
122 AppError::Validation("getUnreadCount error".into())
123 })?
124 .into_output()
125 .map_err(|error| {
126 log_warn_chain("getUnreadCount output error", &error);
127 AppError::Validation("getUnreadCount output error".into())
128 })?;
129
130 Ok(output.count)
131}
132
133/// Returns a human-readable system notification body for a mention notification,
134/// or `None` if the reason is not one that warrants a system notification.
135pub fn mention_notification_body(reason: &str, handle: &str) -> Option<String> {
136 match reason {
137 "mention" => Some(format!("@{handle} mentioned you")),
138 "reply" => Some(format!("@{handle} replied to you")),
139 "quote" => Some(format!("@{handle} quoted your post")),
140 _ => None,
141 }
142}
143
144fn is_main_window_focused(app: &AppHandle) -> bool {
145 app.get_webview_window(MAIN_WINDOW_LABEL)
146 .and_then(|w| w.is_focused().ok())
147 .unwrap_or(false)
148}
149
150fn load_notification_settings(state: &AppState) -> AppSettings {
151 match settings::get_settings(state) {
152 Ok(settings) => settings,
153 Err(error) => {
154 log::warn!("failed to load notification settings, using defaults: {error}");
155 AppSettings::default()
156 }
157 }
158}
159
160pub fn clear_unread_badge(app: &AppHandle) {
161 if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
162 if let Err(error) = window.set_badge_count(None) {
163 log::debug!("failed to clear unread badge: {error}");
164 }
165 }
166}
167
168fn sync_unread_badge(app: &AppHandle, badge_enabled: bool, count: i64) {
169 let badge_count = if badge_enabled && count > 0 { Some(count) } else { None };
170
171 if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
172 if let Err(error) = window.set_badge_count(badge_count) {
173 log::debug!("failed to update unread badge: {error}");
174 }
175 }
176}
177
178fn collect_new_mention_notifications(
179 notifications_value: &serde_json::Value, notified_uris: &VecDeque<String>,
180) -> Vec<(String, String)> {
181 let Some(notifications) = notifications_value.get("notifications").and_then(|v| v.as_array()) else {
182 return Vec::new();
183 };
184
185 let mut new_mentions = Vec::new();
186
187 for notification in notifications {
188 if new_mentions.len() >= MAX_SYSTEM_NOTIFICATIONS {
189 break;
190 }
191
192 let is_read = notification.get("isRead").and_then(|v| v.as_bool()).unwrap_or(true);
193 if is_read {
194 continue;
195 }
196
197 let reason = notification.get("reason").and_then(|v| v.as_str()).unwrap_or("");
198 let handle = notification
199 .get("author")
200 .and_then(|v| v.get("handle"))
201 .and_then(|v| v.as_str())
202 .unwrap_or("someone");
203
204 let Some(body) = mention_notification_body(reason, handle) else {
205 continue;
206 };
207
208 let Some(uri) = notification.get("uri").and_then(|v| v.as_str()) else {
209 continue;
210 };
211
212 if notified_uris.iter().any(|existing| existing == uri) {
213 continue;
214 }
215
216 new_mentions.push((uri.to_owned(), body));
217 }
218
219 new_mentions
220}
221
222fn remember_notified_uri(notified_uris: &mut VecDeque<String>, uri: String) {
223 if notified_uris.iter().any(|existing| existing == &uri) {
224 return;
225 }
226
227 notified_uris.push_front(uri);
228
229 while notified_uris.len() > MAX_TRACKED_NOTIFICATION_URIS {
230 notified_uris.pop_back();
231 }
232}
233
234fn send_mention_system_notifications(
235 app: &AppHandle, notifications_value: &serde_json::Value, notified_uris: &mut VecDeque<String>,
236) {
237 for (uri, body) in collect_new_mention_notifications(notifications_value, notified_uris) {
238 match app.notification().builder().title("Lazurite").body(body).show() {
239 Ok(_) => remember_notified_uri(notified_uris, uri),
240 Err(error) => log::warn!("failed to show system notification: {error}"),
241 }
242 }
243}
244
245/// Spawns a background task that polls for new notifications every 30 seconds
246/// and emits a `notifications:unread-count` event when the count changes.
247/// System notifications are shown for new mentions when the app is not focused.
248pub fn spawn_notification_poll_task(app: AppHandle) {
249 tauri::async_runtime::spawn(async move {
250 tokio::time::sleep(POLL_INITIAL_DELAY).await;
251
252 let mut last_count: i64 = -1;
253 let mut last_did: Option<String> = None;
254 let mut notified_uris = VecDeque::new();
255
256 loop {
257 let state = app.state::<AppState>();
258
259 let active_did = match active_session_did(&state) {
260 Ok(value) => value,
261 Err(error) => {
262 log::warn!("notification poll failed to read active session: {error}");
263 tokio::time::sleep(POLL_INTERVAL).await;
264 continue;
265 }
266 };
267
268 if active_did.is_none() {
269 last_count = -1;
270 last_did = None;
271 notified_uris.clear();
272 clear_unread_badge(&app);
273 tokio::time::sleep(POLL_INTERVAL).await;
274 continue;
275 }
276
277 if active_did != last_did {
278 last_count = -1;
279 last_did = active_did;
280 notified_uris.clear();
281 }
282
283 let notification_settings = load_notification_settings(&state);
284
285 match get_unread_count(&state).await {
286 Ok(count) => {
287 sync_unread_badge(&app, notification_settings.notifications_badge, count);
288
289 if last_count >= 0 && count > last_count {
290 log::info!("new notifications: unread count increased from {last_count} to {count}");
291 let _ = app.emit(NOTIFICATIONS_UNREAD_COUNT_EVENT, count);
292
293 if notification_settings.notifications_desktop && !is_main_window_focused(&app) {
294 if let Ok(value) = list_notifications(None, &state).await {
295 send_mention_system_notifications(&app, &value, &mut notified_uris);
296 }
297 }
298 } else if last_count != count {
299 let _ = app.emit(NOTIFICATIONS_UNREAD_COUNT_EVENT, count);
300 }
301
302 last_count = count;
303 }
304 Err(_) => {
305 log::debug!("notification poll skipped");
306 }
307 }
308
309 tokio::time::sleep(POLL_INTERVAL).await;
310 }
311 });
312}
313
314#[cfg(test)]
315mod tests {
316 use super::{collect_new_mention_notifications, mention_notification_body, remember_notified_uri};
317 use serde_json::json;
318 use std::collections::VecDeque;
319
320 #[test]
321 fn mention_reason_formats_correctly() {
322 let body = mention_notification_body("mention", "alice.bsky.social").unwrap();
323 assert_eq!(body, "@alice.bsky.social mentioned you");
324 }
325
326 #[test]
327 fn reply_reason_formats_correctly() {
328 let body = mention_notification_body("reply", "bob.bsky.social").unwrap();
329 assert_eq!(body, "@bob.bsky.social replied to you");
330 }
331
332 #[test]
333 fn quote_reason_formats_correctly() {
334 let body = mention_notification_body("quote", "carol.bsky.social").unwrap();
335 assert_eq!(body, "@carol.bsky.social quoted your post");
336 }
337
338 #[test]
339 fn non_mention_reasons_return_none() {
340 assert!(mention_notification_body("like", "alice.bsky.social").is_none());
341 assert!(mention_notification_body("repost", "alice.bsky.social").is_none());
342 assert!(mention_notification_body("follow", "alice.bsky.social").is_none());
343 assert!(mention_notification_body("starterpack-joined", "alice.bsky.social").is_none());
344 }
345
346 #[test]
347 fn only_new_mention_notifications_are_collected() {
348 let mut notified_uris = VecDeque::new();
349 remember_notified_uri(&mut notified_uris, "at://notification/1".into());
350
351 let notifications = json!({
352 "notifications": [
353 {
354 "author": { "handle": "alice.bsky.social" },
355 "isRead": false,
356 "reason": "mention",
357 "uri": "at://notification/1"
358 },
359 {
360 "author": { "handle": "bob.bsky.social" },
361 "isRead": false,
362 "reason": "reply",
363 "uri": "at://notification/2"
364 },
365 {
366 "author": { "handle": "carol.bsky.social" },
367 "isRead": true,
368 "reason": "quote",
369 "uri": "at://notification/3"
370 }
371 ]
372 });
373
374 let new_mentions = collect_new_mention_notifications(¬ifications, ¬ified_uris);
375
376 assert_eq!(
377 new_mentions,
378 vec![("at://notification/2".into(), "@bob.bsky.social replied to you".into())]
379 );
380 }
381
382 #[test]
383 fn remembering_notified_uris_avoids_duplicates() {
384 let mut notified_uris = VecDeque::new();
385
386 remember_notified_uri(&mut notified_uris, "at://notification/1".into());
387 remember_notified_uri(&mut notified_uris, "at://notification/1".into());
388
389 assert_eq!(notified_uris.len(), 1);
390 assert_eq!(notified_uris.front().map(String::as_str), Some("at://notification/1"));
391 }
392}