// 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 (
);
}
function Messages() {
const qc = useQueryClient();
const list = useQuery({ queryKey: ['me', 'voicemail'], queryFn: meApi.voicemail });
const [deleting, setDeleting] = useState(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 (
Voicemail
{list.error && {list.error.message}}
{list.data?.length === 0 && No messages.
}
{list.data?.map((m) => (
-
{callerOf(m)}
{isNew(m) && (
New
)}
{m.callerName && m.callerNumber ? `${formatNumber(m.callerNumber)} · ` : ''}
{new Date(m.receivedAt).toLocaleString()} · {mmss(m.durationS)}
))}
Delete the message from {deleting && callerOf(deleting)}? This can't be undone.
}
confirmLabel="Delete"
busy={remove.isPending}
error={remove.error?.message}
onCancel={() => setDeleting(null)}
onConfirm={() => deleting && remove.mutate(deleting.id)}
/>
);
}
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 (
Greetings
{status.error && {status.error.message}}
{status.data &&
GREETING_LABELS.map(([kind, label, fallback]) => (
))}
);
}
function Greeting(props: {
kind: GreetingKind;
label: string;
fallback: string;
recorded: boolean;
version: number;
}) {
const { kind, label, recorded } = props;
const qc = useQueryClient();
const file = useRef(null);
const recorder = useRef(null);
const [recording, setRecording] = useState(false);
const [micError, setMicError] = useState(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 (
{label}
{recorded ? (
) : (
Not recorded. {props.fallback}
)}
{recording ? (
) : (
)}
{
const f = e.target.files?.[0];
if (f) upload.mutate(f);
e.target.value = '';
}}
/>
{recorded && (
)}
{recording &&
Recording…
}
{upload.isPending &&
Saving…
}
{upload.isSuccess && !upload.isPending &&
Saved.
}
{(micError ?? upload.error?.message ?? remove.error?.message) && (
{micError ?? upload.error?.message ?? remove.error?.message}
)}
);
}