// The ONE join affordance, serving patients and practitioners alike. It replaces both surfaces the // plan originally called for - the incoming-call ring (Task 10) and the pending-visit banner // (Task 13) - because under a meeting model those were two mechanisms doing one job, and only the // ring had an accept/decline problem to solve. // // Keyed on `call.available`, NEVER on `call.phase`: an available meeting is a fact about the world, // not a state of the user's own call. That separation is what lets it coexist with a live call // instead of contesting it, and it is the whole of the ring cut (Spencer, 2026-08-01). // // Pure presentation, like everything else in this folder: no SDK, no transport, no fetch. It does not // care whether the meeting arrived by WebSocket push or by the late-joiner `listAvailable()` lookup - // both write the same `AvailableSession` shape into the same state, deliberately. import { useEffect, useState } from 'react'; import { Video } from 'lucide-react'; import { useVideoServiceContext } from '../../services/VideoService'; import { useAuthServiceContext } from '../../services/AuthService'; import { TELEHEALTH_VIDEO_ENABLED } from '../../services/video/videoFlags'; import { Z_MODAL_CLASS } from '../../lib/stackingLayers'; import { useOptionalEncounterServiceContext } from '../../services/EncounterService'; import { useOptionalActiveEncounterContext } from '../../pages/DashboardPage/ClinicianOverviewPage/ActiveEncounterContext'; import { deriveStatus } from '../../pages/DashboardPage/ClinicianOverviewPage/encounterLifecycle'; import { extractEncounterIdFromRoomThreadId, isEncounterRoomThreadId } from '../../utils/encounterRoomThread'; import { VISIT_NOT_STARTED_NOTICE } from '../../services/video/callMachine'; /** Looked at again while a colleague waits for the visit to start. */ const READINESS_RECHECK_MS = 15_000; export const AvailableMeetingBanner = () => { const { call, joinAvailable } = useVideoServiceContext(); const { currentUser } = useAuthServiceContext(); const encounters = useOptionalEncounterServiceContext(); const slot = useOptionalActiveEncounterContext(); // A COLLEAGUE MAY JOIN ONLY ONCE THE VISIT IS IN PROGRESS, as the server's join gate holds: before then only the // patient and the visit's own clinician may be in the room. Read from the visit itself, again while waiting; one // that cannot be read offers Join and the server decides. The patient, the host and a returning participant skip it. const offered = call.available; const viewerRef = currentUser?.fhir?.id ? `${currentUser.fhir.resourceType}/${currentUser.fhir.id}` : undefined; const visitId = offered && isEncounterRoomThreadId(offered.context) ? extractEncounterIdFromRoomThreadId(offered.context) : null; const awaitsStart = !!offered && !!visitId && !offered.returning && currentUser?.fhir?.resourceType === 'Practitioner' && offered.initiator !== viewerRef && !!encounters; const readVisit = encounters?.fetchEncounter; // Kept with the offer and viewer it was read for, so a new offer reads as checking from its first render. const readinessKey = awaitsStart && visitId ? `${offered!.sessionId}|${viewerRef}` : ''; const [read, setRead] = useState<{ key: string; status: 'waiting' | 'ready' }>(); const readiness = read && read.key === readinessKey ? read.status : 'checking'; useEffect(() => { if (!awaitsStart || !visitId || !readVisit) return; let live = true; let timer: ReturnType | undefined; const look = async () => { try { const visit = await readVisit(visitId, true); if (!live) return; const ownClinician = (visit.participant ?? []).some((p) => p.individual?.reference === viewerRef); if (visit.status === 'in-progress' || ownClinician) { setRead({ key: readinessKey, status: 'ready' }); return; } setRead({ key: readinessKey, status: 'waiting' }); timer = setTimeout(() => void look(), READINESS_RECHECK_MS); } catch { if (live) setRead({ key: readinessKey, status: 'ready' }); } }; void look(); return () => { live = false; clearTimeout(timer); }; }, [awaitsStart, visitId, readinessKey, readVisit, viewerRef]); // THE SHIP GATE. This is an ENTRY POINT - for a patient it is the ONLY one, since they never // start a meeting themselves - so it belongs behind the same flag as "Start video" and not with // the call chrome (gating chrome would strand someone inside a live meeting with no controls). // // HIDDEN, not disabled, and that is the opposite of the Start button on purpose. A disabled // Start reads as "telehealth is coming"; a disabled JOIN reads as "your provider is waiting and // this product will not let you in", which is worse than never mentioning it. Nothing is lost - // while the gate is closed nobody can start a meeting for this banner to announce anyway. if (!TELEHEALTH_VIDEO_ENABLED) return null; const available = call.available; if (!available) return null; // Do not invite someone into the meeting they are sitting in. // // Task 13's plan text says `JOIN_AVAILABLE` "deliberately does not clear `available`" - read // `callMachine.ts`, it returns a fresh object with no `available` key, so joining DOES clear it. // The guard is still load-bearing, for the two paths that write `available` AFTER a join: a // `call.available` push racing the join it announces, and the late-joiner lookup, whose // collection route can list a session this user is already in. if (available.sessionId === call.sessionId) return null; // WHO IS READING THIS, not just what kind of meeting it is (Spencer, 2026-08-02 - the first real // browser call showed BOTH sides "Your provider is waiting to start your visit", which is // patient-facing copy handed to the practitioner). This is not only an artefact of that smoke's // setup: T15 lets a practitioner invite a COLLEAGUE into a live visit, and that colleague // would have read the same sentence in production. const isClinician = currentUser?.fhir?.resourceType === 'Practitioner'; const isVisit = available.context.startsWith('encounter#'); // A banner, NEVER a modal takeover - an available meeting must be neither able to hijack a live // call nor silently lost while the user is busy. Prominence while busy is a styling question, and // it is deliberately not solved by hiding. const busy = call.phase !== 'idle'; // Not joinable yet, or not yet known to be: no Join, and the truth about why. const notYet = awaitsStart && readiness !== 'ready'; // The meeting window treats as host only whoever holds this visit's encounter, so a host coming back // through here takes it up first, as Resume documentation does. A failed read still joins. const selfRef = currentUser?.fhir?.id ? `${currentUser.fhir.resourceType}/${currentUser.fhir.id}` : undefined; const ownVisitId = isClinician && !!selfRef && available.initiator === selfRef && isEncounterRoomThreadId(available.context) ? extractEncounterIdFromRoomThreadId(available.context) : null; const join = async () => { if (ownVisitId && encounters && slot && slot.active?.encounter?.id !== ownVisitId) { try { const encounter = await encounters.fetchEncounter(ownVisitId); const patientId = encounter.subject?.reference?.match(/^Patient\/(.+)$/)?.[1]; if (patientId) { await slot.setActive({ encounter, item: { key: `rejoin-${ownVisitId}`, patientId, encounter, status: deriveStatus(undefined, encounter), atISO: encounter.period?.start }, }); } } catch (error) { console.error('Could not take up the visit before rejoining it', error); } } void joinAvailable(); }; return (
{/* 🔔 Card 307 (R9) — SOMEONE COMING BACK IS NOT BEING INVITED. 🗣️ Spencer: *"The button should indicate 'rejoin' once the user has left the meeting."* ⭐ The copy matters as much as the button. After a crash or a fumbled permission prompt, "Your provider is waiting to start your visit" reads as a NEW meeting — so the person who just dropped out of one wonders whether they are about to join the wrong thing, or whether the visit they were in is gone. Naming it as the same visit is the reassurance. ⛔ `returning` is server-derived and optional: absent means first invitation, which is the wording that was already here and is never wrong for a newcomer. */}

{notYet ? VISIT_NOT_STARTED_NOTICE : available.returning ? isVisit ? 'You left this visit — it is still going.' : 'You left this case conference — it is still going.' : !isVisit ? 'A case conference is waiting for you to join.' : isClinician ? 'A video visit is ready for you to join.' : 'Your provider is waiting to start your visit.'}

{/* No Decline. Not joining IS the refusal — nothing is sent, and the meeting simply stays available until it ends. There is no state on the server for "this person said no". */} {!notYet && ( )}
); };