/** * Notification manager — drives global banners and toasts based on app state changes. */ import { deesCatalog } from '../plugins.js'; import { appState, type IAppState } from './appstate.js'; type BannerType = 'info' | 'success' | 'warning' | 'error'; type ToastType = 'info' | 'success' | 'warning' | 'error'; export class NotificationManager { private appdash: InstanceType | null = null; private prevState: IAppState | null = null; private unsubscribe: (() => void) | null = null; private activeBannerIds = new Set(); private userDismissedIds = new Set(); private suppressToastsUntil = 0; private wsDisconnectTimer: ReturnType | null = null; private wsWasConnected = false; init(appdash: InstanceType): void { this.appdash = appdash; this.suppressToastsUntil = Date.now() + 3000; appdash.addEventListener('message-dismiss', ((e: CustomEvent) => { const id = e.detail?.id; if (id) { this.userDismissedIds.add(id); this.activeBannerIds.delete(id); } }) as EventListener); this.unsubscribe = appState.subscribe((state) => this.onStateChange(state)); } destroy(): void { this.unsubscribe?.(); this.unsubscribe = null; if (this.wsDisconnectTimer) { clearTimeout(this.wsDisconnectTimer); this.wsDisconnectTimer = null; } } // --------------------------------------------------------------------------- // Change detection // --------------------------------------------------------------------------- private onStateChange(newState: IAppState): void { const prev = this.prevState; // First state: snapshot and exit without notifications. if (!prev) { this.prevState = newState; // Exception: if we start disconnected, show the banner immediately. if (!newState.connected) { this.addBanner('ws-disconnected', 'error', 'Connection to server lost. Reconnecting...', { dismissible: false }); } return; } this.checkWebSocketConnectivity(prev, newState); this.checkProviders(prev, newState); this.checkDevices(prev, newState); this.checkOriginatedCalls(prev, newState); this.prevState = newState; } // --------------------------------------------------------------------------- // WebSocket connectivity // --------------------------------------------------------------------------- private checkWebSocketConnectivity(prev: IAppState, next: IAppState): void { if (prev.connected && !next.connected) { // Disconnected. this.addBanner('ws-disconnected', 'error', 'Connection to server lost. Reconnecting...', { dismissible: false }); this.wsWasConnected = true; // Debounce: set timer so we can suppress toast on rapid reconnect. if (this.wsDisconnectTimer) clearTimeout(this.wsDisconnectTimer); this.wsDisconnectTimer = setTimeout(() => { this.wsDisconnectTimer = null; }, 500); } else if (!prev.connected && next.connected) { // Reconnected. this.removeBanner('ws-disconnected'); if (this.wsWasConnected && !this.wsDisconnectTimer) { this.showToast('Reconnected to server', 'success'); } if (this.wsDisconnectTimer) { clearTimeout(this.wsDisconnectTimer); this.wsDisconnectTimer = null; } } } // --------------------------------------------------------------------------- // Provider registration // --------------------------------------------------------------------------- private checkProviders(prev: IAppState, next: IAppState): void { const prevMap = new Map(prev.providers.map((p) => [p.id, p])); const nextMap = new Map(next.providers.map((p) => [p.id, p])); for (const [id, np] of nextMap) { const pp = prevMap.get(id); if (!pp) continue; // New provider appeared — no transition to report. const bannerId = `provider-unreg-${id}`; if (pp.registered && !np.registered) { // Registration lost. this.addBanner(bannerId, 'warning', `${np.displayName}: registration lost`); } else if (!pp.registered && np.registered) { // Registration restored. this.removeBanner(bannerId); this.showToast(`${np.displayName} registered`, 'success'); } } } // --------------------------------------------------------------------------- // Device connectivity // --------------------------------------------------------------------------- private checkDevices(prev: IAppState, next: IAppState): void { const prevMap = new Map(prev.devices.map((d) => [d.id, d])); const nextMap = new Map(next.devices.map((d) => [d.id, d])); for (const [id, nd] of nextMap) { // Skip browser devices for banners — connections are transient. if (nd.isBrowser) continue; const pd = prevMap.get(id); if (!pd) continue; // New device appeared — no transition. const bannerId = `device-offline-${id}`; if (pd.connected && !nd.connected) { this.addBanner(bannerId, 'warning', `${nd.displayName} disconnected`); } else if (!pd.connected && nd.connected) { this.removeBanner(bannerId); this.showToast(`${nd.displayName} connected`, 'success'); } } // Handle devices that disappeared entirely (browser device left). for (const [id, pd] of prevMap) { if (pd.isBrowser) continue; if (!nextMap.has(id) && pd.connected) { this.addBanner(`device-offline-${id}`, 'warning', `${pd.displayName} disconnected`); } } } // --------------------------------------------------------------------------- // Originated call state transitions // --------------------------------------------------------------------------- private checkOriginatedCalls(prev: IAppState, next: IAppState): void { const prevMap = new Map(prev.calls.map((c) => [c.id, c])); for (const nc of next.calls) { const pc = prevMap.get(nc.id); if (!pc) continue; const label = nc.calleeNumber || nc.callerNumber || nc.id; if (pc.state !== 'connected' && nc.state === 'connected') { this.showToast(`Call ${label} connected`, 'success'); } else if (pc.state !== 'terminated' && nc.state === 'terminated') { this.showToast(`Call ${label} ended`, 'info'); } } } // --------------------------------------------------------------------------- // Banner helpers // --------------------------------------------------------------------------- private addBanner(id: string, type: BannerType, message: string, opts?: { dismissible?: boolean }): void { if (!this.appdash) return; if (this.activeBannerIds.has(id)) return; if (this.userDismissedIds.has(id)) return; this.appdash.addMessage({ id, type, message, dismissible: opts?.dismissible ?? true, }); this.activeBannerIds.add(id); } private removeBanner(id: string): void { if (!this.appdash) return; if (!this.activeBannerIds.has(id)) { // Also clear user-dismissed tracking so the banner can appear again if the problem recurs. this.userDismissedIds.delete(id); return; } this.appdash.removeMessage(id); this.activeBannerIds.delete(id); this.userDismissedIds.delete(id); } // --------------------------------------------------------------------------- // Toast helper // --------------------------------------------------------------------------- private showToast(message: string, type: ToastType = 'info', duration = 3000): void { if (Date.now() < this.suppressToastsUntil) return; deesCatalog.DeesToast.show({ message, type, duration, position: 'top-right' }); } }