import { DeesElement, customElement, html, css, cssManager, state, type TemplateResult } from '../plugins.js'; import { deesCatalog } from '../plugins.js'; import { appState, type IAppState } from '../state/appstate.js'; import { viewHostCss } from './shared/index.js'; import type { IStatsTile } from '@design.estate/dees-catalog'; // --------------------------------------------------------------------------- // Voicemail message shape (mirrors server IVoicemailMessage) // --------------------------------------------------------------------------- interface IVoicemailMessage { id: string; boxId: string; callerNumber: string; callerName?: string; timestamp: number; durationMs: number; fileName: string; heard: boolean; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function formatDuration(ms: number): string { const totalSec = Math.round(ms / 1000); const min = Math.floor(totalSec / 60); const sec = totalSec % 60; return `${min}:${sec.toString().padStart(2, '0')}`; } function formatDateTime(ts: number): string { const d = new Date(ts); const date = d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); return `${date} ${time}`; } // --------------------------------------------------------------------------- // View element // --------------------------------------------------------------------------- @customElement('sipproxy-view-voicemail') export class SipproxyViewVoicemail extends DeesElement { @state() accessor appData: IAppState = appState.getState(); @state() accessor messages: IVoicemailMessage[] = []; @state() accessor voiceboxIds: string[] = []; @state() accessor selectedBoxId: string = ''; @state() accessor playingMessageId: string | null = null; @state() accessor loading: boolean = false; private audioElement: HTMLAudioElement | null = null; public static styles = [ cssManager.defaultStyles, viewHostCss, css` :host { display: block; padding: 16px; } .view-section { margin-bottom: 24px; } .box-selector { display: flex; align-items: center; gap: 12px; margin-bottom: 24px; } .box-selector label { font-size: 0.85rem; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.04em; } .audio-player { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: #1e293b; border-radius: 8px; margin-top: 16px; } .audio-player audio { flex: 1; height: 32px; } .audio-player .close-btn { cursor: pointer; color: #94a3b8; font-size: 1.1rem; padding: 2px 6px; border-radius: 4px; transition: background 0.15s; } .audio-player .close-btn:hover { background: #334155; color: #e2e8f0; } .empty-state { text-align: center; padding: 48px 16px; color: #64748b; font-size: 0.9rem; } .empty-state .icon { font-size: 2.5rem; margin-bottom: 12px; opacity: 0.5; } `, ]; // ---- lifecycle ----------------------------------------------------------- connectedCallback() { super.connectedCallback(); this.rxSubscriptions.push({ unsubscribe: appState.subscribe((s) => { this.appData = s; }), } as any); this.loadVoiceboxes(); } disconnectedCallback() { super.disconnectedCallback(); this.stopAudio(); } // ---- data loading -------------------------------------------------------- private async loadVoiceboxes() { try { const cfg = await appState.apiGetConfig(); const boxes: { id: string }[] = cfg.voiceboxes || []; this.voiceboxIds = boxes.map((b) => b.id); if (this.voiceboxIds.length > 0 && !this.selectedBoxId) { this.selectedBoxId = this.voiceboxIds[0]; await this.loadMessages(); } } catch { // Config unavailable. } } private async loadMessages() { if (!this.selectedBoxId) { this.messages = []; return; } this.loading = true; try { const res = await fetch(`/api/voicemail/${encodeURIComponent(this.selectedBoxId)}`); const data = await res.json(); this.messages = data.messages || []; } catch { this.messages = []; } this.loading = false; } private async selectBox(boxId: string) { this.selectedBoxId = boxId; this.stopAudio(); await this.loadMessages(); } // ---- audio playback ------------------------------------------------------ private playMessage(msg: IVoicemailMessage) { this.stopAudio(); const url = `/api/voicemail/${encodeURIComponent(msg.boxId)}/${encodeURIComponent(msg.id)}/audio`; const audio = new Audio(url); this.audioElement = audio; this.playingMessageId = msg.id; audio.addEventListener('ended', () => { this.playingMessageId = null; // Auto-mark as heard after playback completes. if (!msg.heard) { this.markHeard(msg); } }); audio.addEventListener('error', () => { this.playingMessageId = null; deesCatalog.DeesToast.error('Failed to play audio'); }); audio.play().catch(() => { this.playingMessageId = null; }); } private stopAudio() { if (this.audioElement) { this.audioElement.pause(); this.audioElement.src = ''; this.audioElement = null; } this.playingMessageId = null; } // ---- message actions ----------------------------------------------------- private async markHeard(msg: IVoicemailMessage) { try { await fetch(`/api/voicemail/${encodeURIComponent(msg.boxId)}/${encodeURIComponent(msg.id)}/heard`, { method: 'POST', }); // Update local state without full reload. this.messages = this.messages.map((m) => m.id === msg.id ? { ...m, heard: true } : m, ); } catch { deesCatalog.DeesToast.error('Failed to mark message as heard'); } } private async deleteMessage(msg: IVoicemailMessage) { const { DeesModal } = await import('@design.estate/dees-catalog'); await DeesModal.createAndShow({ heading: 'Delete Voicemail', width: 'small', showCloseButton: true, content: html`
Are you sure you want to delete the voicemail from ${msg.callerName || msg.callerNumber} (${formatDateTime(msg.timestamp)})?
`, menuOptions: [ { name: 'Cancel', iconName: 'lucide:x', action: async (modalRef: any) => { modalRef.destroy(); }, }, { name: 'Delete', iconName: 'lucide:Trash2', action: async (modalRef: any) => { try { await fetch( `/api/voicemail/${encodeURIComponent(msg.boxId)}/${encodeURIComponent(msg.id)}`, { method: 'DELETE' }, ); if (this.playingMessageId === msg.id) { this.stopAudio(); } this.messages = this.messages.filter((m) => m.id !== msg.id); modalRef.destroy(); deesCatalog.DeesToast.success('Voicemail deleted'); } catch { deesCatalog.DeesToast.error('Failed to delete voicemail'); } }, }, ], }); } // ---- stats tiles --------------------------------------------------------- private getStatsTiles(): IStatsTile[] { const total = this.messages.length; const unheard = this.messages.filter((m) => !m.heard).length; return [ { id: 'total', title: 'Total Messages', value: total, type: 'number', icon: 'lucide:voicemail', description: this.selectedBoxId ? `Box: ${this.selectedBoxId}` : 'No box selected', }, { id: 'unheard', title: 'Unheard Messages', value: unheard, type: 'number', icon: 'lucide:BellRing', color: unheard > 0 ? 'hsl(0 84.2% 60.2%)' : 'hsl(142.1 76.2% 36.3%)', description: unheard > 0 ? 'Needs attention' : 'All caught up', }, ]; } // ---- table columns ------------------------------------------------------- private getColumns() { return [ { key: 'callerNumber', header: 'Caller', sortable: true, renderer: (_val: string, row: IVoicemailMessage) => { const display = row.callerName ? html`${row.callerName}
${row.callerNumber}` : html`${row.callerNumber}`; return html`
${display}
`; }, }, { key: 'timestamp', header: 'Date/Time', sortable: true, value: (row: IVoicemailMessage) => formatDateTime(row.timestamp), renderer: (val: string) => html`${val}`, }, { key: 'durationMs', header: 'Duration', sortable: true, value: (row: IVoicemailMessage) => formatDuration(row.durationMs), renderer: (val: string) => html`${val}`, }, { key: 'heard', header: 'Status', renderer: (val: boolean, row: IVoicemailMessage) => { const isPlaying = this.playingMessageId === row.id; if (isPlaying) { return html` Playing `; } const heard = val; const color = heard ? '#71717a' : '#f59e0b'; const bg = heard ? '#3f3f46' : '#422006'; const label = heard ? 'Heard' : 'New'; return html` ${label} `; }, }, ]; } // ---- table actions ------------------------------------------------------- private getDataActions() { return [ { name: 'Play', iconName: 'lucide:play', type: ['inRow'] as any, actionFunc: async (actionData: any) => { const msg = actionData.item as IVoicemailMessage; if (this.playingMessageId === msg.id) { this.stopAudio(); } else { this.playMessage(msg); } }, }, { name: 'Mark Heard', iconName: 'lucide:check', type: ['inRow'] as any, actionFunc: async (actionData: any) => { const msg = actionData.item as IVoicemailMessage; if (!msg.heard) { await this.markHeard(msg); deesCatalog.DeesToast.success('Marked as heard'); } }, }, { name: 'Delete', iconName: 'lucide:Trash2', type: ['inRow'] as any, actionFunc: async (actionData: any) => { await this.deleteMessage(actionData.item as IVoicemailMessage); }, }, { name: 'Refresh', iconName: 'lucide:refreshCw', type: ['header'] as any, actionFunc: async () => { await this.loadMessages(); deesCatalog.DeesToast.success('Messages refreshed'); }, }, ]; } // ---- render -------------------------------------------------------------- public render(): TemplateResult { return html` ${this.voiceboxIds.length > 1 ? html`
({ option: id, key: id }))} @selectedOption=${(e: CustomEvent) => { this.selectBox(e.detail.key); }} >
` : ''}
${this.messages.length === 0 && !this.loading ? html`
No voicemail messages${this.selectedBoxId ? ` in box "${this.selectedBoxId}"` : ''}
` : html`
`} ${this.playingMessageId ? html`
Now playing this.stopAudio()}>✕
` : ''} `; } }