//! Notification system — Phase 17. //! //! Provides desktop notifications, terminal bell, and highlight-based alerts. //! Uses the `notify-rust` pattern (or a simple fallback) for desktop notifications. //! All notifications are non-blocking and go through an mpsc channel. use crate::core::message::{ChatMessage, MessageKind}; use crate::core::protocol::ProtocolType; use std::collections::HashSet; use std::time::Instant; use tokio::sync::mpsc; use tracing::debug; /// A notification to be displayed to the user. #[derive(Debug, Clone)] pub struct Notification { /// Notification title (e.g. "IRC — #nirc"). pub title: String, /// Notification body (e.g. "bob: hello there"). pub body: String, /// Priority determines the delivery method. pub urgency: NotificationUrgency, /// The protocol that generated this notification. pub protocol: ProtocolType, /// Timestamp when the notification was created. pub created_at: Instant, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NotificationUrgency { /// Normal message — can be batched/delayed. Low, /// Highlight or direct message — show immediately. Normal, /// Error or critical event — show immediately with emphasis. Critical, } /// Configuration for the notification system. #[derive(Debug, Clone)] pub struct NotifyConfig { /// Enable desktop notifications (via D-Bus / terminal fallback). pub desktop_enabled: bool, /// Enable terminal bell on highlights. pub bell_enabled: bool, /// Minimum interval between repeated notifications from the same source (ms). pub debounce_ms: u64, /// Words that trigger highlight notifications (in addition to own nick). pub extra_highlight_words: Vec, /// Only notify for these protocols (empty = all). pub protocol_filter: Vec, /// Maximum notification body length. pub max_body_length: usize, /// Suppress notifications when the terminal is focused. pub suppress_when_focused: bool, } impl Default for NotifyConfig { fn default() -> Self { Self { desktop_enabled: true, bell_enabled: true, debounce_ms: 2000, extra_highlight_words: Vec::new(), protocol_filter: Vec::new(), max_body_length: 200, suppress_when_focused: false, } } } /// The notification engine. Evaluates messages and emits notifications. pub struct NotifyEngine { config: NotifyConfig, /// Own nickname for highlight detection. own_nick: String, /// Combined highlight words. highlight_words: HashSet, /// Debounce tracker: source → last notification time. last_notify: std::collections::HashMap, /// Channel to send notifications to the TUI/frontend. tx: mpsc::Sender, } impl NotifyEngine { /// Create a new notification engine. pub fn new(own_nick: &str, config: NotifyConfig, tx: mpsc::Sender) -> Self { let mut highlight_words: HashSet = config.extra_highlight_words.iter().cloned().collect(); highlight_words.insert(own_nick.to_lowercase()); Self { config, own_nick: own_nick.to_lowercase(), highlight_words, last_notify: std::collections::HashMap::new(), tx } } /// Evaluate a chat message and potentially emit a notification. /// /// Returns true if a notification was sent. pub fn on_message(&mut self, msg: &ChatMessage) -> bool { // Ignore own messages. if msg.is_own { return false; } // Protocol filter. if !self.config.protocol_filter.is_empty() && !self.config.protocol_filter.contains(&msg.protocol) { return false; } // Determine if this message warrants a notification. let (should_notify, urgency) = match &msg.kind { MessageKind::Text => { if self.is_highlight(msg) { (true, NotificationUrgency::Normal) } else { // Only notify for PMs and errors in non-highlight text. (false, NotificationUrgency::Low) } } MessageKind::Private => (true, NotificationUrgency::Normal), MessageKind::Error => (true, NotificationUrgency::Critical), MessageKind::FileTransfer { filename: _, size_bytes: _, .. } => { (true, NotificationUrgency::Normal) } MessageKind::Action | MessageKind::Notice => { if self.is_highlight(msg) { (true, NotificationUrgency::Normal) } else { (false, NotificationUrgency::Low) } } }; if !should_notify { return false; } // Debounce: don't re-notify the same source too quickly. let debounce_key = format!("{}:{}", msg.protocol.tag(), msg.source); if let Some(last) = self.last_notify.get(&debounce_key) { if last.elapsed().as_millis() < self.config.debounce_ms as u128 { return false; } } self.last_notify.insert(debounce_key, Instant::now()); // Build notification. let title = match &msg.kind { MessageKind::Private => format!("{} — PM from {}", msg.protocol.label(), msg.sender), MessageKind::FileTransfer { filename, .. } => format!("{} — File: {}", msg.protocol.label(), filename), MessageKind::Error => format!("{} — Error", msg.protocol.label()), _ => format!("{} — {}", msg.protocol.label(), msg.source), }; let body = match &msg.kind { MessageKind::FileTransfer { filename, size_bytes, .. } => { let sz = if *size_bytes > 1_048_576 { format!("{:.1} MB", *size_bytes as f64 / 1_048_576.0) } else { format!("{} KB", *size_bytes / 1024) }; format!("{} offered {} ({}). Use /acceptfile to receive.", msg.sender, filename, sz) } _ => format!("{}: {}", msg.sender, msg.body), }; // Truncate body. let body = if body.len() > self.config.max_body_length { format!("{}...", &body[..self.config.max_body_length.saturating_sub(3)]) } else { body }; let notification = Notification { title, body, urgency, protocol: msg.protocol, created_at: Instant::now(), }; // Terminal bell for highlights. if self.config.bell_enabled && matches!(urgency, NotificationUrgency::Normal | NotificationUrgency::Critical) { // Use \x07 (BEL) which crossterm will handle. // The TUI layer is responsible for actually emitting the bell character. debug!("Bell triggered for highlight"); } // Desktop notification (non-blocking send). if self.config.desktop_enabled { let _ = self.tx.try_send(notification); return true; } false } /// Check if a message contains a highlight word. fn is_highlight(&self, msg: &ChatMessage) -> bool { let body_lower = msg.body.to_lowercase(); self.highlight_words.iter().any(|w| { // Match whole words only. for segment in body_lower.split(|c: char| !c.is_alphanumeric() && c != '_') { if segment == w { return true; } } false }) } /// Update the own nickname (e.g. after NICK change). pub fn set_nick(&mut self, nick: &str) { self.own_nick = nick.to_lowercase(); self.highlight_words.insert(nick.to_lowercase()); } /// Add an extra highlight word. pub fn add_highlight_word(&mut self, word: &str) { self.highlight_words.insert(word.to_lowercase()); } /// Remove a highlight word (except own nick). pub fn remove_highlight_word(&mut self, word: &str) { if word.to_lowercase() != self.own_nick { self.highlight_words.remove(&word.to_lowercase()); } } } /// Simple in-process notification display (for terminal/TUI integration). /// In a GUI context, this would use the platform's notification daemon. pub fn display_terminal_notification(notif: &Notification) { match notif.urgency { NotificationUrgency::Critical => { eprintln!("\x07[!!] {} — {}", notif.title, notif.body); } NotificationUrgency::Normal => { eprintln!("\x07[*] {} — {}", notif.title, notif.body); } NotificationUrgency::Low => { debug!(title = %notif.title, body = %notif.body, "Low-priority notification suppressed"); } } } #[cfg(test)] mod tests { use super::*; use crate::core::protocol::ProtocolType; fn make_msg(kind: MessageKind, sender: &str, body: &str) -> ChatMessage { ChatMessage { id: "test".into(), protocol: ProtocolType::Irc, kind, source: "#test".into(), sender: sender.into(), body: body.into(), timestamp: chrono::Utc::now(), is_own: false, remote_ts: false } } #[test] fn highlight_own_nick() { let (tx, mut rx) = mpsc::channel(8); let mut engine = NotifyEngine::new("testuser", NotifyConfig::default(), tx); let msg = make_msg(MessageKind::Text, "bob", "hey testuser are you there?"); assert!(engine.on_message(&msg)); let notif = rx.blocking_recv().unwrap(); assert!(notif.title.contains("#test")); } #[test] fn no_highlight_random() { let (tx, _rx) = mpsc::channel(8); let mut engine = NotifyEngine::new("testuser", NotifyConfig::default(), tx); let msg = make_msg(MessageKind::Text, "bob", "hello everyone"); assert!(!engine.on_message(&msg)); } #[test] fn pm_always_notifies() { let (tx, mut rx) = mpsc::channel(8); let mut engine = NotifyEngine::new("testuser", NotifyConfig::default(), tx); let msg = ChatMessage { id: "t".into(), protocol: ProtocolType::Irc, kind: MessageKind::Private, source: "bob".into(), sender: "bob".into(), body: "secret".into(), timestamp: chrono::Utc::now(), is_own: false, remote_ts: false }; assert!(engine.on_message(&msg)); let notif = rx.blocking_recv().unwrap(); assert!(notif.title.contains("PM")); } #[test] fn error_notifies() { let (tx, mut rx) = mpsc::channel(8); let mut engine = NotifyEngine::new("testuser", NotifyConfig::default(), tx); let msg = make_msg(MessageKind::Error, "", "connection reset"); assert!(engine.on_message(&msg)); let notif = rx.blocking_recv().unwrap(); assert_eq!(notif.urgency, NotificationUrgency::Critical); } #[test] fn debounce_prevents_spam() { let (tx, _rx) = mpsc::channel(8); let cfg = NotifyConfig { debounce_ms: 5000, ..Default::default() }; let mut engine = NotifyEngine::new("testuser", cfg, tx); let msg1 = make_msg(MessageKind::Text, "bob", "testuser hello"); let msg2 = make_msg(MessageKind::Text, "bob", "testuser again"); assert!(engine.on_message(&msg1)); assert!(!engine.on_message(&msg2)); // Debounced } #[test] fn extra_highlight_word() { let (tx, mut rx) = mpsc::channel(8); let cfg = NotifyConfig { extra_highlight_words: vec!["urgent".into()], ..Default::default() }; let mut engine = NotifyEngine::new("testuser", cfg, tx); let msg = make_msg(MessageKind::Text, "bob", "this is urgent news"); assert!(engine.on_message(&msg)); } }