306 lines
12 KiB
Rust
Executable File
306 lines
12 KiB
Rust
Executable File
//! 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<String>,
|
|
/// Only notify for these protocols (empty = all).
|
|
pub protocol_filter: Vec<ProtocolType>,
|
|
/// 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<String>,
|
|
/// Debounce tracker: source → last notification time.
|
|
last_notify: std::collections::HashMap<String, Instant>,
|
|
/// Channel to send notifications to the TUI/frontend.
|
|
tx: mpsc::Sender<Notification>,
|
|
}
|
|
|
|
impl NotifyEngine {
|
|
/// Create a new notification engine.
|
|
pub fn new(own_nick: &str, config: NotifyConfig, tx: mpsc::Sender<Notification>) -> Self {
|
|
let mut highlight_words: HashSet<String> = 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));
|
|
}
|
|
} |