import { describeServerRefusal } from "../../utils/serverRefusalMessage"; import type { SendOperation } from "../../utils/messaging/sendOperation"; import { onViewerChange, viewerEpoch } from "../../utils/writeThroughHold"; import { useHeldSendOperations } from "../../hooks/useHeldSendOperations"; import type { ChatSendRecovery } from "../../hooks/useChatSendRecovery"; import { closeAnswerOf, RecoveryFull, recoveryOwner, scopeDigest, type SendCloseAnswer, type UnresolvedSend } from "../../utils/messaging/sendRecovery"; import { senderLabel } from '../../utils/messaging/senderLabel'; import { onBehalfOf, type OnBehalfOf } from '../../utils/messaging/sentOnBehalfBy'; import React, { useCallback, useMemo, useRef, useEffect, useState } from "react"; import { Communication, Patient, Practitioner, RelatedPerson, Attachment } from "fhir/r4"; import { useAuthServiceContext } from "../../services"; import { formatFhirHumanName } from "../../utils/fhirHumanName.tsx"; import PresenceDot from "../PresenceDot/PresenceDot"; import ProxyAttributionBadge from "../ProxyAttributionBadge/ProxyAttributionBadge"; import { ChevronLeft, CornerDownLeft, File, Hospital, Info, MessageSquareText, NotebookText, Paperclip, SendHorizontal, TriangleAlert, UserRound, Smile, Stethoscope, UserRoundPlus, Users, Video, X, } from "lucide-react"; import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, } from "../ui/sheet"; import { Card } from "../ui/card"; import { Textarea } from "../ui/textarea"; import { Button } from "../ui/button"; import { Skeleton } from "../ui/skeleton"; import { toast } from "sonner"; import { getFhirPhotoUrl, scannedPhotoSrc } from "../../utils/fhirPhoto.ts"; import { usePhotoUploadService } from "../../services/PhotoUploadService"; import { ChatAttachment, SENDING_FILE_WORDS, type ChatFile } from "./ChatAttachment"; import { PRE_SCAN_FILE_WORDS } from "../../utils/uploadScanHold"; import { FRONT_DESK_ASIDE_POOL_KEY, poolThreadDescription, poolThreadLabel } from "../../utils/messaging/poolThread"; import { ROOM_THREAD_DESCRIPTION, ROOM_THREAD_LABEL } from "../../utils/messaging/roomThread"; import { resolveLatestOutboundStatus, type MessageStatus, } from "../../utils/messaging/messageStatus"; import { addReadReceiptForReference, hasReadReceiptForReference } from "../../utils/messaging/readReceipts"; import { acknowledgeOnce, createReceiptLedger, unconfirmedOf } from "../../utils/messaging/receiptLedger"; import { editionOf, receiptConfirmed, receiptReferenceOf } from "../../utils/messaging/receiptReference"; import { startSaveOperation, type SaveOperation, type SaveTransport } from "../../utils/saveOperation"; import { MessageStatusLine } from "./MessageStatusLine"; import { InboxAvatar } from "../../pages/DashboardPage/MessagesPage/InboxRow"; import { useReminderClearance } from "./useReminderClearance"; import { isWithheldColleagueName, WITHHELD_COLLEAGUE_COMPOSER, } from "../../utils/staff/withheldColleague"; const defaultPicture = getFhirPhotoUrl(null, "") /** * The message box's element id. - *"Reply anyway"* puts the cursor here. * * Exported rather than typed twice: the strip that focuses it lives in another tree, and a magic * string repeated in two files is a focus call that silently stops working the day one of them changes. */ export const CONVERSATION_COMPOSER_ID = 'akashic-conversation-composer'; // The opening each send began in. While that opening is on screen its failed bubble answers for the send; once it // is gone the send is a leftover, so coming back to the conversation always offers the check. type Opening = { live: boolean; epoch: number }; const liveSends = new Map(); // A retry names the same uploaded object; uploading again would change the body of an unresolved send. const pendingFileAttachments = new Map(); const pendingFileDrafts = new Map(); const fileSendKeys = new Map(); const selectedFileKeys = new WeakMap(); // Refuses a send or check whose opening has closed or whose viewer changed, at the last moment before it leaves // and again on its answer; its hold is kept for the next opening to check. class OpeningRetired extends Error {} const admitIn = (here: Opening) => () => { if (!here.live || viewerEpoch() !== here.epoch) throw new OpeningRetired("This conversation was closed."); }; const recoveryListeners = new Set<() => void>(); const recoveryChanged = () => recoveryListeners.forEach((listener) => listener()); const RECOVERY_UNAVAILABLE = "This browser could not keep track of the message, so it was not sent. Your text is still here."; const RECOVERY_FULL = "Too many messages on this device are still unconfirmed, so this one was not sent. Check them first. Your text is still here."; const EARLIER_SENT = "Your earlier message was sent."; const EARLIER_NOT_SENT = "Your earlier message was not sent. Its words were not kept, so write it again if it is still needed."; const EARLIER_MIXED = "Some earlier messages were sent and some were not. Their words were not kept; read the conversation before writing again."; const EARLIER_UNCHECKED = "It could not be checked just now. Try again in a moment."; interface ChatProps { messages?: Communication[]; practitioner?: Practitioner; patient?: Patient; conversationPartner?: Patient | Practitioner | RelatedPerson; onSendMessage?: (message: string, attachment?: Attachment, send?: SendOperation) => void; /** Holds each send for a full reload and settles what a reload left; only senders that name their sends pass it. */ sendRecovery?: ChatSendRecovery; /** Typing or sending in the composer; the desk uses it to keep its claim on the conversation alive. */ onComposerActivity?: () => void; loading?: boolean; /** The thread's messages could not be read, so an empty thread is unknown rather than new. */ loadError?: string | null; onRetryLoad?: () => void; bottom?: number; right?: number; /** Saves read receipts through `transport`, answering with the messages the server returned. */ onUpdateMessages?: (updatedMessages: Communication[], transport?: SaveTransport) => Promise; /** Reads the current stored edition of each line through `transport`; one it could not read is left out. */ onReadMessages?: (ids: string[], transport?: SaveTransport) => Promise; onClose?: () => void; requestId?: string; selectedMessage?: { id?: string; name?: string; senderReference?: string; status?: string; }; conversationOrganizationName?: string; conversationRoleLabel?: string; /** * - display names for the people who have READ the patient's message, by reference. * A receipt carries a reference; a patient must never be shown one. Absent or unresolved, the status * line falls back to the practice name, which is true and is the fact the patient wanted. */ readerNameByReference?: Record; /** * - names for message AUTHORS this thread's roster already knows, keyed by reference. * * THE CASE IT EXISTS FOR IS THE OTHER PRACTICE'S CLINICIAN. A `Communication` carries whatever * display its own writer stamped, and a cross-org sender's carries none - so the messages that matter * most in a collaboration team were the ones rendered as "Participant", while the roster loaded by the * same screen named them. The roster is the server's answer and is passed IN; this component never * resolves a name itself. * It is consulted only when the stamped display is missing or fails `looksLikeAnIdentifier`, and it * is judged by that same shape test - a denormalised roster name can carry an id too. */ senderNameByReference?: Record; /** `null` when this reader may no longer read the roster: no count is said, rather than one that was true once. */ participantCount?: number | null; /** * WHO IS ACTUALLY IN HERE, for the details panel. A count answers "how many" and the reader's next * question is always "which of them" - on a team that spans practices, that is the whole point of * asking. * * A member whose name the server did not resolve is NOT dropped and NOT rendered as a reference. * On a cross-organization team the one member a patient cannot look up is exactly the one they most * need to know is present, so an unnamed row still counts and says so. */ /** Return to the conversation list. Mobile only - on a phone the list is hidden while a thread is * open, so this is the only route back to it. */ onBack?: () => void; participants?: { name?: string; /** What this person is TO the patient - their practice, their relationship, or independent. */ subtitle?: string | null; /** Resolvable only for somebody the patient may read; absent is normal, not an error. */ avatarUrl?: string; /** `Practitioner/{id}` or `RelatedPerson/{id}`; with `onMessagePrivately`, the row offers a private 1:1. */ memberRef?: string; }[]; /** Opens a private 1:1 with one person in this group (a team's private sub-thread). */ onMessagePrivately?: (person: { memberRef: string; name?: string }) => void; /** This thread IS such a private 1:1, with this person: its badge, empty state and composer say so. */ privateWithName?: string; isGroupThread?: boolean; // Named by the caller when the stream's audience is narrower than the thread's roster: the // aside excludes the patient the team is about, and the ladder below cannot know that. composerPlaceholder?: string; groupName?: string; /** * - the pool this conversation belongs to, when it is with an OFFICE rather than a person. * * One prop, not a boolean plus two strings: the words come from the same module the inbox row reads, * so the pane and the list cannot end up calling the same conversation two different things. A pool * thread has NO practitioner by design, which is exactly why the pane used to refuse to * render one at all. */ poolThreadKey?: string | null; /** How many staff can answer the pool; drawn only when known and above zero. */ poolAnswererCount?: number; /** * - the conversation belongs to a VISIT rather than to a person or an office. * * The fourth way a conversation can exist, and it arrives the same way the pool's does: read off the * selected thread by the page, never set as a flag when a particular route opens one. A room thread * has no practitioner by design - its roster is whoever was admitted to the room - so without this * the pane refused to render one at all, and a patient who clicked the thread from their own visit * was shown "Select a conversation" with the conversation already open in state. */ isRoomThread?: boolean; readOnly?: boolean; /** Why the thread is read-only, when it is for a reason other than a closed team. */ readOnlyReason?: string; needsReply?: boolean; onDismissReply?: () => void; /** * The patient portal's thread, drawn as its deck draws it: the name and who they are above the * conversation rather than in a bar across it, no photograph tile, and each message signed * "Name · time" underneath. The staff panels keep the panel layout. * The front desk's thread is drawn as its own deck draws it: the patient named above the * conversation, each message signed above its bubble, and a failed send kept in the composer. */ layout?: "panel" | "portal" | "desk" | "colleague"; /** On the desk: who is handling this shared conversation, drawn with it. */ threadNotice?: React.ReactNode; } interface ChatMessage { id: string; sender: "me" | "other"; senderReference?: string; senderName?: string; // Set only from the server's stamp: a helper wrote this for the patient the message is anchored to. onBehalfOf?: OnBehalfOf; text?: string; file?: ChatFile; time: string; sent: string; isImage?: boolean; } interface FilePreview { opening: Opening; scope: string; file: File; name: string; size: string; type: string; } interface DoctorDetails { name: string; specialty: string; clinic: string; gender: string; qualifications: string[]; services: string[]; languages: string[]; availability: Record; } const formatDate = (dateString: string): string => { const date = new Date(dateString); const today = new Date(); const yesterday = new Date(today); yesterday.setDate(yesterday.getDate() - 1); const resetTime = (d: Date) => d.setHours(0, 0, 0, 0); const compareDate = new Date(date); resetTime(compareDate); resetTime(today); resetTime(yesterday); if (compareDate.getTime() === today.getTime()) { return "Today"; } else if (compareDate.getTime() === yesterday.getTime()) { return "Yesterday"; } else { return date.toLocaleDateString("en-US", { weekday: "long", month: "short", day: "numeric", year: date.getFullYear() !== today.getFullYear() ? "numeric" : undefined, }); } }; const getDateKey = (dateString: string): string => { const date = new Date(dateString); return date.toDateString(); }; const groupMessagesByDate = (messages: ChatMessage[]) => { const grouped: Record = {}; messages.forEach((message) => { const dateKey = getDateKey(message.sent); if (!grouped[dateKey]) { grouped[dateKey] = []; } grouped[dateKey].push(message); }); return grouped; }; const shouldShowSenderInfo = ( currentMessage: ChatMessage, previousMessage: ChatMessage | undefined, timeThresholdMinutes: number = 4, isGroupThread: boolean = false, ): boolean => { if (!previousMessage) return true; if (currentMessage.sender !== previousMessage.sender) return true; // One patient sender can carry several writers: the patient, and each helper by its stamp. if ((currentMessage.onBehalfOf?.helperReference ?? '') !== (previousMessage.onBehalfOf?.helperReference ?? '')) return true; // Group threads have no single chat partner - a run of consecutive "other" // messages can come from different participants. Force a break whenever the // sender changes, even inside the time-grouping window, so the second // participant's bubble never renders as an anonymous continuation of the // first. Gated on isGroupThread so direct-thread grouping (a single partner, // same senderReference throughout) is provably unchanged. if ( isGroupThread && currentMessage.sender === "other" && currentMessage.senderReference !== previousMessage.senderReference ) { return true; } const currentTime = new Date(currentMessage.sent).getTime(); const previousTime = new Date(previousMessage.sent).getTime(); const timeDiffMinutes = (currentTime - previousTime) / (1000 * 60); return timeDiffMinutes > timeThresholdMinutes; }; /** * WHAT THIS THREAD CAN CARRY, in one place, because it is asserted twice - once as the picker's * `accept` hint and once as the check that actually refuses. Two lists drift; when they do, the * picker offers something the check rejects and the reader is told "try again" about a file that can * never work. */ /** * THE EMOJI A PATIENT REACHES FOR, and deliberately not all of them. * * Spencer, 2026-09-10, against Messenger's composer. A full picker is a dependency, a search * index and a font-coverage problem; this is a short, fixed set that covers what people actually send * to a doctor's office - acknowledgement, thanks, feeling, and the two faces that carry bad news. * * NOT A CLINICAL VOCABULARY. Nobody should describe a symptom with a face, and a picker full of * medical pictograms would invite exactly that. These are for the sentence around the symptom. */ const COMPOSER_EMOJI = [ '\u{1F44D}', '\u{1F64F}', '\u{2764}\u{FE0F}', '\u{1F642}', '\u{1F60A}', '\u{1F603}', '\u{1F614}', '\u{1F615}', '\u{1F622}', '\u{1F621}', '\u{1F644}', '\u{1F62C}', '\u{1F44C}', '\u{1F91E}', '\u{1F4AA}', '\u{2728}', '\u{1F389}', '\u{1F338}', '\u{2705}', '\u{274C}', '\u{2757}', '\u{2753}', '\u{1F4C5}', '\u{1F4DE}', ]; /** * WHO THE COMPOSER SAYS YOU ARE WRITING TO. * It was `chatPartnerName.split(' ')[0]` - the first token - which is a GIVEN NAME only when the * display name happens to start with one. On a doctor it is the title, and the patient portal's own * deck records the result: *"the composer placeholder truncates mid-word: 'Message Dr....' in a 900px * field"*. The field had room for the whole name; the code had thrown it away. * * The structured name is asked first, because a FHIR `given` is the answer to this question rather * than a guess at it. Failing that a leading title is dropped, and failing THAT the full display name * is used - a longer placeholder is only ever a longer placeholder, while a wrong one is wrong. */ const NAME_TITLES = new Set(['dr', 'dr.', 'mr', 'mr.', 'mrs', 'mrs.', 'ms', 'ms.', 'prof', 'prof.']); const composerAddressee = (displayName: string, person?: Patient | Practitioner | RelatedPerson | null): string => { const given = person?.name?.[0]?.given?.[0]?.trim(); if (given) return given; const words = displayName.trim().split(/\s+/).filter(Boolean); if (words.length === 0) return displayName; if (words.length > 1 && NAME_TITLES.has(words[0].toLowerCase())) return words[1]; // One word or an unrecognised first word: say the whole thing. Cutting it is what produced // "Message Dr....", and a name is not improved by being shortened at a space. return words.length === 1 ? words[0] : displayName; }; // What a message can carry, by extension: documents, photographs and plain text. It matches the server's message // upload, so a file is refused here rather than after it was chosen. Never a page a browser would run. const SENDABLE_TYPES: Record = { '.pdf': 'application/pdf', '.doc': 'application/msword', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', '.txt': 'text/plain', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif', '.heic': 'image/heic', '.heif': 'image/heif', }; const SENDABLE_EXTENSIONS = Object.keys(SENDABLE_TYPES); export const SENDABLE_ACCEPT = SENDABLE_EXTENSIONS.join(','); /** The type a file is sent as, or undefined when a message cannot carry it. Some browsers leave a document's type blank. */ const sendableContentType = (file: File): string | undefined => { if (Object.values(SENDABLE_TYPES).includes(file.type)) return file.type; const extension = SENDABLE_EXTENSIONS.find((ext) => file.name.toLowerCase().endsWith(ext)); return extension && !file.type ? SENDABLE_TYPES[extension] : undefined; }; const isSendableFile = (file: File): boolean => sendableContentType(file) !== undefined; const Chat: React.FC = ({ messages = [], practitioner, patient, conversationPartner, onSendMessage, onComposerActivity, loading, loadError, onRetryLoad, onUpdateMessages, onReadMessages, requestId, selectedMessage, conversationOrganizationName, conversationRoleLabel, readerNameByReference = {}, senderNameByReference = {}, participantCount = 2, onBack, onClose, participants, onMessagePrivately, privateWithName, isGroupThread = false, composerPlaceholder, groupName, poolThreadKey, poolAnswererCount, isRoomThread = false, readOnly = false, readOnlyReason, needsReply, onDismissReply, layout = "panel", sendRecovery, threadNotice, }) => { const portal = layout === "portal"; const ThreadTitle = portal ? "h1" : "h3"; const deskLayout = layout === "desk"; // A colleague thread: the desk header, the portal's plain bubbles, and a composer addressed by name. const colleagueLayout = layout === "colleague"; const deskHead = deskLayout || colleagueLayout; const plainBubbles = portal || colleagueLayout; // Controlled because two things open it: the details icon in the header, and the participant count // beside it - and the count sits outside the `Sheet`, so it cannot be a `SheetTrigger`. const [detailsOpen, setDetailsOpen] = useState(false); const [emojiOpen, setEmojiOpen] = useState(false); const [localMessages, setLocalMessages] = useState([]); const [messagesNeedingReadReceipt, setMessagesNeedingReadReceipt] = useState( [], ); const [selectedFile, setFilePreview] = useState(null); const [fileSendFailed, setFileSendFailed] = useState(false); // - ids of sends that failed, or whose answer was lost. They exist ONLY here, by definition. const [failedIds, setFailedIds] = useState>(new Set()); const [deskRefusal, setDeskRefusal] = useState(); // The last failed send may have been written: its answer was lost, so it is unconfirmed, not unsent. const [sendUnsure, setSendUnsure] = useState(false); const heldSends = useHeldSendOperations(); const fileInputRef = useRef(null); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const composerRef = useRef(null); const reminderRoomRef = useRef(null); // Room under the controls for the unfinished-note reminder while it is on screen, so it never covers Send. const reminderRoom = useReminderClearance(reminderRoomRef, composerRef); const activeThreadKeyRef = useRef(""); const hasAutoScrolledForThreadRef = useRef(false); const previousMessageCountRef = useRef(0); const pendingScrollReasonRef = useRef<"thread-load" | "local-send" | null>( null, ); const userIsNearBottomRef = useRef(true); const { currentUser, actingAs, currentOrganization } = useAuthServiceContext(); const { uploadDocument } = usePhotoUploadService(); // A draft belongs to its conversation and is read by that key during render, so the next thread never // paints the previous recipient's words. The key is the conversation, not `threadKey`, which moves // as messages arrive. const draftKey = requestId ?? selectedMessage?.id ?? poolThreadKey ?? (conversationPartner?.id ? `${conversationPartner.resourceType}/${conversationPartner.id}` : undefined) ?? ""; const [drafts, setDrafts] = useState>({}); // What a reload, or a closed conversation, left unconfirmed here under this signer. Until this opening has read // it, while any is open, or while it cannot be read, nothing new is sent: a fresh send could repeat one that went. // A conversation with no identity of its own keeps no record, so no other conversation can settle its sends. const [earlier, setEarlier] = useState<{ scope: string; sends: UnresolvedSend[] } | "unreadable">(); const [readFor, setReadFor] = useState(); const [earlierNotice, setEarlierNotice] = useState(); const [checkingEarlier, setCheckingEarlier] = useState(false); const [recoveryRefusal, setRecoveryRefusal] = useState(); const [recheck, setRecheck] = useState(0); const recoveryKey = sendRecovery && draftKey ? JSON.stringify([...sendRecovery.place, currentUser?.fhir?.resourceType ?? null, currentUser?.fhir?.id ?? null, actingAs?.subjectPatientId ?? null, draftKey, layout]) : undefined; const sendWaits = Boolean(recoveryKey) && (readFor !== recoveryKey || earlier !== undefined); // This opening of this conversation. A hash, a reservation or a check begun in it does nothing once it is gone. const opening = useRef({ live: false, epoch: -1 }); // Read receipts are settled per opening: an answer for an earlier opening never confirms or fails this one. const receiptLedger = useRef(createReceiptLedger()); const receiptSaves = useRef(new Set()); const receiptReference = receiptReferenceOf(currentUser?.fhir, actingAs); const currentReceiptReference = useRef(receiptReference); currentReceiptReference.current = receiptReference; // Bumped when a receipt save settles in this opening, so the lines it left unconfirmed are shown. const [receiptsSettled, setReceiptsSettled] = useState(0); const [recoveringReceipts, setRecoveringReceipts] = useState(null); const recoveringReceiptsRef = useRef(null); const [viewerAt, setViewerAt] = useState(viewerEpoch); const draftOwner = JSON.stringify([sendRecovery?.place, patient?.id, currentUser?.fhir?.resourceType, currentUser?.fhir?.id, currentOrganization?.id, actingAs?.subjectPatientId, actingAs?.relatedPersonId, viewerAt]); const currentDraftOwner = useRef(draftOwner); currentDraftOwner.current = draftOwner; const selectionScope = JSON.stringify([recoveryKey, draftKey, layout, patient?.id, currentUser?.fhir?.resourceType, currentUser?.fhir?.id, currentOrganization?.id, actingAs?.subjectPatientId, actingAs?.relatedPersonId, viewerAt]); const filePreview = selectedFile?.scope === selectionScope && selectedFile.opening.live && selectedFile.opening === opening.current ? selectedFile : null; const sending = useRef(null); const [sendingOpening, setSendingOpening] = useState(null); const sendBusy = sendingOpening === opening.current && Boolean(sendingOpening?.live); useEffect(() => { const here = { live: true, epoch: viewerEpoch() }; opening.current = here; receiptLedger.current = createReceiptLedger(); recoveringReceiptsRef.current = null; setRecoveringReceipts(null); setReceiptsSettled((count) => count + 1); const saves = receiptSaves.current; setFilePreview(null); sending.current = null; setSendingOpening(null); setEarlierNotice(undefined); setRecoveryRefusal(undefined); setCheckingEarlier(false); return () => { here.live = false; for (const [operationId, owner] of liveSends) if (owner === here) liveSends.delete(operationId); for (const save of saves) save.retire(); saves.clear(); }; }, [selectionScope]); // A viewer change ends the opening at once, even when the same viewer is back before the next render. useEffect(() => onViewerChange(() => { opening.current.live = false; setViewerAt(viewerEpoch()); }), []); useEffect(() => { setReadFor(undefined); if (!sendRecovery || !recoveryKey) { setEarlier(undefined); return; } const here = opening.current; const read = () => void scopeDigest(JSON.parse(recoveryKey) as unknown[]).then((scope) => { if (!here.live) return; try { const sends = sendRecovery.store.unresolved(scope).filter((each) => !liveSends.get(each.operationId)?.live); setEarlier(sends.length ? { scope, sends } : undefined); } catch { setEarlier("unreadable"); } setReadFor(recoveryKey); }, () => { if (!here.live) return; setEarlier("unreadable"); setReadFor(recoveryKey); }); read(); recoveryListeners.add(read); return () => { recoveryListeners.delete(read); }; }, [sendRecovery, recoveryKey, recheck, selectionScope]); const settleRecord = (operationId: string) => { if (!sendRecovery) return; try { sendRecovery.store.settle(operationId); } catch { // Still held: after a reload the check finds it settled on the server. return; } liveSends.delete(operationId); const fileKey = fileSendKeys.get(operationId); if (fileKey) heldSends.settle(fileKey); fileSendKeys.delete(operationId); pendingFileAttachments.delete(operationId); pendingFileDrafts.delete(operationId); recoveryChanged(); }; // Asked only when the person chooses to; a send still on its way is stopped by the check. Only a complete // answer settles a send; anything else keeps it held, and nothing more is asked once this opening is gone. const checkEarlier = async () => { if (!sendRecovery || !earlier || earlier === "unreadable" || checkingEarlier) return; const here = opening.current; setCheckingEarlier(true); let sent = 0; let notSent = 0; let open = 0; for (const each of earlier.sends) { let result: SendCloseAnswer = { state: "unknown" }; try { result = closeAnswerOf(await sendRecovery.close(each.operationId, admitIn(here))); } catch { // Unanswered: still open. } if (!here.live) return; if (result.state === "unknown") { open += 1; continue; } try { sendRecovery.store.settle(each.operationId); const fileKey = fileSendKeys.get(each.operationId); if (fileKey) heldSends.settle(fileKey); fileSendKeys.delete(each.operationId); pendingFileAttachments.delete(each.operationId); const draft = pendingFileDrafts.get(each.operationId); if (result.state === "sent" && draft && currentDraftOwner.current === draft.owner) { setFileSendFailed(false); setSendUnsure(false); setFilePreview((current) => current === draft.preview ? null : current); setDrafts((previous) => previous[draft.key] === draft.text ? { ...previous, [draft.key]: "" } : previous); } pendingFileDrafts.delete(each.operationId); } catch { open += 1; continue; } if (result.state === "sent") sent += 1; else notSent += 1; } setCheckingEarlier(false); setEarlierNotice(open ? EARLIER_UNCHECKED : sent && notSent ? EARLIER_MIXED : sent ? EARLIER_SENT : EARLIER_NOT_SENT); setRecheck((count) => count + 1); }; const input = drafts[draftKey] ?? ""; const setInput = useCallback((next: string | ((current: string) => string)) => { setDrafts((previous) => ({ ...previous, [draftKey]: typeof next === "function" ? next(previous[draftKey] ?? "") : next, })); }, [draftKey]); const threadKey = useMemo(() => { if (requestId) return requestId; if (selectedMessage?.id) return selectedMessage.id; if (messages.length > 0) { const firstId = messages[0]?.id || "first"; const lastId = messages[messages.length - 1]?.id || "last"; return `message-set:${firstId}:${lastId}:${messages.length}`; } return ""; }, [requestId, selectedMessage?.id, messages]); const scrollMessagesToBottom = (behavior: ScrollBehavior = "smooth") => { const container = messagesContainerRef.current; const composer = composerRef.current; if (container) { const targetTop = container.scrollHeight - container.clientHeight; if (behavior === "smooth" && "scrollTo" in container) { container.scrollTo({ top: Math.max(targetTop, 0), behavior: "smooth" }); } else { container.scrollTop = Math.max(targetTop, 0); } } if (composer) { window.requestAnimationFrame(() => { composer.scrollIntoView({ behavior, block: "end", inline: "nearest", }); }); } }; const isNearBottom = (element: HTMLDivElement | null, threshold = 120) => { if (!element) return true; const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight; return distanceFromBottom <= threshold; }; useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const handleScroll = () => { userIsNearBottomRef.current = isNearBottom(container); }; handleScroll(); container.addEventListener("scroll", handleScroll, { passive: true }); return () => { container.removeEventListener("scroll", handleScroll); }; }, [threadKey, localMessages.length]); /** * - the roster map, held by VALUE rather than by identity. * * The transform below runs in an effect, so the map has to be in its dependencies or a roster that * arrives after the messages never re-labels them. But the prop defaults to a fresh `{}` on every * render, so depending on the object itself is an infinite loop. Keyed on its serialisation, which * is a handful of members and is the only thing that actually changes. */ const rosterNamesKey = JSON.stringify(senderNameByReference); const rosterNames = useMemo( () => senderNameByReference, // eslint-disable-next-line react-hooks/exhaustive-deps [rosterNamesKey], ); const formatFileSize = (bytes?: number): string => { if (!bytes) return ""; if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }; const transformCommunicationsToChatMessages = ( communications: Communication[], ): ChatMessage[] => { if (!communications || communications.length === 0 || !currentUser?.fhir?.id) { return []; } const currentUserId = currentUser.fhir.id; const currentUserType = currentUser.fhir.resourceType; const currentUserReference = `${currentUserType}/${currentUserId}`; return communications.map((communication: Communication, index) => { const senderReference = communication.sender?.reference || ""; const stampedHelper = onBehalfOf(communication, rosterNames)?.helperReference; // A helper's message is the patient's by sender but not the patient's own. It is mine only to that helper, // acting for that patient: the signed-in account stays the helper's own while the sender is the patient. const isSentByCurrentUser = stampedHelper ? Boolean(actingAs) && stampedHelper === `RelatedPerson/${actingAs?.relatedPersonId}` && senderReference === `Patient/${actingAs?.subjectPatientId}` : senderReference.includes(currentUserReference); const sender: "me" | "other" = isSentByCurrentUser ? "me" : "other"; // - NEVER THE REFERENCE ID. This read // communication.sender?.display || senderReference.split("/")[1] || "Participant" // and the middle term put a 36-character hex string where a colleague name belongs, on a // clinical message. The third term could essentially never run, because a reference always has // an id. // `senderLabel` also judges a stamped `display` on its SHAPE: it is denormalised by whoever // sent the message, so a writer that put an id there produces the same defect with the same // string - trusting `display` merely because it exists is what let this through. // - and the roster is passed IN, because the message alone cannot answer this for a // sender at another practice: their `Communication` is stamped by their own client and carries no // display, so this read "Participant" while the roster on the same screen held their name. const senderName = senderLabel({ display: communication.sender?.display, reference: senderReference, rosterNames, }); const textPayload = communication.payload?.find((p) => p.contentString); const text = textPayload?.contentString || ""; let file; const attachmentPayload = communication.payload?.find( (p) => p.contentAttachment, ); if (attachmentPayload?.contentAttachment) { const attachment = attachmentPayload.contentAttachment; // Only an uploaded file's key opens, through a link signed once its scan is clean. Bytes put inside the // message, or a link to a file elsewhere, were never scanned and stay closed. const key = attachment.url && !/^[a-z][a-z0-9+.-]*:/i.test(attachment.url) ? attachment.url : undefined; file = { name: attachment.title || "File", size: attachment.size ? formatFileSize(attachment.size) : "Unknown size", type: attachment.contentType, ...(key && communication.id ? { key, carrier: `Communication/${communication.id}` } : { closed: PRE_SCAN_FILE_WORDS }), }; } const time = communication.sent ? new Date(communication.sent).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true, }) : "--"; return { id: communication.id || `msg-${index}`, sender, senderReference, senderName, onBehalfOf: onBehalfOf(communication, rosterNames), text, file, time, sent: communication.sent || new Date().toISOString(), isImage: file?.type?.startsWith("image/") || false, }; }); }; useEffect(() => { if (messages && currentUser) { const chatMessages = transformCommunicationsToChatMessages(messages); setLocalMessages(chatMessages); const currentUserId = currentUser.fhir?.id; const currentUserType = currentUser.fhir?.resourceType; const currentUserReference = `${currentUserType}/${currentUserId}`; const messagesToUpdate = receiptReference ? messages.filter((message: Communication) => { const senderReference = message.sender?.reference; // A helper's own message is the one stamped with their link; one another helper sent still needs reading. const stampedHelper = onBehalfOf(message, rosterNames)?.helperReference; const isSentByCurrentUser = stampedHelper ? stampedHelper === receiptReference : senderReference?.includes(currentUserReference); return ( !isSentByCurrentUser && !hasReadReceiptForReference(message, receiptReference) ); }) : []; setMessagesNeedingReadReceipt(messagesToUpdate); } }, [messages, currentUser, rosterNames, receiptReference]); useEffect(() => { const reader = receiptReference; const pending = messagesNeedingReadReceipt; if (pending.length === 0 || !onUpdateMessages || !reader) return; // The opening, its ledger and the reader are taken once; each PUT is admitted against them right before it is // sent, after its token and proof waits, and the answer is only used while they still hold. const here = opening.current; const ledger = receiptLedger.current; const saves = receiptSaves.current; const ours = () => here.live && opening.current === here && currentReceiptReference.current === reader; const markMessagesAsRead = async () => { if (!ours()) return; const save = startSaveOperation(ours); saves.add(save); const now = new Date().toISOString(); const updatedMessages = pending.map((message) => addReadReceiptForReference(message, reader, now)); try { await acknowledgeOnce(ledger, reader, updatedMessages, async (sent) => (await onUpdateMessages(sent, save.transport)) ?? [], receiptConfirmed(reader)); if (save.belongs()) { setMessagesNeedingReadReceipt((current) => (current === pending ? [] : current)); setReceiptsSettled((count) => count + 1); } } catch (error) { console.error("Error updating message read status:", error); } finally { saves.delete(save); } }; const timeoutId = setTimeout(markMessagesAsRead, 1000); return () => clearTimeout(timeoutId); }, [messagesNeedingReadReceipt, onUpdateMessages, receiptReference, selectionScope]); // This opening's lines whose receipt by this reader was not confirmed and that still do not carry it as shown. const unconfirmedReceipts = useMemo( () => (receiptReference && messages ? unconfirmedOf(receiptLedger.current, receiptReference, messages) : []), // eslint-disable-next-line react-hooks/exhaustive-deps [messages, receiptReference, receiptsSettled], ); // Only when asked, and only from a current read: the failed lines stay failed while each is read as stored now. One // stored with this reader's receipt is confirmed with no write; one read current without it is sent once, on that // edition. A line not read, read foreign, older than shown or read after the opening or reader changed sends nothing. const retryReceipts = () => { const reader = receiptReference; const update = onUpdateMessages; const read = onReadMessages; if (!reader || !messages || !update || !read || recoveringReceiptsRef.current?.belongs()) return; const here = opening.current; const ledger = receiptLedger.current; const saves = receiptSaves.current; const ours = () => here.live && opening.current === here && currentReceiptReference.current === reader; if (!ours()) return; const failed = unconfirmedOf(ledger, reader, messages); if (failed.length === 0) return; const save = startSaveOperation(ours); saves.add(save); recoveringReceiptsRef.current = save; setRecoveringReceipts(save); const recover = async () => { const stored = new Map((await read(failed.map((message) => message.id as string), save.transport)) .filter((current) => current?.resourceType === "Communication" && Boolean(current.meta?.versionId)) .map((current) => [current.id, current])); if (!save.belongs()) return; const proved: Communication[] = []; const bases: [Communication, Communication][] = []; for (const shown of failed) { const current = stored.get(shown.id); if (!current) continue; const before = editionOf(shown); const after = editionOf(current); if (before === null || after === null || after < before) continue; if (hasReadReceiptForReference(current, reader)) proved.push(shown); else bases.push([shown, current]); } ledger.confirm(reader, proved); if (bases.length > 0) { // A read at the edition shown proves that base too; its failed entry gives way to this one explicit send. ledger.retry(reader, bases.map(([, current]) => current)); const now = new Date().toISOString(); const { saved } = await acknowledgeOnce(ledger, reader, bases.map(([, current]) => addReadReceiptForReference(current, reader, now)), async (sent) => (await update(sent, save.transport)) ?? [], receiptConfirmed(reader)); const landed = new Set(saved.map((message) => message.id)); if (save.belongs()) ledger.confirm(reader, bases.filter(([shown]) => landed.has(shown.id)).map(([shown]) => shown)); } }; void recover() .catch((error) => { if (save.belongs()) console.error("Read receipts could not be recovered:", error); }) .finally(() => { saves.delete(save); if (recoveringReceiptsRef.current === save) { recoveringReceiptsRef.current = null; if (save.belongs()) { setRecoveringReceipts(null); setReceiptsSettled((count) => count + 1); } } }); }; useEffect(() => { if (!threadKey) { activeThreadKeyRef.current = ""; hasAutoScrolledForThreadRef.current = false; previousMessageCountRef.current = 0; pendingScrollReasonRef.current = null; return; } const messageCount = localMessages.length; const isNewThread = activeThreadKeyRef.current !== threadKey; if (isNewThread) { activeThreadKeyRef.current = threadKey; hasAutoScrolledForThreadRef.current = false; previousMessageCountRef.current = 0; pendingScrollReasonRef.current = messageCount > 0 ? "thread-load" : null; } if (messageCount === 0) { previousMessageCountRef.current = 0; return; } if (!hasAutoScrolledForThreadRef.current) { hasAutoScrolledForThreadRef.current = true; previousMessageCountRef.current = messageCount; pendingScrollReasonRef.current = null; window.requestAnimationFrame(() => { window.setTimeout(() => scrollMessagesToBottom("smooth"), 0); }); return; } const previousCount = previousMessageCountRef.current; const lastMessage = localMessages[messageCount - 1]; const didMessageCountIncrease = messageCount > previousCount; if (didMessageCountIncrease) { const shouldScroll = pendingScrollReasonRef.current === "local-send" || lastMessage?.sender === "me" || userIsNearBottomRef.current; if (shouldScroll) { window.requestAnimationFrame(() => { window.setTimeout(() => scrollMessagesToBottom("smooth"), 0); }); } } pendingScrollReasonRef.current = null; previousMessageCountRef.current = messageCount; }, [threadKey, localMessages]); const getAvatarUrl = ( resource: Patient | Practitioner | RelatedPerson | undefined, ): string | undefined => { if (!resource || !resource.photo || resource.photo.length === 0) { return undefined; } const photo = resource.photo[0]; return scannedPhotoSrc(photo); }; // Sends one message for the conversation named by `key`. A failure marks the bubble failed and puts the // words back in THAT conversation's composer, so nothing typed is lost and none of it moves to another. const sendText = async (text: string, key: string, clearDraft: boolean) => { if (!onSendMessage) return; { const newMessage: ChatMessage = { id: `local-${Date.now()}`, sender: "me", text, time: new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true, }), sent: new Date().toISOString(), }; // The same words sent again after a failure are the same send, so a lost first answer is never written twice. // Held by conversation and words, not by the failed bubble, which a refresh of the thread replaces. const held = `chat|${key}|${text}`; if (sendWaits) return; const here = opening.current; if (!here.live) return; const owner = draftOwner; const send = { ...heldSends.take(held), admit: admitIn(here) }; // Held before it can leave, so a reload while it is on its way still finds it; one that cannot be held does not go. if (sendRecovery && recoveryKey) { const owner = recoveryOwner(); try { const scope = await scopeDigest(JSON.parse(recoveryKey) as unknown[]); if (!here.live) return; // Read again as it leaves: a send left open since this opening read is checked first, and the text stays. const others = sendRecovery.store.unresolved(scope) .filter((each) => each.operationId !== send.operationId && !liveSends.get(each.operationId)?.live); if (others.length) { setEarlier({ scope, sends: others }); return; } sendRecovery.store.reserve(scope, send.operationId, owner); liveSends.set(send.operationId, here); } catch (error) { setRecoveryRefusal(error instanceof RecoveryFull ? RECOVERY_FULL : RECOVERY_UNAVAILABLE); return; } } setRecoveryRefusal(undefined); pendingScrollReasonRef.current = "local-send"; // Resending the same words from the composer replaces their failed copy rather than adding to it. setLocalMessages((prev) => [ ...prev.filter((message) => !(failedIds.has(message.id) && message.text === text)), newMessage, ]); const messageText = text; if (clearDraft) setDrafts((previous) => ({ ...previous, [key]: "" })); setDeskRefusal(undefined); setFileSendFailed(false); setSendUnsure(false); try { onComposerActivity?.(); await onSendMessage(messageText, undefined, send); // An answer after this opening ended confirms nothing here: the hold stays for the next opening's check. if (!here.live) return; heldSends.settle(held); settleRecord(send.operationId); } catch (error) { // Stopped as its opening ended, or answered after: its hold stays for the next opening to check. if (error instanceof OpeningRetired || !here.live) { // Keep the original conversation's words without confirming its retired send or replacing newer typing. if (here.epoch === viewerEpoch() && currentDraftOwner.current === owner) { setDrafts((previous) => ({ ...previous, [key]: previous[key] || messageText })); } return; } console.error("Failed to send message:", error); // A REFUSED SEND IS A REFUSED WRITE, and it was the last call site on this page still // answering one with "Please try again." Measured on a caregiver whose patient had NOT granted // them direct provider messaging: the send 403s, and the person was told to repeat an action // that can never succeed while the server, on the sibling route in the very same click, was // answering "This person has not given you permission to send messages on their behalf." // That sentence reached nobody. // // `describeServerRefusal` is the settled answer to "was this write refused, and is the // server's own sentence fit to show": a 403 is a refusal we authored and its text is copy // written for a person, so it is relayed; a 400 is our own bug and its text is a validator // talking to a developer, so it is not. The chat-OPEN call site on this page was converted // when that helper landed and this one was not - the same one-caller-short shape the helper's // own header describes, which is why the comment is here rather than in a commit message. const refusal = describeServerRefusal(error, "Your message was not sent"); // Said beside the composer, in every layout; a refusal keeps its reason, a failure asks for a retry. const detail = refusal.replace(/^Your message was not sent\.\s*/, ""); setDeskRefusal(/Please try again\.$/.test(detail) ? undefined : detail); const unsure = heldSends.settle(held, error); if (!unsure) settleRecord(send.operationId); setSendUnsure(unsure); // - KEEP the message and mark it failed. Deleting it threw away what the patient had // typed AND left them with no evidence anything went wrong, which is the same silence this // feature exists to end. The status line under it now says so and offers a retry. setFailedIds((prev) => new Set(prev).add(newMessage.id)); setDrafts((previous) => ({ ...previous, [key]: previous[key] || messageText })); } } }; // Try again resends the failed words and drops the failed bubble; newer typing in the composer stays. const retryFailedMessage = (messageId: string) => { const failed = localMessages.find((message) => message.id === messageId); if (!failed?.text) return; const text = failed.text; setLocalMessages((previous) => previous.filter((message) => message.id !== messageId)); setFailedIds((previous) => { const next = new Set(previous); next.delete(messageId); return next; }); setDrafts((previous) => (previous[draftKey] === text ? { ...previous, [draftKey]: "" } : previous)); void sendText(text, draftKey, false); }; const handleSendMessage = async () => { if ((!input.trim() && !filePreview) || !onSendMessage || sendWaits) return; const here = opening.current; if (!here.live || sending.current === here) return; sending.current = here; setSendingOpening(here); try { if (filePreview) { // The words and selected file are one message; an upload refusal must not send only its words. if (await handleFileUpload(filePreview.file, here, input)) { setFilePreview((current) => current === filePreview ? null : current); setDrafts((previous) => previous[draftKey] === input ? { ...previous, [draftKey]: "" } : previous); } } else if (input.trim()) { await sendText(input, draftKey, true); } } finally { if (sending.current === here) { sending.current = null; setSendingOpening(null); } } }; const handleFileUpload = async (file: File, here: Opening, text = ""): Promise => { if (!onSendMessage || sendWaits) return false; if (!here.live || opening.current !== here) return false; let fileKey = selectedFileKeys.get(file); if (!fileKey) { fileKey = crypto.randomUUID(); selectedFileKeys.set(file, fileKey); } const held = `chat-file|${draftKey}|${fileKey}`; const send = { ...heldSends.take(held), admit: admitIn(here) }; if (sendRecovery && recoveryKey) { try { const scope = await scopeDigest(JSON.parse(recoveryKey) as unknown[]); send.admit(); const others = sendRecovery.store.unresolved(scope) .filter((each) => each.operationId !== send.operationId && !liveSends.get(each.operationId)?.live); if (others.length) { setEarlier({ scope, sends: others }); return false; } } catch (error) { if (here.live) setRecoveryRefusal(error instanceof RecoveryFull ? RECOVERY_FULL : RECOVERY_UNAVAILABLE); return false; } } setRecoveryRefusal(undefined); setDeskRefusal(undefined); setFileSendFailed(false); setSendUnsure(false); const isImage = file.type.startsWith("image/"); const newMessage: ChatMessage = { id: `file-${Date.now()}`, sender: "me", text: text || undefined, file: { name: file.name, size: formatFileSize(file.size), type: file.type, closed: SENDING_FILE_WORDS, }, time: new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true, }), sent: new Date().toISOString(), isImage, }; pendingScrollReasonRef.current = "local-send"; setLocalMessages((prev) => [...prev, newMessage]); let sendingMessage = false; try { // The file is uploaded and scanned like any other, and the message names it by key; its bytes never ride // inside the message. About a patient it is held for them; between staff it is the practice's. let attachment = pendingFileAttachments.get(send.operationId); if (!attachment) { const contentType = sendableContentType(file) ?? file.type; const typed = contentType === file.type ? file : new globalThis.File([file], file.name, { type: contentType }); send.admit(); // A helper's file is for the person they act for; the portal hands the chat their own login as its patient. const { key } = await uploadDocument(typed, actingAs ? actingAs.subjectPatientId : patient?.id, { uploadType: "message", transport: { admit: send.admit } }); send.admit(); attachment = { contentType, url: key, title: file.name, size: file.size, creation: new Date().toISOString() }; pendingFileAttachments.set(send.operationId, attachment); } if (sendRecovery && recoveryKey) { const scope = await scopeDigest(JSON.parse(recoveryKey) as unknown[]); send.admit(); const others = sendRecovery.store.unresolved(scope) .filter((each) => each.operationId !== send.operationId && !liveSends.get(each.operationId)?.live); if (others.length) { setEarlier({ scope, sends: others }); setLocalMessages((prev) => prev.filter((message) => message.id !== newMessage.id)); return false; } sendRecovery.store.reserve(scope, send.operationId, recoveryOwner()); liveSends.set(send.operationId, here); fileSendKeys.set(send.operationId, held); } pendingFileDrafts.set(send.operationId, { key: draftKey, text, owner: draftOwner, preview: filePreview }); sendingMessage = true; await onSendMessage(text, attachment, send); if (!here.live) return false; heldSends.settle(held); settleRecord(send.operationId); pendingFileAttachments.delete(send.operationId); return true; } catch (error) { if (error instanceof OpeningRetired || !here.live) return false; console.error("Error processing file:", error); const unsure = heldSends.settle(held, sendingMessage ? error : undefined); if (!unsure) { settleRecord(send.operationId); pendingFileAttachments.delete(send.operationId); } else { liveSends.delete(send.operationId); recoveryChanged(); } setSendUnsure(unsure); setFileSendFailed(true); // Presign also uses 403 for schema diagnostics, so only the message endpoint's refusal text is readable. const uploadForbidden = !sendingMessage && (error as { status?: unknown } | null)?.status === 403; const refusal = describeServerRefusal(uploadForbidden ? { status: 403 } : error, "Your message was not sent"); const detail = refusal.replace(/^Your message was not sent\.\s*/, ""); setDeskRefusal(/Please try again\.$/.test(detail) ? undefined : detail); toast.error(unsure ? "Your file was not confirmed. Check the conversation before sending again." : refusal); setLocalMessages((prev) => prev.filter((msg) => msg.id !== newMessage.id), ); return false; } }; const removeFilePreview = () => { if (filePreview) setFilePreview(null); }; const isCurrentUserPatient = currentUser?.fhir?.resourceType === "Patient"; const chatPartner = conversationPartner || (isCurrentUserPatient ? practitioner : patient); const chatPartnerName = chatPartner?.name ? formatFhirHumanName(chatPartner.name) : selectedMessage?.name || ""; const chatPartnerAvatar = getAvatarUrl(chatPartner); // F1 Task 12: the "on behalf of" attribution rule made visible - when the other party in // this thread is a RelatedPerson (a proxy acting for the patient), their messages read as // FROM the related person, on behalf of the patient. `patient` is only the actual patient // being represented when it's cheaply available in this component's own props (no new // fetch); when it isn't, the badge falls back to a generic "the patient" label. const isChatPartnerRelatedPerson = chatPartner?.resourceType === "RelatedPerson"; const chatPartnerPatientName = patient?.name ? formatFhirHumanName(patient.name) : undefined; /** * - the status of the patient's own last message, and WHY IT HAS TWO SOURCES. * * The receipt truth lives on the server's `messages`; a send that FAILED exists only in * `localMessages`, because it never reached the server at all. That is not two rules about one thing * - it is one rule about two genuinely different states, and collapsing them would mean either losing * the failure (the case this feature exists for) or inventing a receipt for a message the server has * never seen. * * Derived from RECEIPTS, never from local state, for everything except that failure. Inferring * "viewed" from anything adjacent is exactly how the tick this replaces came to lie. */ const selfReference = currentUser?.fhir?.resourceType && currentUser?.fhir?.id ? `${currentUser.fhir.resourceType}/${currentUser.fhir.id}` : undefined; const outboundStatus = useMemo(() => { if (!selfReference) return null; // The last thing the user sent that is ON SCREEN. Optimistic sends live here before the server has // acknowledged them, and `localMessages` is rebuilt from `messages` once it has. let lastOwn: ChatMessage | undefined; for (const message of localMessages) { if (message.sender === "me") lastOwn = message; } if (lastOwn && failedIds.has(lastOwn.id)) { return { messageId: lastOwn.id, status: { kind: "failed" } as MessageStatus }; } // Still optimistic - the server has not seen it, so it can carry no receipt and the honest answer is // "sent". NOT a green double tick, which is precisely the claim it cannot support. if (lastOwn && !messages.some((message) => message.id === lastOwn?.id)) { return { messageId: lastOwn.id, status: { kind: "queued" } as MessageStatus }; } return resolveLatestOutboundStatus(messages, selfReference, failedIds); }, [messages, localMessages, selfReference, failedIds]); const groupedMessages = useMemo(() => { return groupMessagesByDate( localMessages.filter((message) => !failedIds.has(message.id)), ); }, [localMessages, failedIds]); // A failed send stays out of the conversation in every layout; the composer says so and keeps the words. const sendFailed = fileSendFailed || outboundStatus?.status.kind === "failed"; const groupedMessagesArray = useMemo(() => { return Object.entries(groupedMessages).sort( ([dateA], [dateB]) => new Date(dateA).getTime() - new Date(dateB).getTime(), ); }, [groupedMessages]); const doctorDetails: DoctorDetails = { name: chatPartnerName || "Conversation", specialty: practitioner?.qualification?.[0]?.code?.text || "Staff", clinic: conversationOrganizationName || "Organization", gender: "Staff Member", qualifications: ["MBBS", "MD"], services: [ "General Checkup", "Vaccinations", "Health Screening", "Prescriptions", ], languages: ["English", "German"], availability: { Monday: "Available 24 Hours", Tuesday: "Available 24 Hours", Wednesday: "Available 24 Hours", Thursday: "Available 24 Hours", Friday: "Available 24 Hours", Saturday: "Available 24 Hours", Sunday: "Available 24 Hours", }, }; /** * - a conversation with an OFFICE. * * A group thread wins if both are somehow set: a collaboration team has real named members, so * rendering it as an anonymous shared inbox would hide who is in the room. */ const isPoolThread = !!poolThreadKey && !isGroupThread; // A team wins over a room for the same reason it wins over the pool: a collaboration team has real // named members, and rendering it as a visit would hide who is in it. const isRoom = isRoomThread && !isGroupThread; /** * - WHO ANSWERED, on the one surface where several people share a queue. * * Measured: Receptionist One replied in the front-desk pool thread and, to every other staff viewer, * the bubble carried **no author at all** - the string "Receptionist" was absent from its markup. The * non-group byline shows `chatPartnerName`, which on a pool thread is the PATIENT, so a colleague's * reply was attributed to nobody. It rendered only for its own writer, as "You". * * step 6 states the rule: *"the patient sees the practice, staff see WHO wrote each * message."* The first half shipped and the second did not - so a receptionist picking up a thread * cannot tell who already answered, or who to go and ask. Exactly the operational gap the claim * indicator closes for *"someone is replying"*, left open for *"someone already replied"*. * * GATED ON THE VIEWER, NOT ON THE THREAD, AND THAT IS THE CONTROL. A PATIENT looking at the same * pool thread must keep seeing the practice and never a named receptionist - that is the one-directional * anonymity the pool exists for, and naming staff to them would be a real disclosure rather than a * cosmetic slip. */ const viewerIsStaff = currentUser?.fhir?.resourceType === 'Practitioner'; // A VISIT NAMES ITS AUTHORS TO EVERYONE, which is the opposite of the pool and for the opposite // reason. The pool is anonymous one way on purpose - the patient writes to the office. A room holds // the people who were in it, the patient met them there, and the non-group byline shows the single // counterparty's name, which a room does not have. Without this every bubble is attributed to // nobody, exactly as a colleague's pool reply used to be. const namesEachAuthor = isGroupThread || isRoom || (isPoolThread && viewerIsStaff); const poolLabel = poolThreadLabel(poolThreadKey); // A colleague's note to the desk is about the patient but never reaches them, so it is not drawn as theirs. const isDeskAside = isPoolThread && poolThreadKey === FRONT_DESK_ASIDE_POOL_KEY; const poolPill = isDeskAside ? "Staff only" : "Shared inbox"; const conversationTitle = isGroupThread ? groupName || "Collaboration team" : isPoolThread ? poolLabel : isRoom ? ROOM_THREAD_LABEL : chatPartnerName; const headerRoleLabel = isRoom ? ROOM_THREAD_DESCRIPTION : isPoolThread ? poolThreadDescription(poolThreadKey) : (conversationRoleLabel || doctorDetails.specialty || "Staff").trim(); const headerOrganizationName = conversationOrganizationName || doctorDetails.clinic || "Organization"; // The pool has no counterparty name, and the patient is never told which receptionist answered. const plainAuthorName = isPoolThread && !namesEachAuthor ? headerOrganizationName : chatPartnerName; // The desk names the patient on its own shared conversation; the office is who the patient wrote to. const deskTitle = isPoolThread && viewerIsStaff ? chatPartnerName || poolLabel : conversationTitle; const participantLabel = participantCount === null ? null : `${participantCount} ${participantCount === 1 ? "participant" : "participants"}`; const deskMeta = isGroupThread ? participantLabel : isPoolThread ? `${headerOrganizationName} · ${isDeskAside ? "Staff only, about this patient" : "Shared front desk"}` : isRoom ? ROOM_THREAD_DESCRIPTION : `${headerRoleLabel} · ${headerOrganizationName}`; const showPoolAnswerers = isPoolThread && !!poolAnswererCount && poolAnswererCount > 0; const roleKey = headerRoleLabel.toLowerCase(); const HeaderRoleIcon = isRoom ? Video : isPoolThread ? NotebookText : roleKey.includes("practitioner") || roleKey.includes("doctor") ? Stethoscope : roleKey.includes("receptionist") ? NotebookText : UserRoundPlus; const HeaderNameIcon = isRoom ? Video : isPoolThread ? NotebookText : roleKey.includes("practitioner") || roleKey.includes("doctor") ? Stethoscope : roleKey.includes("receptionist") ? NotebookText : UserRoundPlus; const renderAttachment = (msg: ChatMessage, mine = false) => { if (!msg.file) return null; return (
); }; if (loading) { return (
{Array.from({ length: 5 }).map((_, i) => (
))}
); } return ( {/* 🚨 Card 356 — `isPoolThread` is the third way a conversation can exist. This gate used to say "there is a practitioner OR it is a team", and a front-desk thread is NEITHER by design — so the patient who had just created one was shown "Select a conversation" with the conversation already open in state, and there was no composer to write in. */} {(practitioner || isGroupThread || isPoolThread || isRoom) ? ( <> {deskHead ? (

{deskTitle}

{isPoolThread && {poolPill}} {isGroupThread && Collaboration team}
{deskMeta &&

{deskMeta}

}
) : (
{/* 🏷️ An icon and nothing else, so it carries its name for a screen reader - a phone user heard "button" with no way to know it goes back. It is the mobile-ONLY control (`md:hidden lg:hidden`), which is exactly the one a sighted desktop review never sees. ⛔ AND IT WENT TO THE WRONG PLACE. It was a `Link to="dashboard"`, so the one control labelled "Back to conversations" left the Messages screen entirely and landed on the overview - on a phone, where it is the ONLY way back, because the list it should return to is hidden while a thread is open. Back means the list. */} {/* A BARE ARROW, TIGHT TO THE EDGE - the shape every messenger uses, and the reason is width. It wore the ghost button treatment at 36px square with the header's own gap after it, so on a 390px screen a control that means one word took a quarter of the row away from the name of the person you are talking to. The negative margin pulls it back into the card's padding so the arrow sits where a thumb expects it. */}
{/* 🗣️ Spencer, 2026-09-10: *"You can get rid of the redundant collab team icon on the left of header since it's already inline with the collab team name at the top."* A team has no face, so this tile drew the same group glyph that already sits against the name - two marks for one thread, and the larger of the two carrying less information. ⚖️ It stays for a person, whose photograph is a real identifier, and for the front desk, where the note-mark is doing the same job the comment below describes. */} {!portal && (
{isGroupThread || isPoolThread || isRoom ? ( // Never a default face beside "Front desk". A photograph of nobody tells the patient // they are writing to a person, which is the belief the pool exists to correct.
{isGroupThread ? : isRoom ?
) : ( {chatPartnerName} )}
)}
{/* ⚠️ min-w-0 AND flex-wrap, and neither is tidiness. A flex child defaults to a minimum width of its own content, so the title refused to shrink and the badge beside it was drawn straight over the name - at 390 the team read "Test Tean" with a pill on top of it. Truncation cannot work without the first, and at a narrow width the badge needs the second so it drops to its own line rather than fighting for the same one. */}
{/* 🗣️ Spencer, 2026-09-10: sage, 600, medium - the same treatment the overlay titles carry. It is the one heading on this pane and nothing competes with it, which is the standing reason a weight above the 400 cap is allowed here. ⚠️ The utilities are gone rather than added to: `text-base font-semibold text-foreground` all still applied, so leaving them would have left three rules arguing about the same three properties. */} {/* No bottom margin on the patient's page title, so the Shared inbox pill stays centred beside it. */} {isGroupThread ? ( ) : ( <> {!portal && } {/* ⛔ No presence dot on a pool thread — "online" is a fact about a person, and a green dot beside the office would claim somebody is sitting there. */} {!isPoolThread && chatPartner?.id && ( )} )} {conversationTitle} {/* 🗣️ Spencer, 2026-09-10: *"We don't need to say 'Direct thread' at the top or indicate number of participants of direct threads. We should indicate at the top when in a collab team that it's a group collaboration team chat and display number of participants in the collab team."* ⚖️ A LABEL EARNS ITS PLACE BY RULING SOMETHING OUT. "Direct thread" over a thread with one name at the top of it tells the reader what they can already see, which is how a header fills up with words nobody reads. "Collaboration team" and "Shared inbox" both say something the name alone does not - that others are here, and that whoever is on the desk answers - so those stay. */} {isGroupThread && ( {privateWithName !== undefined ? "Private conversation" : "Collaboration team"} )} {/* It says WHERE the message lands, which is the one thing that separates this from writing to a named receptionist: whoever is on the desk answers. The shared pill, as the patient phone board draws it (#s-messages .pool). */} {isPoolThread && !isGroupThread && {poolPill}}
{/* Inline text rather than a wrapping flex row, so a separator stays on the line of the fact before it instead of starting the next line on its own. */}
{/* ⛔ NEITHER OF THESE IS A FACT ABOUT A TEAM. 🗣️ Spencer, 2026-09-10: *"I'm not sure what 'Organization' and 'Staff' is for or means in the header."* - and he could not be, because on a collaboration team they were answering a question that has no answer. A team spans practices, so there is no one organization; its members hold different roles, so there is no one role. Both fell back to a generic word and printed it as though it were information. They stay for a one-to-one and for the front desk, where each names something true and useful: which practice this person is at, and what they do there. */} {!isGroupThread && portal && ( // What they do, then where, as one plain line; the front desk is the practice's // own, so the practice leads there. {(isPoolThread ? [headerOrganizationName, headerRoleLabel] : [headerRoleLabel, headerOrganizationName] ).join(" · ")} {showPoolAnswerers && ( <> {" · "} {poolAnswererCount} {poolAnswererCount === 1 ? 'person' : 'people'} can answer )} )} {!isGroupThread && !portal && ( <> {headerOrganizationName} {" "} {headerRoleLabel} {showPoolAnswerers && ( )} {" "} {showPoolAnswerers && ( {poolAnswererCount} {poolAnswererCount === 1 ? 'person' : 'people'} can answer )} )} {/* HOW MANY PEOPLE ARE IN HERE is worth saying about a group and says nothing about a pair: "2 participants" beside one person's name is arithmetic, not information. Singular is spelled out because a collaboration team of one is a real state - a patient who has started a team and invited nobody yet. */} {isGroupThread && participantLabel && ( // Spencer, 2026-09-10: *"Clicking on participants should display an overlay // list of the participants."* It opens the details panel, which already lists them - // a second overlay saying the same thing is a second place to keep it correct. // The panel is controlled rather than trigger-wrapped: it lives further down the // header, so a `SheetTrigger` here would be outside its own `Sheet`. )}
{/* A BARE GLYPH, NOT A BUTTON. 🗣️ Spencer, 2026-09-10. It wore `btn btn-blue-ghost`, which gave it a button's box, hover ground and 36px hit area in a header row where nothing else is a control - so the quietest thing on the thread read as the loudest. It opens a details panel; it is a footnote, not an action. ⚠️ The `aria-label` stays and is the reason it is still a real `button`: icon-only, it once announced as "button" and nothing else. Dropping the chrome must not drop the name with it. */} {/* 90vw ON A PHONE, so a strip of the page still shows behind it and the thing keeps reading as an overlay, while the panel gets the width its content actually needs. At 390 that leaves about forty pixels - enough to see, not enough to waste. */} Conversation details {/* NO DESCRIPTION. It read "Participant, organization, and thread context for this conversation" - a list of the panel's own section headings, under a title that already says what the panel is. It cost a line of vertical space on the surface with the least of it. */}
{/* NO 64px MARK FOR A GROUP. Spencer, 2026-09-10: *"we all know we're in a collab team at this point here"* - the same glyph sits against the name a line away, and the larger of the two carries less. A person's photograph stays: it is an identifier rather than a restatement of where you are. */} {isGroupThread ? null : isPoolThread ? (
) : ( {chatPartnerName} )}

{isGroupThread ? ( ) : ( )} {conversationTitle}

{/* Same reasoning as the header: an organization and a role are not facts about a team, and printing a generic word for them is worse than saying nothing, because it reads as an answer. */} {!isGroupThread && ( <> {headerOrganizationName} {headerRoleLabel} )} {participantLabel && ( {participantLabel} )}
{/* 🗣️ Spencer, 2026-09-10: pressing the participant count should show the participants. It opened this panel and this panel did not list them - it named the count again and then described the thread, which answers the question the reader had already answered by clicking. */} {!!participants?.length && (

In this conversation

    {participants.map((person, index) => (
  • {person.avatarUrl ? ( ) : ( // No photograph is a fact about what this reader may look up, not about // the person. A neutral mark says somebody is here without inventing a face. )} {person.name ? ( {person.name} ) : ( // NOT A BLANK ROW AND NOT A REFERENCE. The server resolves these names, // and on a cross-organization team the member a patient cannot look up is // the one they most need to know is here. Say a person is present and that // their name did not arrive - never imply the seat is empty. Name unavailable )} {person.subtitle && ( {person.subtitle} )} {onMessagePrivately && person.memberRef && ( )}
  • ))}
)} {/* THREAD CONTEXT IS GONE. Spencer, 2026-09-10: it "adds no value and just eats space". It stated the thread TYPE, which the badge beside the title already says, and its VISIBILITY, which was only ever a restatement of the type in a sentence. Both were true and neither was news, and they cost the bottom half of a panel that is about to carry three tabs. */}
)} {deskLayout && threadNotice &&
{threadNotice}
} {needsReply && onDismissReply && (
You haven't replied to this message yet
)}
{groupedMessagesArray.length === 0 && loadError ? (

Messages could not be loaded

{loadError}

{onRetryLoad && ( )}
) : groupedMessagesArray.length === 0 ? (

No messages yet

{/* ⛔ Card 356 — a pool thread reaches this branch by DESIGN (it is created before its first message), and `chatPartnerName` is empty for one, so the direct wording would read "Start the conversation with below." */} {/* Staff on a pool thread ARE the pool: the desk was being told to write to the desk. */} {privateWithName !== undefined ? `Only you and ${privateWithName || "this person"} can read this conversation.` : isGroupThread ? "Start the conversation with the team below." : isDeskAside && viewerIsStaff ? "Notes here stay with the staff. The patient never sees them." : isPoolThread && viewerIsStaff ? "Nothing here yet. A reply goes to the patient." : isPoolThread ? `Start the conversation with the ${poolLabel.toLowerCase()} below.` : isRoom ? "Start the conversation with everyone in this visit below." : `Start the conversation with ${chatPartnerName} below.`}

) : (
{groupedMessagesArray.map(([dateKey, dateMessages], groupIndex) => (
{/* The day as a quiet centred label, not ruled across the conversation. */}
{formatDate(dateMessages[0].sent)}
{dateMessages.map((msg, index) => { let prevMessage: ChatMessage | undefined; if (groupIndex > 0 && index === 0) { const prevDateMessages = groupedMessagesArray[groupIndex - 1][1]; prevMessage = prevDateMessages[prevDateMessages.length - 1]; } else if (index > 0) { prevMessage = dateMessages[index - 1]; } // Wherever each author is named (teams, visits, the desk's shared thread), a new // author starts a new byline, or a colleague's reply reads as the patient's. const showSenderInfo = shouldShowSenderInfo( msg, prevMessage, 4, namesEachAuthor, ); if (msg.sender === "other" && deskLayout) { const author = msg.onBehalfOf?.helperName ?? (namesEachAuthor ? msg.senderName || "Participant" : chatPartnerName); return (
{showSenderInfo ? : }
{showSenderInfo && (

{author} {msg.time} {(msg.onBehalfOf || (!namesEachAuthor && isChatPartnerRelatedPerson)) && ( )}

)}
{msg.text && (

{msg.text}

)} {renderAttachment(msg, false)}
); } if (deskLayout) { return (
{showSenderInfo && (

You {msg.time}

)}
{msg.text && (

{msg.text}

)} {renderAttachment(msg, true)}
{outboundStatus?.messageId === msg.id && ( )}
); } if (msg.sender === "other") { return (
{!plainBubbles && (
{showSenderInfo ? ( isGroupThread ? (
) : ( {chatPartnerName} ) ) : (
)}
)}
{msg.text && (

{msg.text}

)} {renderAttachment(msg, false)}
{showSenderInfo && plainBubbles && (
{msg.onBehalfOf?.helperName ?? (namesEachAuthor ? msg.senderName || "Participant" : plainAuthorName)} {" · "} {msg.time} {(msg.onBehalfOf || (!namesEachAuthor && isChatPartnerRelatedPerson)) && ( )}
)} {showSenderInfo && !plainBubbles && (
{namesEachAuthor ? ( <> {isGroupThread ? : } {/* A helper writes as the patient; the server-stamped helper is named, with whom they wrote for. */} {msg.onBehalfOf?.helperName ?? (msg.senderName || "Participant")} {msg.time} {msg.onBehalfOf && ( )} ) : ( <> {msg.onBehalfOf?.helperName ?? chatPartnerName} {headerOrganizationName} {msg.time} {(msg.onBehalfOf || isChatPartnerRelatedPerson) && ( )} )}
)}
); } return (
{/* 🗣️ Spencer, 2026-09-10: *"we still don't have the newer message thread design implemented here like we implemented in the ai assistant chat."* The assistant was brought onto the mockup's thread anatomy on 2026-09-09 and this, the thread the mockup was drawn FOR, was left behind - so the help panel looked more like the product than the product did. Bubble, side, attribution UNDER it; `--me` is the primary fill on the right, `--them` the sunken one on the left. The values live in one place, on `.ahr-msg-bubble`, so the two surfaces cannot drift apart again. ⚖️ The tail radius is what makes a run of bubbles read as one speaker without a rule drawn between them - a divider through a conversation separates the wrong thing. */}
{msg.text && (

{msg.text}

)} {renderAttachment(msg, true)}
{plainBubbles && (showSenderInfo || outboundStatus?.messageId === msg.id) && (
{showSenderInfo && You · {msg.time}} {outboundStatus?.messageId === msg.id && ( retryFailedMessage(msg.id) : undefined} /> )}
)} {showSenderInfo && !plainBubbles && (
{/* 🔻 CARD 392 — THE GREEN DOUBLE TICK USED TO LIVE HERE, and that is why it lied. It sat in the sender HEADER, gated only by `showSenderInfo` (the message-GROUPING flag), so it rendered once per group of outbound messages and said "somebody has read this" about all of them, instantly, in every thread — including a patient writing to their own doctor. ⭐ A header that renders once per GROUP cannot express "the latest message, and only while nothing has answered it", which is what the ruling asks for. So this was a MOVE, not a swap: the status now renders under the bubble, from receipts. */} You {msg.time}
)} {!plainBubbles && outboundStatus?.messageId === msg.id && ( retryFailedMessage(msg.id) : undefined} /> )}
); })}
))}
)} {filePreview && (
{/* A chosen file is named, never drawn: it shows only once the server has checked it. */}

{filePreview.name}

{filePreview.size}

)}
{sendRecovery && (earlier || recoveryRefusal) && (
)} {sendRecovery && earlierNotice && (

{earlierNotice}

)} {sendFailed && (
)} {onUpdateMessages && unconfirmedReceipts.length > 0 && (
)} {readOnly ? (
{readOnlyReason ?? "This collaboration team is closed — history is read-only."}
) : (
{colleagueLayout && ( )} {/* VISUALLY HIDDEN, NOT `display: none`. A file input that is `display: none` is not laid out at all, and opening its picker from script is the one thing about it that browsers and extensions treat inconsistently - it works here and is reported as doing nothing elsewhere, which is the worst kind of defect to chase because it reproduces for nobody who can debug it. Kept in the layout at a single transparent pixel instead: the picker is then opened on an element that genuinely exists on screen, which is the pattern every component library settled on for exactly this reason. It is still unreachable by eye, by pointer and by tab. */} { const file = e.target.files?.[0]; if (!file) return; if (file.size > 5 * 1024 * 1024) { toast.error("File size exceeds 5MB limit."); e.target.value = ""; return; } // THE `accept` LIST IS A SUGGESTION, NOT A GATE. Every file picker offers a way past // it, and a file that gets past it reaches the server, is refused there, and comes // back as "Failed to send message. Please try again." - after the message bubble has // already appeared. Trying again cannot work, and the reader is not told why. // Say what is wrong while it can still be acted on, and name the file. if (!isSendableFile(file)) { toast.error( `${file.name} is not a kind of file you can send here. Try an image, a PDF, a Word document or a text file.`, ); e.target.value = ""; return; } setFilePreview({ opening: opening.current, scope: selectionScope, file, name: file.name, size: formatFileSize(file.size), type: file.type, }); e.target.value = ""; }} /> {/* 🗣️ Spencer, 2026-09-10: *"I wanted the emoji picker icon inside the input like facebook has it."* So the field and its control are one object: a relative box, the input filling it, and the button parked on the inside edge. The input carries right padding to match, or a long message runs underneath the glyph. */}