use std::collections::HashMap; use std::time::{Duration, Instant}; use oneclient_core::notification::{ GroupedProgressEvent, Notification, NotificationLevel, PromptKind, UserChoice, }; use oneclient_db::models::ClusterId; use tokio::sync::oneshot; use uuid::Uuid; use crate::components::IconType; pub const MESSAGE_TOAST_TTL: Duration = Duration::from_secs(5); #[derive(Clone, Debug, PartialEq)] pub struct NotificationAction { pub label: String, pub kind: NotificationActionKind, } #[derive(Clone, Debug, PartialEq)] pub enum NotificationActionKind { OpenClusterUpdate(ClusterUpdateSummary), } #[derive(Clone, Debug, PartialEq)] pub struct ClusterUpdateSummary { pub cluster_id: ClusterId, pub cluster_name: String, pub updated: Vec, pub added: Vec, pub removed: Vec, } impl ClusterUpdateSummary { pub fn total(&self) -> usize { self.updated.len() + self.added.len() + self.removed.len() } } #[derive(Clone, Debug, PartialEq)] pub struct InboxEntry { pub level: NotificationLevel, pub id: u64, pub title: String, pub body: String, pub icon: Option, pub progress: Option<(u64, u64)>, pub is_loading: bool, pub read: bool, pub created_at: Instant, pub actions: Vec, pub tasks: Vec, } #[derive(Clone, Debug, PartialEq)] pub struct TaskView { pub label: String, pub phase: &'static str, pub current: u64, pub total: u64, } #[derive(Clone, Debug, PartialEq)] pub struct NotificationSpec { pub title: String, pub body: String, pub level: NotificationLevel, pub icon: Option, pub progress: Option<(u64, u64)>, pub actions: Vec, } impl InboxEntry { pub fn dismissable(&self) -> bool { !self.is_loading } pub fn click_dismissable(&self) -> bool { self.dismissable() && self.actions.is_empty() } } #[derive(Clone, Debug)] pub struct ActiveToast { #[allow(dead_code)] pub toast_id: u64, pub entry_id: u64, } #[derive(Debug)] pub struct PendingPrompt { pub title: String, pub question: String, pub kind: PromptKind, pub reply_tx: Option>, } #[derive(Clone, Debug)] pub struct PendingPromptView { pub title: String, pub question: String, pub kind: PromptKind, } #[derive(Clone, Debug)] #[allow(dead_code)] pub struct ToastDismissTimer { pub toast_id: u64, pub entry_id: u64, pub after: Duration, } #[derive(Clone, Debug, Default)] pub struct NotificationSnapshot { pub inbox: Vec, pub center_open: bool, pub pending_prompt: Option, pub cluster_update: Option, pub active_toast_entry_ids: Vec, } impl NotificationSnapshot { pub fn unread_count(&self) -> usize { NotificationState::unread_count(&self.inbox) } } #[derive(Default)] pub struct NotificationState { next_id: u64, active_toasts: Vec, progress_entries: HashMap, grouped_entries: HashMap, grouped_tasks: HashMap, pending_timers: Vec, cluster_update: Option, } #[derive(Default)] struct GroupedTasks { title: String, children: HashMap, seen: u64, done: u64, done_units: u64, current: Option, } impl GroupedTasks { fn task_list(&self) -> Vec { let mut tasks: Vec = self.children.values().cloned().collect(); tasks.sort_by(|a, b| a.label.cmp(&b.label)); tasks } fn describe(task: &TaskView) -> String { if task.phase == "Verifying" { format!("Verifying {} checksum", task.label) } else { format!("{} {}", task.phase, task.label) } } } impl NotificationState { pub fn snapshot( &self, inbox: &[InboxEntry], center_open: bool, pending_prompt: Option, ) -> NotificationSnapshot { NotificationSnapshot { inbox: inbox.to_vec(), center_open, pending_prompt, cluster_update: self.cluster_update.clone(), active_toast_entry_ids: self.active_toasts.iter().map(|t| t.entry_id).collect(), } } pub fn open_cluster_update(&mut self, summary: ClusterUpdateSummary) { self.cluster_update = Some(summary); } pub fn close_cluster_update(&mut self) { self.cluster_update = None; } pub fn unread_count(inbox: &[InboxEntry]) -> usize { inbox.iter().filter(|entry| !entry.read).count() } pub fn dispatch( &mut self, inbox: &mut Vec, notification: Notification, ) -> ( NotificationSnapshot, Vec, Option, ) { self.pending_timers.clear(); match notification { Notification::Message { title, body, level } => { let entry_id = self.push_inbox(inbox, title, body, level, None, false); self.push_ephemeral_toast(entry_id, MESSAGE_TOAST_TTL); } Notification::Progress { id, label, current, total, } => { self.handle_progress(inbox, id, label, current, total); } Notification::GroupedProgress(event) => { self.handle_grouped_progress(inbox, event); } Notification::InvalidateClusters => {} Notification::InvalidateJava => {} Notification::SyncComplete => {} Notification::MicrosoftLoginStatus(_) => {} Notification::GameStage { .. } | Notification::GameLog { .. } | Notification::GameFailed { .. } => {} Notification::Prompt { title, question, kind, reply_tx, } => { let entry_id = self.push_inbox( inbox, title.clone(), question.clone(), NotificationLevel::Info, None, false, ); self.push_ephemeral_toast(entry_id, MESSAGE_TOAST_TTL); let pending_prompt = Some(PendingPrompt { title: title.clone(), question: question.clone(), kind, reply_tx: Some(reply_tx), }); let pending_view = Some(PendingPromptView { title, question, kind, }); let timers = std::mem::take(&mut self.pending_timers); return ( self.snapshot(inbox, false, pending_view), timers, pending_prompt, ); } } let timers = std::mem::take(&mut self.pending_timers); (self.snapshot(inbox, false, None), timers, None) } pub fn toggle_center(&mut self, _inbox: &mut [InboxEntry], center_open: bool) -> bool { let next = !center_open; if next { self.active_toasts.clear(); self.pending_timers.clear(); } next } pub fn clear_inbox(&mut self) { self.progress_entries.clear(); self.grouped_entries.clear(); self.grouped_tasks.clear(); self.active_toasts.clear(); self.pending_timers.clear(); } pub fn dismiss_toast(&mut self, inbox: &mut Vec, entry_id: u64) { let Some(pos) = self .active_toasts .iter() .position(|t| t.entry_id == entry_id) else { return; }; let entry = inbox.iter().find(|e| e.id == entry_id); if entry.is_some_and(|e| !e.dismissable()) { return; } let has_progress = entry.is_some_and(|e| e.progress.is_some()); self.active_toasts.remove(pos); if !has_progress { self.forget_entry(inbox, entry_id); } } #[allow(dead_code)] pub fn dismiss_toast_timer(&mut self, _inbox: &[InboxEntry], toast_id: u64, _entry_id: u64) { self.active_toasts .retain(|toast| toast.toast_id != toast_id); } pub fn expire_toast(&mut self, _inbox: &[InboxEntry], entry_id: u64) { self.active_toasts .retain(|toast| toast.entry_id != entry_id); } pub fn mark_read(&mut self, inbox: &mut [InboxEntry], entry_id: u64) { if let Some(entry) = inbox.iter_mut().find(|e| e.id == entry_id) { entry.read = true; } } pub fn dismiss_notification(&mut self, inbox: &mut Vec, entry_id: u64) { if inbox .iter() .find(|e| e.id == entry_id) .is_some_and(|e| e.dismissable()) { self.forget_entry(inbox, entry_id); } } fn forget_entry(&mut self, inbox: &mut Vec, entry_id: u64) { inbox.retain(|e| e.id != entry_id); self.active_toasts.retain(|t| t.entry_id != entry_id); self.progress_entries.retain(|_, &mut v| v != entry_id); self.grouped_entries.retain(|_, &mut v| v != entry_id); } fn push_inbox( &mut self, inbox: &mut Vec, title: String, body: String, level: NotificationLevel, progress: Option<(u64, u64)>, is_loading: bool, ) -> u64 { let id = self.next_id; self.next_id += 1; inbox.insert( 0, InboxEntry { id, title, body, level, icon: None, progress, is_loading, read: false, created_at: Instant::now(), actions: Vec::new(), tasks: Vec::new(), }, ); id } pub fn push_custom(&mut self, inbox: &mut Vec, spec: NotificationSpec) -> u64 { let NotificationSpec { title, body, level, icon, progress, actions, } = spec; let is_loading = progress.is_some_and(|(current, total)| total == 0 || current < total); let id = self.next_id; self.next_id += 1; inbox.insert( 0, InboxEntry { id, title, body, level, icon, progress: progress.map(|(current, total)| (current, total.max(1))), is_loading, read: false, created_at: Instant::now(), actions, tasks: Vec::new(), }, ); if progress.is_some() { self.ensure_progress_toast(id); } else { self.push_ephemeral_toast(id, MESSAGE_TOAST_TTL); } id } fn update_inbox_entry( &self, inbox: &mut [InboxEntry], entry_id: u64, title: impl Into, body: impl Into, progress: Option<(u64, u64)>, is_loading: bool, ) { if let Some(entry) = inbox.iter_mut().find(|e| e.id == entry_id) { entry.title = title.into(); entry.body = body.into(); entry.progress = progress; entry.is_loading = is_loading; entry.read = false; } } fn push_ephemeral_toast(&mut self, entry_id: u64, ttl: Duration) { let toast_id = self.next_id; self.next_id += 1; self.active_toasts.push(ActiveToast { toast_id, entry_id }); self.pending_timers.push(ToastDismissTimer { toast_id, entry_id, after: ttl, }); } fn ensure_progress_toast(&mut self, entry_id: u64) { if self .active_toasts .iter() .any(|toast| toast.entry_id == entry_id) { return; } let toast_id = self.next_id; self.next_id += 1; self.active_toasts.push(ActiveToast { toast_id, entry_id }); } fn handle_progress( &mut self, inbox: &mut Vec, id: Uuid, label: String, current: u64, total: u64, ) { let done = total > 0 && current >= total; let body = progress_body(&label, current, total); let progress = Some((current, total.max(1))); let entry_id = if let Some(&entry_id) = self.progress_entries.get(&id) { entry_id } else { let entry_id = self.push_inbox( inbox, label.clone(), body.clone(), NotificationLevel::Info, progress, !done, ); self.progress_entries.insert(id, entry_id); entry_id }; self.update_inbox_entry(inbox, entry_id, label, body, progress, !done); self.ensure_progress_toast(entry_id); if done { self.progress_entries.remove(&id); } } fn handle_grouped_progress( &mut self, inbox: &mut Vec, event: GroupedProgressEvent, ) { match event { GroupedProgressEvent::Start { session_id, title } => { let entry_id = self.push_inbox( inbox, title.clone(), "Preparing...".to_string(), NotificationLevel::Info, None, true, ); self.grouped_entries.insert(session_id, entry_id); self.grouped_tasks.insert( session_id, GroupedTasks { title, ..Default::default() }, ); } GroupedProgressEvent::AddChild { session_id, child_id, label, total, } => { if let Some(group) = self.grouped_tasks.get_mut(&session_id) { let task = TaskView { label, phase: "Downloading", current: 0, total: total.max(1), }; group.current = Some(GroupedTasks::describe(&task)); group.children.insert(child_id, task); group.seen += 1; } self.refresh_grouped_entry(inbox, session_id); if let Some(&entry_id) = self.grouped_entries.get(&session_id) { self.ensure_progress_toast(entry_id); } } GroupedProgressEvent::UpdateChild { session_id, child_id, current, total, } => { if let Some(group) = self.grouped_tasks.get_mut(&session_id) && let Some(task) = group.children.get_mut(&child_id) { task.current = current; task.total = total.max(1); let desc = GroupedTasks::describe(task); group.current = Some(desc); } self.refresh_grouped_entry(inbox, session_id); } GroupedProgressEvent::SetChildPhase { session_id, child_id, phase, } => { if let Some(group) = self.grouped_tasks.get_mut(&session_id) && let Some(task) = group.children.get_mut(&child_id) { task.phase = phase.label(); let desc = GroupedTasks::describe(task); group.current = Some(desc); } self.refresh_grouped_entry(inbox, session_id); } GroupedProgressEvent::FinishChild { session_id, child_id, } => { if let Some(group) = self.grouped_tasks.get_mut(&session_id) && let Some(task) = group.children.remove(&child_id) { group.done += 1; group.done_units += task.total; } self.refresh_grouped_entry(inbox, session_id); } GroupedProgressEvent::End { session_id } => { self.grouped_tasks.remove(&session_id); if let Some(entry_id) = self.grouped_entries.remove(&session_id) { let had_progress = inbox .iter() .find(|e| e.id == entry_id) .is_some_and(|e| e.progress.is_some()); if !had_progress { self.forget_entry(inbox, entry_id); } else if let Some(entry) = inbox.iter_mut().find(|e| e.id == entry_id) { entry.body = "Complete".to_string(); entry.progress = Some((1, 1)); entry.is_loading = false; entry.tasks = Vec::new(); } } } } } fn refresh_grouped_entry(&self, inbox: &mut [InboxEntry], session_id: Uuid) { let Some(&entry_id) = self.grouped_entries.get(&session_id) else { return; }; let Some(group) = self.grouped_tasks.get(&session_id) else { return; }; let Some(entry) = inbox.iter_mut().find(|e| e.id == entry_id) else { return; }; let tasks = group.task_list(); entry.title = group.title.clone(); entry.body = group .current .clone() .unwrap_or_else(|| "Preparing...".to_string()); let completed: u64 = group.done_units + group .children .values() .map(|t| t.current.min(t.total)) .sum::(); let total: u64 = group.done_units + group.children.values().map(|t| t.total).sum::(); entry.progress = Some((completed, total.max(1))); entry.is_loading = true; entry.read = false; entry.tasks = tasks; } } fn progress_body(label: &str, current: u64, total: u64) -> String { if total == 0 { return label.to_string(); } let percent = ((current as f64 / total as f64) * 100.0).round() as u64; format!("{label} - {percent}%") }