Files
siprouter/ts_web/state/notification-manager.ts
Juergen Kunz f3e1c96872 initial commit — SIP B2BUA + WebRTC bridge with Rust codec engine
Full-featured SIP router with multi-provider trunking, browser softphone
via WebRTC, real-time Opus/G.722/PCM transcoding in Rust, RNNoise ML
noise suppression, Kokoro neural TTS announcements, and a Lit-based
web dashboard with live call monitoring and REST API.
2026-04-09 23:03:55 +00:00

216 lines
7.6 KiB
TypeScript

/**
* 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<typeof deesCatalog.DeesSimpleAppDash> | null = null;
private prevState: IAppState | null = null;
private unsubscribe: (() => void) | null = null;
private activeBannerIds = new Set<string>();
private userDismissedIds = new Set<string>();
private suppressToastsUntil = 0;
private wsDisconnectTimer: ReturnType<typeof setTimeout> | null = null;
private wsWasConnected = false;
init(appdash: InstanceType<typeof deesCatalog.DeesSimpleAppDash>): 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' });
}
}