softphone: dock panel, incoming dialog, hold/mute/DTMF, transfers, one tab
The dock opens a panel with a 12-key dialpad (typing, paste normalization, Enter) and microphone/speaker selection. Call cards have mute, hold, keypad (DTMF) and transfer: "Transfer now" (blind) or "Talk first" (attended, with Complete/Cancel). An incoming call opens a dialog when idle, or a card during a call (call waiting holds the first call). A synthesized WebAudio ringtone and desktop notifications, auto-answer for Call-Info answer-after=0, and a poor-connection warning from getStats. One tab registers: a Web Lock plus a BroadcastChannel "Use here". Found by e2e: Chrome can report canInsertDTMF=false, so DTMF falls back to SIP INFO; after a REFER's 2xx NOTIFY the browser sends BYE itself (RFC 5589), otherwise SIP.js keeps the call up. apps/web/TESTING.md is the 12-step manual run for Chrome and Firefox (still open, roadmap note); e2e m5-t2 automates most of it in headless Edge. Task: M5-T2
This commit is contained in:
1 parent
ae3a723a3c
commit
c7fd4df77c
12 files changed
+1035
-94
No files matched your search
@@ -0,0 +1,23 @@
|
||||
# Browser softphone — manual test (M5-T2)
|
||||
|
||||
Run all 12 steps in **Chrome** and again in **Firefox**, on the real server behind the reverse
|
||||
proxy (`https://<your host>/me`). You need: a user with an extension that has the browser
|
||||
softphone on (say 101), a second phone (desk phone or phone app, say 102), and a third extension
|
||||
to transfer to (say 103, any phone). Headphones help with echo.
|
||||
|
||||
| # | Do this | Pass when |
|
||||
| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| 1 | Sign in as the user, click **Enable softphone**, allow the microphone. | Status turns **Ready**; Admin → Dashboard shows 101 online. |
|
||||
| 2 | Reload the page. | It comes back **Ready** without asking again. |
|
||||
| 3 | Open `/me` in a second tab. | The second tab says **Active in another tab**. Click **Use here**: it becomes **Ready** and the first tab says **Active in another tab**. Close the second tab and click **Use here** in the first. |
|
||||
| 4 | Open **Keypad**, type `102` (also try typing on the keyboard and pressing Enter), call. | 102 rings; when answered both sides hear each other; the card shows a running timer. |
|
||||
| 5 | Press **Mute**, talk, then **Unmute**. | 102 hears nothing while muted, then hears you again. |
|
||||
| 6 | Press **Hold**, then **Resume**. | 102 hears hold music, then you again; the card says **On hold** meanwhile. |
|
||||
| 7 | During the call open the call's **Keypad** and press `1 2 3`. If 102 is a voicemail menu or an IVR, it reacts; otherwise call `*97` instead and enter your PIN. | The tones are recognized. |
|
||||
| 8 | Hang up. From 102 call 101. | The **Incoming call** dialog shows 102's name, the ringtone plays; **Answer** connects with two-way audio. Hang up from 102: the card goes away. |
|
||||
| 9 | Switch to another tab or app, then have 102 call 101 again (first allow notifications with **Notify me of calls**). | A desktop notification appears; clicking it brings the tab back. **Decline** sends 102 to busy/voicemail. |
|
||||
| 10 | While on a call with 102, have 103 call 101. | A second, smaller **Incoming call** card appears (no dialog). **Answer** puts 102 on hold (hears music) and connects 103. Hang up both. |
|
||||
| 11 | Call 102, **Transfer** → type `103` → **Transfer now**. | 102 is connected to 103; the browser call ends. |
|
||||
| 12 | Call 102, **Transfer** → `103` → **Talk first**; talk to 103; **Complete transfer**. Repeat with **Cancel** instead. | Complete: 102 and 103 are connected, the browser is out. Cancel: 103's call ends and you're back with 102. Also check **Microphone/Speaker** selects list your devices and a change takes effect on the next call. |
|
||||
|
||||
Record the result (browser, version, pass/fail per step) in the PR or issue for M5-T2.
|
||||
@@ -1,11 +1,16 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Softphone bar on every /me page (05 §5.3). M5-T1: enable, status, dial, answer, hang up.
|
||||
// M5-T2 adds the expanding panel, hold/mute/DTMF, transfer, devices and notifications.
|
||||
import { useEffect, useState, useSyncExternalStore } from 'react';
|
||||
// Softphone on every /me page (05 §5.3): a bar at the bottom that opens a panel with the
|
||||
// dialpad and audio devices; call cards with mute/hold/keypad/transfer; the incoming call dialog.
|
||||
// Not yet: DND toggle (M6), Record via *1 and recent calls (M7).
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
||||
import { formatNumber } from '@selfphoneng/shared';
|
||||
import { meApi } from '../api/client.ts';
|
||||
import { Dialog } from '../components/Dialog.tsx';
|
||||
import { Button, inputClass } from '../components/ui.tsx';
|
||||
import { useLive } from '../live/store.ts';
|
||||
import { onTakeOver, stepDown, takeOver, tryLead } from './leader.ts';
|
||||
import { softphone, type CallInfo } from './SoftphoneEngine.ts';
|
||||
import { startRinging, stopRinging } from './sounds.ts';
|
||||
|
||||
const KEY = 'softphone.enabled';
|
||||
const remembered = () => {
|
||||
@@ -26,23 +31,92 @@ const remember = (on: boolean) => {
|
||||
|
||||
export const useSoftphone = () => useSyncExternalStore(softphone.subscribe, () => softphone.state);
|
||||
|
||||
const STATUS = { off: 'Off', connecting: 'Connecting…', registered: 'Ready', error: 'Error' };
|
||||
onTakeOver(async () => {
|
||||
await softphone.stop();
|
||||
softphone.setElsewhere();
|
||||
});
|
||||
|
||||
async function startSoftphone() {
|
||||
const creds = await meApi.webrtc();
|
||||
await softphone.start(creds);
|
||||
/** Registers in this tab, unless another tab has the softphone (then "Use here" forces it). */
|
||||
async function startSoftphone(force = false) {
|
||||
if (force) await takeOver();
|
||||
else if (!(await tryLead())) return softphone.setElsewhere();
|
||||
await softphone.start(await meApi.webrtc());
|
||||
}
|
||||
|
||||
const STATUS = {
|
||||
off: 'Off',
|
||||
connecting: 'Connecting…',
|
||||
registered: 'Ready',
|
||||
error: 'Error',
|
||||
elsewhere: 'Active in another tab',
|
||||
};
|
||||
const KEYS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#'];
|
||||
const clean = (s: string) => s.replace(/[^0-9*#+]/g, '');
|
||||
|
||||
/** Caller name: extensions (live presence) → the name the caller sent → the number. */
|
||||
function useNameOf() {
|
||||
const exts = useLive((s) => s.extensions);
|
||||
return (c: { remote: string; remoteName: string | null }) =>
|
||||
exts[c.remote]?.name ??
|
||||
c.remoteName ??
|
||||
(c.remote.length > 6 ? formatNumber(c.remote) : c.remote || 'Unknown');
|
||||
}
|
||||
|
||||
function useNow(on: boolean) {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (!on) return;
|
||||
const t = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, [on]);
|
||||
return now;
|
||||
}
|
||||
|
||||
const elapsed = (from: number, now: number) => {
|
||||
const s = Math.max(0, Math.floor((now - from) / 1000));
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
export function SoftphoneDock() {
|
||||
const { reg, error, calls } = useSoftphone();
|
||||
const [number, setNumber] = useState('');
|
||||
const nameOf = useNameOf();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [problem, setProblem] = useState<string | null>(null);
|
||||
const run = (p: Promise<unknown>) => {
|
||||
setProblem(null);
|
||||
p.catch((e: Error) => setProblem(e.message));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (remembered() && softphone.state.reg === 'off')
|
||||
startSoftphone().catch((e: Error) => setProblem(e.message));
|
||||
}, []);
|
||||
|
||||
// Ring while anything is ringing here; tell a hidden tab with a notification.
|
||||
const incoming = calls.filter((c) => c.state === 'incoming');
|
||||
const ringingIds = incoming.map((c) => c.id).join(',');
|
||||
const notified = useRef(new Set<string>());
|
||||
useEffect(() => {
|
||||
if (ringingIds) startRinging();
|
||||
else stopRinging();
|
||||
for (const c of softphone.state.calls.filter((x) => x.state === 'incoming')) {
|
||||
if (notified.current.has(c.id)) continue;
|
||||
notified.current.add(c.id);
|
||||
if (
|
||||
document.hidden &&
|
||||
typeof Notification !== 'undefined' &&
|
||||
Notification.permission === 'granted'
|
||||
) {
|
||||
const n = new Notification('Incoming call', { body: nameOf(c), tag: c.id });
|
||||
n.onclick = () => {
|
||||
window.focus();
|
||||
n.close();
|
||||
};
|
||||
}
|
||||
}
|
||||
}, [ringingIds, nameOf]);
|
||||
useEffect(() => stopRinging, []);
|
||||
|
||||
async function enable() {
|
||||
setProblem(null);
|
||||
try {
|
||||
@@ -60,12 +134,9 @@ export function SoftphoneDock() {
|
||||
}
|
||||
}
|
||||
|
||||
async function dial() {
|
||||
const n = number.replace(/[^0-9*#+]/g, '');
|
||||
if (!n) return;
|
||||
setProblem(null);
|
||||
await softphone.call(n).catch((e: Error) => setProblem(e.message));
|
||||
}
|
||||
const talking = calls.some((c) => c.state !== 'incoming');
|
||||
// The big dialog for a call that rings while nothing else is going on; otherwise a card.
|
||||
const dialogCall = !talking ? incoming[0] : undefined;
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -76,82 +147,339 @@ export function SoftphoneDock() {
|
||||
<span className="text-sm font-medium">
|
||||
Softphone: <span role="status">{STATUS[reg]}</span>
|
||||
</span>
|
||||
{reg === 'off' ? (
|
||||
<span className="flex-1" />
|
||||
{reg === 'off' && (
|
||||
<Button variant="primary" onClick={enable}>
|
||||
Enable softphone
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
remember(false);
|
||||
void softphone.stop();
|
||||
}}
|
||||
>
|
||||
Turn off
|
||||
)}
|
||||
{reg === 'elsewhere' && (
|
||||
<Button variant="primary" onClick={() => run(startSoftphone(true))}>
|
||||
Use here
|
||||
</Button>
|
||||
)}
|
||||
{(reg === 'registered' || reg === 'connecting' || reg === 'error') && (
|
||||
<>
|
||||
<Button
|
||||
aria-expanded={open}
|
||||
aria-controls="sp-panel"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
{open ? 'Hide keypad' : 'Keypad'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
remember(false);
|
||||
run(softphone.stop().then(stepDown));
|
||||
}}
|
||||
>
|
||||
Turn off
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{(problem ?? error) && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{problem ?? error}</p>
|
||||
<p role="alert" className="text-sm text-red-600 dark:text-red-400">
|
||||
{problem ?? error}
|
||||
</p>
|
||||
)}
|
||||
{reg === 'registered' && (
|
||||
<form
|
||||
className="flex gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void dial();
|
||||
}}
|
||||
>
|
||||
<label htmlFor="sp-number" className="sr-only">
|
||||
Number to call
|
||||
</label>
|
||||
<input
|
||||
id="sp-number"
|
||||
className={inputClass}
|
||||
inputMode="tel"
|
||||
autoComplete="off"
|
||||
placeholder="Number or extension"
|
||||
value={number}
|
||||
onChange={(e) => setNumber(e.target.value)}
|
||||
/>
|
||||
<Button type="submit" variant="primary">
|
||||
Call
|
||||
</Button>
|
||||
</form>
|
||||
{calls
|
||||
.filter((c) => c.id !== dialogCall?.id)
|
||||
.map((c) => (
|
||||
<CallCard key={c.id} call={c} name={nameOf(c)} onError={setProblem} />
|
||||
))}
|
||||
{open && reg === 'registered' && (
|
||||
<div id="sp-panel" className="flex flex-col gap-3">
|
||||
<Dialer disabled={calls.length >= 2} onCall={(n) => run(softphone.call(n))} />
|
||||
<Devices />
|
||||
</div>
|
||||
)}
|
||||
{calls.map((c) => (
|
||||
<CallRow key={c.id} call={c} />
|
||||
))}
|
||||
<Dialog
|
||||
open={dialogCall !== undefined}
|
||||
onClose={() => dialogCall && run(softphone.decline(dialogCall.id))}
|
||||
title="Incoming call"
|
||||
footer={
|
||||
dialogCall && (
|
||||
<>
|
||||
<Button onClick={() => run(softphone.decline(dialogCall.id))}>Decline</Button>
|
||||
<Button variant="primary" onClick={() => run(softphone.answer(dialogCall.id))}>
|
||||
Answer
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{dialogCall && (
|
||||
<p className="text-lg">
|
||||
<span className="font-semibold">{nameOf(dialogCall)}</span>
|
||||
{nameOf(dialogCall) !== dialogCall.remote && (
|
||||
<span className="block text-sm text-zinc-500">
|
||||
{dialogCall.remote.length > 6 ? formatNumber(dialogCall.remote) : dialogCall.remote}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</Dialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CallRow({ call }: { call: CallInfo }) {
|
||||
const who = call.remoteName ?? (call.remote.length > 6 ? formatNumber(call.remote) : call.remote);
|
||||
const label = { incoming: 'Incoming call', calling: 'Calling…', active: 'On call' }[call.state];
|
||||
function Keypad({ onKey, label }: { onKey: (k: string) => void; label: string }) {
|
||||
return (
|
||||
<div role="group" aria-label={label} className="grid w-56 grid-cols-3 gap-1">
|
||||
{KEYS.map((k) => (
|
||||
<Button key={k} onClick={() => onKey(k)} className="text-lg">
|
||||
{k}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Dialer({ onCall, disabled }: { onCall: (n: string) => void; disabled: boolean }) {
|
||||
const [number, setNumber] = useState('');
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (clean(number)) onCall(clean(number));
|
||||
setNumber('');
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<label htmlFor="sp-number" className="sr-only">
|
||||
Number to call
|
||||
</label>
|
||||
<input
|
||||
id="sp-number"
|
||||
className={inputClass}
|
||||
inputMode="tel"
|
||||
autoComplete="off"
|
||||
placeholder="Number or extension"
|
||||
value={number}
|
||||
onChange={(e) => setNumber(e.target.value)}
|
||||
// Pasted numbers like "(312) 555-1234" become dialable digits.
|
||||
onPaste={(e) => {
|
||||
e.preventDefault();
|
||||
setNumber((n) => n + clean(e.clipboardData.getData('text')));
|
||||
}}
|
||||
/>
|
||||
<Button type="submit" variant="primary" disabled={disabled}>
|
||||
Call
|
||||
</Button>
|
||||
</div>
|
||||
<Keypad label="Dialpad" onKey={(k) => setNumber((n) => n + k)} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function CallCard({
|
||||
call,
|
||||
name,
|
||||
onError,
|
||||
}: {
|
||||
call: CallInfo;
|
||||
name: string;
|
||||
onError: (m: string | null) => void;
|
||||
}) {
|
||||
const now = useNow(call.state === 'active');
|
||||
const exts = useLive((s) => s.extensions);
|
||||
const [keypad, setKeypad] = useState(false);
|
||||
const [transfer, setTransfer] = useState(false);
|
||||
const [target, setTarget] = useState('');
|
||||
const [consultId, setConsultId] = useState<string | null>(null);
|
||||
const consult = useSoftphone().calls.find((c) => c.id === consultId);
|
||||
const run = (p: Promise<unknown>) => {
|
||||
onError(null);
|
||||
p.catch((e: Error) => onError(e.message));
|
||||
};
|
||||
const label =
|
||||
call.state === 'incoming'
|
||||
? 'Incoming call'
|
||||
: call.state === 'calling'
|
||||
? 'Calling…'
|
||||
: call.held
|
||||
? 'On hold'
|
||||
: 'On call';
|
||||
const listId = `sp-dir-${call.id}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={`${label}: ${who}`}
|
||||
className="flex flex-wrap items-center gap-2 rounded-md bg-zinc-100 p-2 dark:bg-zinc-900"
|
||||
aria-label={`${label}: ${name}`}
|
||||
className="flex flex-col gap-2 rounded-md bg-zinc-100 p-2 dark:bg-zinc-900"
|
||||
>
|
||||
<span className="flex-1 text-sm">
|
||||
<span className="font-medium">{who}</span> · {label}
|
||||
</span>
|
||||
{call.state === 'incoming' && (
|
||||
<>
|
||||
<Button variant="primary" onClick={() => void softphone.answer(call.id)}>
|
||||
Answer
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => void softphone.decline(call.id)}>
|
||||
Decline
|
||||
</Button>
|
||||
</>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="flex-1 text-sm">
|
||||
<span className="font-medium">{name}</span> · {label}
|
||||
{call.answeredAt && <> · {elapsed(call.answeredAt, now)}</>}
|
||||
{call.poorQuality && (
|
||||
<span className="ml-2 text-amber-700 dark:text-amber-400">Poor connection</span>
|
||||
)}
|
||||
</span>
|
||||
{call.state === 'incoming' ? (
|
||||
<>
|
||||
<Button variant="primary" onClick={() => run(softphone.answer(call.id))}>
|
||||
Answer
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => run(softphone.decline(call.id))}>
|
||||
Decline
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{call.state === 'active' && (
|
||||
<>
|
||||
<Button
|
||||
aria-pressed={call.muted}
|
||||
onClick={() => softphone.mute(call.id, !call.muted)}
|
||||
>
|
||||
{call.muted ? 'Unmute' : 'Mute'}
|
||||
</Button>
|
||||
<Button
|
||||
aria-pressed={call.held}
|
||||
onClick={() => run(softphone.hold(call.id, !call.held))}
|
||||
>
|
||||
{call.held ? 'Resume' : 'Hold'}
|
||||
</Button>
|
||||
<Button aria-pressed={keypad} onClick={() => setKeypad((k) => !k)}>
|
||||
Keypad
|
||||
</Button>
|
||||
<Button aria-pressed={transfer} onClick={() => setTransfer((t) => !t)}>
|
||||
Transfer
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button variant="danger" onClick={() => run(softphone.hangup(call.id))}>
|
||||
Hang up
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{keypad && call.state === 'active' && (
|
||||
<Keypad label="Send tones" onKey={(k) => softphone.dtmf(call.id, k)} />
|
||||
)}
|
||||
{call.state !== 'incoming' && (
|
||||
<Button variant="danger" onClick={() => void softphone.hangup(call.id)}>
|
||||
Hang up
|
||||
{transfer && call.state === 'active' && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{consultId ? (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="flex-1">
|
||||
Talking to {target} first{consult?.state === 'calling' ? ' (ringing)' : ''}.
|
||||
</span>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={consult?.state !== 'active'}
|
||||
onClick={() => run(softphone.attendedTransfer(call.id, consultId))}
|
||||
>
|
||||
Complete transfer
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
run(
|
||||
softphone.hangup(consultId).then(() => {
|
||||
setConsultId(null);
|
||||
return softphone.hold(call.id, false);
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<label htmlFor={`${listId}-in`} className="sr-only">
|
||||
Transfer to
|
||||
</label>
|
||||
<input
|
||||
id={`${listId}-in`}
|
||||
className={`${inputClass} max-w-56`}
|
||||
list={listId}
|
||||
placeholder="Transfer to"
|
||||
value={target}
|
||||
onChange={(e) => setTarget(e.target.value)}
|
||||
/>
|
||||
<datalist id={listId}>
|
||||
{Object.values(exts).map((e) => (
|
||||
<option key={e.number} value={e.number}>
|
||||
{e.name}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
<Button
|
||||
disabled={!clean(target)}
|
||||
onClick={() => run(softphone.blindTransfer(call.id, clean(target)))}
|
||||
>
|
||||
Transfer now
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!clean(target)}
|
||||
onClick={() => run(softphone.call(clean(target)).then(setConsultId))}
|
||||
>
|
||||
Talk first
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Devices() {
|
||||
const { inputDeviceId, outputDeviceId } = useSoftphone();
|
||||
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
|
||||
const [perm, setPerm] = useState(() =>
|
||||
typeof Notification === 'undefined' ? 'unsupported' : Notification.permission,
|
||||
);
|
||||
useEffect(() => {
|
||||
const load = () => void navigator.mediaDevices.enumerateDevices().then(setDevices);
|
||||
load();
|
||||
navigator.mediaDevices.addEventListener('devicechange', load);
|
||||
return () => navigator.mediaDevices.removeEventListener('devicechange', load);
|
||||
}, []);
|
||||
const mics = devices.filter((d) => d.kind === 'audioinput');
|
||||
const speakers = devices.filter((d) => d.kind === 'audiooutput');
|
||||
const canPickSpeaker = 'setSinkId' in HTMLMediaElement.prototype && speakers.length > 0;
|
||||
return (
|
||||
<div className="flex flex-wrap items-end gap-3 text-sm">
|
||||
<label className="flex flex-col gap-1">
|
||||
Microphone
|
||||
<select
|
||||
className={inputClass}
|
||||
value={inputDeviceId ?? ''}
|
||||
onChange={(e) => softphone.setInputDevice(e.target.value || null)}
|
||||
>
|
||||
<option value="">Default</option>
|
||||
{mics.map((d) => (
|
||||
<option key={d.deviceId} value={d.deviceId}>
|
||||
{d.label || 'Microphone'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{canPickSpeaker && (
|
||||
<label className="flex flex-col gap-1">
|
||||
Speaker
|
||||
<select
|
||||
className={inputClass}
|
||||
value={outputDeviceId ?? ''}
|
||||
onChange={(e) => void softphone.setOutputDevice(e.target.value || null)}
|
||||
>
|
||||
<option value="">Default</option>
|
||||
{speakers.map((d) => (
|
||||
<option key={d.deviceId} value={d.deviceId}>
|
||||
{d.label || 'Speaker'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{perm === 'default' && (
|
||||
<Button onClick={() => void Notification.requestPermission().then(setPerm)}>
|
||||
Notify me of calls
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// The browser softphone (05 §5.2): SIP.js over WebSocket to Asterisk, no React in here.
|
||||
// React reads it through subscribe()/state (useSyncExternalStore in useSoftphone.ts).
|
||||
// React reads it through subscribe()/state (useSyncExternalStore in SoftphoneDock.tsx).
|
||||
import {
|
||||
Invitation,
|
||||
Inviter,
|
||||
@@ -9,11 +9,14 @@ import {
|
||||
SessionState,
|
||||
UserAgent,
|
||||
Web,
|
||||
type Notification as SipNotification,
|
||||
type Session,
|
||||
} from 'sip.js';
|
||||
import type { WebRtcCreds } from '@selfphoneng/shared';
|
||||
import { beep } from './sounds.ts';
|
||||
|
||||
export type RegState = 'off' | 'connecting' | 'registered' | 'error';
|
||||
/** elsewhere: another tab of this browser has the softphone (one tab only, leader.ts). */
|
||||
export type RegState = 'off' | 'connecting' | 'registered' | 'error' | 'elsewhere';
|
||||
|
||||
export type CallInfo = {
|
||||
id: string;
|
||||
@@ -24,17 +27,38 @@ export type CallInfo = {
|
||||
/** incoming: ringing here; calling: ringing there; active: talking. */
|
||||
state: 'incoming' | 'calling' | 'active';
|
||||
answeredAt: number | null;
|
||||
muted: boolean;
|
||||
held: boolean;
|
||||
/** Packet loss > 5 % or jitter > 50 ms over the last 5 s. */
|
||||
poorQuality: boolean;
|
||||
};
|
||||
|
||||
export type EngineState = { reg: RegState; error: string | null; calls: CallInfo[] };
|
||||
export type EngineState = {
|
||||
reg: RegState;
|
||||
error: string | null;
|
||||
calls: CallInfo[];
|
||||
inputDeviceId: string | null;
|
||||
outputDeviceId: string | null;
|
||||
};
|
||||
|
||||
const BACKOFF_S = [2, 4, 8, 16, 30];
|
||||
const MAX_CALLS = 2;
|
||||
|
||||
type Tracked = { session: Session; info: CallInfo; audio: HTMLAudioElement };
|
||||
type Tracked = {
|
||||
session: Session;
|
||||
info: CallInfo;
|
||||
audio: HTMLAudioElement;
|
||||
stats?: ReturnType<typeof setInterval>;
|
||||
};
|
||||
|
||||
export class SoftphoneEngine {
|
||||
state: EngineState = { reg: 'off', error: null, calls: [] };
|
||||
state: EngineState = {
|
||||
reg: 'off',
|
||||
error: null,
|
||||
calls: [],
|
||||
inputDeviceId: null,
|
||||
outputDeviceId: null,
|
||||
};
|
||||
private listeners = new Set<() => void>();
|
||||
private ua: UserAgent | null = null;
|
||||
private registerer: Registerer | null = null;
|
||||
@@ -58,6 +82,10 @@ export class SoftphoneEngine {
|
||||
this.set({ calls: [...this.sessions.values()].map((t) => ({ ...t.info })) });
|
||||
}
|
||||
|
||||
setElsewhere() {
|
||||
this.set({ reg: 'elsewhere', error: null });
|
||||
}
|
||||
|
||||
async start(creds: WebRtcCreds): Promise<void> {
|
||||
await this.stop();
|
||||
this.stopped = false;
|
||||
@@ -138,15 +166,35 @@ export class SoftphoneEngine {
|
||||
this.set({ reg: 'off', error: null, calls: [] });
|
||||
}
|
||||
|
||||
async call(number: string): Promise<void> {
|
||||
const target = UserAgent.makeURI(`sip:${encodeURIComponent(number)}@${this.host}`);
|
||||
if (!this.ua || !target) throw new Error('Softphone is not started');
|
||||
private constraints(): MediaStreamConstraints {
|
||||
const id = this.state.inputDeviceId;
|
||||
return { audio: id ? { deviceId: { exact: id } } : true, video: false };
|
||||
}
|
||||
|
||||
private uri(number: string) {
|
||||
const u = UserAgent.makeURI(`sip:${encodeURIComponent(number)}@${this.host}`);
|
||||
if (!u) throw new Error(`Can't call ${number}`);
|
||||
return u;
|
||||
}
|
||||
|
||||
/** Puts every other talking call on hold first (call waiting, consult calls). */
|
||||
private async holdOthers(except?: string) {
|
||||
for (const t of this.sessions.values())
|
||||
if (t.info.id !== except && t.info.state === 'active' && !t.info.held)
|
||||
await this.hold(t.info.id, true);
|
||||
}
|
||||
|
||||
/** Returns the new call's id. */
|
||||
async call(number: string): Promise<string> {
|
||||
if (!this.ua) throw new Error('Softphone is not started');
|
||||
if (this.sessions.size >= MAX_CALLS) throw new Error('Two calls already');
|
||||
const inviter = new Inviter(this.ua, target, {
|
||||
sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } },
|
||||
await this.holdOthers();
|
||||
const inviter = new Inviter(this.ua, this.uri(number), {
|
||||
sessionDescriptionHandlerOptions: { constraints: this.constraints() },
|
||||
});
|
||||
this.track(inviter, 'out', number, null);
|
||||
await inviter.invite();
|
||||
return inviter.id;
|
||||
}
|
||||
|
||||
private incoming(inv: Invitation) {
|
||||
@@ -160,13 +208,17 @@ export class SoftphoneEngine {
|
||||
inv.remoteIdentity.uri.user ?? '',
|
||||
inv.remoteIdentity.displayName || null,
|
||||
);
|
||||
// Paging/intercom (03 sp-sub-autoanswer): answer after a short beep.
|
||||
if (/answer-after=0/.test(inv.request.getHeader('Call-Info') ?? '') && this.sessions.size === 1)
|
||||
void beep().then(() => this.answer(inv.id));
|
||||
}
|
||||
|
||||
async answer(id: string): Promise<void> {
|
||||
const t = this.sessions.get(id);
|
||||
if (!(t?.session instanceof Invitation)) return;
|
||||
await this.holdOthers(id);
|
||||
await t.session.accept({
|
||||
sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } },
|
||||
sessionDescriptionHandlerOptions: { constraints: this.constraints() },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -184,10 +236,91 @@ export class SoftphoneEngine {
|
||||
else if (s instanceof Invitation) await s.reject();
|
||||
}
|
||||
|
||||
private sdh(id: string) {
|
||||
const sdh = this.sessions.get(id)?.session.sessionDescriptionHandler;
|
||||
return sdh instanceof Web.SessionDescriptionHandler ? sdh : null;
|
||||
}
|
||||
|
||||
/** re-INVITE with a=sendonly (hold) or a=sendrecv (resume). */
|
||||
async hold(id: string, on: boolean): Promise<void> {
|
||||
const t = this.sessions.get(id);
|
||||
if (!t || t.session.state !== SessionState.Established || t.info.held === on) return;
|
||||
t.session.sessionDescriptionHandlerOptionsReInvite = {
|
||||
...t.session.sessionDescriptionHandlerOptionsReInvite,
|
||||
hold: on,
|
||||
} as Web.SessionDescriptionHandlerOptions;
|
||||
await t.session.invite();
|
||||
this.sdh(id)?.enableReceiverTracks(!on);
|
||||
this.sdh(id)?.enableSenderTracks(!on && !t.info.muted);
|
||||
t.info.held = on;
|
||||
this.publishCalls();
|
||||
}
|
||||
|
||||
mute(id: string, on: boolean): void {
|
||||
const t = this.sessions.get(id);
|
||||
if (!t) return;
|
||||
this.sdh(id)?.enableSenderTracks(!on && !t.info.held);
|
||||
t.info.muted = on;
|
||||
this.publishCalls();
|
||||
}
|
||||
|
||||
/** RFC 4733 through the RTCDTMFSender; SIP INFO when the browser can't (canInsertDTMF false). */
|
||||
dtmf(id: string, digit: string): void {
|
||||
if (this.sdh(id)?.sendDtmf(digit)) return;
|
||||
void this.sessions
|
||||
.get(id)
|
||||
?.session.info({
|
||||
requestOptions: {
|
||||
body: {
|
||||
contentDisposition: 'render',
|
||||
contentType: 'application/dtmf-relay',
|
||||
content: `Signal=${digit}\r\nDuration=160`,
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
/** RFC 5589: once the NOTIFY says the transfer worked (2xx sipfrag), the transferor hangs up. */
|
||||
private referOptions(s: Session) {
|
||||
return {
|
||||
onNotify: (n: SipNotification) => {
|
||||
void n.accept();
|
||||
if (/^SIP\/2\.0 2\d\d/m.test(n.request.body ?? '') && s.state === SessionState.Established)
|
||||
void s.bye().catch(() => undefined);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async blindTransfer(id: string, target: string): Promise<void> {
|
||||
const s = this.sessions.get(id)?.session;
|
||||
if (s) await s.refer(this.uri(target), this.referOptions(s));
|
||||
}
|
||||
|
||||
/** REFER with Replaces: the other side of `id` is connected to the other side of `consultId`. */
|
||||
async attendedTransfer(id: string, consultId: string): Promise<void> {
|
||||
const s = this.sessions.get(id)?.session;
|
||||
const consult = this.sessions.get(consultId)?.session;
|
||||
if (s && consult) await s.refer(consult, this.referOptions(s));
|
||||
}
|
||||
|
||||
async setOutputDevice(deviceId: string | null): Promise<void> {
|
||||
this.set({ outputDeviceId: deviceId });
|
||||
for (const t of this.sessions.values())
|
||||
await t.audio.setSinkId?.(deviceId ?? '').catch(() => undefined);
|
||||
}
|
||||
|
||||
/** Used for the next call. */
|
||||
setInputDevice(deviceId: string | null): void {
|
||||
this.set({ inputDeviceId: deviceId });
|
||||
}
|
||||
|
||||
private track(session: Session, direction: 'in' | 'out', remote: string, name: string | null) {
|
||||
const id = session.id;
|
||||
const audio = new Audio();
|
||||
audio.autoplay = true;
|
||||
if (this.state.outputDeviceId)
|
||||
void audio.setSinkId?.(this.state.outputDeviceId).catch(() => undefined);
|
||||
const info: CallInfo = {
|
||||
id,
|
||||
direction,
|
||||
@@ -195,19 +328,25 @@ export class SoftphoneEngine {
|
||||
remoteName: name,
|
||||
state: direction === 'in' ? 'incoming' : 'calling',
|
||||
answeredAt: null,
|
||||
muted: false,
|
||||
held: false,
|
||||
poorQuality: false,
|
||||
};
|
||||
this.sessions.set(id, { session, info, audio });
|
||||
const tracked: Tracked = { session, info, audio };
|
||||
this.sessions.set(id, tracked);
|
||||
session.stateChange.addListener((s) => {
|
||||
if (s === SessionState.Established) {
|
||||
const sdh = session.sessionDescriptionHandler;
|
||||
if (sdh instanceof Web.SessionDescriptionHandler) {
|
||||
if (s === SessionState.Established && info.state !== 'active') {
|
||||
const sdh = this.sdh(id);
|
||||
if (sdh) {
|
||||
audio.srcObject = sdh.remoteMediaStream;
|
||||
void audio.play().catch(() => undefined);
|
||||
tracked.stats = this.watchQuality(sdh.peerConnection, info);
|
||||
}
|
||||
info.state = 'active';
|
||||
info.answeredAt = Date.now();
|
||||
}
|
||||
if (s === SessionState.Terminated) {
|
||||
clearInterval(tracked.stats);
|
||||
audio.srcObject = null;
|
||||
this.sessions.delete(id);
|
||||
}
|
||||
@@ -215,6 +354,27 @@ export class SoftphoneEngine {
|
||||
});
|
||||
this.publishCalls();
|
||||
}
|
||||
|
||||
/** Every 5 s: loss and jitter of what we receive. */
|
||||
private watchQuality(pc: RTCPeerConnection | undefined, info: CallInfo) {
|
||||
let last = { lost: 0, received: 0 };
|
||||
return setInterval(() => {
|
||||
void pc?.getStats().then((report) => {
|
||||
report.forEach((s: RTCInboundRtpStreamStats) => {
|
||||
if (s.type !== 'inbound-rtp' || s.kind !== 'audio') return;
|
||||
const lost = (s.packetsLost ?? 0) - last.lost;
|
||||
const received = (s.packetsReceived ?? 0) - last.received;
|
||||
last = { lost: s.packetsLost ?? 0, received: s.packetsReceived ?? 0 };
|
||||
const poor =
|
||||
!info.held && (lost / Math.max(1, lost + received) > 0.05 || (s.jitter ?? 0) > 0.05);
|
||||
if (poor !== info.poorQuality) {
|
||||
info.poorQuality = poor;
|
||||
this.publishCalls();
|
||||
}
|
||||
});
|
||||
});
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
/** One engine per tab. */
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// One tab only (05 §5.2): the tab holding the Web Lock registers. "Use here" asks the holder,
|
||||
// over a BroadcastChannel, to stop and let go; the asking tab then gets the lock.
|
||||
|
||||
const NAME = 'selfphoneng-softphone';
|
||||
const channel = typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(NAME);
|
||||
let release: (() => void) | null = null;
|
||||
|
||||
/** Holds the lock until stepDown(); resolves once it's ours. */
|
||||
function hold(resolve: () => void) {
|
||||
return () => {
|
||||
resolve();
|
||||
return new Promise<void>((r) => (release = r));
|
||||
};
|
||||
}
|
||||
|
||||
/** True if this tab is (now) the softphone tab; false if another tab has it. */
|
||||
export async function tryLead(): Promise<boolean> {
|
||||
if (release) return true;
|
||||
if (!navigator.locks) return true; // very old browser: no coordination
|
||||
return new Promise((resolve) => {
|
||||
void navigator.locks.request(NAME, { ifAvailable: true }, (lock) =>
|
||||
lock ? hold(() => resolve(true))() : resolve(false),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** "Use here": the other tab stops, then this one leads. */
|
||||
export async function takeOver(): Promise<void> {
|
||||
if (release || !navigator.locks) return;
|
||||
channel?.postMessage('take');
|
||||
await new Promise<void>((resolve) => void navigator.locks.request(NAME, hold(resolve)));
|
||||
}
|
||||
|
||||
export function stepDown() {
|
||||
release?.();
|
||||
release = null;
|
||||
}
|
||||
|
||||
/** Runs when another tab takes over; the lock is released after `stop` finishes. */
|
||||
export function onTakeOver(stop: () => Promise<void>) {
|
||||
channel?.addEventListener('message', (e) => {
|
||||
if (e.data !== 'take' || !release) return;
|
||||
void stop().finally(stepDown);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Ringtone and beep made with WebAudio (US ring: 440 + 480 Hz, 2 s on, 4 s off).
|
||||
// ponytail: synthesized instead of the ringtone.mp3 in 05 §5.2, so there's no file or license.
|
||||
|
||||
let ctx: AudioContext | null = null;
|
||||
let ringing: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
function tone(freqs: number[], seconds: number, volume: number) {
|
||||
ctx ??= new AudioContext();
|
||||
const gain = ctx.createGain();
|
||||
gain.gain.value = volume;
|
||||
gain.connect(ctx.destination);
|
||||
for (const f of freqs) {
|
||||
const o = ctx.createOscillator();
|
||||
o.frequency.value = f;
|
||||
o.connect(gain);
|
||||
o.start();
|
||||
o.stop(ctx.currentTime + seconds);
|
||||
}
|
||||
}
|
||||
|
||||
export function startRinging() {
|
||||
if (ringing) return;
|
||||
tone([440, 480], 2, 0.15);
|
||||
ringing = setInterval(() => tone([440, 480], 2, 0.15), 6000);
|
||||
}
|
||||
|
||||
export function stopRinging() {
|
||||
clearInterval(ringing);
|
||||
ringing = undefined;
|
||||
}
|
||||
|
||||
export function beep(): Promise<void> {
|
||||
tone([1000], 0.2, 0.2);
|
||||
return new Promise((r) => setTimeout(r, 300));
|
||||
}
|
||||
Reference in new issue
Block a user