me: voicemail page, greetings, My Phone home
ci / images (push) Canceled after 0s
ci / check (push) Canceled after 25s

/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:
iamdoubz committed 2026-10-17 22:56:10 -05:00
1 parent fe2eb4cc22
commit f60dda0bb0
18 files changed
+978 -42

No files matched your search

+30
View File
@@ -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}`),
};
+5
View File
@@ -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:
+77 -20
View File
@@ -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>
);
}
+242
View File
@@ -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>
);
}