447 lines
14 KiB
TypeScript
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">✉</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()}>✕</span>
|
||
|
|
</div>
|
||
|
|
` : ''}
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
}
|