From 609965bba52d2eca1172b0517182ed4ee455d918 Mon Sep 17 00:00:00 2001 From: iamdoubz Date: Sun, 18 Oct 2026 10:06:28 -0500 Subject: [PATCH] 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 --- apps/api/src/live/state.ts | 9 + apps/api/src/routes/me.ts | 30 +++- apps/api/test/asterisk/trunk-test.ts | 6 +- apps/api/test/me-voicemail.test.ts | 20 ++- apps/web/src/api/client.ts | 1 + apps/web/src/pages/user/MePage.tsx | 26 ++- apps/web/src/softphone/PresencePanel.tsx | 114 +++++++++++++ apps/web/src/softphone/SoftphoneDock.tsx | 23 ++- apps/web/src/softphone/SoftphoneEngine.ts | 3 + apps/web/src/softphone/useSoftphone.ts | 6 + docs/spec/05-web-ui.md | 6 + scripts/e2e/README.md | 5 + scripts/e2e/cdp.mjs | 7 +- scripts/e2e/m5-t1.mjs | 12 +- scripts/e2e/m5-t2.mjs | 26 +-- scripts/e2e/m5-t3.mjs | 191 ++++++++++++++++++++++ 16 files changed, 452 insertions(+), 33 deletions(-) create mode 100644 apps/web/src/softphone/PresencePanel.tsx create mode 100644 apps/web/src/softphone/useSoftphone.ts create mode 100644 scripts/e2e/m5-t3.mjs diff --git a/apps/api/src/live/state.ts b/apps/api/src/live/state.ts index 604edd6..c732d71 100644 --- a/apps/api/src/live/state.ts +++ b/apps/api/src/live/state.ts @@ -86,6 +86,15 @@ export class LiveState extends EventEmitter<{ message: [LiveMessage] }> { return admin ? { ...rest, contacts } : rest; } + /** A desk phone or phone app (not the browser) is registered for this extension. */ + deskOnline(number: string): boolean { + return ( + this.exts + .get(number) + ?.contacts.some((c) => c.endpoint === number && c.status !== 'unreachable') ?? false + ); + } + private setAmi(connected: boolean) { if (this.amiConnected === connected) return; this.amiConnected = connected; diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts index f3cdf7d..022fc8d 100644 --- a/apps/api/src/routes/me.ts +++ b/apps/api/src/routes/me.ts @@ -16,7 +16,7 @@ import { decrypt } from '../crypto/secrets.ts'; import { getSetting } from '../services/settings.ts'; import { requireAuth } from '../auth/guards.ts'; import { extensions, voicemailMessages } from '../db/schema.ts'; -import { badRequest, HttpError, notFound } from '../errors.ts'; +import { badRequest, conflict, HttpError, notFound } from '../errors.ts'; import { AudioError, MAX_UPLOAD_BYTES, toPcmWav } from '../media/audio.ts'; import { toVoicemailDto, type watchVoicemail } from '../services/voicemail.ts'; @@ -29,6 +29,13 @@ declare module 'fastify' { const IdParams = z.object({ id: z.uuid() }); const KindParams = z.object({ kind: z.enum(GREETINGS) }); const MoveBody = z.object({ folder: z.enum(['INBOX', 'Old']) }); +/** Anything a person would type: "(312) 555-1234", "+44 20 …", "172". */ +const ClickToCallBody = z.object({ + number: z + .string() + .max(40) + .regex(/^[0-9*#+()\s.-]*[0-9*#][0-9*#+()\s.-]*$/, 'Enter a number'), +}); export async function meRoutes(app: FastifyInstance) { const { db } = app; @@ -95,6 +102,27 @@ export async function meRoutes(app: FastifyInstance) { }; }); + /** Rings the user's desk phone first; when they pick up, it dials `number` (04 §7.3). */ + app.post('/api/me/click-to-call', async (req) => { + const { number } = ClickToCallBody.parse(req.body); + const ext = await myExtension(req.user!.id); + if (!app.ami.isConnected()) + throw new HttpError(503, 'unavailable', 'Asterisk is not connected'); + if (!app.live.deskOnline(ext.number)) + throw conflict("Your desk phone isn't connected, so it can't ring"); + const target = number.replace(/[^0-9*#+]/g, ''); + await app.ami.action({ + Action: 'Originate', + Channel: `PJSIP/${ext.number}`, + Context: 'sp-from-ext', + Exten: target, + Priority: '1', + CallerID: `"${target}" <${ext.number}>`, + Async: 'true', + }); + return { ok: true }; + }); + app.get('/api/me/voicemail', async (req): Promise> => { const ext = await myExtension(req.user!.id); const rows = await db diff --git a/apps/api/test/asterisk/trunk-test.ts b/apps/api/test/asterisk/trunk-test.ts index 8a40e81..c01ac12 100644 --- a/apps/api/test/asterisk/trunk-test.ts +++ b/apps/api/test/asterisk/trunk-test.ts @@ -471,7 +471,11 @@ async function main() { // eslint-disable-next-line no-control-regex -- strips Asterisk's ANSI colors const plainLog = docker('logs', PBX).out.replace(/\x1b\[[0-9;]*m/g, ''); - const errors = plainLog.split('\n').filter((l) => /ERROR\[/.test(l)); + // An RTCP report sent while a test container's network is going away fails with EPERM; + // that's teardown noise, not a config problem. + const errors = plainLog + .split('\n') + .filter((l) => /ERROR\[/.test(l) && !/RTCP SR transmission error/.test(l)); problems.push(...errors.map((l) => `pbx log: ${l.trim()}`)); } catch (e) { problems.push((e as Error).message); diff --git a/apps/api/test/me-voicemail.test.ts b/apps/api/test/me-voicemail.test.ts index 619d2ca..2641905 100644 --- a/apps/api/test/me-voicemail.test.ts +++ b/apps/api/test/me-voicemail.test.ts @@ -77,7 +77,7 @@ describe.skipIf(!hasTestDb)('/api/me/voicemail and greetings', () => { // Asterisk's side of VoicemailMove/VoicemailRemove, on disk (one message, msg0000). t.ami.action = async function (f: Record) { this.actions.push(f); - const from = join(box(), f.Folder!, 'msg0000'); + const from = join(box(), f.Folder ?? '', 'msg0000'); if (f.Action === 'VoicemailMove') { mkdirSync(join(box(), f.ToFolder!), { recursive: true }); for (const x of ['wav', 'txt']) @@ -144,6 +144,24 @@ describe.skipIf(!hasTestDb)('/api/me/voicemail and greetings', () => { t.ami.connected = true; }); + it('click-to-call: rings my desk phone first, then dials the number', async () => { + expect((await dan('POST', '/api/me/click-to-call', { number: '172' })).statusCode).toBe(409); + t.live.deskOnline = (n) => n === '101'; + const r = await dan('POST', '/api/me/click-to-call', { number: '(312) 555-1234' }); + expect(r.statusCode).toBe(200); + expect(t.ami.actions.at(-1)).toEqual({ + Action: 'Originate', + Channel: 'PJSIP/101', + Context: 'sp-from-ext', + Exten: '3125551234', + Priority: '1', + CallerID: '"3125551234" <101>', + Async: 'true', + }); + expect((await dan('POST', '/api/me/click-to-call', { number: 'abc' })).statusCode).toBe(400); + expect((await eve('POST', '/api/me/click-to-call', { number: '172' })).statusCode).toBe(404); + }); + it('webrtc: creds for -web with ICE servers; 404 without an extension', async () => { await setSetting(t.db, 'network.stunServer', 'stun.example.org:3478'); const r = await dan('GET', '/api/me/webrtc'); diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index b798321..8a5d787 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -147,6 +147,7 @@ export const systemApi = { export const meApi = { webrtc: () => api('GET', '/api/me/webrtc'), + clickToCall: (number: string) => api<{ ok: true }>('POST', '/api/me/click-to-call', { number }), voicemail: () => api>('GET', '/api/me/voicemail').then((r) => r.items), audioUrl: (id: string, download = false) => `/api/me/voicemail/${id}/audio${download ? '?download=1' : ''}`, diff --git a/apps/web/src/pages/user/MePage.tsx b/apps/web/src/pages/user/MePage.tsx index 79c70d7..75a331f 100644 --- a/apps/web/src/pages/user/MePage.tsx +++ b/apps/web/src/pages/user/MePage.tsx @@ -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 ( -
+

{APP_NAME}

@@ -58,12 +60,24 @@ export function MePage({ user }: { user: AuthUser }) { Voicemail + {/* People is a right-hand column on wide screens, a tab on small ones (05 §4.1). */} + `${tab(s)} lg:hidden`}> + People + - - } /> - } /> - - +
+
+ + } /> + } /> + } /> + + +
+ +
); } diff --git a/apps/web/src/softphone/PresencePanel.tsx b/apps/web/src/softphone/PresencePanel.tsx new file mode 100644 index 0000000..4d84771 --- /dev/null +++ b/apps/web/src/softphone/PresencePanel.tsx @@ -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(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, 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 ( +
+

+ People +

+ + setQ(e.target.value)} + /> + {note && ( +

+ {note} +

+ )} +
    + {people.map((e) => ( +
  • { + 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)); + }} + > + + + {e.name}{' '} + + {e.number} · {statusLabel(e)} + + + {active ? ( + <> + + + + ) : ( + + )} +
  • + ))} +
+
+ ); +} diff --git a/apps/web/src/softphone/SoftphoneDock.tsx b/apps/web/src/softphone/SoftphoneDock.tsx index ff129f0..ee04314 100644 --- a/apps/web/src/softphone/SoftphoneDock.tsx +++ b/apps/web/src/softphone/SoftphoneDock.tsx @@ -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(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) => { 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)} >
@@ -330,6 +335,14 @@ function CallCard({ ) : ( <> + {call.held && other && !consultId && ( + + )} {call.state === 'active' && ( <>