use crate::notification::{Notification, Urgency};
use gtk4::gio;
use std::collections::HashSet;
use std::path::PathBuf;
pub struct AppGroup {
pub app_name: String,
pub app_icon: String,
pub notifications: Vec<Notification>,
}
pub struct NotificationState {
pub history: Vec<Notification>,
next_id: u32,
pub dnd: bool,
pub dnd_expires: Option<std::time::SystemTime>,
pub app_dirs: Vec<PathBuf>,
pub active_popups: HashSet<u32>,
pub max_history: usize,
pub dbus_connection: Option<gio::DBusConnection>,
}
impl NotificationState {
pub fn new(app_dirs: Vec<PathBuf>, max_history: usize) -> Self {
Self {
history: Vec::new(),
next_id: 1,
dnd: false,
dnd_expires: None,
app_dirs,
active_popups: HashSet::new(),
max_history,
dbus_connection: None,
}
}
pub fn add(&mut self, mut notif: Notification) -> u32 {
let id = self.next_id;
self.next_id = self.next_id.wrapping_add(1).max(1);
notif.id = id;
self.history.insert(0, notif);
self.trim_history();
id
}
pub fn replace(&mut self, replaces_id: u32, mut notif: Notification) -> u32 {
if replaces_id > 0
&& let Some(existing) = self.history.iter_mut().find(|n| n.id == replaces_id)
{
notif.id = replaces_id;
*existing = notif;
return replaces_id;
}
self.add(notif)
}
pub fn remove(&mut self, id: u32) -> Option<Notification> {
if let Some(pos) = self.history.iter().position(|n| n.id == id) {
self.active_popups.remove(&id);
Some(self.history.remove(pos))
} else {
None
}
}
pub fn dismiss_app(&mut self, app_name: &str) {
self.history.retain(|n| n.app_name != app_name);
}
pub fn dismiss_all(&mut self) {
self.history.clear();
self.active_popups.clear();
}
pub fn mark_read(&mut self, id: u32) {
if let Some(notif) = self.history.iter_mut().find(|n| n.id == id) {
notif.read = true;
}
}
pub fn unread_count(&self) -> usize {
self.history.iter().filter(|n| !n.read).count()
}
pub fn should_show_popup(&self, urgency: Urgency) -> bool {
if !self.dnd {
return true;
}
urgency == Urgency::Critical
}
pub fn grouped_by_app(&self) -> Vec<AppGroup> {
let mut index: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
let mut groups: Vec<AppGroup> = Vec::new();
for notif in &self.history {
if let Some(&idx) = index.get(notif.app_name.as_str()) {
groups[idx].notifications.push(notif.clone());
} else {
index.insert(¬if.app_name, groups.len());
groups.push(AppGroup {
app_name: notif.app_name.clone(),
app_icon: notif.app_icon.clone(),
notifications: vec![notif.clone()],
});
}
}
groups
}
fn trim_history(&mut self) {
if self.history.len() > self.max_history {
self.history.truncate(self.max_history);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::SystemTime;
fn test_notif(app: &str, summary: &str) -> Notification {
Notification {
id: 0,
app_name: app.into(),
app_icon: String::new(),
summary: summary.into(),
body: String::new(),
actions: Vec::new(),
urgency: Urgency::Normal,
timeout_ms: -1,
timestamp: SystemTime::now(),
read: false,
desktop_entry: None,
}
}
#[test]
fn add_assigns_sequential_ids() {
let mut state = NotificationState::new(vec![], 100);
let id1 = state.add(test_notif("app1", "first"));
let id2 = state.add(test_notif("app2", "second"));
assert_eq!(id1, 1);
assert_eq!(id2, 2);
}
#[test]
fn replace_reuses_id() {
let mut state = NotificationState::new(vec![], 100);
let id = state.add(test_notif("app", "original"));
let replaced = state.replace(id, test_notif("app", "updated"));
assert_eq!(replaced, id);
assert_eq!(state.history.len(), 1);
assert_eq!(state.history[0].summary, "updated");
}
#[test]
fn dismiss_app_removes_only_matching() {
let mut state = NotificationState::new(vec![], 100);
state.add(test_notif("firefox", "tab1"));
state.add(test_notif("discord", "msg1"));
state.add(test_notif("firefox", "tab2"));
state.dismiss_app("firefox");
assert_eq!(state.history.len(), 1);
assert_eq!(state.history[0].app_name, "discord");
}
#[test]
fn unread_count() {
let mut state = NotificationState::new(vec![], 100);
let id1 = state.add(test_notif("app", "one"));
state.add(test_notif("app", "two"));
assert_eq!(state.unread_count(), 2);
state.mark_read(id1);
assert_eq!(state.unread_count(), 1);
}
#[test]
fn grouped_by_app_groups_correctly() {
let mut state = NotificationState::new(vec![], 100);
state.add(test_notif("firefox", "tab1"));
state.add(test_notif("discord", "msg"));
state.add(test_notif("firefox", "tab2"));
let groups = state.grouped_by_app();
assert_eq!(groups.len(), 2);
}
#[test]
fn dnd_suppresses_normal_popups() {
let mut state = NotificationState::new(vec![], 100);
state.dnd = true;
assert!(!state.should_show_popup(Urgency::Normal));
assert!(!state.should_show_popup(Urgency::Low));
assert!(state.should_show_popup(Urgency::Critical));
}
#[test]
fn trim_history_caps_at_max() {
let mut state = NotificationState::new(vec![], 3);
state.add(test_notif("app", "1"));
state.add(test_notif("app", "2"));
state.add(test_notif("app", "3"));
state.add(test_notif("app", "4"));
assert_eq!(state.history.len(), 3);
}
#[test]
fn id_wrapping_at_max() {
assert_eq!(u32::MAX.wrapping_add(1).max(1), 1);
assert_eq!(0u32.wrapping_add(1).max(1), 1);
assert_eq!(41u32.wrapping_add(1).max(1), 42);
let mut state = NotificationState::new(vec![], 100);
let id1 = state.add(test_notif("app", "a"));
let id2 = state.add(test_notif("app", "b"));
assert_eq!(id1, 1);
assert_eq!(id2, 2);
assert!(id1 >= 1);
assert!(id2 >= 1);
}
#[test]
fn remove_nonexistent_returns_none() {
let mut state = NotificationState::new(vec![], 100);
assert!(state.remove(999).is_none());
}
#[test]
fn dismiss_all_clears_everything() {
let mut state = NotificationState::new(vec![], 100);
let id1 = state.add(test_notif("app1", "one"));
let id2 = state.add(test_notif("app2", "two"));
state.add(test_notif("app3", "three"));
state.active_popups.insert(id1);
state.active_popups.insert(id2);
state.dismiss_all();
assert!(state.history.is_empty());
assert!(state.active_popups.is_empty());
}
#[test]
fn mark_read_nonexistent_no_panic() {
let mut state = NotificationState::new(vec![], 100);
state.add(test_notif("app", "exists"));
state.mark_read(999);
assert_eq!(state.unread_count(), 1);
}
#[test]
fn active_popups_tracking() {
let mut state = NotificationState::new(vec![], 100);
state.active_popups.insert(42);
assert!(state.active_popups.contains(&42));
state.active_popups.remove(&42);
assert!(!state.active_popups.contains(&42));
}
#[test]
fn empty_state_operations() {
let mut state = NotificationState::new(vec![], 100);
assert_eq!(state.unread_count(), 0);
assert!(state.grouped_by_app().is_empty());
state.dismiss_all();
assert!(state.history.is_empty());
}
#[test]
fn replace_nonexistent_creates_new() {
let mut state = NotificationState::new(vec![], 100);
let id = state.replace(999, test_notif("app", "new"));
assert_eq!(id, 1);
assert_eq!(state.history.len(), 1);
assert_eq!(state.history[0].summary, "new");
}
#[test]
fn history_ordering_newest_first() {
let mut state = NotificationState::new(vec![], 100);
state.add(test_notif("app", "first"));
state.add(test_notif("app", "second"));
state.add(test_notif("app", "third"));
assert_eq!(state.history[0].summary, "third");
assert_eq!(state.history[1].summary, "second");
assert_eq!(state.history[2].summary, "first");
}
}