import { useEffect, useMemo, useRef, useState } from 'react'; import type { Communication } from 'fhir/r4'; import { useCommunicationServiceContext } from '../services'; import { useWebSocketServiceContext } from '../services/WebSocketService'; import { summarizeThread } from '../utils/threadSummary'; import { combineTeamSummary, resolveCollabEventThread, type TeamMessageSummary } from '../utils/collabTeamSummaries'; import { buildCollabTeamThreadId, buildCollabTeamAsideThreadId } from '../utils/collaborationTeamThread'; const upsert = (list: Communication[], next: Communication): Communication[] => [...list.filter((m) => m.id !== next.id), next]; type ThreadState = Record; // org-agnostic fanout (aws/sam/services/api/src/utils/messageFanout.ts) - see // useCollaborationTeamThreadMessages.ts for the full rationale. IDs only; never render from it. interface ThreadUpdateNotice { threadId: string; communicationId: string; operation: 'created' | 'updated'; } // Per-collaboration-team last-message + unread summaries for a LIST of visible teams, kept live via // the global communication WS channels filtered to those teams' thread-ids. `includeAside=false` // (patient) never fetches or reacts to an aside thread. export const useCollabTeamSummaries = ( teams: { teamId: string }[], { includeAside }: { includeAside: boolean }, ): Map => { const { fetchCommunicationByThread, getCommunicationThreadId, getCurrentUserReference, getReceiptReference, hasReadReceiptForReference, } = useCommunicationServiceContext(); const { subscribe, unsubscribe } = useWebSocketServiceContext(); const [threads, setThreads] = useState({}); // Failed reads, one key per team and thread (`teamId:public`, `teamId:aside`): a public read that // answers must not clear an aside read that did not. const [unreadable, setUnreadable] = useState>(new Set()); // One read order per thread key, shared by the initial and the live reads: only the newest read of a // thread may say whether it could be read. const latestRead = useRef(new Map()); const readCount = useRef(0); const beginRead = (key: string) => { const ticket = ++readCount.current; latestRead.current.set(key, ticket); return ticket; }; const settleRead = (key: string, ticket: number, wasRead: boolean) => { if (latestRead.current.get(key) !== ticket) return; setUnreadable((prev) => { if (prev.has(key) === !wasRead) return prev; const next = new Set(prev); if (wasRead) next.delete(key); else next.add(key); return next; }); }; // Mirrors `threads` for the message.thread.updated handler below, which needs to read the CURRENT // state synchronously to dedupe without depending on `threads` directly - that would tear down and // resubscribe the WS handler on every message. const threadsRef = useRef(threads); useEffect(() => { threadsRef.current = threads; }, [threads]); const teamIds = useMemo(() => teams.map((t) => t.teamId), [teams]); const teamIdKey = teamIds.join(','); // eslint-disable-next-line react-hooks/exhaustive-deps const teamIdSet = useMemo(() => new Set(teamIds), [teamIdKey]); // Fetch each visible team's thread(s) when the set changes. useEffect(() => { let cancelled = false; const loadOne = (teamId: string, variant: 'public' | 'aside', threadId: string) => { const key = `${teamId}:${variant}`; const ticket = beginRead(key); return fetchCommunicationByThread(threadId) .then((b) => { if (!cancelled) settleRead(key, ticket, true); return (b.entry || []).map((e) => e.resource).filter((r): r is Communication => !!r); }) .catch(() => { if (!cancelled) settleRead(key, ticket, false); return [] as Communication[]; }); }; (async () => { const next: ThreadState = {}; await Promise.all(teamIds.map(async (teamId) => { const [pub, aside] = await Promise.all([ loadOne(teamId, 'public', buildCollabTeamThreadId(teamId)), includeAside ? loadOne(teamId, 'aside', buildCollabTeamAsideThreadId(teamId)) : Promise.resolve([] as Communication[]), ]); next[teamId] = { public: pub, aside }; })); if (cancelled) return; // Merge the fetched messages into any WS events that landed after subscribe but before this // fetch resolved (a wholesale overwrite would silently drop such a `created` message, and the // unread count would undercount permanently - the sibling useCollaborationTeamThreadMessages is // patched for the same race). Rebuild over `teamIds` so teams that dropped out of the visible // set are pruned; hard-clear `aside` when !includeAside so a merge can't resurrect stale aside data. setThreads((prev) => { const merged: ThreadState = {}; for (const teamId of teamIds) { const fetched = next[teamId] ?? { public: [], aside: [] }; const prior = prev[teamId] ?? { public: [], aside: [] }; merged[teamId] = { public: fetched.public.reduce((acc, m) => upsert(acc, m), prior.public), aside: includeAside ? fetched.aside.reduce((acc, m) => upsert(acc, m), prior.aside) : [], }; } return merged; }); })(); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [teamIdKey, includeAside, fetchCommunicationByThread]); // Live: one subscription filtered to the visible teams' threads. useEffect(() => { const onEvent = (cloudEvent: { type: string; data: Communication }) => { const payload = cloudEvent.data; const tid = getCommunicationThreadId(payload); const target = tid ? resolveCollabEventThread(tid, teamIdSet, includeAside) : null; if (!target) return; setThreads((prev) => { const current = prev[target.teamId] ?? { public: [], aside: [] }; const list = current[target.variant]; const nextList = cloudEvent.type.endsWith('.deleted') ? list.filter((m) => m.id !== payload.id) : upsert(list, payload); return { ...prev, [target.teamId]: { ...current, [target.variant]: nextList } }; }); }; 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)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [teamIdKey, includeAside, subscribe, unsubscribe, getCommunicationThreadId, teamIdSet]); // client half: a cross-org member's unread badge / last-message preview is stale for the // same reason a cross-org member's open thread was (the full-resource events above never reach them - // see useCollaborationTeamThreadMessages.ts). `resolveCollabEventThread` is the SAME visible-team + // includeAside filter the live-events subscription above already uses, so a notice for a team that // has dropped out of view (or an aside notice on the patient path) is dropped, not refetched. useEffect(() => { let live = true; const onThreadUpdated = (data: ThreadUpdateNotice) => { const target = resolveCollabEventThread(data.threadId, teamIdSet, includeAside); if (!target) return; // Same-org sends already land via the full-resource events above; only pay for a refetch when // this id isn't already known - the cross-org case those events never reach. const known = threadsRef.current[target.teamId]?.[target.variant]?.some((m) => m.id === data.communicationId); if (known) return; const key = `${target.teamId}:${target.variant}`; const ticket = beginRead(key); fetchCommunicationByThread(data.threadId) .then((b) => (b.entry || []).map((e) => e.resource).filter((r): r is Communication => !!r)) .then((list) => { if (!live) return; settleRead(key, ticket, true); setThreads((prev) => { const current = prev[target.teamId] ?? { public: [], aside: [] }; const merged = list.reduce((acc, m) => upsert(acc, m), current[target.variant]); return { ...prev, [target.teamId]: { ...current, [target.variant]: merged } }; }); }) .catch(() => { if (live) settleRead(key, ticket, false); }); }; subscribe('message.thread.updated', onThreadUpdated); return () => { live = false; unsubscribe('message.thread.updated', onThreadUpdated); }; }, [teamIdSet, includeAside, subscribe, unsubscribe, fetchCommunicationByThread]); // Derive per-team summaries. return useMemo(() => { const selfRef = getCurrentUserReference(); // Read as this viewer's receipt reader (their link when acting); an unknown link leaves every line unread. const receiptRef = getReceiptReference(); const readBy = (message: Communication) => receiptRef !== null && hasReadReceiptForReference(message, receiptRef); const map = new Map(); for (const teamId of teamIds) { const t = threads[teamId]; const pub = summarizeThread(t?.public ?? [], selfRef, readBy); const aside = includeAside ? summarizeThread(t?.aside ?? [], selfRef, readBy) : null; const summary = combineTeamSummary(pub, aside); const failed = unreadable.has(`${teamId}:public`) || (includeAside && unreadable.has(`${teamId}:aside`)); map.set(teamId, failed ? { ...summary, unreadable: true } : summary); } return map; // eslint-disable-next-line react-hooks/exhaustive-deps }, [threads, unreadable, teamIdKey, includeAside, getCurrentUserReference, getReceiptReference, hasReadReceiptForReference]); };