import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAuthServiceContext } from "../services"; import { onViewerChange, viewerEpoch } from "../utils/writeThroughHold"; import { sendUnconfirmed, type SendOperation } from "../utils/messaging/sendOperation"; import { closeAnswerOf, RecoveryFull, recoveryOwner, scopeDigest, type SendCloseAnswer, type UnresolvedSend } from "../utils/messaging/sendRecovery"; import { useChatSendRecovery } from "./useChatSendRecovery"; import { useHeldSendOperations } from "./useHeldSendOperations"; export const SEND_NOT_KEPT = "This browser could not keep track of the message, so it was not sent. Your text is still here."; export const SEND_TOO_MANY = "Too many messages on this device are still unconfirmed, so this one was not sent. Check them first. Your text is still here."; export const SEND_WAITS = "An earlier message here may have been sent. Check it before sending another. Your text is still here."; export const EARLIER_MAY_HAVE_BEEN_SENT = "An earlier message here may have been sent."; export const EARLIER_SENT = "Your earlier message was sent."; export const EARLIER_NOT_SENT = "Your earlier message was not sent. Its words were not kept, so write it again if it is still needed."; export const EARLIER_MIXED = "Some earlier messages were sent and some were not. Their words were not kept; read the conversation before writing again."; export const EARLIER_UNCHECKED = "It could not be checked just now. Try again in a moment."; export const EARLIER_UNREADABLE = "This browser could not read its record of earlier messages, so nothing can be sent here just now."; /** The opening a send began in is gone: it publishes nothing, and what it held stays for the next opening to check. */ export class SendRetired extends Error {} type Opening = { live: boolean; epoch: number }; export interface SendAttempt { readonly parts: readonly unknown[]; readonly owner: number; /** Throws once the opening this attempt began in is gone. */ readonly admit: () => void; } export type SendResult = | { state: "sent"; value: T } /** Kept for Check; `unsure` when it may have been written, otherwise the server refused this request. */ | { state: "held"; error: unknown; unsure: boolean } | { state: "retired" } | { state: "not-sent"; reason: string }; // Sends held by this page load. Their own retry is not blocked; anything else unresolved in their scope is. const heldHere = new Set(); const pendingHere = new Map(); const pendingRetired = (operationId: string) => { const pending = pendingHere.get(operationId); if (!pending) return false; try { pending.admit(); return false; } catch { return true; } }; const listeners = new Set<() => void>(); const changed = () => listeners.forEach((listener) => listener()); /** * One sender's unconfirmed sends to one target, kept across a full reload. The scope is derived from what is known * at the click (`preKey`), so a reload finds it again before anything is resolved. Until its record is read, and * while it holds an unresolved send from before, nothing new is sent there: Check settles it first. */ export const useScopedSendRecovery = (callerName: string, preKey: string | undefined) => { const recovery = useChatSendRecovery(); const heldSends = useHeldSendOperations(); const { currentUser, actingAs } = useAuthServiceContext(); const partsOf = (target: string) => [...recovery.place, currentUser?.fhir?.resourceType ?? null, currentUser?.fhir?.id ?? null, actingAs?.subjectPatientId ?? "self", callerName, target]; const partsKey = preKey ? JSON.stringify(partsOf(preKey)) : undefined; const signerKey = JSON.stringify(partsOf("")); const [viewerAt, setViewerAt] = useState(viewerEpoch); const opening = useRef({ live: false, epoch: -1 }); const [readFor, setReadFor] = useState(); const [earlier, setEarlier] = useState([]); const [checking, setChecking] = useState(false); const [notice, setNotice] = useState(); const [recheck, setRecheck] = useState(0); const reopen = useCallback(() => { opening.current.live = false; const here = { live: true, epoch: viewerEpoch() }; opening.current = here; setReadFor(undefined); setNotice(undefined); setChecking(false); setRecheck((count) => count + 1); return () => { here.live = false; }; }, []); useEffect(() => { const here = opening.current.live && opening.current.epoch === viewerEpoch() ? opening.current : { live: true, epoch: viewerEpoch() }; opening.current = here; setNotice(undefined); setChecking(false); return () => { here.live = false; }; }, [partsKey, viewerAt]); useEffect(() => onViewerChange(() => { opening.current.live = false; setViewerAt(viewerEpoch()); }), []); useEffect(() => { setReadFor(undefined); if (!partsKey) return; const here = opening.current; let knownScope: string | undefined; const read = () => { if (!here.live || !knownScope) return; try { setEarlier(recovery.store.unresolved(knownScope)); } catch { setEarlier("unreadable"); } setReadFor(partsKey); }; void scopeDigest(JSON.parse(partsKey) as unknown[]).then((scope) => { knownScope = scope; read(); }, () => { if (!here.live) return; setEarlier("unreadable"); setReadFor(partsKey); }); listeners.add(read); return () => { listeners.delete(read); }; }, [recovery.store, partsKey, recheck, viewerAt]); const unresolved = earlier === "unreadable" ? [] : earlier; // The send itself re-reads the record before anything leaves, so only a read showing a hold from before blocks here. const waits = Boolean(partsKey) && readFor === partsKey && (earlier === "unreadable" || unresolved.some((each) => !heldHere.has(each.operationId) || pendingRetired(each.operationId))); /** * Taken synchronously at the click, before any await, so nothing it starts can act once its opening is gone. A * sender whose target is known only at the click names it here; otherwise the shown target is used. `owns` is * the caller's own opening (a dialog or composer it was pressed in): once false, the send is retired too. */ const begin = useCallback((target?: string, owns?: () => boolean): SendAttempt => { const here = opening.current; const parts = target === undefined ? (partsKey ? JSON.parse(partsKey) as unknown[] : undefined) : [...(JSON.parse(signerKey) as unknown[]).slice(0, -1), target]; if (!parts) throw new SendRetired("Nothing to send to."); return { parts, owner: recoveryOwner(), admit: () => { if (!here.live || viewerEpoch() !== here.epoch || (owns && !owns())) throw new SendRetired("This conversation was closed."); }, }; }, [partsKey, signerKey]); /** * Holds the send before anything else is awaited, sends it through `dispatch` (which calls `leaving` just before it * posts the message), and settles it by its answer. A * held send is released only by its own success; everything else keeps it for Check. */ const run = useCallback(async (attempt: SendAttempt, wordsKey: string, dispatch: (send: SendOperation, leaving: () => void) => Promise): Promise> => { let scope: string; let send: SendOperation; try { scope = await scopeDigest(attempt.parts); attempt.admit(); send = heldSends.take(wordsKey); const originals = recovery.store.unresolved(scope); const others = originals.filter((each) => each.operationId !== send.operationId); if (others.length || originals.some((each) => each.operationId === send.operationId && pendingRetired(each.operationId))) { changed(); return { state: "not-sent", reason: SEND_WAITS }; } recovery.store.reserve(scope, send.operationId, attempt.owner); heldHere.add(send.operationId); pendingHere.set(send.operationId, attempt); changed(); } catch (error) { if (error instanceof SendRetired) return { state: "retired" }; return { state: "not-sent", reason: error instanceof RecoveryFull ? SEND_TOO_MANY : SEND_NOT_KEPT }; } // Whether this attempt got as far as sending the message: one that never did cannot have written it. let left = false; const operation: SendOperation = { operationId: send.operationId, admit: () => attempt.admit() }; try { const value = await dispatch(operation, () => { left = true; }); // Only a success this opening still owns settles; a retired one keeps its hold for the live opening's Check. try { attempt.admit(); } catch { changed(); return { state: "retired" }; } heldSends.settleOutcome(wordsKey, operation.operationId, "owned-success"); try { recovery.store.settle(operation.operationId); heldHere.delete(operation.operationId); } catch { // Still held: a later Check finds it settled on the server. } changed(); return { state: "sent", value }; } catch (error) { // Any other answer, a refusal included, keeps the send held until Check settles it. changed(); if (error instanceof SendRetired) return { state: "retired" }; try { attempt.admit(); } catch { return { state: "retired" }; } return { state: "held", error, unsure: left && sendUnconfirmed(error) }; } finally { if (pendingHere.get(send.operationId) === attempt) pendingHere.delete(send.operationId); } }, [heldSends, recovery.store]); /** Asks the server about every unresolved send here; only a complete answer settles one. */ const check = useCallback(async () => { if (earlier === "unreadable" || !unresolved.length || checking) return; const here = opening.current; const admit = () => { if (!here.live || viewerEpoch() !== here.epoch) throw new SendRetired("This conversation was closed."); }; setChecking(true); let sent = 0; let notSent = 0; let open = 0; for (const each of unresolved) { let result: SendCloseAnswer = { state: "unknown" }; try { result = closeAnswerOf(await recovery.close(each.operationId, admit)); } catch { // Unanswered: still open. } if (!here.live) return; if (result.state === "unknown") { open += 1; continue; } try { recovery.store.settle(each.operationId); } catch { open += 1; continue; } heldSends.forget(each.operationId); heldHere.delete(each.operationId); if (result.state === "sent") sent += 1; else notSent += 1; } setChecking(false); setNotice(open ? EARLIER_UNCHECKED : sent && notSent ? EARLIER_MIXED : sent ? EARLIER_SENT : EARLIER_NOT_SENT); setRecheck((count) => count + 1); changed(); }, [earlier, unresolved, checking, recovery, heldSends]); return useMemo(() => ({ /** Nothing new may be sent here: the record is unreadable, or holds a send from before this page load. */ waits, unreadable: earlier === "unreadable", earlier: unresolved, checking, notice, reopen, begin, run, check, }), [waits, earlier, unresolved, checking, notice, reopen, begin, run, check]); }; export type ScopedSendRecovery = ReturnType;