From c8af6f5731f8587d54ff9ae1b3b001102cdfe94d Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 5 Sep 2026 22:48:48 +0300 Subject: [PATCH 01/44] feat(browser): run calls end to end on the REST signaling surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createSignalingCallsBackend: the CallsBackend over CallsSignalingClient — outbound calls mint a browser-side call id and establish media with the SDP offer, answering posts an offer for the announced id, reject and hang-up release the pod's session through the idempotent teardown route. Everything runs on the client token. - IncomingCallRelay + incomingCallFromWebhook: the application relays its `call.received` (and `call.ended`) webhooks into the browser; the mapper turns the JIDRef caller into the displayed peer. - CallsController carries the calling `line` (`linkedDevice` audio+video, `cloudApi` audio-only) and `capabilities`, device lists and selections (`setPreferredDevices`, `switchDevice`, `refreshDevices`), and `connectedAt`; WebRtcMediaFactory honours preferred devices, lists devices, and swaps a live microphone/camera by replacing the outgoing track. - @polymorfa/ui: `calls.*` messages for the complete call UI. --- packages/browser/README.md | 10 +- packages/browser/src/calls/backend.ts | 122 ++++++++++++ packages/browser/src/calls/controller.ts | 140 +++++++++++++- packages/browser/src/calls/index.ts | 14 ++ packages/browser/src/calls/media.ts | 73 ++++++- packages/browser/test/calls-backend.test.ts | 199 ++++++++++++++++++++ packages/browser/test/calls.test.ts | 7 +- packages/ui/src/locale.ts | 23 +++ 8 files changed, 576 insertions(+), 12 deletions(-) create mode 100644 packages/browser/src/calls/backend.ts create mode 100644 packages/browser/test/calls-backend.test.ts diff --git a/packages/browser/README.md b/packages/browser/README.md index 9080a9a4..e6d2c1bd 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -39,9 +39,13 @@ routes directly. Available controller families are QuickLink, conversations and composing, template building, and calls. Calls use `CallsSignalingClient` for the -`voip-v2` offer/answer and trickle ICE REST paths plus `WebRtcMediaFactory` for -browser media. Applications supply `CallsBackend` for lifecycle and call-control -operations that are not part of that REST signaling surface. +offer/answer and trickle ICE REST paths, `WebRtcMediaFactory` for browser +media and device switching, and `createSignalingCallsBackend` as the +`CallsBackend` over that surface. The application relays its `call.received` +webhook into `IncomingCallRelay` (`incomingCallFromWebhook` maps the payload); +reject and hang-up run through the idempotent teardown route. `CallsController` +tracks the calling `line` (`linkedDevice` or `cloudApi`), its `capabilities`, +device lists and selections, and `connectedAt` for duration display. ## Template builder diff --git a/packages/browser/src/calls/backend.ts b/packages/browser/src/calls/backend.ts new file mode 100644 index 00000000..345714b8 --- /dev/null +++ b/packages/browser/src/calls/backend.ts @@ -0,0 +1,122 @@ +import type { + CallEndReason, + CallLifecycleEvent, + CallLine, + CallsBackend, + IncomingCall, + PlaceCallInput, +} from "./controller.js"; +import type { CallsSignaling } from "./signaling.js"; + +/** + * The parts of the `call.received` webhook payload the browser needs. The + * caller is a JID reference; a bare string is accepted for hand-built + * payloads. + */ +export interface CallReceivedWebhookPayload { + readonly callId: string; + readonly from: + | string + | { + readonly id?: string; + readonly phoneNumber?: string; + readonly lid?: string; + }; + readonly hasVideo?: boolean; +} + +/** + * Turn a `call.received` webhook payload into an {@link IncomingCall}. The + * application receives the webhook on its server, relays it to the browser + * over its own realtime channel, and hands it to {@link IncomingCallRelay}. + * The caller is shown by phone number when present, else by LID, else by + * raw JID. + */ +export function incomingCallFromWebhook( + payload: CallReceivedWebhookPayload, + options: { readonly line?: CallLine } = {}, +): IncomingCall { + const from = + typeof payload.from === "string" + ? payload.from + : (payload.from.phoneNumber ?? payload.from.lid ?? payload.from.id ?? ""); + return { + callId: payload.callId, + from, + video: payload.hasVideo === true, + line: options.line ?? "linkedDevice", + }; +} + +/** + * Application-fed source of inbound call notifications. The signaling REST + * surface has no browser push channel, so the application relays + * `call.received` (and optionally `call.ended`) webhook events here. + */ +export class IncomingCallRelay { + readonly #listeners = new Set<(event: CallLifecycleEvent) => void>(); + + /** Announce an inbound call; the controller moves to `incoming`. */ + receive(call: IncomingCall): void { + this.#emit({ type: "incomingCall", call }); + } + + /** Announce that the remote side ended a call (from `call.ended`). */ + ended(callId: string, reason?: CallEndReason): void { + this.#emit( + reason === undefined + ? { type: "ended", callId } + : { type: "ended", callId, reason }, + ); + } + + subscribe(listener: (event: CallLifecycleEvent) => void): () => void { + this.#listeners.add(listener); + return () => this.#listeners.delete(listener); + } + + #emit(event: CallLifecycleEvent): void { + for (const listener of [...this.#listeners]) listener(event); + } +} + +export interface SignalingCallsBackendOptions { + /** REST signaling client for the `/api/voip/calls/{id}` paths. */ + readonly signaling: CallsSignaling; + /** Inbound call notifications relayed by the application. */ + readonly incoming: Pick; + /** Call id generator for outbound calls. Defaults to `crypto.randomUUID`. */ + readonly createCallId?: () => string; +} + +/** + * A {@link CallsBackend} over the REST signaling surface alone. Outbound calls + * mint a browser-side call id and establish media by posting the SDP offer + * (the media factory does that); answering an inbound call likewise posts an + * offer for the announced call id; reject and hang-up release the pod's + * session with the idempotent teardown route. No server API key is involved: + * everything runs on the client token. + */ +export function createSignalingCallsBackend( + options: SignalingCallsBackendOptions, +): CallsBackend { + const createCallId = options.createCallId ?? (() => crypto.randomUUID()); + return { + subscribe: (listener) => options.incoming.subscribe(listener), + place: async (input: PlaceCallInput, signal: AbortSignal) => { + throwIfAborted(signal); + return { callId: createCallId() }; + }, + answer: async (_callId: string, signal: AbortSignal) => { + throwIfAborted(signal); + }, + reject: (callId: string, signal: AbortSignal) => + options.signaling.teardown(callId, signal), + hangup: (callId: string, signal: AbortSignal) => + options.signaling.teardown(callId, signal), + }; +} + +function throwIfAborted(signal: AbortSignal): void { + if (signal.aborted) throw signal.reason; +} diff --git a/packages/browser/src/calls/controller.ts b/packages/browser/src/calls/controller.ts index 30a73f6d..a373e1da 100644 --- a/packages/browser/src/calls/controller.ts +++ b/packages/browser/src/calls/controller.ts @@ -24,10 +24,42 @@ export type CallEndReason = | "capacity" | "remote_hangup" | (string & {}); +/** + * Which Polymorfa calling line carries a call: a linked WhatsApp device + * session (audio + video) or the WhatsApp Business Calling API (audio only). + */ +export type CallLine = "linkedDevice" | "cloudApi"; + +export interface CallCapabilities { + readonly video: boolean; + readonly mute: boolean; +} + +export function capabilitiesFor(line: CallLine): CallCapabilities { + return { video: line !== "cloudApi", mute: true }; +} + +export type CallDeviceKind = "audioinput" | "videoinput" | "audiooutput"; + +export interface CallDevice { + readonly deviceId: string; + readonly kind: CallDeviceKind; + readonly label: string; +} + +/** Capture/playback device choices. `audioOutput` needs `setSinkId` support. */ +export interface SelectedCallDevices { + readonly audioInput?: string; + readonly videoInput?: string; + readonly audioOutput?: string; +} + export interface IncomingCall { readonly callId: string; readonly from: string; readonly video: boolean; + /** Defaults to `linkedDevice`. */ + readonly line?: CallLine; } export type CallLifecycleEvent = | { readonly type: "incomingCall"; readonly call: IncomingCall } @@ -48,6 +80,7 @@ export type CallLifecycleEvent = export interface PlaceCallInput { readonly to: string; readonly video: boolean; + readonly line: CallLine; readonly idempotencyKey: string; } export interface CallsBackend { @@ -65,9 +98,15 @@ export interface CallsSnapshot extends ControllerSnapshot { readonly callId?: string; readonly peer?: string; readonly direction?: "incoming" | "outgoing"; + readonly line: CallLine; + readonly capabilities: CallCapabilities; readonly video: boolean; readonly audioMuted: boolean; readonly videoMuted: boolean; + /** Set once media connected; drives the call duration display. */ + readonly connectedAt?: number; + readonly devices: readonly CallDevice[]; + readonly selectedDevices: SelectedCallDevices; readonly endReason?: CallEndReason; readonly error?: { readonly code: string; @@ -84,6 +123,7 @@ export class CallsController extends ObservableController { readonly #backend: CallsBackend; readonly #mediaFactory: CallMediaFactory; readonly #createKey: () => string; + readonly #now: () => number; #abort = new AbortController(); #unsubscribe: (() => void) | undefined; #media: CallMediaSession | undefined; @@ -95,11 +135,21 @@ export class CallsController extends ObservableController { options: CallsControllerOptions = {}, ) { super( - { status: "idle", video: false, audioMuted: false, videoMuted: false }, + { + status: "idle", + line: "linkedDevice", + capabilities: capabilitiesFor("linkedDevice"), + video: false, + audioMuted: false, + videoMuted: false, + devices: [], + selectedDevices: {}, + }, options.now, ); this.#backend = backend; this.#mediaFactory = mediaFactory; + this.#now = options.now ?? Date.now; this.#createKey = options.createIdempotencyKey ?? (() => crypto.randomUUID()); } @@ -115,21 +165,26 @@ export class CallsController extends ObservableController { async place( to: string, - options: { readonly video?: boolean } = {}, + options: { readonly video?: boolean; readonly line?: CallLine } = {}, ): Promise { const operation = this.#begin(); - const video = options.video ?? false; + const line = options.line ?? "linkedDevice"; + const capabilities = capabilitiesFor(line); + const video = (options.video ?? false) && capabilities.video; try { const { callId } = await this.#backend.place( - { to, video, idempotencyKey: this.#createKey() }, + { to, video, line, idempotencyKey: this.#createKey() }, this.#abort.signal, ); if (operation !== this.#operation) return; this.transition({ + ...this.#deviceFields(), status: "ringing", callId, peer: to, direction: "outgoing", + line, + capabilities, video, audioMuted: false, videoMuted: false, @@ -145,7 +200,8 @@ export class CallsController extends ObservableController { if (current.status !== "incoming" || current.callId === undefined) throw new Error("No incoming call is available to answer."); const operation = this.#begin(false); - const video = options.video ?? current.video; + const video = + (options.video ?? current.video) && current.capabilities.video; try { await this.#backend.answer(current.callId, this.#abort.signal); if (operation !== this.#operation) return; @@ -183,6 +239,54 @@ export class CallsController extends ObservableController { videoMuted: muted.video ?? current.videoMuted, }); } + /** Choose the devices the next call acquires (and the speaker the UI plays through). */ + setPreferredDevices(devices: SelectedCallDevices): void { + this.assertActive(); + const current = this.getSnapshot(); + this.transition({ + ...callFields(current), + status: current.status, + selectedDevices: { ...current.selectedDevices, ...devices }, + }); + } + + /** + * Switch the live microphone or camera mid-call (the outgoing track is + * replaced in place, mute state carries over) and remember the choice for + * the next call. Without an active media session only the preference is + * stored. + */ + async switchDevice( + kind: "audioInput" | "videoInput", + deviceId: string, + ): Promise { + this.setPreferredDevices({ [kind]: deviceId }); + const media = this.#media; + if (media?.switchInput === undefined) return; + try { + await media.switchInput( + kind === "audioInput" ? "audio" : "video", + deviceId, + this.#abort.signal, + ); + } catch { + // Device unavailable — keep the current capture. + } + } + + /** Re-enumerate capture/playback devices into the snapshot. */ + async refreshDevices(): Promise { + this.assertActive(); + if (this.#mediaFactory.listDevices === undefined) return; + const devices = await this.#mediaFactory.listDevices(); + const current = this.getSnapshot(); + this.transition({ + ...callFields(current), + status: current.status, + devices, + }); + } + get localStream(): MediaStream | undefined { return this.#media?.localStream; } @@ -227,6 +331,7 @@ export class CallsController extends ObservableController { }, }, this.#abort.signal, + { devices: this.getSnapshot().selectedDevices }, ); if (operation !== this.#operation) { await media.close(); @@ -238,7 +343,11 @@ export class CallsController extends ObservableController { const current = this.getSnapshot(); if (current.callId !== callId) return; if (state === "connected") - this.transition({ ...callFields(current), status: "connected" }); + this.transition({ + ...callFields(current), + status: "connected", + connectedAt: current.connectedAt ?? this.#now(), + }); else if (state === "failed" || state === "closed") { void this.#closeMedia(); this.transition({ @@ -278,12 +387,17 @@ export class CallsController extends ObservableController { return; if (event.type === "incomingCall") { if (!["idle", "ready", "ended"].includes(current.status)) return; + const line = event.call.line ?? "linkedDevice"; + const capabilities = capabilitiesFor(line); this.transition({ + ...this.#deviceFields(), status: "incoming", callId: event.call.callId, peer: event.call.from, direction: "incoming", - video: event.call.video, + line, + capabilities, + video: event.call.video && capabilities.video, audioMuted: false, videoMuted: false, }); @@ -304,6 +418,11 @@ export class CallsController extends ObservableController { this.transition({ ...callFields(current), status: event.type }); } } + #deviceFields(): Pick { + const { devices, selectedDevices } = this.getSnapshot(); + return { devices, selectedDevices }; + } + #requireIncoming(): CallsSnapshot & { callId: string } { const current = this.getSnapshot(); if (current.status !== "incoming" || current.callId === undefined) @@ -340,9 +459,16 @@ function callFields( ...(snapshot.direction === undefined ? {} : { direction: snapshot.direction }), + line: snapshot.line, + capabilities: snapshot.capabilities, video: snapshot.video, audioMuted: snapshot.audioMuted, videoMuted: snapshot.videoMuted, + ...(snapshot.connectedAt === undefined + ? {} + : { connectedAt: snapshot.connectedAt }), + devices: snapshot.devices, + selectedDevices: snapshot.selectedDevices, ...(snapshot.endReason === undefined ? {} : { endReason: snapshot.endReason }), diff --git a/packages/browser/src/calls/index.ts b/packages/browser/src/calls/index.ts index 01ae6b9e..54a2f1e3 100644 --- a/packages/browser/src/calls/index.ts +++ b/packages/browser/src/calls/index.ts @@ -1,18 +1,32 @@ export { CallsController, + capabilitiesFor, + type CallCapabilities, + type CallDevice, + type CallDeviceKind, type CallEndReason, type CallLifecycleEvent, + type CallLine, type CallStatus, type CallsBackend, type CallsControllerOptions, type CallsSnapshot, type IncomingCall, type PlaceCallInput, + type SelectedCallDevices, } from "./controller.js"; +export { + IncomingCallRelay, + createSignalingCallsBackend, + incomingCallFromWebhook, + type CallReceivedWebhookPayload, + type SignalingCallsBackendOptions, +} from "./backend.js"; export { WebRtcMediaFactory, type CallMediaCallbacks, type CallMediaFactory, + type CallMediaPreferences, type CallMediaSession, type WebRtcMediaFactoryOptions, } from "./media.js"; diff --git a/packages/browser/src/calls/media.ts b/packages/browser/src/calls/media.ts index 4f9ad011..7f7177d4 100644 --- a/packages/browser/src/calls/media.ts +++ b/packages/browser/src/calls/media.ts @@ -3,6 +3,7 @@ import type { SdpAnswer, TrickleCandidate, } from "./signaling.js"; +import type { CallDevice, SelectedCallDevices } from "./controller.js"; export interface CallMediaCallbacks { readonly onConnectionState: (state: RTCPeerConnectionState) => void; @@ -14,15 +15,30 @@ export interface CallMediaSession { setMuted(muted: { readonly audio?: boolean; readonly video?: boolean }): void; audioEnabled(): boolean; videoEnabled(): boolean; + /** + * Swap the live capture device: acquires `deviceId` and replaces the + * outgoing track in place (mute state carries over). Optional for fakes. + */ + switchInput?( + kind: "audio" | "video", + deviceId: string, + signal: AbortSignal, + ): Promise; close(): Promise; } +export interface CallMediaPreferences { + readonly devices?: SelectedCallDevices; +} export interface CallMediaFactory { open( callId: string, video: boolean, callbacks: CallMediaCallbacks, signal: AbortSignal, + preferences?: CallMediaPreferences, ): Promise; + /** Enumerate capture/playback devices. Optional for fakes. */ + listDevices?(): Promise; } export interface WebRtcMediaFactoryOptions { readonly signaling: CallsSignaling; @@ -54,14 +70,30 @@ export class WebRtcMediaFactory implements CallMediaFactory { this.#clearInterval = options.clearInterval ?? globalThis.clearInterval; } + async listDevices(): Promise { + const devices = await this.#mediaDevices.enumerateDevices(); + return devices + .filter((device) => + ["audioinput", "videoinput", "audiooutput"].includes(device.kind), + ) + .map((device) => ({ + deviceId: device.deviceId, + kind: device.kind as CallDevice["kind"], + label: device.label, + })); + } + async open( callId: string, video: boolean, callbacks: CallMediaCallbacks, signal: AbortSignal, + preferences: CallMediaPreferences = {}, ): Promise { throwIfAborted(signal); - const local = await this.#mediaDevices.getUserMedia({ audio: true, video }); + const local = await this.#mediaDevices.getUserMedia( + constraintsFor(video, preferences.devices), + ); if (signal.aborted) { for (const track of local.getTracks()) track.stop(); throw signal.reason; @@ -111,6 +143,28 @@ export class WebRtcMediaFactory implements CallMediaFactory { }, audioEnabled: () => local.getAudioTracks().some(({ enabled }) => enabled), videoEnabled: () => local.getVideoTracks().some(({ enabled }) => enabled), + switchInput: async (kind, deviceId, switchSignal) => { + throwIfAborted(switchSignal); + const stream = await this.#mediaDevices.getUserMedia( + kind === "audio" + ? { audio: { deviceId: { ideal: deviceId } } } + : { video: { deviceId: { ideal: deviceId } } }, + ); + const track = + kind === "audio" + ? stream.getAudioTracks()[0] + : stream.getVideoTracks()[0]; + if (track === undefined) return; + const old = local.getTracks().find((t) => t.kind === kind); + if (old !== undefined) track.enabled = old.enabled; + const sender = peer.getSenders().find((s) => s.track?.kind === kind); + if (sender !== undefined) await sender.replaceTrack(track); + if (old !== undefined) { + old.stop(); + local.removeTrack(old); + } + local.addTrack(track); + }, close: async () => { if (closed) return; closed = true; @@ -126,6 +180,23 @@ export class WebRtcMediaFactory implements CallMediaFactory { } } +function constraintsFor( + video: boolean, + devices: SelectedCallDevices | undefined, +): MediaStreamConstraints { + const audioInput = devices?.audioInput; + const videoInput = devices?.videoInput; + return { + audio: + audioInput === undefined ? true : { deviceId: { ideal: audioInput } }, + video: video + ? videoInput === undefined + ? true + : { deviceId: { ideal: videoInput } } + : false, + }; +} + function candidateFrom(candidate: RTCIceCandidateInit): TrickleCandidate { return { candidate: candidate.candidate ?? "", diff --git a/packages/browser/test/calls-backend.test.ts b/packages/browser/test/calls-backend.test.ts new file mode 100644 index 00000000..59b02955 --- /dev/null +++ b/packages/browser/test/calls-backend.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it, vi } from "vitest"; +import { + CallsController, + IncomingCallRelay, + capabilitiesFor, + createSignalingCallsBackend, + incomingCallFromWebhook, + type CallMediaFactory, + type CallMediaSession, + type CallsSignaling, +} from "../src/index.js"; + +function signaling(): CallsSignaling & { teardown: ReturnType } { + return { + offer: vi.fn(async () => ({ sdp: "v=0", iceServers: [] })), + candidate: vi.fn(async () => undefined), + candidates: vi.fn(async () => []), + teardown: vi.fn(async () => undefined), + }; +} + +function media(): { + factory: CallMediaFactory; + session: CallMediaSession & { switchInput: ReturnType }; + listDevices: ReturnType; +} { + const session = { + localStream: {} as MediaStream, + remoteStream: {} as MediaStream, + setMuted: vi.fn(), + audioEnabled: () => true, + videoEnabled: () => true, + switchInput: vi.fn(async () => undefined), + close: vi.fn(async () => undefined), + }; + const listDevices = vi.fn(async () => [ + { deviceId: "mic-2", kind: "audioinput" as const, label: "Desk mic" }, + { deviceId: "cam-1", kind: "videoinput" as const, label: "Webcam" }, + ]); + const factory: CallMediaFactory = { + open: vi.fn(async () => session), + listDevices, + }; + return { factory, session, listDevices }; +} + +describe("incomingCallFromWebhook", () => { + it("maps a call.received payload onto an IncomingCall", () => { + expect( + incomingCallFromWebhook({ + callId: "CALL-1", + from: { + id: "12025550123@s.whatsapp.net", + phoneNumber: "+12025550123", + lid: "5550123@lid", + }, + hasVideo: true, + }), + ).toEqual({ + callId: "CALL-1", + from: "+12025550123", + video: true, + line: "linkedDevice", + }); + expect( + incomingCallFromWebhook({ callId: "c", from: { lid: "5550123@lid" } }) + .from, + ).toBe("5550123@lid"); + expect( + incomingCallFromWebhook( + { callId: "c", from: "+12025550123", hasVideo: true }, + { line: "cloudApi" }, + ).line, + ).toBe("cloudApi"); + }); +}); + +describe("createSignalingCallsBackend", () => { + it("relays inbound calls, mints call ids, and tears down on reject/hangup", async () => { + const relay = new IncomingCallRelay(); + const s = signaling(); + const backend = createSignalingCallsBackend({ + signaling: s, + incoming: relay, + createCallId: () => "call-out-1", + }); + const m = media(); + const controller = new CallsController(backend, m.factory); + controller.initialize(); + + relay.receive({ callId: "CALL-1", from: "+12025550123", video: false }); + expect(controller.getSnapshot()).toMatchObject({ + status: "incoming", + peer: "+12025550123", + capabilities: { video: true, mute: true }, + }); + await controller.reject(); + expect(s.teardown).toHaveBeenCalledWith("CALL-1", expect.any(AbortSignal)); + expect(controller.getSnapshot()).toMatchObject({ + status: "ended", + endReason: "rejected", + }); + + await controller.place("+12025550199", { video: true }); + expect(controller.getSnapshot()).toMatchObject({ + status: "connecting", + callId: "call-out-1", + direction: "outgoing", + video: true, + }); + expect(m.factory.open).toHaveBeenCalledWith( + "call-out-1", + true, + expect.any(Object), + expect.any(AbortSignal), + { devices: {} }, + ); + await controller.hangup(); + expect(s.teardown).toHaveBeenLastCalledWith( + "call-out-1", + expect.any(AbortSignal), + ); + + relay.receive({ callId: "CALL-2", from: "+12025550123", video: false }); + relay.ended("CALL-2", "missed"); + expect(controller.getSnapshot()).toMatchObject({ + status: "ended", + endReason: "missed", + }); + }); + + it("keeps the Business Calling API line audio-only", async () => { + const relay = new IncomingCallRelay(); + const backend = createSignalingCallsBackend({ + signaling: signaling(), + incoming: relay, + }); + const m = media(); + const controller = new CallsController(backend, m.factory); + controller.initialize(); + expect(capabilitiesFor("cloudApi")).toEqual({ video: false, mute: true }); + + relay.receive({ + callId: "CALL-3", + from: "+12025550123", + video: true, + line: "cloudApi", + }); + expect(controller.getSnapshot()).toMatchObject({ + line: "cloudApi", + video: false, + capabilities: { video: false }, + }); + await controller.answer({ video: true }); + expect(m.factory.open).toHaveBeenCalledWith( + "CALL-3", + false, + expect.any(Object), + expect.any(AbortSignal), + { devices: {} }, + ); + }); + + it("stores device preferences, switches live inputs, and lists devices", async () => { + const relay = new IncomingCallRelay(); + const backend = createSignalingCallsBackend({ + signaling: signaling(), + incoming: relay, + createCallId: () => "call-dev", + }); + const m = media(); + const controller = new CallsController(backend, m.factory, { + now: () => 1_000, + }); + controller.initialize(); + controller.setPreferredDevices({ audioInput: "mic-1", audioOutput: "spk" }); + await controller.refreshDevices(); + expect(controller.getSnapshot().devices).toHaveLength(2); + + await controller.place("+12025550123"); + expect(m.factory.open).toHaveBeenLastCalledWith( + "call-dev", + false, + expect.any(Object), + expect.any(AbortSignal), + { devices: { audioInput: "mic-1", audioOutput: "spk" } }, + ); + await controller.switchDevice("audioInput", "mic-2"); + expect(m.session.switchInput).toHaveBeenCalledWith( + "audio", + "mic-2", + expect.any(AbortSignal), + ); + expect(controller.getSnapshot().selectedDevices).toEqual({ + audioInput: "mic-2", + audioOutput: "spk", + }); + }); +}); diff --git a/packages/browser/test/calls.test.ts b/packages/browser/test/calls.test.ts index 9683f7cc..ba75537a 100644 --- a/packages/browser/test/calls.test.ts +++ b/packages/browser/test/calls.test.ts @@ -77,7 +77,12 @@ describe("CallsController (voip-v2 contract)", () => { controller.initialize(); await controller.place("+12025550123", { video: true }); expect(f.backend.place).toHaveBeenCalledWith( - { to: "+12025550123", video: true, idempotencyKey: "idem-1" }, + { + to: "+12025550123", + video: true, + line: "linkedDevice", + idempotencyKey: "idem-1", + }, expect.any(AbortSignal), ); controller.setMuted({ audio: true, video: true }); diff --git a/packages/ui/src/locale.ts b/packages/ui/src/locale.ts index 7fe7bef1..a0eabbf1 100644 --- a/packages/ui/src/locale.ts +++ b/packages/ui/src/locale.ts @@ -28,6 +28,29 @@ export const ENGLISH_MESSAGES = { "calls.cameraOff": "Turn camera off", "calls.reconnecting": "Reconnecting", "calls.permissionDenied": "Camera or microphone permission was denied.", + "calls.audioCall": "WhatsApp audio call", + "calls.videoCall": "WhatsApp video call", + "calls.ringing": "Ringing", + "calls.connecting": "Connecting", + "calls.ended": "Call ended", + "calls.failed": "Call failed", + "calls.answerWithCamera": "Answer with camera", + "calls.answerWithoutCamera": "Answer without camera", + "calls.answerMuted": "Answer muted", + "calls.answerUnmuted": "Answer unmuted", + "calls.deviceSettings": "Audio and video settings", + "calls.microphone": "Microphone", + "calls.speaker": "Speaker", + "calls.camera": "Camera", + "calls.microphoneSettings": "Microphone and speaker settings", + "calls.cameraSettings": "Camera settings", + "calls.popOut": "Open in a separate window", + "calls.popIn": "Return to the page", + "calls.placeAudioCall": "Place audio call", + "calls.placeVideoCall": "Place video call", + "calls.dialPlaceholder": "Phone number", + "calls.dismiss": "Dismiss", + "calls.incomingFrom": "Incoming call from {peer}", } as const; export type MessageKey = keyof typeof ENGLISH_MESSAGES; From 97d36ca327b0cbbb8abdc5eebda3c3cf9e1df8da Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 5 Sep 2026 22:48:48 +0300 Subject: [PATCH 02/44] feat(react): the complete calls UI over CallsController MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the placeholder CallSurface with the full call experience shaped after the official WhatsApp desktop call windows in a monochrome Material-3 voice: IncomingCallCard (name, audio/video subtitle, avatar or mirrored self-preview with camera/mute toggles and a ⋯ device menu, Reject/Answer), CallStage (remote video or avatar hero, PiP self-view, name + ringing/ number ⇄ duration line, always dark), CallControls (split [camera|⌄] and [mic|⌄] pills with live device pickers, red hang-up), DialPad (digits only; video call only where the line carries video), useCallPopout (separate resizable call window), useCallDuration/formatDuration. Colors derive from the shared --pmfa-* appearance variables; strings from the calls.* locale keys; everything gates on snapshot.capabilities. pmfa-call follows the same controller contract with localized labels. Happy DOM tests cover the incoming card, decline over the wire, answer/mute/hang-up, dialing per line, and provider direction/theme. Design record: docs/superpowers/specs/ 2026-09-05-calls-ui-design.md; React example wires the signaling backend. --- .../specs/2026-09-05-calls-ui-design.md | 78 + examples/react/App.tsx | 55 + packages/elements/src/calls.ts | 79 +- packages/react/README.md | 54 + packages/react/src/calls.tsx | 1500 +++++++++++++++++ packages/react/src/components.tsx | 56 - packages/react/src/index.ts | 21 +- packages/react/test/calls.test.tsx | 213 +++ 8 files changed, 1979 insertions(+), 77 deletions(-) create mode 100644 docs/superpowers/specs/2026-09-05-calls-ui-design.md create mode 100644 packages/react/src/calls.tsx create mode 100644 packages/react/test/calls.test.tsx diff --git a/docs/superpowers/specs/2026-09-05-calls-ui-design.md b/docs/superpowers/specs/2026-09-05-calls-ui-design.md new file mode 100644 index 00000000..c90bfcfb --- /dev/null +++ b/docs/superpowers/specs/2026-09-05-calls-ui-design.md @@ -0,0 +1,78 @@ +# Calls UI design + +**Date:** 2026-09-05 +**Status:** Shipped on `dev` in `@polymorfa/react` (`CallSurface`, +`IncomingCallCard`, `CallStage`, `CallControls`, `DialPad`) over the +`@polymorfa/browser` `CallsController`. + +## Outcome + +One call UI for both calling lines — a paired WhatsApp device session +(`linkedDevice`, audio and video) and the WhatsApp Business Calling API +(`cloudApi`, audio only) — behind a single capability-gated call model. +Components read `snapshot.capabilities`; nothing branches on the line name. + +## Design language + +Monochrome Material 3: black identity, white surfaces, tonal fills, hairline +outlines, 8px radius, Roboto with the appearance font as override. The verbs +carry the only color — success green answers or places a call, danger red +declines or hangs up. Cards follow the appearance theme (`light`, `dark`, or +`system`); the call stage, and the incoming card once it shows a camera +preview, is always dark like an OS call screen. + +Colors resolve from the shared `--pmfa-*` appearance variables with the +monochrome palette as fallbacks: `--pmfa-color-foreground` is the identity +accent, `--pmfa-color-background` / `--pmfa-color-border` / `--pmfa-color-muted` +shape cards, `--pmfa-color-success` and `--pmfa-color-danger` are the verbs, +`--pmfa-radius-medium` and `--pmfa-font-family` carry shape and type. The +`call` entry of `appearance.elements` adds a class and inline styles to every +call root. + +## Layout (after the official desktop call windows) + +- **Incoming card** — big centered name, muted "WhatsApp audio/video call" + subtitle, then the media middle: the avatar for audio offers, a mirrored + self-preview with camera and mute toggles and a ⋯ device menu for video + offers. Two labeled verbs close the card: Reject, Answer. Voice offers keep + the same 16/10 footprint as video offers and carry no mute row. +- **Stage** — remote video full-bleed when the far side sends video, the + centered avatar hero otherwise, the local preview as a picture-in-picture + tile; before connecting, an outgoing video call shows the identity above an + inset self-preview. The line under the name reads "Ringing +number…" while + an outgoing call waits, then alternates number ⇄ duration every five seconds. +- **Dock** — split pills `[camera|⌄]` and `[mic|⌄]`: the toggle is the + action, the chevron opens the device panel (camera picks the camera; mic + picks microphone and speaker). The camera pill is disabled until the call + connects and on lines without video. Red hang-up pill on the right. +- **Dial pad** — digits only (no letter sub-labels), E.164 entry, audio call + always, video call only where the line carries video. +- **Pop-out** — `CallSurface` offers a ↗ button that moves the stage and dock + into a separate resizable window; closing it, or the call ending, brings + the call back inline. + +## Behavior rules + +- Video is per direction: enabling the local camera never renegotiates the + peer's stream. Answering a video offer with the camera toggled off still + acquires video muted so the in-call toggle can enable it. +- Device panels overlay the widget and never resize it. Device picks apply + live: a microphone or camera change replaces the outgoing track in place + and mute state carries over; the speaker pick uses `setSinkId` where the + browser supports it. +- No hover growth, no press bounce, no drop shadows. All motion (answer pulse, + dock rise) is disabled under `prefers-reduced-motion: reduce`. +- Avatars: a resolved profile picture wins; otherwise a stable hash of the + caller id picks one of eight muted palette tones so the same caller always + gets the same color. +- Examples and documentation use fictional numbers and personas only. + +## Wire contract + +Signaling is the REST surface on the client token — offer/answer, trickle ICE +submission and polling, idempotent teardown — through +`createSignalingCallsBackend`. Inbound calls arrive as the server's +`call.received` webhook and are relayed into `IncomingCallRelay`; +`incomingCallFromWebhook` maps the payload. The server mints the browser token +with `MessagingClient.voip.token` after granting the session's client rules the +`voip_place`, `voip_answer`, and `voip_signal` actions. diff --git a/examples/react/App.tsx b/examples/react/App.tsx index ae8e9489..2e3b69e3 100644 --- a/examples/react/App.tsx +++ b/examples/react/App.tsx @@ -25,3 +25,58 @@ export function App() { ); } + +// ── Calls ───────────────────────────────────────────────────────────── +// +// The browser signs into the signaling surface with a client token minted by +// your server (`MessagingClient.voip.token`). Inbound calls reach your server +// as the `call.received` webhook; relay them to the browser and hand them to +// the relay below. +import { + BrowserTransport, + CallsController, + CallsSignalingClient, + IncomingCallRelay, + WebRtcMediaFactory, + createClientTokenProvider, + createSignalingCallsBackend, + incomingCallFromWebhook, +} from "@polymorfa/browser"; +import { CallSurface, DialPad } from "@polymorfa/react"; + +const callTransport = new BrowserTransport({ + getClientToken: createClientTokenProvider(), +}); +const callSignaling = new CallsSignalingClient(callTransport); +export const incomingCalls = new IncomingCallRelay(); +const calls = new CallsController( + createSignalingCallsBackend({ + signaling: callSignaling, + incoming: incomingCalls, + }), + new WebRtcMediaFactory({ signaling: callSignaling }), +); +calls.initialize(); + +/** Wire this to your realtime channel that forwards the webhook payload. */ +export function onCallReceivedWebhook(payload: { + callId: string; + from: { id?: string; phoneNumber?: string; lid?: string }; + hasVideo?: boolean; +}): void { + incomingCalls.receive(incomingCallFromWebhook(payload)); +} + +export function CallsApp() { + return ( + + + + peer === "+12025550123" ? "Casey Rivera" : undefined + } + /> + + ); +} diff --git a/packages/elements/src/calls.ts b/packages/elements/src/calls.ts index 096046a8..24049709 100644 --- a/packages/elements/src/calls.ts +++ b/packages/elements/src/calls.ts @@ -1,49 +1,90 @@ import type { CallsController, CallsSnapshot } from "@polymorfa/browser"; +import type { Locale } from "@polymorfa/ui"; import { PolymorfaElement, button, element, textElement } from "./base.js"; +const ACTIVE = new Set([ + "ringing", + "accepted", + "connecting", + "connected", +]); + +/** + * Portable call surface: incoming card (answer/reject) and the active call + * controls (mute, camera when the line carries video, hang up). The React + * package carries the full official-window layout; this element keeps the + * same controller contract for React-free hosts. + */ export class PolymorfaCallElement extends PolymorfaElement { protected renderContent( snapshot: CallsSnapshot | undefined, + locale: Locale, ): readonly Node[] { + const messages = locale.messages; const panel = element("section", "panel call"); panel.setAttribute("aria-live", "assertive"); - panel.append(textElement("h2", snapshot?.status ?? "ready", "status")); - if (snapshot?.status === "incoming") + const status = snapshot?.status ?? "ready"; + const heading = + status === "incoming" + ? messages["calls.incoming"] + : status === "ringing" + ? messages["calls.ringing"] + : status === "connecting" || status === "accepted" + ? messages["calls.connecting"] + : status === "ended" + ? messages["calls.ended"] + : status === "error" + ? messages["calls.failed"] + : status; + panel.append(textElement("h2", heading, "status")); + if (snapshot?.peer !== undefined) + panel.append(textElement("p", snapshot.peer, "peer")); + const controller = this.configuredController(); + if (status === "incoming") panel.append( button( - "Answer", + messages["calls.answer"], "primary answer", - () => void this.configuredController()?.answer(), + () => void controller?.answer(), ), button( - "Reject", + messages["calls.reject"], "reject", - () => void this.configuredController()?.reject(), + () => void controller?.reject(), ), ); - if ( - ["connected", "accepted", "connecting", "ringing"].includes( - snapshot?.status ?? "", - ) - ) { + if (snapshot !== undefined && ACTIVE.has(status)) { panel.append( - button(snapshot?.audioMuted ? "Unmute" : "Mute", "mute", () => - this.configuredController()?.setMuted({ - audio: !(snapshot?.audioMuted ?? false), - }), + button( + snapshot.audioMuted + ? messages["calls.unmute"] + : messages["calls.mute"], + "mute", + () => controller?.setMuted({ audio: !snapshot.audioMuted }), ), + ); + if (snapshot.capabilities.video && snapshot.video) + panel.append( + button( + snapshot.videoMuted + ? messages["calls.cameraOn"] + : messages["calls.cameraOff"], + "camera", + () => controller?.setMuted({ video: !snapshot.videoMuted }), + ), + ); + panel.append( button( - "Hang up", + messages["calls.hangup"], "hangup", - () => void this.configuredController()?.hangup(), + () => void controller?.hangup(), ), ); } - if (snapshot?.peer !== undefined) - panel.append(textElement("p", snapshot.peer, "peer")); return [panel]; } } + export function defineCallElements( registry: CustomElementRegistry = customElements, ): void { diff --git a/packages/react/README.md b/packages/react/README.md index 2fb1ce42..88287ccb 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -21,3 +21,57 @@ carousel card bodies, and variable examples. It keeps “Save draft” and “Su to Meta” as separate actions and renders structured previews. Use `renderPreview` to replace the preview region without replacing controller state. + +## Calls + +`CallSurface` is the whole call experience: the incoming card while a call +rings, the stage and control dock during the call, and a ↗ button that pops +the call into its own resizable window. `IncomingCallCard`, `CallStage`, +`CallControls`, and `DialPad` compose individually. All of them render from a +`CallsController` and follow the provider's appearance and locale. + +```tsx +import { + BrowserTransport, + CallsController, + CallsSignalingClient, + IncomingCallRelay, + WebRtcMediaFactory, + createClientTokenProvider, + createSignalingCallsBackend, + incomingCallFromWebhook, +} from "@polymorfa/browser"; +import { CallSurface, DialPad, PolymorfaProvider } from "@polymorfa/react"; + +const transport = new BrowserTransport({ + getClientToken: createClientTokenProvider(), // POST /api/polymorfa-token → pmfa_ct_… +}); +const signaling = new CallsSignalingClient(transport); +const incoming = new IncomingCallRelay(); +const calls = new CallsController( + createSignalingCallsBackend({ signaling, incoming }), + new WebRtcMediaFactory({ signaling }), +); +calls.initialize(); + +// Your realtime channel delivers the server's `call.received` webhook: +realtime.on("call.received", (payload) => + incoming.receive(incomingCallFromWebhook(payload)), +); + + + + +; +``` + +The incoming card mirrors the official desktop call window: name, "WhatsApp +audio/video call" subtitle, avatar or a mirrored self-preview with camera and +mute toggles and a ⋯ device menu, then Reject and Answer. Answering a video +offer with the camera off still acquires video muted, so the in-call camera +toggle can enable it. The dock carries split pills — `[camera|⌄]` and +`[mic|⌄]` whose dropdowns pick devices live — and a red hang-up pill. Video is +per direction; `disableVideo` hides the camera control. Avatars come from +`resolveAvatar` (a URL or a promise of one; `BrowserMessagingClient.contacts +.picture` works when the token carries `read_contact`); without one a stable +hash of the caller id picks one of eight palette tones. diff --git a/packages/react/src/calls.tsx b/packages/react/src/calls.tsx new file mode 100644 index 00000000..caebaf43 --- /dev/null +++ b/packages/react/src/calls.tsx @@ -0,0 +1,1500 @@ +// Polymorfa Calls UI — React components over `CallsController`. +// +// Shaped after the official WhatsApp desktop call windows in a monochrome +// Material-3 voice: black identity, tonal fills, hairline outlines, 8px +// radius; the verbs carry the only color (success green answers, danger red +// declines/hangs up). Cards follow the appearance theme; the call stage — and +// the incoming card once it carries a camera preview — is always dark, like an +// OS call screen. Widgets are fluid and never resize when a panel opens; +// device panels overlay them. Every affordance gates on the snapshot's +// `capabilities`, never on the line name. + +import { + capabilitiesFor, + type CallDevice, + type CallLine, + type CallsController, + type CallsSnapshot, +} from "@polymorfa/browser"; +import { appearanceToCssVariables, type Locale } from "@polymorfa/ui"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type CSSProperties, + type ReactNode, + type SVGProps, +} from "react"; +import { createPortal } from "react-dom"; +import { usePolymorfa } from "./context.js"; +import { useController, useResolvedController } from "./hooks.js"; + +type ControllerProps = { + readonly controller?: CallsController; + readonly createController?: () => CallsController; +}; + +const ACTIVE_STATUSES = new Set([ + "ringing", + "accepted", + "connecting", + "connected", +]); + +// ── Icons (Feather, MIT — inline so the package ships zero assets) ──── + +type IconProps = SVGProps; + +function Icon({ children, ...props }: IconProps) { + return ( + + ); +} + +const PhoneIcon = (p: IconProps) => ( + + + +); + +/** The end-call handset: the phone glyph rotated to point down. */ +const HangupIcon = ({ style, ...p }: IconProps) => ( + +); + +const MicIcon = (p: IconProps) => ( + + + + + + +); + +const MicOffIcon = (p: IconProps) => ( + + + + + + + +); + +const VideoIcon = (p: IconProps) => ( + + + + +); + +const VideoOffIcon = (p: IconProps) => ( + + + + +); + +const EraseIcon = (p: IconProps) => ( + + + + + +); + +const UserIcon = (p: IconProps) => ( + + + + +); + +const ChevronDownIcon = (p: IconProps) => ( + + + +); + +const MoreIcon = (p: IconProps) => ( + + + + + +); + +const PopoutIcon = (p: IconProps) => ( + + + + + +); + +// ── Root / theming ──────────────────────────────────────────────────── + +const STYLE_TAG_ID = "pmfa-calls-styles"; + +/** + * Inject the calls stylesheet once per document. SSR-safe (no-op without a + * `document`) and idempotent. + */ +export function injectCallsStyles( + target: Document | undefined = globalThis.document, +): void { + if (target === undefined) return; + if (target.getElementById(STYLE_TAG_ID)) return; + const tag = target.createElement("style"); + tag.id = STYLE_TAG_ID; + tag.textContent = CALLS_STYLES; + target.head.appendChild(tag); +} + +function useCallsRoot(): { + readonly className: string; + readonly style: CSSProperties; + readonly dir: "ltr" | "rtl"; + readonly locale: Locale; +} { + const configuration = usePolymorfa(); + useEffect(() => injectCallsStyles(), []); + const call = configuration.appearance.elements["call"]; + const theme = configuration.appearance.theme; + const className = [ + "pmfa-calls", + theme === "dark" + ? "pmfa-calls-dark" + : theme === "system" + ? "pmfa-calls-auto" + : "", + call?.className ?? "", + ] + .filter(Boolean) + .join(" "); + const style = useMemo( + () => + ({ + ...appearanceToCssVariables(configuration.appearance), + ...call?.styles, + }) as CSSProperties, + [configuration.appearance, call], + ); + return { + className, + style, + dir: configuration.locale.direction, + locale: configuration.locale, + }; +} + +function t( + locale: Locale, + key: keyof Locale["messages"], + values: Record = {}, +): string { + return locale.messages[key].replace( + /\{(\w+)\}/g, + (_, name: string) => values[name] ?? "", + ); +} + +// ── Building blocks ─────────────────────────────────────────────────── + +function initialsOf(name: string | undefined): string | null { + if (name === undefined) return null; + const words = name.trim().split(/\s+/).filter(Boolean); + const first = words[0]; + if (first === undefined || /^[+\d(]/.test(first)) return null; + const last = words.length > 1 ? words[words.length - 1] : undefined; + const initials = `${first[0] ?? ""}${last?.[0] ?? ""}`.toUpperCase(); + return initials.length > 0 ? initials : null; +} + +/** + * Deterministic per-caller avatar color: a stable hash of the peer id picks + * one of 8 palette tones, so the same caller always renders the same color. + * A resolved profile picture wins. + */ +function avatarColorClass(peer: string | undefined): string { + if (peer === undefined) return ""; + let h = 5381; + for (let i = 0; i < peer.length; i += 1) + h = ((h * 33) ^ peer.charCodeAt(i)) >>> 0; + return ` pmfa-calls-avatar-c${(h % 8) + 1}`; +} + +function CallAvatar({ + displayName, + avatarUrl, + peer, +}: { + readonly displayName: string | undefined; + readonly avatarUrl: string | undefined; + readonly peer: string | undefined; +}) { + const initials = initialsOf(displayName); + return ( +
+
+ {avatarUrl !== undefined ? ( + + ) : initials !== null ? ( + initials + ) : ( + + )} +
+
+ ); +} + +/** Resolve a peer's picture: a URL, nothing, or a promise of either. */ +export type AvatarResolver = ( + peer: string, +) => string | undefined | Promise; + +function usePeerAvatar( + peer: string | undefined, + resolveAvatar: AvatarResolver | undefined, + explicit?: string, +): string | undefined { + const [resolved, setResolved] = useState>( + {}, + ); + useEffect(() => { + if (peer === undefined || resolveAvatar === undefined || peer in resolved) + return; + let cancelled = false; + void Promise.resolve() + .then(() => resolveAvatar(peer)) + .catch(() => undefined) + .then((url) => { + if (!cancelled) + setResolved((previous) => ({ ...previous, [peer]: url })); + }); + return () => { + cancelled = true; + }; + }, [peer, resolveAvatar, resolved]); + return explicit ?? (peer === undefined ? undefined : resolved[peer]); +} + +/** + * A muted, mirrored self-view for the pre-answer preview on video offers. + * Null while the camera warms up or when access was denied — callers fall + * back to the dark block. + */ +function useSelfPreview( + enabled: boolean, + videoInput: string | undefined, +): MediaStream | null { + const [stream, setStream] = useState(null); + useEffect(() => { + const devices = globalThis.navigator?.mediaDevices; + if ( + !enabled || + devices === undefined || + typeof devices.getUserMedia !== "function" + ) + return; + let cancelled = false; + let acquired: MediaStream | null = null; + void devices + .getUserMedia({ + video: + videoInput === undefined ? true : { deviceId: { ideal: videoInput } }, + audio: false, + }) + .then((s) => { + acquired = s; + if (cancelled) for (const track of s.getTracks()) track.stop(); + else setStream(s); + }) + .catch(() => undefined); + return () => { + cancelled = true; + if (acquired !== null) + for (const track of acquired.getTracks()) track.stop(); + setStream(null); + }; + }, [enabled, videoInput]); + return stream; +} + +function attach( + element: HTMLMediaElement | null, + stream: MediaStream | undefined | null, +): void { + if (element !== null && stream != null && element.srcObject !== stream) + element.srcObject = stream; +} + +// ── Device panels ───────────────────────────────────────────────────── + +const canSetSink = + typeof HTMLMediaElement !== "undefined" && + "setSinkId" in HTMLMediaElement.prototype; + +type DeviceFieldKind = "audioInput" | "videoInput" | "audioOutput"; + +const DEVICE_KIND: Record = { + audioInput: "audioinput", + videoInput: "videoinput", + audioOutput: "audiooutput", +}; + +const DEVICE_LABEL: Record< + DeviceFieldKind, + "calls.microphone" | "calls.camera" | "calls.speaker" +> = { + audioInput: "calls.microphone", + videoInput: "calls.camera", + audioOutput: "calls.speaker", +}; + +/** + * One-panel-at-a-time open state plus the label unlock: device labels are + * empty until the page holds a media permission, so opening a panel takes + * (and immediately releases) a throwaway audio capture so real names show. + */ +function useDevicePanels(controller: CallsController, snapshot: CallsSnapshot) { + const [open, setOpen] = useState(null); + const toggle = (panel: string) => { + const next = open === panel ? null : panel; + setOpen(next); + if (next === null) return; + void (async () => { + const devices = globalThis.navigator?.mediaDevices; + if ( + snapshot.devices.every((d) => d.label.length === 0) && + devices !== undefined && + typeof devices.getUserMedia === "function" + ) { + try { + const s = await devices.getUserMedia({ audio: true }); + for (const track of s.getTracks()) track.stop(); + } catch { + // Denied — enumerate anyway; generic labels render. + } + } + await controller.refreshDevices().catch(() => undefined); + })(); + }; + return { open, toggle }; +} + +/** + * Device selects, scoped per panel: the mic dropdown carries microphone + + * speaker, the camera dropdown carries the camera only (the official + * split-button layout). Selections apply live mid-call. + */ +function DeviceFields({ + controller, + snapshot, + fields, + locale, +}: { + readonly controller: CallsController; + readonly snapshot: CallsSnapshot; + readonly fields: readonly DeviceFieldKind[]; + readonly locale: Locale; +}) { + const select = (kind: DeviceFieldKind, deviceId: string) => { + if (kind === "audioOutput") + controller.setPreferredDevices({ audioOutput: deviceId }); + else if (deviceId.length > 0) void controller.switchDevice(kind, deviceId); + else controller.setPreferredDevices({ [kind]: undefined }); + }; + return ( + <> + {fields.map((kind) => { + if (kind === "audioOutput" && !canSetSink) return null; + const group = snapshot.devices.filter( + (d) => d.kind === DEVICE_KIND[kind], + ); + return ( + + ); + })} + + ); +} + +// ── IncomingCallCard ────────────────────────────────────────────────── + +export interface IncomingCallCardProps extends ControllerProps { + /** Human name to show instead of the raw number, if the app knows one. */ + readonly displayName?: string; + readonly avatarUrl?: string; + readonly resolveAvatar?: AvatarResolver; + readonly className?: string; +} + +/** + * The incoming-call window: name, "WhatsApp audio/video call" subtitle, the + * media middle (avatar for audio; a mirrored self-preview with camera/mic + * toggles and a ⋯ device menu for video), then Decline / Accept. Answering a + * video offer with the camera toggled off still acquires video muted, so the + * in-call camera toggle can enable it later. + */ +export function IncomingCallCard({ + controller, + createController, + displayName, + avatarUrl, + resolveAvatar, + className, +}: IncomingCallCardProps) { + const resolved = useResolvedController(controller, createController); + const snapshot = useController(resolved); + const root = useCallsRoot(); + const { locale } = root; + const incoming = snapshot.status === "incoming"; + const offersVideo = incoming && snapshot.video && snapshot.capabilities.video; + const [cameraOn, setCameraOn] = useState(true); + const [preMuted, setPreMuted] = useState(false); + const preview = useSelfPreview( + offersVideo && cameraOn, + snapshot.selectedDevices.videoInput, + ); + const previewRef = useRef(null); + const peerAvatar = usePeerAvatar(snapshot.peer, resolveAvatar, avatarUrl); + const { open, toggle } = useDevicePanels(resolved, snapshot); + + useEffect(() => attach(previewRef.current, preview), [preview]); + + if (!incoming) return null; + const peer = snapshot.peer ?? ""; + const name = displayName ?? peer; + + const accept = () => + void resolved.answer({ video: offersVideo }).then(() => { + if (preMuted || (offersVideo && !cameraOn)) + resolved.setMuted({ + ...(preMuted ? { audio: true } : {}), + ...(offersVideo && !cameraOn ? { video: true } : {}), + }); + }); + + const micToggle = ( + + ); + + return ( +
+
+
{name}
+
+ {t(locale, offersVideo ? "calls.videoCall" : "calls.audioCall")} +
+ + {offersVideo ? ( +
+ {cameraOn && preview !== null ? ( +
+ ) : ( +
+ +
+ )} + + {open === "more" && ( +
+ +
+ )} + +
+
+ + {t(locale, "calls.reject")} +
+
+ + {t(locale, "calls.answer")} +
+
+
+
+ ); +} + +// ── Duration ────────────────────────────────────────────────────────── + +/** Seconds since the call connected, ticking once a second (0 before that). */ +export function useCallDuration( + snapshot: Pick, +): number { + const connectedAt = + snapshot.status === "connected" ? snapshot.connectedAt : undefined; + const [seconds, setSeconds] = useState(0); + useEffect(() => { + if (connectedAt === undefined) { + setSeconds(0); + return; + } + const tick = () => + setSeconds(Math.max(0, Math.floor((Date.now() - connectedAt) / 1000))); + tick(); + const timer = setInterval(tick, 1000); + return () => clearInterval(timer); + }, [connectedAt]); + return seconds; +} + +/** Format seconds as `m:ss` (or `h:mm:ss` past an hour). */ +export function formatDuration(totalSeconds: number): string { + const h = Math.floor(totalSeconds / 3600); + const m = Math.floor((totalSeconds % 3600) / 60); + const s = totalSeconds % 60; + const mm = h > 0 ? String(m).padStart(2, "0") : String(m); + const ss = String(s).padStart(2, "0"); + return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`; +} + +/** + * Name with the line under it: "Ringing +number…" while an outgoing call + * rings, the peer number until an incoming call connects, then alternating + * number ⇄ duration on the 5-second cadence (crossfaded via keyed remount). + */ +function CallIdentity({ + name, + peer, + live, + ringingLine, + flashPeer, + seconds, +}: { + readonly name: string; + readonly peer: string | null; + readonly live: boolean; + readonly ringingLine: string | null; + readonly flashPeer: boolean; + readonly seconds: number; +}) { + const line = live + ? peer !== null && flashPeer + ? peer + : formatDuration(seconds) + : (ringingLine ?? peer); + const lineKey = live && !(peer !== null && flashPeer) ? "duration" : "peer"; + return ( +
+
{name}
+ {line !== null && ( +
+ {line} +
+ )} +
+ ); +} + +// ── CallStage ───────────────────────────────────────────────────────── + +export interface CallStageProps extends ControllerProps { + readonly displayName?: string; + readonly avatarUrl?: string; + readonly resolveAvatar?: AvatarResolver; + readonly className?: string; + /** Replace the media region (remote/local streams) without forking state. */ + readonly renderMedia?: (streams: { + readonly local?: MediaStream; + readonly remote?: MediaStream; + }) => ReactNode; +} + +/** + * The in-call stage: remote video full-bleed when the far side sends video, + * the centered avatar hero otherwise, the local preview as a + * picture-in-picture tile, and the identity (name + flashing number/duration + * line). Always dark by design. + */ +export function CallStage({ + controller, + createController, + displayName, + avatarUrl, + resolveAvatar, + className, + renderMedia, +}: CallStageProps) { + const resolved = useResolvedController(controller, createController); + const snapshot = useController(resolved); + const root = useCallsRoot(); + const { locale } = root; + const remoteRef = useRef(null); + const localRef = useRef(null); + const [remoteHasVideo, setRemoteHasVideo] = useState(false); + const seconds = useCallDuration(snapshot); + const peerAvatar = usePeerAvatar(snapshot.peer, resolveAvatar, avatarUrl); + const live = snapshot.status === "connected"; + const localStream = resolved.localStream; + const remoteStream = resolved.remoteStream; + + const [flashPeer, setFlashPeer] = useState(false); + useEffect(() => { + if (!live) { + setFlashPeer(false); + return; + } + const timer = setInterval(() => setFlashPeer((v) => !v), 5000); + return () => clearInterval(timer); + }, [live]); + + useEffect(() => { + attach(remoteRef.current, remoteStream); + if (remoteStream === undefined) return; + const update = () => + setRemoteHasVideo(remoteStream.getVideoTracks().length > 0); + update(); + remoteStream.addEventListener("addtrack", update); + remoteStream.addEventListener("removetrack", update); + return () => { + remoteStream.removeEventListener("addtrack", update); + remoteStream.removeEventListener("removetrack", update); + }; + }, [remoteStream, snapshot.revision]); + + useEffect( + () => attach(localRef.current, localStream), + [localStream, snapshot.revision], + ); + + const sink = snapshot.selectedDevices.audioOutput; + useEffect(() => { + const element = remoteRef.current as + (HTMLVideoElement & { setSinkId?: (id: string) => Promise }) | null; + if (element?.setSinkId !== undefined && sink !== undefined) + void element.setSinkId(sink).catch(() => undefined); + }, [sink, snapshot.revision]); + + if (!ACTIVE_STATUSES.has(snapshot.status)) return null; + const peer = snapshot.peer ?? ""; + const showLocalVideo = snapshot.video && !snapshot.videoMuted; + const preAccept = showLocalVideo && !live; + const ringingLine = + !live && snapshot.direction === "outgoing" + ? `${t(locale, "calls.ringing")} ${peer}…` + : null; + const identity = ( + + ); + + return ( +
+
+ {renderMedia !== undefined ? ( + renderMedia({ + ...(localStream === undefined ? {} : { local: localStream }), + ...(remoteStream === undefined ? {} : { remote: remoteStream }), + }) + ) : ( + <> +
+
+ ); +} + +// ── CallControls ────────────────────────────────────────────────────── + +export interface CallControlsProps extends ControllerProps { + readonly className?: string; + /** Force-hide the camera control even when the line could carry video. */ + readonly disableVideo?: boolean; +} + +/** + * The in-call dock, after the official window: the camera split group + * ([toggle|⌄] — the dropdown picks the camera) and the mic split group + * ([toggle|⌄] — the dropdown picks microphone + speaker) on the left, the + * red hang-up pill on the right. The camera group renders whenever the line + * can carry video (unless `disableVideo`) and is disabled until the call + * connects. Video is per direction: the camera controls only the outgoing + * stream. + */ +export function CallControls({ + controller, + createController, + className, + disableVideo, +}: CallControlsProps) { + const resolved = useResolvedController(controller, createController); + const snapshot = useController(resolved); + const root = useCallsRoot(); + const { locale } = root; + const { open, toggle } = useDevicePanels(resolved, snapshot); + if (!ACTIVE_STATUSES.has(snapshot.status)) return null; + + const live = snapshot.status === "connected"; + const showCamera = snapshot.capabilities.video && disableVideo !== true; + const cameraOff = !snapshot.video || snapshot.videoMuted; + const onCamera = () => { + // Audio→video upgrade needs a re-offer on the signaling wire; the + // affordance exists, the wiring is a fast-follow. Toggling only ever + // touches the local outgoing track. + if (snapshot.video) resolved.setMuted({ video: !snapshot.videoMuted }); + }; + + return ( +
+
+ {open === "mic" && ( +
+ +
+ )} + {open === "cam" && ( +
+ +
+ )} +
+ {showCamera && ( +
+ + +
+ )} + {snapshot.capabilities.mute && ( +
+ + +
+ )} + +
+
+
+ ); +} + +// ── DialPad ─────────────────────────────────────────────────────────── + +const KEYS = [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "+", + "0", + "#", +] as const; + +export interface DialPadProps extends ControllerProps { + /** Prefill the number field. */ + readonly defaultValue?: string; + /** Calling line the call is placed over. Defaults to `linkedDevice`. */ + readonly line?: CallLine; + /** Called after `place()` resolves. */ + readonly onPlaced?: (to: string) => void; + readonly className?: string; +} + +/** + * A dial pad card: E.164 entry plus place-call buttons — audio always, and a + * video-call button when the line can carry video (linked device; the + * Business Calling API line is audio-only, so the button never renders there). + */ +export function DialPad({ + controller, + createController, + defaultValue = "", + line = "linkedDevice", + onPlaced, + className, +}: DialPadProps) { + const resolved = useResolvedController(controller, createController); + const snapshot = useController(resolved); + const root = useCallsRoot(); + const { locale } = root; + const [value, setValue] = useState(defaultValue); + const busy = + snapshot.status === "incoming" || ACTIVE_STATUSES.has(snapshot.status); + const canVideo = capabilitiesFor(line).video; + const dial = (video: boolean) => { + const to = value.trim(); + if (to.length === 0) return; + void resolved.place(to, { video, line }).then(() => onPlaced?.(to)); + }; + + return ( +
+
+ setValue(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Enter") dial(false); + }} + aria-label={t(locale, "calls.dialPlaceholder")} + /> +
+ {KEYS.map((digit) => ( + + ))} +
+
+ + {canVideo && ( + + )} + +
+
+
+ ); +} + +// ── Pop-out call window ─────────────────────────────────────────────── + +export interface CallPopoutHandle { + /** True while the separate call window is open. */ + readonly popped: boolean; + /** Open the call window (no-op if already open or the popup was blocked). */ + readonly open: () => void; + /** Close the call window (popping the call back inline). */ + readonly close: () => void; + /** Portal target inside the call window; null while not popped. */ + readonly container: HTMLElement | null; +} + +/** + * Owns a separate, freely resizable call window (like the desktop client's) + * and hands back a container to portal the stage + controls into. Media + * streams keep playing across same-origin windows, so portal'd `