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.
216 lines
7.6 KiB
TypeScript
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' });
|
|
}
|
|
}
|