// Pure presentation, same as VideoTileGrid: every action here is a context call. No SDK, no // transport, no fetch. import { useState, type ReactNode } from 'react'; import { Mic, MicOff, PhoneOff, Square, Upload, Video, VideoOff } from 'lucide-react'; import { useVideoServiceContext } from '../../services/VideoService'; import { useAuthServiceContext } from '../../services/AuthService'; import { mediaFailureNotice } from '../../services/video/mediaFailure'; import { DevicePicker } from './DevicePicker'; import { useMeetingBarPanels } from './meetingBarPanels'; import { useEncounterFinisher } from './encounterFinish'; import { finishBlockedMessage, isFinishRefusal } from '../../services/scribe/recordingDisposition'; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog'; interface CallControlsProps { /** Present only for a viewer who may show a record item; the caller decides who that is. */ onShowFromRecord?: () => void; onStopShowing?: () => void; showing?: boolean; participants?: ReactNode; // A visit ends for everyone through Finish encounter, which confirms it; only a conference ends here. endForAll?: boolean; } export const CallControls = ({ onShowFromRecord, onStopShowing, showing, participants, endForAll = true }: CallControlsProps = {}) => { const { call, muted, cameraOn, cameraAvailable, micAvailable, micFailure, cameraFailure, audioOutputBound, setMuted, setCameraOn, endCall, endMeetingForAll, } = useVideoServiceContext(); const { currentUser } = useAuthServiceContext(); const [error, setError] = useState(null); const [confirmingEnd, setConfirmingEnd] = useState(false); const barPanels = useMeetingBarPanels(); // On a phone the bar is one row of equal cells, each an icon over a short label, as the phone // frame draws it; the count and Show move top right. const stack = barPanels ? ' ahr-call__rail-btn flex-col' : ''; // The server remains authoritative; this only avoids offering its host-only action to other users. const isInitiator = currentUser?.fhir?.resourceType === 'Practitioner' && !!call.initiator && !!currentUser?.fhir?.id && call.initiator === `Practitioner/${currentUser.fhir.id}`; const ending = call.phase === 'ending'; // Board 15: on a phone the call covers the encounter page, so its host finishes from here, through // the page's own finish. Absent when no encounter page has registered one for this call. const finishVisit = useEncounterFinisher(call.context?.startsWith('encounter#') ? call.context : undefined); const [confirmingFinish, setConfirmingFinish] = useState(false); // On a phone the call covers the page, so a refused End visit is said here, in the call. const [finishRefused, setFinishRefused] = useState(null); const endVisit = async () => { setConfirmingFinish(false); setFinishRefused(null); const outcome = await finishVisit?.(); if (isFinishRefusal(outcome)) setFinishRefused(finishBlockedMessage(outcome.blocked)); }; // Devices are acquired DURING the join, so every availability flag reads false until it // completes. Warning before then would tell every user on every call that their microphone is // blocked. `active` is the only phase in which these flags mean anything. const connected = call.phase === 'active'; const micBlocked = connected && !micAvailable; // Only with a known cause: the session records one whenever acquiring or switching a camera // fails, and a failed switch in the picker otherwise just reverts the menu without a word. const cameraBlocked = connected && !cameraAvailable && !!cameraFailure; const deafened = connected && !audioOutputBound; // With no camera acquired for this meeting the session REFUSES `setCameraOn(true)` - it // would publish a blank tile that every other participant reads as "their camera is on". The // refusal RESOLVES, so `attempt`'s error path never fires: the button read "Turn camera on", // clicking it did nothing at all, and the user was told nothing at all. Disabled with an // honest name is the two-line truth. const cameraLabel = !cameraAvailable ? 'No camera available' : cameraOn ? 'Turn camera off' : 'Turn camera on'; // THE SAME LIE, ON THE MICROPHONE, AND WORSE (Task 14). `muted` defaults to FALSE, so with no // mic acquired this button read "Mute" - i.e. "you are live" - while the user talked to a patient // who could not hear a word of it. The camera half of this was fixed at Task 7 and the microphone // half was left, which is the more consequential of the two: a blocked camera is visible to // everyone in the meeting, a blocked microphone looks exactly like someone who is not talking. const micLabel = micBlocked ? 'Microphone blocked' : muted ? 'Unmute' : 'Mute'; // `setCameraOn` returns a promise that can REJECT (a denied device), and `void promise` would // discard it: an unhandled rejection in the console and nothing at all for the user. Small inline // state is the proportionate answer - the call is still running, this surface is still mounted, // and the user can act on it by retrying. // // `endCall` is NOT routed through here, and that is deliberate - see the Leave button below. const attempt = (run: () => Promise, fallback: string) => { setError(null); run().catch((e: any) => setError(e?.message || fallback)); }; return (
{/* On the phone the End question takes the bar's place while it is open: added below it, the question took its height from the call's scroll region and cut the composer off. */} {!(barPanels && confirmingEnd) && (
{onShowFromRecord && !barPanels && (showing ? ( ) : ( ))} {!barPanels && participants} {barPanels && (
{barPanels.panels.map((panel) => { const Icon = panel.icon; return ( ); })}
)} {/* ✅ FIXED 2026-08-01 (Lane A §3.3). This button LEAVES: `endCall()` now hits the broker's `/leave`, which marks this participant gone and lets the SERVER delete the meeting only once the room is empty. It used to hit `/end` (a Chime DeleteMeeting), so a patient clicking Leave tore the meeting down for their own doctor — unrecoverably, and equally wrong for a multi-party case conference. If you are ever tempted to route this at `endSession`, don't: it is host-only and would be refused for every non-host anyway. */} {/* `LEAVING` preserves `sessionId`, so a second click would fire a second `/leave` against the same meeting id. Disabled while ending is the whole guard. */} {/* 🔴 A FAILED LEAVE IS NOT REPORTED HERE — it cannot be. `endCall()` rejecting means the provider's teardown has already returned the phase to idle, which unmounts this entire surface before the `.catch` could paint anything. It used to set the inline alert above, which no user could ever have seen: state that cannot be observed is worse than no state, because it reads as handled. The provider records it on `call.error` (a failed `/leave` may leave this user in a live, BILLING meeting) and `CallOverlay`, which outlives the call chrome, is what tells them. Catching is still REQUIRED: uncaught, it is an unhandled promise rejection. */} {!barPanels && endForAll && isInitiator && !confirmingEnd && ( )}
)} {/* The phone bar gives every control an equal column, too narrow for this label. */} {barPanels && endForAll && isInitiator && !confirmingEnd && ( )} {barPanels && isInitiator && finishVisit && ( )} {barPanels && finishRefused && (

{finishRefused}

)} End the video visit and finish this encounter?

This ends the call for everyone. Nobody can rejoin this ended visit. Your note’s draft or filed state does not change.

{endForAll && isInitiator && confirmingEnd && (

End this conference for everyone? Nobody can rejoin it, and the team stops being offered it.

)} {error && (

{error}

)} {/* 🔴 THE TWO SILENT FAILURES (Task 14, spec §5). Both were already tracked truthfully by `ChimeCallSession` and rendered NOWHERE, so in each case the call looked perfectly healthy while half of it did not work. They are separate lines because they are separate facts and can occur independently — one is "they can't hear me", the other is "I can't hear them", and telling someone the wrong one sends them to the wrong setting. Amber, not red: an audio-only or listen-only visit is degraded, not broken, and `ChimeCallSession` deliberately joins anyway rather than refusing the visit. */} {/* 🚨 CARD 302 — ONE SENTENCE PER CAUSE, not one sentence for four. This line used to read "Your microphone is blocked — allow microphone access in your browser, then rejoin" for a DENIED permission, a device another app was holding, a device that does not exist, and (measured 2026-08-15) a page served over http, where the browser withholds the feature entirely and the permission control it pointed at is greyed out. ⭐ Spencer spent ~15 minutes on 08-14 changing Windows, Edge and site settings against the busy-device case, and none of them was the problem. Telling someone to fix a setting that is not the problem costs them the visit while they look in the wrong place. ⛔ "nobody can hear you" is kept OUT of the table and stated here: it is the CONSEQUENCE, identical for every cause, and repeating it in five rows is how they drift apart. */} {micBlocked && (

{mediaFailureNotice(micFailure ?? 'unknown', 'microphone')} Nobody can hear you.

)} {cameraBlocked && (

{mediaFailureNotice(cameraFailure!, 'camera')} Nobody can see you.

)} {/* 🚨 CARD 302 done-when 1 — the control that was missing entirely. It sits BELOW the notice on purpose: two of the five causes ("Another app is using your microphone", "could not be started") name it in their copy, so the sentence and the fix read in order. It renders for a WORKING device too — knowing which microphone a visit is using is the question this card was raised about, and only offering the menu after something breaks makes it a repair tool rather than a control. */} {deafened && (

You won’t hear anyone — this browser would not start audio playback. Try rejoining.

)}
); };