import { useEffect, useRef, useState } from "react"; import { buildCollabTeamThreadId } from "../utils/collaborationTeamThread"; import { onViewerChange, viewerEpoch } from "../utils/writeThroughHold"; import { withinDeadline } from "../utils/withinDeadline"; import { SaveRetiredError } from "../utils/saveOperation"; import { isNotAParticipant, MEMBERSHIP_CHECK_DEADLINE_MS } from "./useCollaborationTeamThreadMessages"; import { useRereadWhileSeen } from "./useRereadWhileSeen"; /** What a recheck learned about a team the list no longer shows this viewer as active on. */ export type TeamParticipationNote = "not-a-participant" | "unconfirmed"; type Request = { admit: () => void; signal: AbortSignal }; const ENDED = new Set(["removed", "left", "declined", "denied"]); // A membership the server has newly offered: a new admission, decided by its own invitation and acceptance. const OFFERED = new Set(["invited", "pending-patient-approval"]); /** * Reads the team list again every 30 seconds while seen. The list is eventually consistent, so a team it shows ended or * omits is asked through its own consistently gated thread: a not-a-participant refusal is kept until a new invitation, * an answer keeps the team, any other failure is unconfirmed. A newly offered membership is taken as listed. */ export const useTeamParticipationRecheck = ({ enabled, teams, setTeams, membershipState, listTeams, readThread, }: { enabled: boolean; teams: T[]; setTeams: (teams: T[]) => void; membershipState: (team: T) => string | undefined; listTeams: (request: Request) => Promise; readThread: (threadId: string, request: Request) => Promise; }): Map => { const [viewer, setViewer] = useState(viewerEpoch); useEffect(() => onViewerChange(() => setViewer(viewerEpoch())), []); const [notes, setNotes] = useState<{ viewer: number; byTeam: Map }>({ viewer, byTeam: new Map() }); const teamsRef = useRef(teams); teamsRef.current = teams; const stateRef = useRef(membershipState); stateRef.current = membershipState; // A team shown as newly offered, by any read of the list, retires what an earlier membership was noted as. useEffect(() => { const offered = teams.filter((team) => OFFERED.has(stateRef.current(team) ?? "")).map((team) => team.teamId); if (!offered.length) return; setNotes((prev) => { if (!offered.some((teamId) => prev.byTeam.has(teamId))) return prev; const byTeam = new Map(prev.byTeam); offered.forEach((teamId) => byTeam.delete(teamId)); return { viewer: prev.viewer, byTeam }; }); }, [teams]); useRereadWhileSeen(enabled, async (signal) => { const epoch = viewerEpoch(); const shown = teamsRef.current; const owns = () => !signal.aborted && viewerEpoch() === epoch && teamsRef.current === shown; const request = { admit: () => { if (!owns()) throw new SaveRetiredError(); }, signal }; const check = async () => { const next = await listTeams(request); if (!owns()) return; const state = stateRef.current; const lapsed = shown.filter((team) => { if (state(team) !== "active") return false; const listed = next.find((entry) => entry.teamId === team.teamId); return !listed || ENDED.has(state(listed) ?? ""); }); const outcomes = await Promise.all(lapsed.map(async (team) => { try { await readThread(buildCollabTeamThreadId(team.teamId), request); return { team, note: undefined }; } catch (failure) { return { team, note: isNotAParticipant(failure) ? "not-a-participant" as const : "unconfirmed" as const }; } })); if (!owns()) return; const keptIds = new Set(lapsed.map((team) => team.teamId)); setTeams([...next.filter((listed) => !keptIds.has(listed.teamId)), ...lapsed]); setNotes((prev) => { const byTeam = new Map(prev.viewer === epoch ? prev.byTeam : []); for (const listed of next) { if (state(listed) === "active" && byTeam.get(listed.teamId) === "unconfirmed") byTeam.delete(listed.teamId); } for (const { team, note } of outcomes) { if (byTeam.get(team.teamId) === "not-a-participant") continue; if (note) byTeam.set(team.teamId, note); else byTeam.delete(team.teamId); } return { viewer: epoch, byTeam }; }); }; // Bounded: a check that hangs is given up and its reads cancelled, so the next one can run. await withinDeadline(check(), MEMBERSHIP_CHECK_DEADLINE_MS, "team membership check"); }); return notes.viewer === viewer ? notes.byTeam : new Map(); };