// 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.

} 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 ? (
); }