// - extracted out of `MeetingParticipantsPanel.tsx` so the main stage's name card // (Task 4) and the participants list resolve a participant's identity through ONE implementation // instead of two. See `MeetingParticipantsPanel.tsx`'s header for the full "why" on `externalUserId` // being a FHIR reference and the fallback rules living in `participantIdentity.ts`. // // Participant identity is PHI-adjacent: resolved names live in the CALLER's own React state only - // no localStorage/sessionStorage, never shared call state - and are re-fetched fresh on every mount. import { useEffect, useRef, useState } from 'react'; import { usePractitionerServiceContext } from '../../services/PractitionerService'; import { usePatientServiceContext } from '../../services/PatientService'; import { useRelatedPersonServiceContext } from '../../services/RelatedPersonService'; import { formatFhirHumanName } from '../../utils/fhirHumanName'; import type { RemoteMeetingParticipant } from '../../services/video/ChimeCallSession'; import { parseParticipantReference } from './participantIdentity'; // The join/split delimiter for the id-list dependency key below. A comma rather than a bare space - // FHIR ids are not expected to contain either, but a character that could never plausibly appear inside // a resource id is the safer bet than "unlikely". const ID_KEY_DELIMITER = ','; /** externalUserId -> resolved name. An empty string means "fetched, no usable name" - distinct from an * absent key ("not yet attempted") so the caller can fold either into "no name yet" with `||` before * handing it to `resolveParticipantLabel`, which owns the fallbacks. (`attempted` below - not this * value - is what actually stops a resolved id from being re-fetched; see its comment.) */ export function useParticipantNames(participants: RemoteMeetingParticipant[]): Record { const { fetchPractitioner } = usePractitionerServiceContext(); const { fetchPatient } = usePatientServiceContext(); const { fetchRelatedPerson } = useRelatedPersonServiceContext(); // externalUserId -> resolved name. An empty string means "fetched, no usable name"; an absent key // means "not yet attempted" - `attempted` below is the actual retry guard, this value only carries // that distinction through to render. In memory only, per the PHI-adjacency rule above - this // state is discarded the moment the caller unmounts. const [names, setNames] = useState>({}); // Tracks which ids have been ATTEMPTED, synchronously - `names` only updates once a fetch resolves, // and without this a burst of participants arriving in the same tick would each kick off their own // fetch for the same id before the first one's result had landed. This is ALSO the only thing that // stops an id from ever being re-fetched once resolved: once an id is in this set it stays there, // and no later effect run will re-add it, for the life of the hook. const attempted = useRef>(new Set()); // A STRING key, not the array itself: `remoteParticipants` is a fresh array on every emit (see // `ChimeCallSession.emit()`), so depending on the array reference would re-run this effect on every // unrelated call-state change (a mute toggle, a tile arriving) for no reason - `attempted` would // find nothing new to fetch, but the effect (and its Promise.all/setNames machinery) would still // spin up and tear down needlessly on every one of those emits. // // IT IS NOW AN OPTIMISATION, AND IT DID NOT USED TO BE - worth knowing before you either delete it // or try to pin it with a test. It once carried a CORRECTNESS role: while a cancelled run discarded // its results, a re-run mid-fetch stranded that participant's name forever, and reference-keying made // such a re-run near-certain. Applying results by key unconditionally closed that hole, so the only // thing left to lose is wasted effect churn. That is also why no test isolates this key on its own: // after the fix, deleting it changes no observable value or fetch count, and the only way to catch it // would be a white-box spy on effect invocations - which would pin an implementation detail rather // than a behaviour. Deliberately not written. const idsKey = participants.map((p) => p.externalUserId || '').join(ID_KEY_DELIMITER); useEffect(() => { const toFetch = idsKey .split(ID_KEY_DELIMITER) // `parseParticipantReference` here is defense-in-depth, not redundant with the per-type // switch below it: it rejects anything that isn't a genuine `Type/id` shape, which is what // makes the `!` non-null assertion on the parsed reference, below, actually sound. .filter((id) => id && !attempted.current.has(id) && parseParticipantReference(id)); if (toFetch.length === 0) return; toFetch.forEach((id) => attempted.current.add(id)); void (async () => { const results = await Promise.all(toFetch.map(async (externalUserId) => { const ref = parseParticipantReference(externalUserId)!; try { if (ref.type === 'Practitioner') { const { practitioner } = await fetchPractitioner(ref.id); return [externalUserId, formatFhirHumanName(practitioner?.name) || ''] as const; } if (ref.type === 'Patient') { const patient = await fetchPatient(ref.id); return [externalUserId, formatFhirHumanName(patient?.name) || ''] as const; } if (ref.type === 'RelatedPerson') { const relatedPerson = await fetchRelatedPerson(ref.id); return [externalUserId, formatFhirHumanName(relatedPerson?.name) || ''] as const; } // No fetch exists for this resource type on this client - unresolvable, not // invented. `resolveParticipantLabel` falls back to 'Someone' for it. return [externalUserId, ''] as const; } catch { // A failed read is exactly as unresolvable as one that was never attempted - the // fallback chain, not an error state, is what the viewer sees. return [externalUserId, ''] as const; } })); // Applied by id, UNCONDITIONALLY - not gated on whether idsKey has since moved on. `attempted` // hands every id to exactly ONE effect run, ever, so no two runs can hold a result for the // SAME id; there is nothing here for a later run to clobber. A discard-on-cancel guard here // was tried and shipped a real defect ( fix round 1): once idsKey changed - which // happens on every roster change, not just this id's - the superseded run's cleanup flipped a // flag, and this fetch's eventual result was thrown away right here. Because `attempted` // guarantees no later run ever retries that id, the participant was stuck on the generic // fallback for the rest of the call. Do not reintroduce a cancellation gate here without also // releasing the id from `attempted` on cancel (which reopens the "fetch it twice" race // `attempted` exists to prevent) or otherwise guaranteeing an interrupted id gets retried. setNames((prev) => { const next = { ...prev }; for (const [id, name] of results) next[id] = name; return next; }); })(); }, [idsKey, fetchPractitioner, fetchPatient, fetchRelatedPerson]); return names; }