import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import type { Communication } from "fhir/r4"; // THE MODULE, NEVER THE `../services` BARREL: this hook is rendered by `components/video/MeetingWindow`, whose // folder rule is "no barrel in here", and the barrel drags the whole service graph in behind it. import { useCommunicationServiceContext } from "../services/CommunicationService"; import { useWebSocketServiceContext } from "../services/WebSocketService"; import { buildEncounterRoomThreadId } from "../utils/encounterRoomThread"; import { onViewerChange, viewerEpoch } from "../utils/writeThroughHold"; import { withinDeadline } from "../utils/withinDeadline"; import { SaveRetiredError } from "../utils/saveOperation"; import { createPoolPingReread } from "../utils/messaging/poolPingReread"; import { newerEdition } from "../utils/messaging/messageEdition"; /** A read of the room is given up after this long; the reader is then offered one explicit Try again. */ export const ROOM_THREAD_READ_DEADLINE_MS = 15_000; const sentTime = (c: Communication): number => (c.sent ? new Date(c.sent).getTime() : 0); const sortBySent = (list: Communication[]): Communication[] => [...list].sort((a, b) => sentTime(a) - sentTime(b)); const upsert = (list: Communication[], next: Communication): Communication[] => sortBySent([...list.filter((m) => m.id !== next.id), newerEdition(list.find((m) => m.id === next.id), next)]); const isNotAParticipant = (failure: unknown): boolean => { const refusal = failure as { status?: number; body?: { code?: string } } | null; return refusal?.status === 403 && refusal.body?.code === "NOT_A_PARTICIPANT"; }; // The entitlement write has not caught up with the meeting yet - retryable, never a refusal. const isRoomPublicationPending = (failure: unknown): boolean => { const refusal = failure as { status?: number; body?: { code?: string } } | null; return refusal?.status === 503 && refusal.body?.code === "ROOM_PUBLICATION_PENDING"; }; // org-agnostic fanout (aws/sam/services/api/src/utils/messageFanout.ts): IDs only, never rendered from; the // thread is read again through the normal authorized path instead. interface ThreadUpdateNotice { threadId: string; communicationId: string; operation: "created" | "updated"; } // `decided` is set once a read for this opening has answered, whatever it said: from then the newest answer, not // the meeting's own pending hint, says what the room is. type RoomState = { owner: string; messages: Communication[]; loading: boolean; error: string | null; refused: boolean; pending: boolean; decided: boolean }; const fresh = (owner: string): RoomState => ({ owner, messages: [], loading: false, error: null, refused: false, pending: false, decided: false }); /** * One encounter room thread, read once this attendee is admitted (before that the server rightly refuses). What is * read, refused or delivered live belongs to one opening, retired for good when replaced, even by the same room. * A read is bounded; a failed one is said, with one explicit Try again. */ export const useEncounterRoomThreadMessages = ( encounterId: string | null, options: { admitted?: boolean; sessionKey?: string | null } = {}, ) => { const { fetchCommunicationByThread, getCommunicationThreadId } = useCommunicationServiceContext(); const { subscribe, unsubscribe } = useWebSocketServiceContext(); const threadId = encounterId ? buildEncounterRoomThreadId(encounterId) : null; const admitted = options.admitted ?? true; const reading = Boolean(threadId) && admitted; const [viewer, setViewer] = useState(viewerEpoch); useEffect(() => onViewerChange(() => setViewer(viewerEpoch())), []); const scope = `${viewer}|${threadId ?? ""}|${options.sessionKey ?? ""}|${reading}`; const ownership = useRef({ scope, generation: 0 }); if (ownership.current.scope !== scope) ownership.current = { scope, generation: ownership.current.generation + 1 }; const owner = `${ownership.current.generation}|${scope}`; const currentOwner = useRef(owner); currentOwner.current = owner; const mounted = useRef(true); useLayoutEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); const [stateIn, setStateIn] = useState(() => fresh(owner)); const state = stateIn.owner === owner ? stateIn : fresh(owner); const stateRef = useRef(state); const readsStarted = useRef(0); const readsDecided = useRef(0); stateRef.current = state; const publish = useCallback((at: string, change: (s: RoomState) => RoomState) => { setStateIn((prev) => change(prev.owner === at ? prev : fresh(at))); }, []); /** Begun now: it may act only while this opening and viewer are still the ones on screen. */ const begin = useCallback(() => { const at = currentOwner.current; const epoch = viewerEpoch(); return { at, owns: () => mounted.current && currentOwner.current === at && viewerEpoch() === epoch }; }, []); // `silent` skips the loading/error flags: Chat blanks its pane while loading, which would flicker on a live refresh. const refetch = useCallback(async (opts?: { silent?: boolean }) => { if (!reading || !threadId) return; const silent = opts?.silent ?? false; const read = begin(); // An answer older than the one already shown only ends its own loading. const order = ++readsStarted.current; const newest = () => { if (order < readsDecided.current) return false; readsDecided.current = order; return true; }; const settleOnly = () => { if (!silent) publish(read.at, (s) => ({ ...s, loading: false })); }; const controller = new AbortController(); const owns = () => read.owns() && !controller.signal.aborted; const admit = () => { if (!owns()) throw new SaveRetiredError(); }; if (!silent) publish(read.at, (s) => ({ ...s, loading: true, error: null, refused: false, pending: false })); try { const bundle = await withinDeadline( fetchCommunicationByThread(threadId, { admit, signal: controller.signal }), ROOM_THREAD_READ_DEADLINE_MS, "visit messages", ); if (!owns()) return; if (!newest()) { settleOnly(); return; } const list = (bundle.entry || []).map((e) => e.resource).filter((r): r is Communication => !!r); // Merged, not replaced: a live message can land between subscribe and this answer. publish(read.at, (s) => ({ ...s, messages: list.reduce((merged, m) => upsert(merged, m), s.messages), loading: false, error: null, refused: false, pending: false, decided: true, })); } catch (failure) { if (!owns() || silent) return; if (!newest()) { settleOnly(); return; } const refused = isNotAParticipant(failure); const pending = !refused && isRoomPublicationPending(failure); publish(read.at, (s) => ({ ...s, loading: false, refused, pending, decided: true, error: refused ? "You are not part of the conversation for this visit." : pending ? "The visit chat is still being set up." : "The messages for this visit could not be loaded just now.", })); } finally { controller.abort(); } }, [reading, threadId, fetchCommunicationByThread, begin, publish]); useEffect(() => { if (reading) void refetch(); }, [owner, reading, refetch]); const appendOptimistic = useCallback((communication: Communication) => { publish(currentOwner.current, (s) => ({ ...s, messages: upsert(s.messages, communication) })); }, [publish]); useEffect(() => { if (!reading || !threadId) return; const at = currentOwner.current; const onEvent = (cloudEvent: { type: string; data: Communication }) => { if (currentOwner.current !== at) return; const payload = cloudEvent.data; if (getCommunicationThreadId(payload) !== threadId) return; publish(at, (s) => ({ ...s, messages: cloudEvent.type.endsWith(".deleted") ? s.messages.filter((m) => m.id !== payload.id) : upsert(s.messages, payload), })); }; // One read at a time; a notice heard during it reads once more after, so a newer update is never lost. let inFlight = false; let again = false; const readNow = () => { if (inFlight) { again = true; return; } inFlight = true; void refetch({ silent: true }).finally(() => { inFlight = false; if (again && currentOwner.current === at) { again = false; readNow(); } }); }; // The search trails the write it was told about, so the room is read again across that window. const reread = createPoolPingReread(readNow); const onThreadUpdated = (data: ThreadUpdateNotice) => { if (currentOwner.current !== at || data.threadId !== threadId) return; // A created line already held came by its full-resource event; an update to a held line (a read receipt) did not. if (data.operation !== "updated" && stateRef.current.messages.some((m) => m.id === data.communicationId)) return; readNow(); reread.ping(); }; const channels = [ "com.akashichealthrecords.fhir.communication.created", "com.akashichealthrecords.fhir.communication.updated", "com.akashichealthrecords.fhir.communication.deleted", ]; channels.forEach((c) => subscribe(c, onEvent)); subscribe("message.thread.updated", onThreadUpdated); return () => { channels.forEach((c) => unsubscribe(c, onEvent)); unsubscribe("message.thread.updated", onThreadUpdated); reread.cancel(); }; }, [owner, reading, threadId, subscribe, unsubscribe, getCommunicationThreadId, publish, refetch]); return { messages: state.messages, loading: state.loading, error: state.error, refused: state.refused, pending: state.pending, decided: state.decided, waiting: Boolean(threadId) && !admitted, refetch, appendOptimistic, begin, }; };