import { useCallback, useEffect, useRef, useState } from "react"; import type { Communication } from "fhir/r4"; // THE MODULE, NEVER THE `../services` BARREL - see the identical note in the sibling // `useEncounterRoomThreadMessages.ts`. Both hooks are rendered by `components/video/MeetingWindow`, whose // "no barrel in this folder" rule only holds if it holds transitively. Same binding, no behaviour change. import { useCommunicationServiceContext } from "../services/CommunicationService"; import { useWebSocketServiceContext } from "../services/WebSocketService"; import { buildCollabTeamThreadId, buildCollabTeamAsideThreadId } from "../utils/collaborationTeamThread"; import { describeFetchFailure, isRetryableFailure, relayServerFailure } from "../utils/fetchFailureMessage"; import { onViewerChange, viewerEpoch } from "../utils/writeThroughHold"; import { useRereadWhileSeen } from "./useRereadWhileSeen"; import { withinDeadline } from "../utils/withinDeadline"; import { SaveRetiredError } from "../utils/saveOperation"; /** How long one membership recheck may take before it is given up and the next one may run. */ export const MEMBERSHIP_CHECK_DEADLINE_MS = 15_000; /** The server's own answer that this viewer is not on the team: its thread gates read membership consistently. */ export const isNotAParticipant = (error: unknown): boolean => { const refusal = error as { status?: unknown; body?: { code?: unknown } } | null | undefined; return refusal?.status === 403 && refusal.body?.code === "NOT_A_PARTICIPANT"; }; /** * Whether this viewer may still take part in the open thread: `refused` once a read or send of it was refused as not a * participant, `unconfirmed` when a recheck failed for any other reason, which proves nothing either way. */ export type ThreadParticipation = "current" | "refused" | "unconfirmed"; /** The opening a composer was rendered for; a send carries it from the press, through any preparation, to its answer. */ export type SendOpening = { at: string; epoch: number }; 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), next]); // org-agnostic fanout (aws/sam/services/api/src/utils/messageFanout.ts) - the ONLY signal a // cross-org collab-team member gets, since the full-resource `communication.*` broadcast below is // filtered to the WRITER's org while each socket is stamped with its own connecting user's org. IDs // ONLY by design (enforced server-side by this shape, not a comment) - never render from it; refetch // through the normal authorized read path instead. interface ThreadUpdateNotice { threadId: string; communicationId: string; operation: "created" | "updated"; } // C2 - component-local message state for ONE collaboration-team group thread, independent of the // CommunicationRequest-anchored patient/direct message store. Fetches the thread on mount (server gates // read access) and live-appends via the existing Communication WS channels, filtered to this thread id. // `threadIdOverride`: a private 1:1 sub-thread of the same team (`collabprivate#...`), read and live-updated the same way. // `followMembership`: read the open thread again every 30 seconds while it is seen and when it is seen again, so a // removal shows without a reload. Off for readers whose admission is decided elsewhere. export const useCollaborationTeamThreadMessages = ( teamId: string | null, variant: 'public' | 'aside' = 'public', threadIdOverride: string | null = null, { followMembership = false }: { followMembership?: boolean } = {}, ) => { const { fetchCommunicationByThread, getCommunicationThreadId } = useCommunicationServiceContext(); const { subscribe, unsubscribe } = useWebSocketServiceContext(); const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [canRetry, setCanRetry] = useState(true); const threadId = threadIdOverride ?? (teamId ? (variant === 'aside' ? buildCollabTeamAsideThreadId(teamId) : buildCollabTeamThreadId(teamId)) : null); // Mirrors `messages` for the message.thread.updated handler below, which needs to read the CURRENT // list synchronously to dedupe. It deliberately reads a ref rather than depending on `messages` // directly - depending on it would tear down and resubscribe the WS handler on every message. const messagesRef = useRef(messages); useEffect(() => { messagesRef.current = messages; }, [messages]); // The thread on screen NOW. One instance serves the team thread and its private sub-threads, so a read or a // send that finishes after the reader switched belongs to a thread that is no longer shown and must change // nothing here. Set during render, so there is no moment where a finished request sees the old value. const activeThreadRef = useRef(threadId); activeThreadRef.current = threadId; // Participation belongs to one viewer's opening of one thread. Once refused it stays refused for that opening: // a later answer that still lists this viewer was read before the removal and does not bring Send back. Opening // the thread again is a new opening, decided by its own reads. const [viewer, setViewer] = useState(viewerEpoch); useEffect(() => onViewerChange(() => setViewer(viewerEpoch())), []); const owner = `${viewer}|${threadId ?? ""}`; const ownership = useRef({ owner, generation: 0 }); if (ownership.current.owner !== owner) ownership.current = { owner, generation: ownership.current.generation + 1 }; const opening = `${ownership.current.generation}|${owner}`; const openingRef = useRef(opening); openingRef.current = opening; const [participationOf, setParticipationOf] = useState<{ opening: string; state: ThreadParticipation } | null>(null); const participation: ThreadParticipation = participationOf?.opening === opening ? participationOf.state : "current"; const refused = participation === "refused"; // Each read that answers is numbered, so a caller can tell an answer that came after something it saw from one // it already had. `lastConfirmed` is this opening's latest; null until one of its reads has answered. const confirmations = useRef(0); const [confirmedOf, setConfirmedOf] = useState<{ opening: string; seq: number } | null>(null); const lastConfirmed = confirmedOf?.opening === opening ? confirmedOf.seq : null; const refusedOpenings = useRef(new Set()); const noteParticipation = useCallback((at: string, state: ThreadParticipation) => { if (refusedOpenings.current.has(at)) return; if (state === "refused") refusedOpenings.current.add(at); setParticipationOf({ opening: at, state }); }, []); // The hook instance persists across a team switch (MessagesPage calls it with a changing // `activeTeamId`, it isn't remounted per team) - so a thread change has to clear the previous // thread's messages here rather than relying on refetch's overwrite to do it. This runs, and its // synchronous setMessages([]) commits, before the async fetch below ever resolves, regardless of // effect ordering. useEffect(() => { setMessages([]); }, [threadId]); // `silent` skips the loading/error flags. Chat.tsx blanks its ENTIRE pane for a full-screen skeleton // while `loading` is true, which is correct for the very first mount but would flash a visible // flicker if reused for a background live refresh triggered by the message.thread.updated notice // below (which fires alongside the full-resource event even for a same-org member). const refetch = useCallback(async (opts?: { silent?: boolean; signal?: AbortSignal }) => { if (!threadId) return; const silent = opts?.silent ?? false; const readFor = openingRef.current; const epoch = viewerEpoch(); const stillShown = () => activeThreadRef.current === threadId && openingRef.current === readFor && viewerEpoch() === epoch && !opts?.signal?.aborted; // A reader following membership owns its pages: none is prepared or sent once its opening has moved on. const admit = () => { if (!stillShown()) throw new SaveRetiredError(); }; if (!silent) { setLoading(true); setError(null); } try { const bundle = followMembership ? await fetchCommunicationByThread(threadId, { admit, signal: opts?.signal }) : await fetchCommunicationByThread(threadId); if (!stillShown()) return; if (refusedOpenings.current.has(readFor)) return; noteParticipation(readFor, "current"); setConfirmedOf({ opening: readFor, seq: ++confirmations.current }); const list = (bundle.entry || []).map((e) => e.resource).filter((r): r is Communication => !!r); // Upsert-merge rather than overwrite: a `created` WS event can land after subscribe but // before this fetch resolves (see the WS-subscribe effect below), appending straight into // `messages` via the same `upsert` helper. A full overwrite here would silently drop that // live message if the fetched bundle hasn't caught up to it yet. setMessages((prev) => list.reduce((merged, message) => upsert(merged, message), prev)); } catch (err) { if (!stillShown()) return; if (isNotAParticipant(err)) noteParticipation(readFor, "refused"); else if (silent) noteParticipation(readFor, "unconfirmed"); // A refusal keeps the server's reason and offers no retry: pressing again is refused identically. if (!silent) { setError(relayServerFailure(err, describeFetchFailure(err, "These messages could not be loaded"))); setCanRetry(isRetryableFailure(err)); } } finally { if (!silent && stillShown()) setLoading(false); } }, [threadId, followMembership, fetchCommunicationByThread, noteParticipation]); useEffect(() => { void refetch(); }, [refetch]); // Each recheck is bounded: one that hangs is given up (its read cancelled) and said to be unconfirmed, so the next // check can run. const recheck = useCallback(async (signal: AbortSignal) => { const at = openingRef.current; try { await withinDeadline(refetch({ silent: true, signal }), MEMBERSHIP_CHECK_DEADLINE_MS, "membership check"); } catch { if (openingRef.current === at) noteParticipation(at, "unconfirmed"); } }, [refetch, noteParticipation]); useRereadWhileSeen(followMembership && Boolean(threadId) && !refused, recheck); /** * A send, owned by the opening it was pressed in: its answer is used, and its refusal settles participation, only * while that opening and viewer are still the ones on screen. */ const beginSend = useCallback((from?: SendOpening) => { const at = from?.at ?? openingRef.current; const epoch = from?.epoch ?? viewerEpoch(); const owns = () => openingRef.current === at && viewerEpoch() === epoch; return { owns, noteRefusal: (err: unknown): boolean => { if (!owns() || !isNotAParticipant(err)) return false; noteParticipation(at, "refused"); return true; }, }; }, [noteParticipation]); // A send that finishes after the reader switched threads is not shown in the thread they switched to. const appendOptimistic = useCallback((communication: Communication) => { const sentIn = getCommunicationThreadId(communication); if (sentIn && sentIn !== activeThreadRef.current) return; setMessages((prev) => upsert(prev, communication)); }, [getCommunicationThreadId]); // A refused opening stops following the thread; what is already on screen stays. useEffect(() => { if (!threadId || refused) return; const onEvent = (cloudEvent: { type: string; data: Communication }) => { const payload = cloudEvent.data; if (getCommunicationThreadId(payload) !== threadId) return; setMessages((prev) => cloudEvent.type.endsWith(".deleted") ? prev.filter((m) => m.id !== payload.id) : upsert(prev, payload)); }; const channels = [ "com.akashichealthrecords.fhir.communication.created", "com.akashichealthrecords.fhir.communication.updated", "com.akashichealthrecords.fhir.communication.deleted", ]; channels.forEach((c) => subscribe(c, onEvent)); return () => channels.forEach((c) => unsubscribe(c, onEvent)); }, [threadId, refused, subscribe, unsubscribe, getCommunicationThreadId]); // client half. Filtered to THIS hook's own threadId - unfiltered, every mounted thread // would refetch on any collab-team traffic anywhere, a silent perf problem that gets worse with // scale. useEffect(() => { if (!threadId || refused) return; const onThreadUpdated = (data: ThreadUpdateNotice) => { if (data.threadId !== threadId) return; // Same-org sends already land via the full-resource events above (both fire for one send, // deliberately, so same-org live messaging didn't regress before this landed); only pay for // a refetch when this id isn't already known - the cross-org case those events never reach. if (messagesRef.current.some((m) => m.id === data.communicationId)) return; void refetch({ silent: true }); }; subscribe("message.thread.updated", onThreadUpdated); return () => unsubscribe("message.thread.updated", onThreadUpdated); }, [threadId, refused, subscribe, unsubscribe, refetch]); return { messages, loading, error, canRetry, refetch, appendOptimistic, participation, lastConfirmed, beginSend, sendOpening: { at: opening, epoch: viewer } }; };