softphone: People panel with presence, click-to-call
ci / images (push) Canceled after 0s
ci / check (push) Canceled after 4s

People lists every extension with its status dot and a search box: a
right-hand column on wide screens, /me/people on small ones. Call uses the
browser softphone when it's on, else POST /api/me/click-to-call, which
originates to the user's desk phone first (409 if none is registered).
During a call: Transfer (blind), Talk first (consult), or drag the call
card onto a person; a held call card offers Complete transfer whenever
another call is active. useSoftphone moved to its own module.

e2e m5-t3: the dot changes ~30 ms after Asterisk's hint; click-to-call
rings 174 before 172. cdp click() takes a scope (People also has
Call/Transfer buttons). trunk-test ignores the RTCP-at-teardown EPERM
error seen once.

Task: M5-T3
This commit is contained in:
iamdoubz committed 2026-10-18 10:06:28 -05:00
1 parent c7fd4df77c
commit 609965bba5
16 files changed
+452 -33

No files matched your search

+1
View File
@@ -147,6 +147,7 @@ export const systemApi = {
export const meApi = {
webrtc: () => api<WebRtcCreds>('GET', '/api/me/webrtc'),
clickToCall: (number: string) => api<{ ok: true }>('POST', '/api/me/click-to-call', { number }),
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' : ''}`,
+20 -6
View File
@@ -10,6 +10,7 @@ import { ThemeToggle } from '../../components/ThemeToggle.tsx';
import { Alert, Button } from '../../components/ui.tsx';
import { subscribeLive, useLive } from '../../live/store.ts';
import { SoftphoneDock } from '../../softphone/SoftphoneDock.tsx';
import { PresencePanel } from '../../softphone/PresencePanel.tsx';
import { VoicemailPage, callerOf, isNew, mmss } from './VoicemailPage.tsx';
const link = 'text-sky-700 underline dark:text-sky-400';
@@ -17,6 +18,7 @@ const link = 'text-sky-700 underline dark:text-sky-400';
export function MePage({ user }: { user: AuthUser }) {
const qc = useQueryClient();
const nav = useNavigate();
const me = user.extension?.number ?? null;
useEffect(subscribeLive, []);
const voicemailSeq = useLive((s) => s.voicemailSeq);
useEffect(() => {
@@ -26,7 +28,7 @@ export function MePage({ user }: { user: AuthUser }) {
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">
<main className="mx-auto flex min-h-dvh max-w-6xl flex-col gap-6 p-4 sm:p-8">
<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">
@@ -58,12 +60,24 @@ export function MePage({ user }: { user: AuthUser }) {
<NavLink to="/me/voicemail" className={tab}>
Voicemail
</NavLink>
{/* People is a right-hand column on wide screens, a tab on small ones (05 §4.1). */}
<NavLink to="/me/people" className={(s) => `${tab(s)} lg:hidden`}>
People
</NavLink>
</nav>
<Routes>
<Route index element={<Home user={user} />} />
<Route path="voicemail" element={<VoicemailPage />} />
</Routes>
<SoftphoneDock />
<div className="grid flex-1 gap-8 lg:grid-cols-[1fr_20rem]">
<div className="flex min-w-0 flex-col gap-6">
<Routes>
<Route index element={<Home user={user} />} />
<Route path="voicemail" element={<VoicemailPage />} />
<Route path="people" element={<PresencePanel me={me} />} />
</Routes>
<SoftphoneDock />
</div>
<aside className="hidden lg:block">
<PresencePanel me={me} />
</aside>
</div>
</main>
);
}
+114
View File
@@ -0,0 +1,114 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Presence panel (05 §4.1): everyone's status, search, click to call. During a call: transfer
// (blind), talk first (attended), or drag the call card onto a person (blind).
import { useState } from 'react';
import type { ExtensionLive } from '@selfphoneng/shared';
import { meApi } from '../api/client.ts';
import { StatusDot, statusLabel } from '../components/StatusDot.tsx';
import { Button, inputClass } from '../components/ui.tsx';
import { useLive } from '../live/store.ts';
import { useSoftphone } from './useSoftphone.ts';
import { CALL_DRAG, softphone } from './SoftphoneEngine.ts';
export function PresencePanel({ me }: { me: string | null }) {
const exts = useLive((s) => s.extensions);
const { reg, calls } = useSoftphone();
const [q, setQ] = useState('');
const [note, setNote] = useState<string | null>(null);
const active = calls.find((c) => c.state === 'active' && !c.held);
const query = q.trim().toLowerCase();
const people = Object.values(exts)
.filter((e) => e.number !== me)
.filter((e) => !query || e.name.toLowerCase().includes(query) || e.number.includes(query))
.sort((a, b) => Number(a.number) - Number(b.number));
const run = (p: Promise<unknown>, ok?: string) => {
setNote(null);
p.then(
() => ok && setNote(ok),
(e: Error) => setNote(e.message),
);
};
// The softphone when it's on; otherwise the desk phone rings first (click-to-call).
const call = (e: ExtensionLive) =>
reg === 'registered'
? run(softphone.call(e.number))
: run(
meApi.clickToCall(e.number),
`Your desk phone is ringing. Pick it up to call ${e.name}.`,
);
return (
<section aria-labelledby="people-h" className="flex flex-col gap-2">
<h2 id="people-h" className="text-lg font-semibold">
People
</h2>
<label htmlFor="people-q" className="sr-only">
Search people
</label>
<input
id="people-q"
className={inputClass}
placeholder="Search people"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
{note && (
<p role="status" className="text-sm text-zinc-600 dark:text-zinc-400">
{note}
</p>
)}
<ul className="flex flex-col">
{people.map((e) => (
<li
key={e.number}
data-number={e.number}
className="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-zinc-100 dark:hover:bg-zinc-900"
onDragOver={(ev) => {
if (active && ev.dataTransfer.types.includes(CALL_DRAG)) ev.preventDefault();
}}
onDrop={(ev) => {
const id = ev.dataTransfer.getData(CALL_DRAG);
if (id) run(softphone.blindTransfer(id, e.number));
}}
>
<StatusDot ext={e} />
<span className="min-w-0 flex-1 truncate text-sm">
<span className="font-medium">{e.name}</span>{' '}
<span className="text-zinc-500">
{e.number} · {statusLabel(e)}
</span>
</span>
{active ? (
<>
<Button
className="px-2 py-1"
aria-label={`Transfer to ${e.name}`}
onClick={() => run(softphone.blindTransfer(active.id, e.number))}
>
Transfer
</Button>
<Button
className="px-2 py-1"
aria-label={`Talk to ${e.name} first`}
onClick={() => run(softphone.call(e.number))}
>
Talk first
</Button>
</>
) : (
<Button
className="px-2 py-1"
aria-label={`Call ${e.name}`}
disabled={calls.length >= 2}
onClick={() => call(e)}
>
Call
</Button>
)}
</li>
))}
</ul>
</section>
);
}
+18 -5
View File
@@ -2,14 +2,15 @@
// 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 { useEffect, useRef, useState } 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 { CALL_DRAG, softphone, type CallInfo } from './SoftphoneEngine.ts';
import { useSoftphone } from './useSoftphone.ts';
import { startRinging, stopRinging } from './sounds.ts';
const KEY = 'softphone.enabled';
@@ -29,8 +30,6 @@ const remember = (on: boolean) => {
}
};
export const useSoftphone = () => useSyncExternalStore(softphone.subscribe, () => softphone.state);
onTakeOver(async () => {
await softphone.stop();
softphone.setElsewhere();
@@ -290,7 +289,10 @@ function CallCard({
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 all = useSoftphone().calls;
const consult = all.find((c) => c.id === consultId);
// Held here while talking on another call (call waiting, or "Talk first" from People).
const other = all.find((c) => c.id !== call.id && c.state === 'active' && !c.held);
const run = (p: Promise<unknown>) => {
onError(null);
p.catch((e: Error) => onError(e.message));
@@ -310,6 +312,9 @@ function CallCard({
role="group"
aria-label={`${label}: ${name}`}
className="flex flex-col gap-2 rounded-md bg-zinc-100 p-2 dark:bg-zinc-900"
// Drop it on a person in People to transfer it there.
draggable={call.state === 'active'}
onDragStart={(e) => e.dataTransfer.setData(CALL_DRAG, call.id)}
>
<div className="flex flex-wrap items-center gap-2">
<span className="flex-1 text-sm">
@@ -330,6 +335,14 @@ function CallCard({
</>
) : (
<>
{call.held && other && !consultId && (
<Button
variant="primary"
onClick={() => run(softphone.attendedTransfer(call.id, other.id))}
>
Complete transfer
</Button>
)}
{call.state === 'active' && (
<>
<Button
@@ -379,3 +379,6 @@ export class SoftphoneEngine {
/** One engine per tab. */
export const softphone = new SoftphoneEngine();
/** dataTransfer type for dragging a call card onto a person in People (blind transfer). */
export const CALL_DRAG = 'application/x-selfphoneng-call';
+6
View File
@@ -0,0 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// React view of the softphone engine (05 §5.1).
import { useSyncExternalStore } from 'react';
import { softphone } from './SoftphoneEngine.ts';
export const useSoftphone = () => useSyncExternalStore(softphone.subscribe, () => softphone.state);