import { useEffect, useMemo, useRef, useState } from "react"; import type { Communication, CommunicationRequest, Patient, Reference } from "fhir/r4"; import { Send } from "lucide-react"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "../ui/dialog.tsx"; import { useCommunicationManager } from "../../hooks/useCommunicationManager"; import { useAuthServiceContext, useCommunicationServiceContext } from "../../services"; import { resolveCounterpartyReference, toSelfReference } from "../../utils/messaging/counterparty"; import { replyRecipientsFor } from "../../utils/messaging/poolThread"; import { namedSend } from "../../utils/messaging/sendOperation"; import { MAY_HAVE_BEEN_SENT, useHeldSendOperations } from "../../hooks/useHeldSendOperations"; import { describeServerRefusal } from "../../utils/serverRefusalMessage"; import { onViewerChange, viewerEpoch } from "../../utils/writeThroughHold"; import { useChatSendRecovery } from "../../hooks/useChatSendRecovery"; import { closeAnswerOf, recoveryOwner, scopeDigest, type UnresolvedSend } from "../../utils/messaging/sendRecovery"; const activeReplies = new Map(); const replyKeys = new Map(); /** * A conversation, read and answered WHERE THE PATIENT IS. R5,. * * Spencer, 2026-08-14: *"The goal for this overview page was to try and prevent the need of being * directed off to another section for quick access stuff."* Reading *"can you come at 3 instead?"* and * answering *"yes"* is quick-access work - it is precisely what the overview exists for. Sending the * patient to the messages page to do it is the same shape of loss as sending them to the appointments * list to read one appointment. * * IT READS AND WRITES THROUGH THE SHARED LAYER, NOT A SECOND COPY OF THE MESSAGES PAGE. * `getMessagesForRequest` is the manager the page itself reads, `createCommunicationForThread` is the * service the page itself sends with, and the counterparty comes from `resolveCounterpartyReference` - * the same resolver the queue row already uses to decide who a message is from. A second implementation * of "show a thread" would drift, and the drift would show as two different answers to *who is this * conversation with*. * * IT IS NOT A SECOND INBOX. There is no thread list here, no search, no roster - one conversation, * because one conversation is what was clicked. The full page stays the place you go for the rest. */ export interface PatientMessageDialogProps { open: boolean; onOpenChange: (open: boolean) => void; request: CommunicationRequest | undefined; } const bodyOf = (message: Communication): string => (message.payload ?? []) .map((part) => part.contentString?.trim()) .filter(Boolean) .join("\n"); const Conversation = ({ request }: { request: CommunicationRequest }) => { const { currentUser } = useAuthServiceContext(); const { getMessagesForRequest, setActiveThread } = useCommunicationManager(); const { createCommunication, createCommunicationForThread } = useCommunicationServiceContext(); const [draft, setDraft] = useState(""); const [sending, setSending] = useState(false); const [failed, setFailed] = useState(false); const [unsure, setUnsure] = useState(false); const [refusal, setRefusal] = useState(); const opening = useRef({ live: true, epoch: viewerEpoch() }); const held = useHeldSendOperations(); const recovery = useChatSendRecovery(); const [pending, setPending] = useState<{ scope: string; sends: UnresolvedSend[] }>(); const [readFailed, setReadFailed] = useState(false); const [revision, setRevision] = useState(0); const [checking, setChecking] = useState(false); const [notice, setNotice] = useState(); useEffect(() => { const here = { live: true, epoch: viewerEpoch() }; opening.current = here; const retire = onViewerChange(() => { here.live = false; }); return () => { here.live = false; for (const [operationId, owner] of activeReplies) if (owner === here) activeReplies.delete(operationId); retire(); }; }, []); useEffect(() => { const here = opening.current; setPending(undefined); setReadFailed(false); void scopeDigest([...recovery.place, currentUser?.fhir?.resourceType, currentUser?.fhir?.id, request.id, "patient-overview"]) .then((scope) => { if (!here.live) return; const sends = recovery.store.unresolved(scope).filter((send) => !activeReplies.get(send.operationId)?.live); setPending({ scope, sends }); }).catch(() => { if (here.live) setReadFailed(true); }); }, [recovery, currentUser?.fhir?.resourceType, currentUser?.fhir?.id, request.id, revision]); const checkReplies = async () => { const here = opening.current; if (!pending || checking || !here.live) return; setChecking(true); let unknown = false; let sent = false; let notSent = false; for (const send of pending.sends) { try { const answer = closeAnswerOf(await recovery.close(send.operationId, () => { if (!here.live || viewerEpoch() !== here.epoch) throw new Error("This conversation was closed."); })); if (!here.live) return; if (answer.state === "unknown") unknown = true; else { recovery.store.settle(send.operationId); if (answer.state === "sent") sent = true; else notSent = true; const key = replyKeys.get(send.operationId); if (answer.state === "sent" && key?.startsWith(request.id + "|")) { const words = key.slice((request.id + "|").length); setDraft((current) => current === words ? "" : current); } if (key) held.settle(key); replyKeys.delete(send.operationId); activeReplies.delete(send.operationId); } } catch { unknown = true; } } if (!here.live) return; setNotice(unknown ? "Your earlier reply could not be checked. Try again in a moment." : sent && notSent ? "Some earlier replies were sent and some were not. Read the conversation before writing again." : sent ? "Your earlier reply was sent." : "Your earlier reply was not sent. You can try again."); setChecking(false); setRevision((value) => value + 1); }; const self = currentUser?.fhir as Patient | undefined; const selfReference = toSelfReference(self); const counterparty: Reference | undefined = resolveCounterpartyReference(request, selfReference); // Opening the conversation is what marks it read - the same call the messages page makes, so the // unread badge, the summary band and this dialog cannot disagree about what has been seen. // Cleared on unmount: leaving it set would keep this thread "active" after the dialog closed, and // the next message to arrive would be silently marked read on a screen nobody is looking at. useEffect(() => { if (!request.id) return; setActiveThread(request.id); return () => setActiveThread(null); }, [request.id, setActiveThread]); const messages = useMemo( () => [...getMessagesForRequest(request)].sort( (a, b) => new Date(a.sent ?? 0).getTime() - new Date(b.sent ?? 0).getTime(), ), [getMessagesForRequest, request], ); const send = async () => { const here = opening.current; if (!draft.trim() || !self || sending || !pending || pending.sends.length || readFailed || !here.live || viewerEpoch() !== here.epoch) return; setSending(true); setFailed(false); setUnsure(false); setRefusal(undefined); const words = draft; const key = request.id + "|" + words; const operation = { ...held.take(key), admit: () => { if (!here.live || viewerEpoch() !== here.epoch) throw new Error("This conversation was closed."); } }; try { operation.admit(); const earlier = recovery.store.unresolved(pending.scope).filter((send) => send.operationId !== operation.operationId && !activeReplies.get(send.operationId)?.live); if (earlier.length) { setPending({ scope: pending.scope, sends: earlier }); return; } recovery.store.reserve(pending.scope, operation.operationId, recoveryOwner()); activeReplies.set(operation.operationId, here); replyKeys.set(operation.operationId, key); await createCommunication( namedSend( createCommunicationForThread( words, request.id as string, self, replyRecipientsFor(request, counterparty, selfReference), ), operation, ), operation, ); if (!here.live || viewerEpoch() !== here.epoch) return; held.settle(key); try { recovery.store.settle(operation.operationId); replyKeys.delete(operation.operationId); } catch { setReadFailed(true); } setDraft((current) => current === words ? "" : current); } catch (error) { if (!here.live || viewerEpoch() !== here.epoch) return; const unknown = held.settle(key, error); setUnsure(unknown); if (!unknown) { try { recovery.store.settle(operation.operationId); replyKeys.delete(operation.operationId); } catch { setReadFailed(true); } } setRefusal(describeServerRefusal(error, "Your reply was not sent")); // Said on screen, never swallowed. A reply that vanished silently is worse than one that failed // loudly: the patient believes their practice has been told something it has not. setFailed(true); } finally { if (here.live && viewerEpoch() === here.epoch) { activeReplies.delete(operation.operationId); setSending(false); setRevision((value) => value + 1); } } }; return ( <> {counterparty?.display || request.requester?.display || "Your practice"} This conversation, and a box to reply in.
{messages.length === 0 ? (

No messages in this conversation yet.

) : ( messages.map((message) => { const mine = message.sender?.reference === selfReference; return (
{/* Who wrote it is stamped on the message, never resolved at read time — a practitioner who leaves would otherwise make every historical message render as nobody. */}

{mine ? "You" : message.sender?.display || "Your practice"}

{bodyOf(message)}

); }) )}
{(readFailed || (pending?.sends.length ?? 0) > 0) && (
{readFailed ? "Earlier replies could not be checked. Close and reopen this conversation to try again." : "An earlier reply is unconfirmed. Check it before sending another."} {!readFailed && }
)} {notice &&

{notice}

}