import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ReactNode } from "react"; import { useCollaborationTeamServiceContext, useCommunicationServiceContext, usePatientServiceContext, } from "../../services"; import { useCollabTeamSummaries } from "../../hooks/useCollabTeamSummaries"; import { useMyProxies } from "../../hooks/useMyProxies"; import { teamMessagingRefusal } from "../../utils/teamMessagingRefusal"; import { countPatientInboxUnread } from "../../utils/patientInboxUnread"; import { PatientInboxContext, type PatientInboxTeam, type PatientInboxValue } from "./patientInboxContext"; import type { CollaborationTeam } from "../../services/collaborationTeam"; import { onViewerChange, viewerEpoch } from "../../utils/writeThroughHold"; import { useWebSocketServiceContext } from "../../services/WebSocketService"; /** The server's id-only notice that a team's roster changed: who joined, left or was removed is re-read, never told. */ export const TEAM_MEMBERSHIP_UPDATED = "team.membership.updated"; /** How often, and how far apart, a roster that does not yet show an announced change is read again. */ export const ROSTER_REREADS = 2; export const ROSTER_REREAD_MS = 1000; const rosterOf = (team?: PatientInboxTeam): string => (team?.members ?? []).map((member) => `${member.memberRef}:${member.state}`).sort().join("|"); const timeOf = (stamp?: string): number => { const at = Date.parse(stamp ?? ""); return Number.isNaN(at) ? -Infinity : at; }; // One place owns the patient's teams and unread count, so the header and the Inbox tab cannot differ. export const PatientInboxProvider = ({ children }: { children: ReactNode }) => { const { listTeams } = useCollaborationTeamServiceContext(); const { patientState } = usePatientServiceContext(); const { patientUnreadMessages } = useCommunicationServiceContext(); const { proxies } = useMyProxies(); const [listedTeams, setTeams] = useState([]); const [teamsLoading, setTeamsLoading] = useState(true); const [teamsError, setTeamsError] = useState(null); const [openTeamId, setOpenTeamId] = useState(null); const listedRef = useRef(listedTeams); listedRef.current = listedTeams; // Whose inbox this is: the viewer and the patient. Each change is a new owner, counted during render so no answer // can see the old one; an action's answer publishes only for the owner it was started under, even if they return. const [viewer, setViewer] = useState(viewerEpoch); useEffect(() => onViewerChange(() => setViewer(viewerEpoch())), []); const owner = `${viewer}|${patientState?.id ?? ""}`; const ownership = useRef({ owner, generation: 0 }); if (ownership.current.owner !== owner) ownership.current = { owner, generation: ownership.current.generation + 1 }; const generation = ownership.current.generation; // Reads are numbered as they start; only the latest may publish, so an older answer never replaces a newer one. const readSeq = useRef(0); // For each team acted on here, the newest state accepted so far, from the action or a later list row: the list is // read from an index that can lag, so an older or missing row never lowers it, while a newer one raises it. const acknowledged = useRef(new Map()); const withAcknowledged = useCallback((listed: PatientInboxTeam[], held: PatientInboxTeam[]): PatientInboxTeam[] => { const merged = listed.map((team) => { const ack = acknowledged.current.get(team.teamId); if (!ack) return team; if (Date.parse(team.updatedAt) >= Date.parse(ack.updatedAt)) { acknowledged.current.set(team.teamId, team); return team; } return { ...team, ...ack, members: team.members }; }); const missing = held.filter((team) => acknowledged.current.has(team.teamId) && !listed.some((row) => row.teamId === team.teamId)); return [...merged, ...missing]; }, []); // The teams and acknowledgements belong to this patient: a different patient's inbox waits for their own list. useEffect(() => { acknowledged.current.clear(); ++readSeq.current; setTeams([]); setTeamsLoading(true); }, [patientState?.id]); // Answers the list it published, or nothing when a newer read or another owner superseded it. A read belongs to the // owner it started under, even one who left and came back; a new owner starts its own read. const readTeams = useCallback(async (): Promise => { // patientState resolves asynchronously; stay loading rather than report a false failure. if (!patientState) return undefined; const mine = ++readSeq.current; // The viewer is read directly too: a change is known at once, while the generation waits for the next render. const startedViewer = viewerEpoch(); const current = () => mine === readSeq.current && ownership.current.generation === generation && viewerEpoch() === startedViewer; setTeamsLoading(true); setTeamsError(null); try { const listed = await listTeams(); if (!current()) return undefined; setTeams((held) => withAcknowledged(listed, held)); return listed; } catch { if (current()) setTeamsError("Failed to load your collaboration teams"); return undefined; } finally { if (current()) setTeamsLoading(false); } }, [patientState, generation, listTeams, withAcknowledged]); const reloadTeams = useCallback(async () => { await readTeams(); }, [readTeams]); // Someone joined, left or was removed from one of these teams: its roster, and so an open thread's count, is read // again now. The list can trail the change the notice announced, so a roster that does not show it yet is read a // couple more times, briefly; nothing is counted here that the read has not returned. const { subscribe, unsubscribe } = useWebSocketServiceContext(); useEffect(() => { let stopped = false; const timers: ReturnType[] = []; const onRoster = (data: { teamId?: string } | undefined) => { const teamId = data?.teamId; const held = listedRef.current.find((team) => team.teamId === teamId); if (!teamId || !held) return; const before = rosterOf(held); const read = async (left: number) => { const listed = await readTeams(); if (stopped || !listed || left === 0) return; if (rosterOf(listed.find((team) => team.teamId === teamId)) === before) { timers.push(setTimeout(() => void read(left - 1), ROSTER_REREAD_MS)); } }; void read(ROSTER_REREADS); }; subscribe(TEAM_MEMBERSHIP_UPDATED, onRoster); return () => { stopped = true; timers.forEach(clearTimeout); unsubscribe(TEAM_MEMBERSHIP_UPDATED, onRoster); }; }, [subscribe, unsubscribe, readTeams]); // A lifecycle action's own answer, so a dissolved team stops taking messages in this session without a reload. // A create's answer adds the team, when it is this patient's: nobody is on a team yet when it is made, and the // list read may leave it out for a while, so it is held like any other acknowledged team until the list has it. const subjectId = patientState?.id; const noteTeam = useMemo(() => { const startedUnder = generation; const startedViewer = viewer; return (team: CollaborationTeam) => { // Work started for another owner changes nothing here - not the reads, not the floor, not the list. if (ownership.current.generation !== startedUnder || viewerEpoch() !== startedViewer) return; // Nor does an answer older than the state already held, from the list or an earlier action. const held = listedRef.current.find((row) => row.teamId === team.teamId); const newest = Math.max(timeOf(held?.updatedAt), timeOf(acknowledged.current.get(team.teamId)?.updatedAt)); if (newest > -Infinity && !(timeOf(team.updatedAt) >= newest)) return; if (!held && team.patientId !== subjectId) return; ++readSeq.current; acknowledged.current.set(team.teamId, team); setTeams((listed) => { if (listed.some((row) => row.teamId === team.teamId)) { return listed.map((row) => (row.teamId === team.teamId && timeOf(row.updatedAt) <= timeOf(team.updatedAt) ? { ...row, ...team, members: row.members } : row)); } return team.patientId === subjectId ? [...listed, { ...team, members: [] }] : listed; }); void reloadTeams(); }; }, [generation, viewer, reloadTeams, subjectId]); useEffect(() => { void reloadTeams(); }, [reloadTeams]); const selfPatientId = patientState?.id; const teams = useMemo( () => listedTeams.map((team) => ({ ...team, messagingRefusal: teamMessagingRefusal(team, selfPatientId, proxies) })), [listedTeams, selfPatientId, proxies], ); // A dissolved team cannot be written to, so it is neither listed nor counted. const openTeams = useMemo( () => teams.filter((team) => team.status === "active" || team.status === "dormant"), [teams], ); // The practitioner-only aside is never fetched for a patient. const teamSummaries = useCollabTeamSummaries(openTeams, { includeAside: false }); const inboxUnread = useMemo( () => countPatientInboxUnread( patientUnreadMessages, openTeams.map((team) => team.teamId), teamSummaries, openTeamId, ), [patientUnreadMessages, openTeams, teamSummaries, openTeamId], ); const value = useMemo( () => ({ teams, openTeams, teamsLoading, teamsError, teamSummaries, inboxUnread, reloadTeams, setOpenTeamId, noteTeam }), [teams, openTeams, teamsLoading, teamsError, teamSummaries, inboxUnread, reloadTeams, noteTeam], ); return {children}; };