softphone: People panel with presence, click-to-call
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:
1 parent
c7fd4df77c
commit
609965bba5
16 files changed
+452
-33
No files matched your search
@@ -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' : ''}`,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user