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.
This commit is contained in:
215
ts_web/state/notification-manager.ts
Normal file
215
ts_web/state/notification-manager.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* 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' });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user