import { useCallback, useState } from 'react'; import { Communication, CommunicationRequest } from 'fhir/r4'; import { toast } from 'sonner'; import { useCommunicationServiceContext } from '../services'; import { isDismissedBy, markNoReplyNeeded } from '../utils/messageDismissal'; import { latestMessageIn } from '../pages/DashboardPage/ReceptionistHomePage/frontDeskRail'; import { describeFetchFailure } from '../utils/fetchFailureMessage'; interface UseNeedsReplyOptions { requests: CommunicationRequest[]; getMessagesForRequest: (request: CommunicationRequest) => Communication[]; getUnreadCountForRequest: (threadId: string) => number; currentUserRef: string; /** * WHICH SENDERS COUNT AS "US", and it is not always just me. * * A reply is what takes a thread out of the owed count, and on a SHARED inbox the person who replies * is usually somebody else at the desk. Judging that by sender equals me asks "did I reply", when the * question the count is really asking is "has this been answered". * * Default: only me, which is right for a direct thread and for colleague threads, where the other * participant IS the counterparty and their message is precisely what makes a reply owed. * * A pooled patient thread passes its own: every practitioner is our side, so any of them answering * clears it for the whole desk. */ isOurSide?: (senderRef: string) => boolean; enabled?: boolean; } interface UseNeedsReplyReturn { needsReply: (threadId: string) => boolean; dismiss: (threadId: string, lastCounterpartyMessageId: string) => void; isDismissing: (threadId: string) => boolean; getLastCounterpartyMessageId: (threadId: string) => string | undefined; } /** * - ONE CARRIER FOR "I HAVE DECIDED THIS NEEDS NO ANSWER", and this hook used to be the second one. * * The dismissal is an extension ON THE MESSAGE, which is the shape the product settled on and which * `messageDismissal.ts` argues for at length. This hook kept its own record instead - a per-user * preference keyed by thread, written to the preferences endpoint. Both stores worked. Neither could * see the other. * * WHAT THAT COST, measured on journey F2 on 2026-09-13 with two real front-desk credentials: a * receptionist pressed **Dismiss** on a patient's message, the band went away, and their own overview * rail still read *"1 patient awaiting reply"* - naming the same patient and the same message, through * a full page load. It cleared only when the OTHER desk replied, which is a different mechanism doing * a different job. The one control that exists to move that number could not move it, and because the * band did go away the screen said the action had worked. * * `messageDismissal.ts` had already written down why this must not happen: *"a second storage * mechanism for 'something this person did to this message' is how the two come to disagree."* * * SO THE MARK GOES ON THE MESSAGE, and every surface that asks reads the same resource: this band, the * front desk's rail, and the practitioner's owed-work list. It also inherits what the extension was * designed for - a new inbound message re-owes the thread by itself, because it is a different * resource carrying no mark. */ export const useNeedsReply = ({ requests, getMessagesForRequest, getUnreadCountForRequest, currentUserRef, isOurSide, enabled = true, }: UseNeedsReplyOptions): UseNeedsReplyReturn => { const senderIsOurSide = useCallback( (senderRef: string | undefined): boolean => !!senderRef && (isOurSide ? isOurSide(senderRef) : senderRef === currentUserRef), [isOurSide, currentUserRef], ); const { getCurrentUserReference, saveCommunicationBundle, updateCommunicationInState, } = useCommunicationServiceContext(); const selfRef = getCurrentUserReference() ?? currentUserRef; const [dismissingSet, setDismissingSet] = useState>(new Set()); const getSortedMessages = useCallback( (request: CommunicationRequest): Communication[] => { const getTime = (msg: Communication): number => new Date(msg.sent || msg.meta?.lastUpdated || 0).getTime(); return [...getMessagesForRequest(request)].sort((a, b) => getTime(a) - getTime(b)); }, [getMessagesForRequest], ); const getLastCounterpartyMessage = useCallback( (request: CommunicationRequest): Communication | undefined => { const messages = getSortedMessages(request); for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; const senderRef = msg.sender?.reference; // A colleague at the same desk is not the counterparty. Treating them as one made their reply // the thing awaiting an answer, and pointed the dismissal at their message rather than the // patient's. if (senderRef && !senderIsOurSide(senderRef)) { return msg; } } return undefined; }, [getSortedMessages, senderIsOurSide], ); const needsReply = useCallback( (threadId: string): boolean => { if (!enabled) return false; const request = requests.find((r) => r.id === threadId); if (!request) return false; const messages = getSortedMessages(request); // ANSWERED, NOT "ANSWERED BY ME". // // Spencer, 2026-08-27: "once someone replies it should no longer show in anyone's waiting on // reply count." This used to compare against currentUserRef alone, so a colleague answering a // shared front-desk thread left it owed for everybody else - and it then read THEIR reply as the // message awaiting one, since anybody who was not me counted as the other side. // // Dismissing stays personal, which is the other half of the same ruling: it clears the thread for // the one who pressed it and leaves it owed for the rest of the desk until somebody replies. const lastMsg = messages[messages.length - 1]; if (senderIsOurSide(lastMsg?.sender?.reference)) return false; if (getUnreadCountForRequest(threadId) > 0) return false; const lastCounterpartyMsg = getLastCounterpartyMessage(request); if (!lastCounterpartyMsg?.id) return false; // Read off the LATEST message, which is the same message whenever the thread is owed and is what // `threadNeedsMyReply` asks on every other surface. Asking one question of one resource is the // whole point of the change. return !isDismissedBy(latestMessageIn(messages), selfRef); }, [enabled, requests, getSortedMessages, getLastCounterpartyMessage, getUnreadCountForRequest, senderIsOurSide, selfRef], ); const getLastCounterpartyMessageId = useCallback( (threadId: string): string | undefined => { const request = requests.find((r) => r.id === threadId); if (!request) return undefined; return getLastCounterpartyMessage(request)?.id; }, [requests, getLastCounterpartyMessage], ); /** * THE WRITE AND THE LOCAL COPY, IN ONE PLACE. * * `saveCommunicationBundle` PUTs and returns; it does not touch the manager's state. Without the * second half the mark is on the server and invisible until a reload - and the rail this defect was * raised against lives on another page, which re-renders from that same manager without refetching. * `updateCommunicationInState` is the manager's own replace, so the band, the rail and the owed list * all re-render off one resource. * * ON FAILURE IT SAYS SO and nothing is marked, rather than clearing the row optimistically: a * receptionist told that a message needs no answer, when the server never recorded it, has been handed * a queue that quietly refills. */ const dismiss = useCallback( (threadId: string, lastCounterpartyMessageId: string) => { const request = requests.find((r) => r.id === threadId); const messages = request ? getSortedMessages(request) : []; const latest = latestMessageIn(messages); // You may only dismiss the message you actually saw. If the thread has moved on since the band // rendered, somebody wrote in between, and marking the current latest would take the thread out // of the count while they wait on an answer that is then counted nowhere. if (!latest?.id || latest.id !== lastCounterpartyMessageId) { toast.error('A newer message arrived in that conversation. Have a look before dismissing it.', { position: 'top-right', }); return; } const marked = markNoReplyNeeded(latest, selfRef, new Date().toISOString()); // Already dismissed by me. Idempotent, and no write worth making. if (marked === latest) return; setDismissingSet((prev) => new Set(prev).add(threadId)); saveCommunicationBundle([marked]) .then((saved: Communication[]) => { if (saved.length === 0) throw new Error('That could not be saved.'); updateCommunicationInState(saved[0] ?? marked); }) .catch((err: unknown) => { toast.error(describeFetchFailure(err, 'Failed to dismiss'), { position: 'top-right' }); }) .finally(() => { setDismissingSet((prev) => { const next = new Set(prev); next.delete(threadId); return next; }); }); }, [requests, getSortedMessages, selfRef, saveCommunicationBundle, updateCommunicationInState], ); const isDismissing = useCallback( (threadId: string): boolean => dismissingSet.has(threadId), [dismissingSet], ); return { needsReply, dismiss, isDismissing, getLastCounterpartyMessageId }; };