Files
siprouter/ts_web/elements/sipproxy-view-voicemail.ts

447 lines
14 KiB
TypeScript

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`
<div style="padding:8px 0;font-size:.9rem;color:#e2e8f0;">
Are you sure you want to delete the voicemail from
<strong style="color:#f87171;">${msg.callerName || msg.callerNumber}</strong>
(${formatDateTime(msg.timestamp)})?
</div>
`,
menuOptions: [
{
name: 'Cancel',
iconName: 'lucide:x',
action: async (modalRef: any) => { modalRef.destroy(); },
},
{
name: 'Delete',
iconName: 'lucide:trash-2',
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:bell-ring',
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`<span>${row.callerName}</span><br><span style="font-size:.75rem;color:#64748b">${row.callerNumber}</span>`
: html`<span style="font-family:'JetBrains Mono',monospace;font-size:.85rem">${row.callerNumber}</span>`;
return html`<div>${display}</div>`;
},
},
{
key: 'timestamp',
header: 'Date/Time',
sortable: true,
value: (row: IVoicemailMessage) => formatDateTime(row.timestamp),
renderer: (val: string) =>
html`<span style="font-size:.85rem">${val}</span>`,
},
{
key: 'durationMs',
header: 'Duration',
sortable: true,
value: (row: IVoicemailMessage) => formatDuration(row.durationMs),
renderer: (val: string) =>
html`<span style="font-family:'JetBrains Mono',monospace;font-size:.85rem">${val}</span>`,
},
{
key: 'heard',
header: 'Status',
renderer: (val: boolean, row: IVoicemailMessage) => {
const isPlaying = this.playingMessageId === row.id;
if (isPlaying) {
return html`
<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:.7rem;font-weight:600;text-transform:uppercase;background:#1e3a5f;color:#60a5fa">Playing</span>
`;
}
const heard = val;
const color = heard ? '#71717a' : '#f59e0b';
const bg = heard ? '#3f3f46' : '#422006';
const label = heard ? 'Heard' : 'New';
return html`
<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:.7rem;font-weight:600;text-transform:uppercase;background:${bg};color:${color}">${label}</span>
`;
},
},
];
}
// ---- 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:trash-2',
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`
<div class="box-selector">
<label>Voicebox</label>
<dees-input-dropdown
.key=${'voicebox'}
.selectedOption=${{ option: this.selectedBoxId, key: this.selectedBoxId }}
.options=${this.voiceboxIds.map((id) => ({ option: id, key: id }))}
@selectedOption=${(e: CustomEvent) => { this.selectBox(e.detail.key); }}
></dees-input-dropdown>
</div>
` : ''}
<div class="view-section">
<dees-statsgrid
.tiles=${this.getStatsTiles()}
.minTileWidth=${220}
.gap=${16}
></dees-statsgrid>
</div>
${this.messages.length === 0 && !this.loading ? html`
<div class="empty-state">
<div class="icon">&#9993;</div>
<div>No voicemail messages${this.selectedBoxId ? ` in box "${this.selectedBoxId}"` : ''}</div>
</div>
` : html`
<div class="view-section">
<dees-table
heading1="Voicemail"
heading2="${this.messages.length} message${this.messages.length !== 1 ? 's' : ''}"
dataName="voicemail"
.data=${this.messages}
.rowKey=${'id'}
.searchable=${true}
.columns=${this.getColumns()}
.dataActions=${this.getDataActions()}
></dees-table>
</div>
`}
${this.playingMessageId ? html`
<div class="audio-player">
<span style="color:#60a5fa;font-size:.8rem;font-weight:600;">Now playing</span>
<span style="flex:1"></span>
<span class="close-btn" @click=${() => this.stopAudio()}>&#10005;</span>
</div>
` : ''}
`;
}
}