import { useEffect, useRef, useState } from "react"; import { describeBlockedAction } from "../../utils/fetchFailureMessage"; import { CONVERSATION_CLAIM_VOCABULARY, claimNotice, type ClaimVocabulary, type ThreadClaimState, } from "../../utils/messaging/threadClaim"; /** * Who is answering this front-desk conversation. S5, the visible half. * * Spencer: *"once someone opens the message and starts typing it should lock it... so none of the other * receptionists can start sending a message at the same time."* The goal is right; the lock is SOFT. * * NOTHING HERE BLOCKS A REPLY, and that is the ruling rather than a shortcut. A hard block cannot tell * "Alice is mid-sentence" from "Alice's browser crashed", and at a real front desk the second happens * often enough that an inescapable lock generates more phone calls than the duplicate replies it prevents. * So this strip *says* who is replying and the composer beside it stays usable - "Reply anyway" is simply * the absence of a gate, which is why it needs nothing from the server. * * AND CLAIMING DOES NOT HIDE THE CONVERSATION FROM ANYONE (D2). This is a marker, never a filter: * an abandoned claim that hid the thread is the failure mode this feature would otherwise introduce. */ export type FrontDeskClaimStripProps = { claim: ThreadClaimState | undefined; /** The signed-in receptionist. */ viewerAccountId: string | undefined; nameFor: (accountId: string) => string | undefined; onClaim: () => Promise; onRelease: () => Promise; onChanged: (claim: ThreadClaimState) => void; /** * Put the cursor in the message box.. * * Deliberately NOT a server call and NOT a promise. The whole point of the override is that nothing * has to change for a colleague to reply - the composer was never disabled - so anything asynchronous * here would be re-introducing the gate this affordance exists to say does not exist. */ onReplyAnyway?: () => void; /** * - the words, because the same claim does two jobs. A conversation is *replied to*; an * arriving referral is *worked*, and nobody ever replies to it. Defaulted, so every existing caller * is untouched and reads exactly as it did. */ vocabulary?: ClaimVocabulary; /** The patient wrote last, so a claim of your own reads "Awaiting your reply" beside it, as the deck draws. */ awaitingReply?: boolean; }; export const FrontDeskClaimStrip = ({ claim, viewerAccountId, nameFor, onClaim, onRelease, onChanged, onReplyAnyway, vocabulary = CONVERSATION_CLAIM_VOCABULARY, awaitingReply = false, }: FrontDeskClaimStripProps) => { const [busy, setBusy] = useState(false); const [failure, setFailure] = useState(); const identity = JSON.stringify([claim?.threadId, viewerAccountId]); const opening = useRef({ identity, live: true }); if (opening.current.identity !== identity) { opening.current.live = false; opening.current = { identity, live: true }; } useEffect(() => { const here = opening.current; here.live = true; setFailure(undefined); setBusy(false); return () => { here.live = false; }; }, [identity]); // A claim past its expiry has lapsed on the server, whatever `claimed` said when it was read. Read it // that way, and re-render at the moment it lapses rather than at the next event that re-reads it. const [now, setNow] = useState(() => Date.now()); const expiresAt = claim?.claimed && claim.claimExpiresAt ? Date.parse(claim.claimExpiresAt) : NaN; useEffect(() => { if (Number.isNaN(expiresAt)) return undefined; const wait = expiresAt - Date.now(); if (wait <= 0) return undefined; const timer = setTimeout(() => setNow(Date.now()), wait + 250); return () => clearTimeout(timer); }, [expiresAt]); const lapsed = !Number.isNaN(expiresAt) && expiresAt <= Math.max(now, Date.now()); const live = claim && lapsed ? { ...claim, claimed: false } : claim; const heldByOther = claimNotice(live, viewerAccountId, nameFor, vocabulary); const heldByMe = Boolean(live?.claimed && live.claimedByAccountId === viewerAccountId); const run = async (action: () => Promise) => { const here = opening.current; setBusy(true); setFailure(undefined); try { // The RESULT is what updates the screen - including a 409, which is not a failure but the answer // "someone else has it" carrying the holder. Assuming success and setting the state locally is how // two receptionists both end up believing they hold the same conversation. const result = await action(); if (here.live) onChanged(result); } catch (error) { if (here.live) setFailure(describeBlockedAction(error, "That didn't work")); } finally { if (here.live) setBusy(false); } }; return ( // `shrink-0` KEEPS THE STRIP ITS OWN HEIGHT. Its parent is the thread pane, a flex column, and // the `Chat` card beside it asks for the full height - so without this the strip gives up its // share of the overflow and the row it draws gets squeezed rather than the card.
{heldByOther ? ( <> {heldByOther} {/* ⛔ Card 608 — ABSENT ON A WORK ITEM, not disabled. A referral has no reply, so an override that focuses a composer would point at a control that is not on the screen. Callers that have no composer simply pass no `onReplyAnyway`. */} {/* The override. It is not styled as the main action: answering a conversation a colleague is already in should be possible and deliberate, not the obvious next click. 🚨 CARD 439 — IT WAS WIRED TO `onClaim`, WHICH IS THE ONE VERB IT MUST NOT USE. Pressing it asked to TAKE the claim, the server correctly refused with a 409 naming the holder, the strip re-rendered identically, and the receptionist saw nothing happen. ⭐ The file header already said what it should do — *"'Reply anyway' is simply the absence of a gate, which is why it needs nothing from the server"* — so the fix is to stop calling the server at all and put the cursor in the message box, which was never disabled. ⛔ It must NOT take the claim. A colleague may always reply; they just do not become the person answering. Stealing it would re-create the duplicate reply this exists to prevent. */} {onReplyAnyway && ( )} ) : heldByMe ? ( <> {vocabulary.heldByMe} {awaitingReply && ( Awaiting your reply )} ) : ( <> {/* ⭐ Unclaimed is the NORMAL resting state of a pool conversation, not an error state — "nobody is set" is what a pool IS, and it is why nothing rots when a receptionist leaves. */} {vocabulary.unclaimed} )} {failure && ( // Said out loud. A receptionist who believes they hold a conversation and does not is exactly // the duplicate-reply this feature exists to prevent. {failure} )}
); }; export default FrontDeskClaimStrip;