me: voicemail page, greetings, My Phone home
/api/me/voicemail lists, streams (Range, download) and marks read or deletes through AMI VoicemailMove/VoicemailRemove, then rescans so MWI and the list agree. /api/me/greetings takes the raw recording or file and converts it with ffmpeg to 8 kHz PCM (media/audio.ts). My Phone gets a layout with Home (new voicemails) and Voicemail (play marks read, download, delete, record/upload greetings via MediaRecorder); the list refetches on the live voicemail.new event. e2e m4-t3 checks MWI goes off within 5 s after play and delete, and a browser-recorded greeting is what callers hear. Task: M4-T3
This commit is contained in:
1 parent
fe2eb4cc22
commit
f60dda0bb0
18 files changed
+978
-42
No files matched your search
@@ -8,6 +8,7 @@ import type {
|
||||
ExtensionCreate,
|
||||
ExtensionDto,
|
||||
ExtensionUpdate,
|
||||
GreetingKind,
|
||||
ListResponse,
|
||||
NumberKind,
|
||||
OutboundRouteCreate,
|
||||
@@ -23,6 +24,7 @@ import type {
|
||||
UserCreate,
|
||||
UserDto,
|
||||
UserUpdate,
|
||||
VoicemailDto,
|
||||
Warning,
|
||||
} from '@selfphoneng/shared';
|
||||
|
||||
@@ -141,3 +143,31 @@ export const systemApi = {
|
||||
setupStatus: () => api<{ completed: boolean }>('GET', '/api/setup/status'),
|
||||
completeSetup: () => api<{ ok: true }>('POST', '/api/setup/complete'),
|
||||
};
|
||||
|
||||
export const meApi = {
|
||||
voicemail: () => api<ListResponse<VoicemailDto>>('GET', '/api/me/voicemail').then((r) => r.items),
|
||||
audioUrl: (id: string, download = false) =>
|
||||
`/api/me/voicemail/${id}/audio${download ? '?download=1' : ''}`,
|
||||
moveVoicemail: (id: string, folder: 'INBOX' | 'Old') =>
|
||||
api<VoicemailDto>('PATCH', `/api/me/voicemail/${id}`, { folder }),
|
||||
deleteVoicemail: (id: string) => api<{ ok: true }>('DELETE', `/api/me/voicemail/${id}`),
|
||||
greetings: () => api<Record<GreetingKind, boolean>>('GET', '/api/me/greetings'),
|
||||
greetingUrl: (kind: GreetingKind) => `/api/me/greetings/${kind}/audio`,
|
||||
/** The raw recording or file is the body; the server converts it with ffmpeg. */
|
||||
putGreeting: async (kind: GreetingKind, audio: Blob) => {
|
||||
const res = await fetch(`/api/me/greetings/${kind}`, {
|
||||
method: 'PUT',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'X-Requested-With': 'selfphoneng',
|
||||
'Content-Type': audio.type || 'application/octet-stream',
|
||||
},
|
||||
body: audio,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const e = (await res.json().catch(() => null))?.error ?? {};
|
||||
throw new ApiError(res.status, e.code ?? 'internal', e.message ?? res.statusText, e.details);
|
||||
}
|
||||
},
|
||||
deleteGreeting: (kind: GreetingKind) => api<{ ok: true }>('DELETE', `/api/me/greetings/${kind}`),
|
||||
};
|
||||
@@ -12,6 +12,8 @@ type LiveStore = {
|
||||
trunks: Record<string, TrunkLive>;
|
||||
/** 911 alerts received while this tab was open, newest last; dismissed ones are removed. */
|
||||
emergencies: EmergencyCall[];
|
||||
/** Bumped on every voicemail.new, so the voicemail list knows to refetch. */
|
||||
voicemailSeq: number;
|
||||
};
|
||||
|
||||
export const useLive = create<LiveStore>(() => ({
|
||||
@@ -20,6 +22,7 @@ export const useLive = create<LiveStore>(() => ({
|
||||
extensions: {},
|
||||
trunks: {},
|
||||
emergencies: [],
|
||||
voicemailSeq: 0,
|
||||
}));
|
||||
|
||||
function apply(m: LiveMessage) {
|
||||
@@ -46,6 +49,8 @@ function apply(m: LiveMessage) {
|
||||
}
|
||||
case 'emergency.call':
|
||||
return { emergencies: [...s.emergencies, m.data] };
|
||||
case 'voicemail.new':
|
||||
return { voicemailSeq: s.voicemailSeq + 1 };
|
||||
case 'ami.status':
|
||||
return { amiConnected: m.data.connected };
|
||||
default:
|
||||
|
||||
@@ -1,20 +1,39 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// My Phone (05 §4) arrives in M4/M5; for now a signed-in landing page for non-admins.
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
// My Phone (05 §4). M4: Home and Voicemail. Calls, contacts, settings and the softphone follow
|
||||
// with their milestones (M5–M7).
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect } from 'react';
|
||||
import { Link, NavLink, Route, Routes, useNavigate } from 'react-router';
|
||||
import { APP_NAME, type AuthUser } from '@selfphoneng/shared';
|
||||
import { authApi } from '../../api/client.ts';
|
||||
import { authApi, meApi } from '../../api/client.ts';
|
||||
import { ThemeToggle } from '../../components/ThemeToggle.tsx';
|
||||
import { Button } from '../../components/ui.tsx';
|
||||
import { Alert, Button } from '../../components/ui.tsx';
|
||||
import { subscribeLive, useLive } from '../../live/store.ts';
|
||||
import { VoicemailPage, callerOf, isNew, mmss } from './VoicemailPage.tsx';
|
||||
|
||||
const link = 'text-sky-700 underline dark:text-sky-400';
|
||||
|
||||
export function MePage({ user }: { user: AuthUser }) {
|
||||
const qc = useQueryClient();
|
||||
const nav = useNavigate();
|
||||
useEffect(subscribeLive, []);
|
||||
const voicemailSeq = useLive((s) => s.voicemailSeq);
|
||||
useEffect(() => {
|
||||
if (voicemailSeq) void qc.invalidateQueries({ queryKey: ['me', 'voicemail'] });
|
||||
}, [voicemailSeq, qc]);
|
||||
|
||||
const tab = ({ isActive }: { isActive: boolean }) =>
|
||||
`rounded-md px-3 py-2 text-sm font-medium ${isActive ? 'bg-zinc-100 dark:bg-zinc-800' : 'hover:bg-zinc-100 dark:hover:bg-zinc-800'}`;
|
||||
return (
|
||||
<main className="mx-auto flex min-h-dvh max-w-3xl flex-col gap-6 p-4 sm:p-8">
|
||||
<header className="flex items-center justify-between gap-2">
|
||||
<header className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h1 className="text-xl font-semibold">{APP_NAME}</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
{user.role === 'admin' && (
|
||||
<Link className={`${link} text-sm`} to="/admin">
|
||||
Admin
|
||||
</Link>
|
||||
)}
|
||||
<ThemeToggle />
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -28,20 +47,58 @@ export function MePage({ user }: { user: AuthUser }) {
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<p>
|
||||
Hi {user.displayName}. Voicemail, call history and the browser softphone are coming in
|
||||
M4–M5.
|
||||
</p>
|
||||
<p className="flex gap-4 text-sm">
|
||||
<Link className="text-sky-700 underline dark:text-sky-400" to="/change-password">
|
||||
Change password
|
||||
</Link>
|
||||
{user.role === 'admin' && (
|
||||
<Link className="text-sky-700 underline dark:text-sky-400" to="/admin">
|
||||
Admin
|
||||
</Link>
|
||||
)}
|
||||
</p>
|
||||
<nav
|
||||
aria-label="My phone"
|
||||
className="flex gap-1 border-b border-zinc-200 pb-2 dark:border-zinc-800"
|
||||
>
|
||||
<NavLink to="/me" end className={tab}>
|
||||
Home
|
||||
</NavLink>
|
||||
<NavLink to="/me/voicemail" className={tab}>
|
||||
Voicemail
|
||||
</NavLink>
|
||||
</nav>
|
||||
<Routes>
|
||||
<Route index element={<Home user={user} />} />
|
||||
<Route path="voicemail" element={<VoicemailPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Home({ user }: { user: AuthUser }) {
|
||||
const list = useQuery({ queryKey: ['me', 'voicemail'], queryFn: meApi.voicemail });
|
||||
const fresh = list.data?.filter(isNew) ?? [];
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<p>Hi {user.displayName}.</p>
|
||||
<section aria-labelledby="new-vm" className="flex flex-col gap-2">
|
||||
<h2 id="new-vm" className="text-lg font-semibold">
|
||||
New voicemails
|
||||
</h2>
|
||||
{list.error && <Alert>{list.error.message}</Alert>}
|
||||
{list.data && fresh.length === 0 && <p className="text-sm text-zinc-500">None.</p>}
|
||||
<ul className="flex flex-col gap-1">
|
||||
{fresh.slice(0, 5).map((m) => (
|
||||
<li key={m.id} className="text-sm">
|
||||
<span className="font-semibold">{callerOf(m)}</span>{' '}
|
||||
<span className="text-zinc-500">
|
||||
{new Date(m.receivedAt).toLocaleString()} · {mmss(m.durationS)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{list.data && list.data.length > 0 && (
|
||||
<Link className={`${link} text-sm`} to="/me/voicemail">
|
||||
Open voicemail
|
||||
</Link>
|
||||
)}
|
||||
</section>
|
||||
<p className="text-sm">
|
||||
<Link className={link} to="/change-password">
|
||||
Change password
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// My voicemail (05 §4): messages (play marks read, download, delete) and greetings.
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useRef, useState } from 'react';
|
||||
import { formatNumber, type GreetingKind, type VoicemailDto } from '@selfphoneng/shared';
|
||||
import { meApi } from '../../api/client.ts';
|
||||
import { ConfirmDialog } from '../../components/Dialog.tsx';
|
||||
import { Alert, Button } from '../../components/ui.tsx';
|
||||
|
||||
export const mmss = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
||||
export const callerOf = (m: VoicemailDto) =>
|
||||
m.callerName ?? (m.callerNumber ? formatNumber(m.callerNumber) : 'Unknown caller');
|
||||
export const isNew = (m: VoicemailDto) => m.folder !== 'Old';
|
||||
|
||||
export function VoicemailPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<Messages />
|
||||
<Greetings />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Messages() {
|
||||
const qc = useQueryClient();
|
||||
const list = useQuery({ queryKey: ['me', 'voicemail'], queryFn: meApi.voicemail });
|
||||
const [deleting, setDeleting] = useState<VoicemailDto | null>(null);
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['me', 'voicemail'] });
|
||||
const markRead = useMutation({
|
||||
mutationFn: (id: string) => meApi.moveVoicemail(id, 'Old'),
|
||||
onSuccess: refresh,
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => meApi.deleteVoicemail(id),
|
||||
onSuccess: async () => {
|
||||
setDeleting(null);
|
||||
await refresh();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<section aria-labelledby="vm-h" className="flex flex-col gap-3">
|
||||
<h2 id="vm-h" className="text-lg font-semibold">
|
||||
Voicemail
|
||||
</h2>
|
||||
{list.error && <Alert>{list.error.message}</Alert>}
|
||||
{list.data?.length === 0 && <p className="text-sm text-zinc-500">No messages.</p>}
|
||||
<ul className="flex flex-col gap-2">
|
||||
{list.data?.map((m) => (
|
||||
<li
|
||||
key={m.id}
|
||||
className="flex flex-col gap-2 rounded-lg border border-zinc-200 p-3 sm:flex-row sm:items-center dark:border-zinc-800"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={isNew(m) ? 'font-semibold' : ''}>
|
||||
{callerOf(m)}
|
||||
{isNew(m) && (
|
||||
<span className="ml-2 rounded bg-sky-100 px-1.5 py-0.5 text-xs text-sky-800 dark:bg-sky-900 dark:text-sky-200">
|
||||
New
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-sm text-zinc-500">
|
||||
{m.callerName && m.callerNumber ? `${formatNumber(m.callerNumber)} · ` : ''}
|
||||
{new Date(m.receivedAt).toLocaleString()} · {mmss(m.durationS)}
|
||||
</p>
|
||||
</div>
|
||||
<audio
|
||||
controls
|
||||
preload="none"
|
||||
src={meApi.audioUrl(m.id)}
|
||||
aria-label={`Voicemail from ${callerOf(m)}`}
|
||||
className="w-full sm:w-64"
|
||||
onPlay={() => isNew(m) && !markRead.isPending && markRead.mutate(m.id)}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<a
|
||||
href={meApi.audioUrl(m.id, true)}
|
||||
className="inline-flex items-center rounded-md px-3 py-2 text-sm font-medium hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
<Button variant="ghost" onClick={() => setDeleting(m)}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<ConfirmDialog
|
||||
open={deleting !== null}
|
||||
title="Delete voicemail"
|
||||
message={
|
||||
<p>Delete the message from {deleting && callerOf(deleting)}? This can't be undone.</p>
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
busy={remove.isPending}
|
||||
error={remove.error?.message}
|
||||
onCancel={() => setDeleting(null)}
|
||||
onConfirm={() => deleting && remove.mutate(deleting.id)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const GREETING_LABELS: [GreetingKind, string, string][] = [
|
||||
['unavail', "When you don't answer", 'Callers hear the standard "is unavailable" message.'],
|
||||
['busy', "When you're on another call", 'Callers hear the standard "is busy" message.'],
|
||||
['greet', 'Your name', 'Callers hear your extension number.'],
|
||||
];
|
||||
|
||||
function Greetings() {
|
||||
const status = useQuery({ queryKey: ['me', 'greetings'], queryFn: meApi.greetings });
|
||||
return (
|
||||
<section aria-labelledby="gr-h" className="flex flex-col gap-3">
|
||||
<h2 id="gr-h" className="text-lg font-semibold">
|
||||
Greetings
|
||||
</h2>
|
||||
{status.error && <Alert>{status.error.message}</Alert>}
|
||||
{status.data &&
|
||||
GREETING_LABELS.map(([kind, label, fallback]) => (
|
||||
<Greeting
|
||||
key={kind}
|
||||
kind={kind}
|
||||
label={label}
|
||||
fallback={fallback}
|
||||
recorded={status.data[kind]}
|
||||
version={status.dataUpdatedAt}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Greeting(props: {
|
||||
kind: GreetingKind;
|
||||
label: string;
|
||||
fallback: string;
|
||||
recorded: boolean;
|
||||
version: number;
|
||||
}) {
|
||||
const { kind, label, recorded } = props;
|
||||
const qc = useQueryClient();
|
||||
const file = useRef<HTMLInputElement>(null);
|
||||
const recorder = useRef<MediaRecorder | null>(null);
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [micError, setMicError] = useState<string | null>(null);
|
||||
const done = () => qc.invalidateQueries({ queryKey: ['me', 'greetings'] });
|
||||
const upload = useMutation({
|
||||
mutationFn: (audio: Blob) => meApi.putGreeting(kind, audio),
|
||||
onSuccess: done,
|
||||
});
|
||||
const remove = useMutation({ mutationFn: () => meApi.deleteGreeting(kind), onSuccess: done });
|
||||
|
||||
async function startRecording() {
|
||||
setMicError(null);
|
||||
let stream: MediaStream;
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
} catch {
|
||||
setMicError('No microphone, or the browser blocked it.');
|
||||
return;
|
||||
}
|
||||
const rec = new MediaRecorder(stream);
|
||||
const chunks: Blob[] = [];
|
||||
rec.ondataavailable = (e) => chunks.push(e.data);
|
||||
rec.onstop = () => {
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
upload.mutate(new Blob(chunks, { type: rec.mimeType }));
|
||||
};
|
||||
rec.start();
|
||||
recorder.current = rec;
|
||||
setRecording(true);
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
recorder.current?.stop();
|
||||
recorder.current = null;
|
||||
setRecording(false);
|
||||
}
|
||||
|
||||
const id = `greeting-${kind}`;
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-labelledby={id}
|
||||
className="flex flex-col gap-2 rounded-lg border border-zinc-200 p-3 dark:border-zinc-800"
|
||||
>
|
||||
<p id={id} className="font-medium">
|
||||
{label}
|
||||
</p>
|
||||
{recorded ? (
|
||||
<audio
|
||||
controls
|
||||
preload="none"
|
||||
src={`${meApi.greetingUrl(kind)}?v=${props.version}`}
|
||||
aria-label={`${label} greeting`}
|
||||
className="w-full sm:w-80"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-zinc-500">Not recorded. {props.fallback}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{recording ? (
|
||||
<Button variant="danger" onClick={stopRecording}>
|
||||
Stop and save
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={startRecording} disabled={upload.isPending}>
|
||||
Record
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => file.current?.click()} disabled={recording || upload.isPending}>
|
||||
Upload a file
|
||||
</Button>
|
||||
<input
|
||||
ref={file}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
hidden
|
||||
aria-label={`Upload ${label} greeting`}
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) upload.mutate(f);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
{recorded && (
|
||||
<Button variant="ghost" onClick={() => remove.mutate()} disabled={remove.isPending}>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{recording && <p className="text-sm text-red-600 dark:text-red-400">Recording…</p>}
|
||||
{upload.isPending && <p className="text-sm text-zinc-500">Saving…</p>}
|
||||
{upload.isSuccess && !upload.isPending && <p className="text-sm text-zinc-500">Saved.</p>}
|
||||
{(micError ?? upload.error?.message ?? remove.error?.message) && (
|
||||
<Alert>{micError ?? upload.error?.message ?? remove.error?.message}</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user