// The only place React touches a video call. `ChimeCallSession` (below) owns the Chime SDK and // knows nothing about React; `callReducer` is a pure state machine. This provider wires the two // together and exposes one context that every video surface consumes. // // MEETING MODEL, not a call model (Spencer 2026-08-01): there is no `acceptCall` and no // `declineCall` here - that was ring protocol, and it is gone. `joinAvailable()` is the only way // in besides starting your own meeting: it joins whatever is sitting in `call.available`. See // videoContract.ts and callMachine.ts for the full rationale. import { ReactNode, createContext, useCallback, useContext, useEffect, useMemo, useReducer, useRef, useState, } from 'react'; import { ChimeCallSession, JoinRetired, type CallSessionState, type RemoteMeetingParticipant } from './video/ChimeCallSession'; import { callReducer, INITIAL_CALL_STATE, VISIT_NOT_STARTED_NOTICE, type CallState } from './video/callMachine'; import type { MediaFailureReason, MediaKind } from './video/mediaFailure'; import type { DeviceChoice } from './video/deviceChoices'; import type { VideoTransport } from './video/VideoTransport'; import { CALL_EVENTS, type CallAvailableEvent, type InviteTeamResult, type SessionShare, type StartWithheld, type VideoContext, } from './video/videoContract'; import { useLiveVideoTransport } from './video/LiveVideoTransport'; import { TELEHEALTH_VIDEO_ENABLED } from './video/videoFlags'; import { useAuthServiceContext } from './AuthService'; import { encounterFinishVideoAction } from '../components/video/encounterFinishVideoAction'; import { useWebSocketServiceContext } from './WebSocketService'; import { useCapabilityServiceContext } from './CapabilityService.tsx'; import { isBillingLimitRefusal } from '../utils/billingLimitRefusal'; import { contextTakenNotice } from '../utils/contextTakenNotice'; import { refusalCode, videoRefusalNotice } from '../utils/videoRefusalNotice'; import { HttpError } from './HttpService'; import { onViewerChange, viewerEpoch, writeThroughViewer } from '../utils/writeThroughHold'; interface VideoContextType { call: CallState; /** Every tile to render, the local self-view included. */ tiles: number[]; /** Somebody ELSE's tiles - a strict subset of `tiles`. A LAYOUT fact. * NOT a presence signal. An audio-only participant never produces one, and turning a camera * off mid-visit takes one away - in both cases the person is still in the room. See * `remoteAttendees`. */ remoteTiles: number[]; /** EVERYONE ELSE IN THE MEETING. A surface derives "waiting for the patient to join" from * this being empty during a live call; there is no ringing phase to read it off instead (the * ring protocol was cut 2026-08-01). Never `tiles.length` - with the practitioner's own camera * on it reads 1 whether or not anyone else has arrived - and never `remoteTiles.length`, which * says "has a camera on", not "is here". */ remoteAttendees: string[]; /** Task 9 - WHO is in the meeting, not just how many. Same roster as `remoteAttendees`, one * entry per remote attendee, carrying the SDK's `externalUserId` (their stable FHIR reference, * e.g. `Practitioner/abc`) alongside the Chime attendee id. `MeetingParticipantsPanel` resolves * this to a name or a neutral fallback - never render `externalUserId` itself, it is an internal * id a related person may be sitting in front of. */ remoteParticipants: RemoteMeetingParticipant[]; muted: boolean; cameraOn: boolean; /** Whether a camera/microphone was actually acquired for this meeting. Exposed so a surface can * be HONEST about a denied device instead of showing a toggle that cannot do anything. The * audio-only UX that consumes these is Task 14 - this is only the truthful state beneath it. */ cameraAvailable: boolean; micAvailable: boolean; /** - WHY a device is unavailable, so a surface can say something TRUE instead of telling * every user to change a browser permission. Absent when the device works. */ micFailure?: MediaFailureReason; cameraFailure?: MediaFailureReason; /** - which device the visit is USING, so the picker shows the live choice. */ micDeviceId?: string; cameraDeviceId?: string; /** - the devices this participant may switch to, read from the browser ON DEMAND. * Not cached at join: a headset plugged in mid-visit is exactly the case someone opens the menu * for, and it is also when labels finally exist (a browser reports them blank before permission). */ listInputs: () => Promise<{ microphones: DeviceChoice[]; cameras: DeviceChoice[] }>; /** Switch device mid-visit, no rejoin. A failed switch updates the failure reason rather than * silently reporting success and leaving the participant mute behind a menu that looked fine. */ selectInput: (kind: MediaKind, deviceId: string) => Promise; /** False means remote audio has nowhere to play: this participant hears NOBODY, while the * call otherwise looks perfectly healthy. */ audioOutputBound: boolean; /** Has anybody else EVER been in this meeting? The only thing that distinguishes "they left" from * "nobody has arrived" - both are an empty roster. See `roomPresence.ts`. */ everRemote: boolean; /** Task 3 - WHO is talking, for a narrow-window layout to collapse to a single tile without * guessing which one. Resolves as: the most-recent REMOTE speaker the SDK reported (Task 2 - * never the local participant, guarded at the write site) else the first entry of * `remoteParticipants` else `undefined`. See `ChimeCallSession`'s own field comment for why * this is never cleared back to an empty tile once anybody has spoken or arrived. * * MAY NAME AN ATTENDEE WHO HAS LEFT. Departure never clears this - see the "never cleared" * note above - so a stale id can outlive the person it names indefinitely. No consumer today is * harmed by that (`VideoTileGrid`'s `speaker` layout re-validates against the roster before * trusting it - fix round 1), but nothing stops a future one from trusting this field's * definedness as presence, which is exactly the bug that fix closed. */ activeSpeakerAttendeeId: string | undefined; speakingAttendeeIds: string[]; /** - attendee id the tile carrying their video. An absent key means camera off. */ tilesByAttendee: Record; /** `dockedSurfaceMounted` / `registerDockedSurface` LIVED HERE and deleted them, along * with `presentation` ('floating' | 'docked') before them. All three were answers to ONE question - * which of two surfaces should show this call - and both shipped defects in this feature were that * question answered wrongly (a PATIENT told "docked" when no docked surface could exist for them; * a live billed meeting with no Leave button once the docked pane unmounted). `MeetingWindow` is * now the only in-call surface, so the "exactly one `VideoTileGrid`" rule the flag protected is * structural. Do not reintroduce any of the three: a second surface is what makes the question * askable again. */ startCall: (context: VideoContext) => Promise; /** Joins the meeting currently sitting in `call.available`. No-ops if there isn't one. */ joinAvailable: () => Promise; /** LEAVES the meeting (`POST /{id}/leave`); it does NOT end it for the other participants. The * name is historical - this is the "Leave" button, and under the meeting model the meeting outlives * any one participant. The server ends it only when the last person is out. */ endCall: () => Promise; /** HOST verb - ends the meeting for EVERYONE (`POST /{id}/end` Chime `DeleteMeeting`, NOT * recoverable). Only for a practitioner deliberately concluding the visit. A non-host calling it is * refused 403. Never wire a "Leave" affordance here - use `endCall`. */ endMeetingForAll: () => Promise; /** Pulls the patient's collaboration team into the visit ALREADY IN PROGRESS * (`POST /{id}/invite`). A DISCLOSURE EXPANSION, audited as one server-side (§5.5). * * IT TAKES NO SESSION ID - the provider reads `call.sessionId` itself. A surface that could * name a session could name the WRONG one, and this route decides who receives PHI; the server * learned the same lesson as a live vulnerability (). * * RETURNS THE FAN-OUT RESULT INTACT rather than a boolean. `missed` is returned rather than * swallowed (§7) because a missed `call.available` HAS NO SECOND PATH - unlike a message it does * not reappear on refresh, so a caller told "invited" would wait for colleagues never reached. * Rejects with the `HttpError`, so a refusal (403) stays distinguishable from a transport * failure - the distinction option A bought over `send()` returning false. */ inviteTeam: (teamId: string) => Promise; /** Repairs discovery for the session it names, only while that is still this provider's call. */ repairDiscovery: (sessionId: string) => Promise; /** * Host verb - ejects one other person; the visit carries on (`POST /{id}/remove`). The third of * three verbs that are easy to confuse: `endCall` leaves, `endMeetingForAll` ends it for * everyone. Never reach for `endMeetingForAll` to get one person out - it also finalises the * encounter. * * The person removed cannot rejoin, so the UI must say so before calling this. Takes no session * id for the same reason `inviteTeam` does not: the provider reads `call.sessionId` itself. */ removeParticipant: (participantRef: string) => Promise; /** What the host is showing from the patient record in THIS call: a reference and who shows it, * never the item. Each viewer reads the item under their own authorization. */ shown: SessionShare | null; /** Why this call's automatic visit start was held back, as the participant session read says; null otherwise. */ startWithheld: StartWithheld | null; /** Reads this call's session again; reading never starts the visit. */ refreshSession: () => void; /** Host verb - show one record item to the room. Rejects on a refusal so the picker can say why. */ showFromRecord: (reference: string) => Promise; stopShowing: () => Promise; /** * The encounter-finish path: end the visit for everyone, whether or not the finisher is still in it. * * IT TAKES THE ENCOUNTER'S CONTEXT, and that is the whole fix. It used to ask `phase === 'idle'` - * *am I in a call?* - when the question a Finish handler needs answered is *is there a live meeting * for this encounter?* A practitioner who pressed Leave, wrote the note and then pressed Finish took * the idle path, so the meeting was never ended: the encounter flipped to `finished` while the * session stayed live with the patient in it. The server's join gate only runs on JOIN, so nobody * could get back in to end it either - the patient was left in a room no one could re-enter. * * That is the exact failure the "ends for everyone on purpose" note below exists to prevent, reached * by a different door. * * Still a no-op when there is no call AND no live meeting for this context, which is the * overwhelmingly common in-person case - a Finish must never be blocked by a video system it never * used. The context is optional so a caller that has none keeps the old behaviour exactly. */ endCallIfActive: (context?: VideoContext) => Promise; setMuted: (b: boolean) => void; setCameraOn: (b: boolean) => Promise; bindTile: (tileId: number, el: HTMLVideoElement) => void; /** Pass the element you bound. The unbind is REFUSED if another surface has since taken this * tile - the SDK unbinds by tile id, so a late cleanup would otherwise kill the newer surface's * live element. `el` is required, not optional, so no caller can reach the unsafe form. */ unbindTile: (tileId: number, el: HTMLVideoElement) => void; /** * The far side of the call as one MediaStream, or null when there is nothing to hear yet. * Exists for the ambient scribe: on a video visit the microphone alone cannot capture the patient, * because echo cancellation removes them. See the implementation note. */ getRemoteAudioStream: () => MediaStream | null; } const VideoServiceContext = createContext(undefined); interface ProviderProps { children: ReactNode; transport: VideoTransport; /** The established session's user id. Every action no-ops without it - see the comment below. */ sessionUserId?: string; subscribeToEvent?: (action: string, cb: (data: any) => void) => void; unsubscribeFromEvent?: (action: string, cb: (data: any) => void) => void; } /** A start the server could not confirm, and whose call could not be found. Never says it failed. */ export const START_UNCONFIRMED_NOTICE = 'We could not confirm the call started. Check the call list in a moment, or try again.'; /** A start whose named call had already ended. */ export const START_FAILED_NOTICE = 'The call could not be started. Please try again.'; /** The session a refused or unconfirmed start names: UNCONFIRMED's own id, or CONTEXT_TAKEN's live one. */ const namedSessionId = (error: unknown): string | undefined => { if (!(error instanceof HttpError)) return undefined; const body = error.body as { code?: unknown; sessionId?: unknown; liveSessionId?: unknown } | undefined; const id = body?.code === 'VIDEO_START_UNCONFIRMED' ? body.sessionId : body?.code === 'CONTEXT_TAKEN' ? body.liveSessionId : undefined; return typeof id === 'string' && id ? id : undefined; }; /** One Start or Join, and whether it still owns its pending admission. */ type Opening = { id: number; owns: () => boolean; account: string | null }; /** No answer at all: the network failed, the answer could not be read, or the gateway timed out first. */ const answerLost = (error: unknown): boolean => { const name = (error as { name?: unknown } | null)?.name; return name === 'TypeError' || name === 'UnreadableWriteAnswerError' || (error instanceof HttpError && error.status === 504); }; export const VideoServiceProvider: React.FC = ({ children, transport, sessionUserId, subscribeToEvent, unsubscribeFromEvent, }) => { const [call, dispatch] = useReducer(callReducer, INITIAL_CALL_STATE); // Must agree with `ChimeCallSession`'s idle state: nothing is acquired before a join, and // `cameraOn: true` here is what made the camera button render inverted with no call running. const [media, setMedia] = useState({ tiles: [], remoteTiles: [], tilesByAttendee: {}, remoteAttendees: [], remoteParticipants: [], muted: false, cameraOn: false, cameraAvailable: false, micAvailable: false, audioOutputBound: false, everRemote: false, activeSpeakerAttendeeId: undefined, speakingAttendeeIds: [], }); const sessionRef = useRef(null); const audioRef = useRef(null); const { mayCall, state: capabilityState } = useCapabilityServiceContext(); // This provider wraps the whole app (including /login) and its actions make authenticated // calls. Without this gate, an authed fetch with no session runs getValidToken() // refreshAccessToken() logout() window.location.reload(), which remounts this provider and // loops the login page forever. Same remedy as PresenceService.tsx:40-42. A ref (not the prop // directly) keeps every callback below stable across renders while still reading the latest value. const sessionUserIdRef = useRef(sessionUserId); sessionUserIdRef.current = sessionUserId; // Mirrors call.sessionId so long-lived closures (the WS subscription below, registered once per // subscribe-effect run) always read the CURRENT session id rather than one captured stale. const callSessionIdRef = useRef(call.sessionId); callSessionIdRef.current = call.sessionId; // Same reasoning, for the offered-but-not-yet-joined meeting. const availableSessionIdRef = useRef(call.available?.sessionId); availableSessionIdRef.current = call.available?.sessionId; // Bumped on every endCall (and on a matched inbound `ended`) to invalidate any startCall/ // joinAvailable continuation already in flight for the call being ended - see those callbacks. const epochRef = useRef(0); // Each Start or Join is an opening. It owns its pending admission only while it is the newest // opening, under the same viewer and service epoch, in a mounted provider. A viewer change or an // unmount retires pending openings; neither ends a call that has already joined. const openingRef = useRef(0); const mountedRef = useRef(true); const beginOpening = useCallback(() => { const opening = ++openingRef.current; const viewer = viewerEpoch(); const account = writeThroughViewer(); const epoch = epochRef.current; const owns = () => mountedRef.current && opening === openingRef.current && viewer === viewerEpoch() && epoch === epochRef.current; return { id: opening, owns, account }; }, []); // The newest opening to take up each session. Opening ids only grow and a claim never moves back, so an // opening a newer one displaced from a session stays displaced, whatever other session is claimed later. const sessionOwnersRef = useRef(new Map()); const claimSession = (opening: Opening, sessionId: string) => { const owners = sessionOwnersRef.current; if ((owners.get(sessionId) ?? -1) < opening.id) owners.set(sessionId, opening.id); }; useEffect(() => onViewerChange(() => { openingRef.current += 1; }), []); if (!sessionRef.current) sessionRef.current = new ChimeCallSession(); useEffect(() => { const s = sessionRef.current!; return s.subscribe(setMedia); }, []); // Hand the session the one element Chime plays every remote participant through. Its own // effect (empty deps, runs once after the DOM is in place) so it can never entangle with the // subscribe effect's lifecycle. The provider owns the ELEMENT and nothing else about media: // `bindAudioElement` needs a live facade, so the actual binding happens inside `join()`. useEffect(() => { const s = sessionRef.current!; s.setAudioElement(audioRef.current); return () => { s.setAudioElement(null); }; }, []); // Tear down any live Chime session on unmount. This provider is mounted once, app-wide, and // never unmounts today - so this is latent - but it is structural: the moment the tree becomes // conditional on auth state (logout, org switch), an unmounted provider with a live call would // otherwise leak a BILLED Chime meeting forever. Kept as its own effect (empty deps) so its // cleanup can never entangle with the subscribe effect's dependency array above. useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; openingRef.current += 1; void sessionRef.current?.leave(); }; }, []); // Best-effort: release an abandoned session server-side, using the id from the response that just // resolved - NOT call.sessionId, which by definition no longer refers to it once the epoch has // moved on. createSession()/joinSession() already provisioned/attached to a real, billed Chime // meeting server-side by the time either caller below can hit an epoch mismatch, so undoing that // locally only is exactly the leak the epoch guard exists to prevent, on a different path. Must // never throw out of a bailout path - the local teardown that surrounds every call site is not // optional and must run either way. // // `leaveSession`, NEVER `endSession`, and that is not cosmetic. This runs on the bailout from // BOTH startCall and joinAvailable, and on the joinAvailable path the user is NOT the host - the // host-only `/end` would be REFUSED (403) there, leaving the meeting orphaned and billing, which is // the precise outcome this callback exists to prevent. `leave` is correct on both paths: an // abandoning caller is by definition the last participant out of a meeting nobody else joined, and // the server does the DeleteMeeting itself in that case. const abandonSession = useCallback(async (sessionId: string, opening?: Opening) => { if (!sessionUserIdRef.current) return; // Released only as the account that opened it, and never once a newer opening joined this same // session. Checked now and again at dispatch, after the token waits, where a newer claim lands. const owned = () => { const owner = sessionOwnersRef.current.get(sessionId); return !opening || (opening.account === writeThroughViewer() && (owner === undefined || owner === opening.id)); }; if (!owned()) return; const admit = () => { if (!owned()) throw new Error('A newer opening owns this session.'); }; try { await (opening ? transport.leaveSession(sessionId, { admit }) : transport.leaveSession(sessionId)); } catch { // The server independently reaps unattended sessions; swallow so a network failure here // can never block (or throw out of) the bailout that's already in progress. } }, [transport]); /** * Admits this opening to the meeting it was answered with. True once its media is live and still * owned; false when it retired, after releasing what it took and that session server-side. */ const enter = useCallback(async ( sessionId: string, chime: Parameters[0], opening: Opening, ): Promise => { let handle: object | undefined; try { handle = await sessionRef.current!.join(chime, opening.owns); } catch (e) { if (!(e instanceof JoinRetired) && opening.owns()) throw e; await abandonSession(sessionId, opening); return false; } if (opening.owns()) return true; // Admitted, then retired before it was published: leave only this join, never a successor. await sessionRef.current!.leave(handle); await abandonSession(sessionId, opening); return false; }, [abandonSession]); /** * Joins the call a start could not confirm: the named session first, else this context's listed one. * 'not-found' leaves the caller to say so; every other outcome is already published. */ const recoverStart = useCallback(async ( context: VideoContext, sessionId: string | undefined, opening: Opening, ): Promise<'joined' | 'failed' | 'retired' | 'not-found'> => { const { owns } = opening; const joinTarget = async (target: { sessionId: string; initiator?: string; createdAt?: string }) => { claimSession(opening, target.sessionId); dispatch({ type: 'CREATED', sessionId: target.sessionId, initiator: target.initiator, createdAt: target.createdAt }); const joined = await transport.joinSession(target.sessionId); if (!owns()) { await abandonSession(joined.sessionId, opening); return 'retired' as const; } if (!await enter(joined.sessionId, joined.chime, opening)) return 'retired' as const; dispatch({ type: 'JOINED', room: joined.room }); return 'joined' as const; }; if (sessionId) { try { return await joinTarget({ sessionId }); } catch (e) { if (!owns()) return 'retired'; const code = refusalCode(e); if (code === 'SESSION_ENDED' || code === 'SESSION_NOT_FOUND' || (e instanceof HttpError && e.status === 404)) { dispatch({ type: 'FAILED', error: START_FAILED_NOTICE }); return 'failed'; } } } try { const own = (await transport.listAvailable()).find((s) => s.context === context); if (!owns()) return 'retired'; if (!own) return 'not-found'; return await joinTarget(own); } catch { return owns() ? 'not-found' : 'retired'; } }, [transport, abandonSession, enter]); const startCall = useCallback(async (context: VideoContext) => { if (!sessionUserIdRef.current) return; // Captured before the first await: a newer opening, an end, a viewer change or an unmount // retires this one, and every step below then backs out instead of publishing. const opening = beginOpening(); const { owns } = opening; dispatch({ type: 'START', context }); try { const created = await transport.createSession(context); if (!owns()) { // The server already provisioned a real meeting for this createSession() call - there // is nothing local to tear down yet, but the server-side record must still be ended. await abandonSession(created.sessionId, opening); return; } claimSession(opening, created.sessionId); dispatch({ type: 'CREATED', sessionId: created.sessionId, createdAt: created.createdAt, initiator: sessionUserIdRef.current ? `Practitioner/${sessionUserIdRef.current}` : undefined, room: created.room, findability: created.discovery || created.noticeUnconfirmed ? { discovery: created.discovery, noticeUnconfirmed: created.noticeUnconfirmed } : undefined, }); // MEETING MODEL: the host joins their own meeting immediately rather than ringing out and // waiting. Being alone in a live meeting is normal - the UI reports "waiting for patient" // by observing zero remote tiles. The server derives who to notify from `context`, so no // explicit invite is sent here; Task 15 adds the one case that needs an explicit notify // (pulling the care team into a visit already in progress). if (await enter(created.sessionId, created.chime, opening)) dispatch({ type: 'JOINED' }); } catch (e: any) { if (!owns()) return; // A BILLING REFUSAL ALREADY HAS A REPORTER, AND IT IS A BETTER ONE. `HttpService` // intercepts the 402, reads the server's billing envelope and raises the app-wide limit modal // - which names the cap, the numbers and the add-on, and offers Upgrade only to someone who // can actually buy it. It then rethrows, so reporting here too would paint a second, vaguer // message ("Billing limit reached") beside it. Same rule `CallOverlay` enforces for // `call.error` - exactly one reporter per event - applied where the better reporter is not us. // `ENDED` rather than a FAILED with no message: the phase must return to idle so the control // is usable again, and `ENDED` is the transition that already means exactly that. It cannot // discard an unrelated offer - it only clears `available` when it names the session we were in, // and no session was ever created here. if (isBillingLimitRefusal(e)) { dispatch({ type: 'ENDED' }); return; } // A start whose outcome is unknown, or a context already live, is recovered by joining the // call the server named, else the one listed for this context. Never by a second POST. // A REFUSAL THE SERVER NAMED IS NOT A TRANSPORT FAULT: CONTEXT_TAKEN is keyed on the code, // never the sentence, so the copy cannot drift with the server's wording. const unknownStart = refusalCode(e) === 'VIDEO_START_UNCONFIRMED' || answerLost(e); const taken = Boolean(contextTakenNotice(e, context)); if (unknownStart || taken) { const recovered = await recoverStart(context, namedSessionId(e), opening); if (recovered !== 'not-found' || !owns()) return; if (unknownStart) { dispatch({ type: 'FAILED', error: START_UNCONFIRMED_NOTICE }); return; } } dispatch({ type: 'FAILED', error: videoRefusalNotice(e, context) ?? e?.message ?? 'Could not start video.' }); } }, [transport, abandonSession, beginOpening, recoverStart]); // MEETING MODEL: joins the meeting sitting in `call.available`. There is no accept and no // decline - you either join or you don't, and not joining leaves the meeting available rather // than sending a refusal. const joinAvailable = useCallback(async () => { const target = call.available; if (!sessionUserIdRef.current || !target) return; // Same opening ownership as startCall - see the comment there. const opening = beginOpening(); const { owns } = opening; claimSession(opening, target.sessionId); dispatch({ type: 'JOIN_AVAILABLE' }); try { const joined = await transport.joinSession(target.sessionId); if (!owns()) { await abandonSession(joined.sessionId, opening); return; } if (await enter(joined.sessionId, joined.chime, opening)) dispatch({ type: 'JOINED', room: joined.room }); } catch (e: any) { if (!owns()) return; // Refused only because the visit has not started: the meeting is still there to join once it does. if (refusalCode(e) === 'ENCOUNTER_NOT_IN_PROGRESS') { dispatch({ type: 'FAILED', error: VISIT_NOT_STARTED_NOTICE, reoffer: target }); return; } dispatch({ type: 'FAILED', error: videoRefusalNotice(e, target.context) ?? e?.message ?? 'Could not join the meeting.' }); } }, [call.available, transport, abandonSession, beginOpening, enter]); // Shared teardown. `depart` is the ONLY thing that varies between "I am leaving" and "I am the host // ending this for everyone" - everything else is identical and must STAY identical: the epoch bump, // the LEAVING dispatch, and above all the unconditional local SDK teardown. Keeping one body means a // later change to either verb cannot quietly acquire a different teardown. // // A FAILED DEPARTURE IS REPORTED, NOT JUST THROWN, and that is the point of `notice`. This used // to be a `try/finally` that dispatched `ENDED` unconditionally and let the rejection propagate - // which meant the ONLY record of the failure was a promise rejection arriving at a component the // `ENDED` had already unmounted. The user was told nothing while very possibly still a participant // in a live, BILLING meeting. `FAILED` puts it on `call.error`, which outlives every video surface; // `CallOverlay` reports it from outside the call chrome. The rejection is STILL rethrown - the // encounter-finish path decides whether the visit concluded from exactly that. const teardown = useCallback(async ( depart: (sessionId: string) => Promise, notice: string, // RE-OFFER THE MEETING IF IT OUTLIVED YOUR DEPARTURE (found in the first browser call, // 2026-08-02: a patient who left a still-running visit had NO WAY BACK IN). `ENDED` clears // `available` for the session you just left - correctly, so a dead meeting does not linger as // an offer - and the late-joiner lookup only runs once per established session. Between them, // nothing ever re-offered a meeting that was still going. // // The answer was already on the wire and was being discarded: `/leave` returns `status`, and // `'active'` means others are still in the room (videoContract.ts says exactly this). No extra // request. Only for the LEAVE verb - `/end` destroys the meeting, so re-offering it would put a // Join button in front of a session that cannot be joined. reofferIfStillLive = false, ) => { // Invalidate any startCall/joinAvailable continuation already in flight, BEFORE anything else - // this must run unconditionally, session or not. epochRef.current += 1; dispatch({ type: 'LEAVING' }); // A SEPARATE BOOLEAN, NOT `failure`'s TRUTHINESS. `throw undefined` / `Promise.reject()` / // `throw ''` are all legal, and keying the report on truthiness swallowed them entirely: no // FAILED, no `call.error`, and `endCall()` RESOLVING - telling the encounter-finish path the // visit concluded when the departure had in fact failed. That is the exact lie this whole // reporting path exists to prevent, and the `try/finally` this replaced never had it (it // propagated any thrown value). `??` below is wrong for the same reason - a thrown 0 or '' // would be replaced by a later error. let failed = false; let failure: unknown; let departed: unknown; // Captured BEFORE the awaits: `ENDED` clears both, and the re-offer below needs the meeting // this departure was actually about, not whatever the state says afterwards. const departingSessionId = call.sessionId; const departingContext = call.context; // Captured for the same reason, and load-bearing for the re-offer below: without it the // rejoined call has no initiator, so the host who stepped out loses their End control and // nobody can end the conference at all. const departingInitiator = call.initiator; const departingCreatedAt = call.createdAt; try { // The transport calls are authenticated network calls - gate those on the session. Local // SDK teardown (below) must run regardless: if the session dropped mid-call (token expiry), // the guard that exists to stop authenticated-fetch loops must never be the reason a live // meeting never gets torn down. if (sessionUserIdRef.current && call.sessionId) { departed = await depart(call.sessionId); } } catch (e) { failed = true; failure = e; } try { // Unconditional, exactly as the old `finally` was - and now it cannot wedge the phase. // When this threw out of that `finally`, NEITHER dispatch ran and the call sat in `ending` // forever with Leave disabled and nothing said. await sessionRef.current!.leave(); } catch (e) { // The remote departure is the one the user can act on; a local SDK teardown failure only // gets to speak if nothing worse happened. if (!failed) { failed = true; failure = e; } } if (failed) { const reason = (failure as any)?.message; dispatch({ type: 'FAILED', error: reason ? `${notice} ${reason}` : notice }); throw failure; } dispatch({ type: 'ENDED' }); // AFTER `ENDED`, never before: `ENDED` clears `available` when it matches the session just // left, so re-offering first would simply be undone. The meeting is still running for whoever // remains, so the person who just left gets their way back in - the Join banner returns. if ( reofferIfStillLive && (departed as { status?: string } | undefined)?.status === 'active' && departingSessionId && departingContext ) { // (R9) - `returning: true` is not a guess here, it is the definition: this branch only // runs for the person who just left a visit that is still running. dispatch({ type: 'AVAILABLE', sessionId: departingSessionId, context: departingContext, initiator: departingInitiator, returning: true, createdAt: departingCreatedAt, }); } }, [call.sessionId, call.context, call.initiator, call.createdAt]); // LEAVE, not END (Lane A §3.3). This is what a participant clicking "Leave" runs, and routing it // to the host-only `/end` meant a Chime DeleteMeeting: a patient leaving tore the meeting down FOR // THEIR OWN DOCTOR, unrecoverably. The server decides whether the meeting dies - only when the last // participant is out. const endCall = useCallback( // The notice says what the FAILURE means, not what the code was doing - "leaveSession failed" // tells a user nothing, and the consequence here is that they may still be in the room. // `true` = re-offer the meeting if it outlived this departure, so a patient who leaves (or // misclicks) a still-running visit gets the Join banner back instead of being locked out. () => teardown( (sessionId) => transport.leaveSession(sessionId), 'You may still be in the meeting.', true, ), [teardown, transport], ); // The HOST verb - ends the meeting for EVERYONE (`DeleteMeeting`, not recoverable). Correct for // exactly one thing: a practitioner deliberately concluding the visit. Never wire a "Leave" affordance // to this; a non-host calling it is refused 403 anyway. const endMeetingForAll = useCallback( () => teardown( (sessionId) => transport.endSession(sessionId), 'The meeting may still be running for the other participants.', ), [teardown, transport], ); // A DISCLOSURE EXPANSION, not a convenience. This pulls the patient's collaboration team into a // visit already in progress, and the roster it notifies is who receives PHI in the media stream. // // NO SESSION-ID PARAMETER, deliberately. The target is read from `call.sessionId` here rather // than accepted from a surface, for the same reason the server stopped trusting body-supplied ids // (): a caller that CAN name the target can name the // wrong one, and the blast radius of a wrong target on this particular route is a PHI disclosure // to a roster nobody chose. // // Rejects rather than resolving when there is nothing to invite into - a resolve would let a // surface paint "care team invited" for a call that does not exist. The `HttpError` propagates // untouched so the caller can tell a 403 refusal from a network failure. // `teamId` IS THE CALLER'S CHOICE AND THE SESSION IS NOT. A patient may own several // purpose-specific teams, so WHICH one is a clinical decision the practitioner makes - but WHICH // SESSION is a fact this provider already holds, and letting a surface pass that too would reopen the // wrong-target class for the one route that decides who receives PHI. const inviteTeam = useCallback(async (teamId: string): Promise => { if (!sessionUserIdRef.current) throw new Error('You are not signed in.'); if (!call.sessionId) throw new Error('There is no video visit to invite anyone into.'); if (!teamId) throw new Error('Choose which care team to invite.'); return transport.inviteTeam(call.sessionId, teamId); }, [transport, call.sessionId]); // The session is the one the caller captured, and it must still be this call: a repair never reaches a // later call, and it names no team. const repairDiscovery = useCallback(async (sessionId: string): Promise => { if (!sessionUserIdRef.current) throw new Error('You are not signed in.'); if (!sessionId || sessionId !== call.sessionId) throw new Error('That video visit is no longer open here.'); return transport.repairDiscovery(sessionId); }, [transport, call.sessionId]); // Deliberately does not touch the local call: the removed person is somebody else, the host stays // in the meeting, and the roster updates from the server's ordinary PARTICIPANT_LEFT push. Refusals // reject rather than resolve quietly - 403 not the host, 400 removing yourself, 404 never in the room // - so the UI can say which. const removeParticipant = useCallback(async (participantRef: string): Promise => { if (!sessionUserIdRef.current) throw new Error('You are not signed in.'); if (!call.sessionId) throw new Error('There is no video visit to remove anyone from.'); if (!participantRef) throw new Error('Choose who to remove from the visit.'); await transport.removeParticipant(call.sessionId, participantRef); }, [transport, call.sessionId]); // The encounter-finish path. Finishing an encounter must never throw just because there was no video // running, hence the idle no-op. // This ENDS FOR EVERYONE, and that is the point: concluding the visit is precisely the host action // the `/end` verb exists for. Routing it at `endCall` (leave) would let the practitioner walk out // while leaving the PATIENT sitting alone in a live, billing meeting with no way to know it was over. // That is the mirror image of the defect Lane A caught, and it is just as wrong. const endCallIfActive = useCallback(async (context?: VideoContext) => { if (encounterFinishVideoAction({ context, phase: call.phase, callContext: call.context, available: call.available }).kind === 'end-my-call') { await endMeetingForAll(); return; } // LEFT, BUT THE VISIT OUTLIVED THE DEPARTURE. `endCall` re-offers a meeting that was still // live when you left it, so `available` scoped to THIS encounter is the standing evidence // that a room is still open - and it is the only evidence there is, since nothing about the // user's own idle phase can carry it. The scoping and the enumeration live in // `encounterFinishVideoAction`, which is pure and therefore actually testable. const action = encounterFinishVideoAction({ context, phase: call.phase, callContext: call.context, available: call.available }); if (action.kind !== 'end-left-session') return; // No local SDK teardown: there is no session object to leave, which is what being idle means. // The throw is deliberate and the caller depends on it - a failed end must leave the encounter // unfinished rather than anchor a visit that is still running. await transport.endSession(action.sessionId); dispatch({ type: 'AVAILABLE_ENDED', sessionId: action.sessionId }); }, [call.phase, call.context, call.available, endMeetingForAll, transport]); // Inbound events. Subscribing is gated on a session for the same reason every action is. useEffect(() => { if (!sessionUserId || !subscribeToEvent || !unsubscribeFromEvent) return; // `available` never touches `phase` - a meeting becoming available can neither disturb a call // in progress nor be lost while the user is busy. const onAvailable = (d: CallAvailableEvent) => { dispatch({ type: 'AVAILABLE', sessionId: d.sessionId, context: d.context, initiator: d.initiator, createdAt: d.createdAt }); if (d.createdAt) return; // An untimed offer cannot be ranked, so its creation time is read from the authorized list. void transportRef.current.listAvailable().then((sessions) => { const listed = sessions.find((s) => s.sessionId === d.sessionId); if (!listed?.createdAt) return; dispatch({ type: 'AVAILABLE', sessionId: listed.sessionId, context: listed.context, initiator: listed.initiator, returning: listed.returning, createdAt: listed.createdAt, }); }, () => undefined); }; // Payload isn't in the frozen contract's typed exports yet, but every emission carries the // ended session's id - require a truthy one rather than trusting that, so a malformed event // can never match `undefined === undefined` (idle, or mid-`creating` before CREATED lands) // and silently drop a call that is just starting. const onEnded = (d?: { sessionId?: string }) => { const endedSessionId = d?.sessionId; if (!endedSessionId) return; if (endedSessionId === availableSessionIdRef.current) { // The offered-but-not-yet-joined meeting ended before the user acted on it - clear the // join affordance rather than leaving it pointing at a dead meeting. Never touches // `phase`, so it can't disturb an unrelated call this user is actually in. dispatch({ type: 'AVAILABLE_ENDED', sessionId: endedSessionId }); } // Compare against callSessionIdRef (the CURRENT session, not one closed over when this // handler was registered) and ignore anything that isn't the user's OWN call - otherwise // any session's `call.ended`, including one for a totally unrelated meeting, would kill // whatever call this user is actually in. if (endedSessionId !== callSessionIdRef.current) return; // Same reasoning as endCall: an ended call must not be resurrected by a startCall/ // joinAvailable already in flight for it. epochRef.current += 1; void sessionRef.current?.leave(); // ENDED_REMOTELY, not ENDED: this branch is reached only from the server push, which // means somebody else ended the meeting. The user's own leave goes through `endCall` // and stays silent, because a person who pressed Leave does not need telling. dispatch({ type: 'ENDED_REMOTELY' }); }; // Sent ONLY to the person removed; everybody else sees an ordinary participant departure. Guard // the session id for the same reason `onEnded` does - a stray event must never tear down a call // this user is in - and tear down the local Chime session the same way, because the server has // already barred them from the rejoin grace window and a client still holding the meeting open // would look live while nothing about it is. const onRemoved = (d?: { sessionId?: string }) => { const removedSessionId = d?.sessionId; if (!removedSessionId) return; const isOwnCall = removedSessionId === callSessionIdRef.current; if (!isOwnCall && removedSessionId !== availableSessionIdRef.current) return; if (isOwnCall) { epochRef.current += 1; void sessionRef.current?.leave(); } dispatch({ type: 'REMOVED', sessionId: removedSessionId }); }; subscribeToEvent(CALL_EVENTS.AVAILABLE, onAvailable); subscribeToEvent(CALL_EVENTS.ENDED, onEnded); subscribeToEvent(CALL_EVENTS.PARTICIPANT_REMOVED, onRemoved); return () => { unsubscribeFromEvent(CALL_EVENTS.AVAILABLE, onAvailable); unsubscribeFromEvent(CALL_EVENTS.ENDED, onEnded); unsubscribeFromEvent(CALL_EVENTS.PARTICIPANT_REMOVED, onRemoved); }; }, [sessionUserId, subscribeToEvent, unsubscribeFromEvent]); // THE OTHER HALF OF THE JOIN AFFORDANCE - the late arriver. `call.available` above is a push, // so it only ever reaches a client that was ALREADY CONNECTED when the meeting started. The // patient who opens the portal afterwards is the ORDINARY case (a provider starts the visit, then // the patient goes looking for it) and would otherwise never be told a meeting exists for them. // `GET /R4/video-session` returns the same `AvailableSession` shape the push carries - Lane A made // them one type on purpose - so this dispatches the SAME `AVAILABLE` event and there is exactly // one rendering path regardless of how the client learned. // // DEPS ARE `[sessionUserId]` ALONE, and the transport is read through a ref rather than // depended on. `useLiveVideoTransport`'s memo is keyed on `useHttpService`'s `get`/`post`, whose // stability this provider cannot vouch for - and an unstable transport identity in the deps of an // effect that FETCHES is a render loop against a live gateway. Once per established session is // exactly the intent anyway. const transportRef = useRef(transport); transportRef.current = transport; useEffect(() => { // Same authed-fetch gate as every action here: without a session this runs getValidToken() // refreshAccessToken() logout() reload(), which remounts the provider and loops /login. if (!sessionUserId) return; // THE SHIP GATE, and it earns its place here rather than only on the banner: this fires on // EVERY app mount for EVERY signed-in user, so leaving it open would put an authenticated // request for an unshipped feature on the critical path of every session - against a route // that does not exist outside staging. The banner it feeds is gated too. if (!TELEHEALTH_VIDEO_ENABLED) return; // (B30) - DO NOT ASK A QUESTION THIS SESSION CANNOT ASK. This fires on every app // mount for every signed-in user, and `GET /R4/video-session` is denied for a receptionist and // for an admin - measured, one denied request each on an ordinary page load. The `catch` below // keeps it silent in the app, but the BROWSER still logs the 4xx, which is why nobody reads this // console; and whole method is reading it. // // `mayCall` returns `undefined` for NOT KNOWN - never for "no" - so `=== false` is the only // safe test. An unreachable capability endpoint leaves this exactly as it was yesterday, which // is what lets the client and the server ship in either order. A truthiness check here would // silence the late-joiner lookup for everyone the moment the capability route hiccups, and a // patient who opens the portal after a visit has started would never be offered it. // (B30) - HOLD THE FIRST LOOKUP UNTIL CAPABILITY HAS AN ANSWER. // // The gate below was correct and still fired the request: on a cold load `mayCall` is // `undefined`, so `=== false` is not satisfied, the lookup goes out, and the 403 lands before the // answer does. A landing-screen network log is exactly where that shows up - which is how the // browser run still measured `video-session` denied for `hr1` and `marketing1` on a build that // already had this gate. // ONLY `loading` waits. `unavailable` PROCEEDS - that separation is the whole reason the // capability service distinguishes "we could not ask" from "you may not", and waiting on it would // turn a capability outage into a video outage for everyone. Same shape as // `useCommunicationManager`, which already got this right. if (capabilityState === 'loading') return; if (mayCall('GET', '/video-session') === false) return; let cancelled = false; void (async () => { let sessions; try { sessions = await transportRef.current.listAvailable(); } catch { // Nobody asked for this lookup, so nobody is owed an error about it - and `call.error` // is reserved for failures the user can act on. "Nothing to join" is the safe reading; // the push path still covers whoever was connected. return; } if (cancelled) return; // Every listed meeting goes through the same ordered selection as a push, so the newest wins // whatever order the list and the pushes arrive in. for (const s of sessions) { // A session this user is already IN is not something to be offered. The banner guards // this too; skipping here keeps the state itself honest. if (s.sessionId === callSessionIdRef.current) continue; dispatch({ type: 'AVAILABLE', sessionId: s.sessionId, context: s.context, initiator: s.initiator, // (R9) - the server's answer, from the caller's own `leftAt`. This is the poll // path, which is precisely the one a reload lands on, and a reload is why the client // cannot answer this itself. returning: s.returning, createdAt: s.createdAt, }); } })(); return () => { cancelled = true; }; // ⭐ `mayCall` joins the deps deliberately: capabilities arrive asynchronously, so on a cold load // this effect runs while the answer is still unknown — which is the fallback path, and correct — // and must run AGAIN once the session's real answer lands. Without it a genuinely-eligible user // whose capabilities resolved a beat late would never get the late-joiner lookup at all. // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionUserId, mayCall, capabilityState]); // Cleared when the call changes. The session read is the source; a push only says to read again. const [shown, setShown] = useState(null); const [startWithheld, setStartWithheld] = useState(null); useEffect(() => { setShown(null); setStartWithheld(null); }, [call.sessionId]); const readShare = useCallback(async (sessionId: string) => { try { const record = await transportRef.current.getSession(sessionId); if (callSessionIdRef.current === sessionId) { setShown(record?.share ?? null); setStartWithheld(record?.startWithheld ?? null); } } catch { // Nothing is shown rather than something stale; the next push reads again. if (callSessionIdRef.current === sessionId) setShown(null); } }, []); const refreshSession = useCallback(() => { const sessionId = callSessionIdRef.current; if (sessionId) void readShare(sessionId); }, [readShare]); useEffect(() => { if (!sessionUserId || call.phase !== 'active' || !call.sessionId) return; void readShare(call.sessionId); }, [sessionUserId, call.phase, call.sessionId, readShare]); useEffect(() => { if (!sessionUserId || !subscribeToEvent || !unsubscribeFromEvent) return; // Only this call's events count, for the reason `onEnded` gives: a stray event must never put // another meeting's item on this screen. const onShareStarted = (d?: { sessionId?: string }) => { if (!d?.sessionId || d.sessionId !== callSessionIdRef.current) return; void readShare(d.sessionId); }; const onShareStopped = (d?: { sessionId?: string }) => { if (!d?.sessionId || d.sessionId !== callSessionIdRef.current) return; setShown(null); }; subscribeToEvent(CALL_EVENTS.SHARE_STARTED, onShareStarted); subscribeToEvent(CALL_EVENTS.SHARE_STOPPED, onShareStopped); return () => { unsubscribeFromEvent(CALL_EVENTS.SHARE_STARTED, onShareStarted); unsubscribeFromEvent(CALL_EVENTS.SHARE_STOPPED, onShareStopped); }; }, [sessionUserId, subscribeToEvent, unsubscribeFromEvent, readShare]); const showFromRecord = useCallback(async (reference: string): Promise => { if (!sessionUserIdRef.current) throw new Error('You are not signed in.'); const sessionId = call.sessionId; if (!sessionId) throw new Error('There is no video visit to show anything in.'); const result = await transport.shareItem(sessionId, reference); if (callSessionIdRef.current === sessionId) setShown(result?.share ?? null); }, [transport, call.sessionId]); const stopShowing = useCallback(async (): Promise => { if (!sessionUserIdRef.current) throw new Error('You are not signed in.'); const sessionId = call.sessionId; if (!sessionId) return; await transport.shareItem(sessionId, null); if (callSessionIdRef.current === sessionId) setShown(null); }, [transport, call.sessionId]); // THE DOCKED-SURFACE REGISTRY WAS HERE (a ref-counted register/release pair behind // `dockedSurfaceMounted`). deleted it with the docked surface itself - see the note on // the context type above. There is nothing to arbitrate with one surface. const setMuted = useCallback((b: boolean) => { if (!sessionUserIdRef.current) return; sessionRef.current!.setMuted(b); }, []); const setCameraOn = useCallback(async (b: boolean) => { if (!sessionUserIdRef.current) return; await sessionRef.current!.setCameraOn(b); }, []); //. Same session gate as every other action here - see the comment on `sessionUserIdRef`. const listInputs = useCallback(async () => { if (!sessionUserIdRef.current) return { microphones: [], cameras: [] }; return sessionRef.current!.listInputs(); }, []); const selectInput = useCallback(async (kind: MediaKind, deviceId: string) => { if (!sessionUserIdRef.current) return; await sessionRef.current!.selectInput(kind, deviceId); }, []); const bindTile = useCallback((tileId: number, el: HTMLVideoElement) => { if (!sessionUserIdRef.current) return; sessionRef.current!.bindTile(tileId, el); }, []); const unbindTile = useCallback((tileId: number, el: HTMLVideoElement) => { if (!sessionUserIdRef.current) return; sessionRef.current!.unbindTile(tileId, el); }, []); /** * The remote mix, for the ambient scribe ( - telehealth). * * Chime mixes EVERY remote participant into the one `