BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 392 lines 13 kB view raw
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(&notifications, &notified_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}