use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Mutex; use tauri::{AppHandle, Emitter, Manager, Runtime}; use tauri_plugin_notification::NotificationExt; use crate::commands::AppState; use crate::config::NotificationLevel; use crate::patterns::MatchedEvent; static PENDING: Mutex> = Mutex::new(vec![]); // True while ChatPanel is mounted and listening for live events. // When true, dispatch skips PENDING so events are not shown twice on re-open. static PANEL_OPEN: AtomicBool = AtomicBool::new(false); pub fn set_panel_open(open: bool) { PANEL_OPEN.store(open, Ordering::Relaxed); } pub fn dispatch(app: &AppHandle, event: MatchedEvent) { let level = app .state::() .config .lock() .map(|c| c.display.notification_level.clone()) .unwrap_or_default(); if !PANEL_OPEN.load(Ordering::Relaxed) { match PENDING.lock() { Ok(mut pending) => pending.push(event.clone()), Err(e) => log::warn!("notify: pending queue lock poisoned — event dropped: {e}"), } } match level { NotificationLevel::Off => {} NotificationLevel::BadgeOnly => { crate::tray::badge_on(app); } NotificationLevel::BadgeAndToast => { crate::tray::badge_on(app); send_toast(app, &event); } } let _ = app.emit("robin:event", &event); } fn send_toast(app: &AppHandle, event: &MatchedEvent) { let _ = app .notification() .builder() .title(&event.title) .body(&event.body) .show(); } pub fn take_pending() -> Vec { PENDING .lock() .map(|mut v| std::mem::take(&mut *v)) .unwrap_or_default() } #[cfg(test)] mod tests { use super::*; fn make_event(id: &str) -> MatchedEvent { MatchedEvent { pattern_id: id.into(), title: "Title".into(), body: "Body".into(), severity: "warn".into(), timestamp: 0, } } #[test] fn take_pending_drains_queue() { // Ensure clean state — drain any events from other tests sharing the static let _ = take_pending(); if let Ok(mut q) = PENDING.lock() { q.push(make_event("a")); q.push(make_event("b")); } let first = take_pending(); assert_eq!(first.len(), 2); assert_eq!(first[0].pattern_id, "a"); let second = take_pending(); assert!(second.is_empty(), "queue must be empty after drain"); } }