import { useChatSendRecovery } from '../../../hooks/useChatSendRecovery'; import { scannedPhotoSrc } from '../../../utils/fhirPhoto'; import React, { useEffect, useRef, useState } from "react"; import { PractitionerWithRoles, useAuthServiceContext, useCommunicationServiceContext, useFhirServiceContext, usePractitionerServiceContext, STATUS_LABELS, TEAM_STATUS_TONE, } from "../../../services"; import type { CollaborationTeam, TeamMembership } from "../../../services"; import { DismissibleAlert } from '../../../components/ui/dismissible-alert'; import { Spacer } from "../../../components/ui/spacer"; import { Communication, CommunicationRequest, Patient, Practitioner, Attachment, Reference, PractitionerRole, } from "fhir/r4"; import { counterpartyRoleWord as counterpartyRoleWordFor } from "../../../utils/messaging/counterpartyRoleWord"; import { isIdentifierDisplay } from "../../../utils/messaging/counterpartyRowName"; import { Chat, FhirCommunicationRequestDisplay } from "../../../components"; import { PracticeMark } from "../MyProvidersPage/OrgListCard.tsx"; import { ORG_LOGO_EXTENSION_URL } from "../../../config/organizationExtensions.ts"; import { isPractitionerMessageableByPatient, frontDeskAnswererCount, organizationIdsWithFrontDesk, organizationRoleLabelsForPatient, } from "../../../utils/patientMessagingDirectory.ts"; import { declinesNewMessagesNotice, patientPickerRowState, } from "../../../utils/messaging/patientInitiatedMessages.ts"; import { FRONT_DESK_POOL_KEY, getThreadPoolKey, isPoolThreadRequest, poolThreadDescription, poolThreadLabel, threadBelongsInInbox, } from "../../../utils/messaging/poolThread"; import { isEncounterRoomThreadRequest, ROOM_THREAD_LABEL, ROOM_THREAD_DESCRIPTION, } from "../../../utils/messaging/roomThread"; import { useFhirCommunicationRequestTargetedBroadcast, useCommunicationManager, useFhirPractitioners, useFhirRelatedPersons, } from "../../../hooks"; import { useCollaborationTeamThreadMessages } from "../../../hooks/useCollaborationTeamThreadMessages"; import { usePatientInbox } from "../../../components/PatientInbox/patientInboxContext"; import { buildCollabTeamThreadId, buildCollabTeamPrivateThreadId } from "../../../utils/collaborationTeamThread"; import { Card, CardContent, CardDescription, CardTitle, } from "../../../components/ui/card"; import { Skeleton } from "../../../components/ui/skeleton"; import { RowStatus } from "../../../components/ui/row-meta"; import { teamDisplayStatus } from "../../../utils/collaborationTeamDormancy"; import clsx from "clsx"; import { ChevronDown, ChevronRight, MapPin, MessageSquareText, Phone, SearchIcon, Users, Video, } from "lucide-react"; import { InputGroup, InputGroupAddon, InputGroupInput, } from "../../../components/ui/input-group"; import { Button } from "../../../components/ui/button"; import { getFhirPhotoUrl } from "../../../utils/fhirPhoto.ts"; import { getThreadOrganizationId, resolveCounterpartyReference, toSelfReference, } from "../../../utils/messaging/counterparty.ts"; import { formatFhirHumanName } from "../../../utils/fhirHumanName.tsx"; import { shouldSendOnThread, recipientForThread } from "./sendGuard"; import { toast } from "sonner"; import { useSearchParams } from "react-router-dom"; import { usePresenceContext } from "../../../services/PresenceService"; import { EmptyState } from "../../../components/ui/empty-state"; import { MESSAGES_PAGE_HEIGHT, MESSAGES_SPLIT_GRID, messagesInboxPane, messagesThreadPane } from "../../../utils/messagesLayout"; import { useMessagesViewportFit } from "../../../hooks/useMessagesViewportFit"; import { threadLastActivity, threadPreview } from "../../../utils/threadSummary"; import { formatRelativeTime } from "../../../utils/formatRelativeTime"; import { useMyOrganizations } from "../../../hooks/useMyOrganizations"; import { usePracticeStaff } from "../../../hooks/usePracticeStaff"; import { ProvidersListStatus } from "./ProvidersListStatus"; import { InboxAvatar, InboxRow } from "./InboxRow"; import { buildPatientProviderGroups, organizationLogoOf } from "../../../utils/patientProviderGroups"; import { describeBlockedAction } from "../../../utils/fetchFailureMessage"; import { isSaveRetired } from "../../../utils/saveOperation"; import { namedSend, type SendOperation } from "../../../utils/messaging/sendOperation"; import { rankThreadListing, rowsNoticeFor } from "../../../utils/messaging/threadListingNotice"; import { ThreadListingNotice } from "../../../components/messages/ThreadListingNotice"; import type { SaveTransport } from '../../../utils/saveOperation'; const NOT_READY_TO_SEND = "This conversation is not ready to send yet. Please try again in a moment."; const MessagesPage: React.FC = () => { // Chat holds each named send for a full reload and settles what a reload left. const sendRecovery = useChatSendRecovery(); const { currentUser, actingAs, getCognitoGroup, USER_ROLES, currentOrganization } = useAuthServiceContext(); // The practices this patient belongs to, whichever one they are signed into - the same hook My // providers and the practice switcher read, so a practice named on those screens is named on this one. // Publishes `--ak-messages-top` on the container below, so the column ends at the bottom of the // window whatever the chrome above it currently is. const messagesFitRef = useMessagesViewportFit(); const { organizations: myOrganizations, loading: myOrganizationsLoading, error: myOrganizationsError, retry: retryMyOrganizations, } = useMyOrganizations(); const { createCommunication, // The write's own result goes on screen - see the note at the send. addCommunicationToState, createCommunicationForThread, saveCommunicationBundle, readCommunications, messagingIdentity, // A helper's listing the server refused, and the messaging grant on their link when it could be read. threadListingRefused, refusedMessagingGrant, // A no-practice helper whose grant is still being read, or could not be read: not "none yet". helperGrantState, // The listing answer for THIS viewer/link/practice and viewing lifetime only - see `rankThreadListing`. threadListingState, } = useCommunicationServiceContext(); const { extractReferenceId, createReferenceObject } = useFhirServiceContext(); const { fetchPractitioner } = usePractitionerServiceContext(); // A receptionist who joined while this session was open must still become the front desk here. const { practitionersList, loading: practitionersLoading, error: practitionersError, loadPractitioners, } = useFhirPractitioners({ refreshOnMount: true }); const myOrganizationIds = React.useMemo( () => (myOrganizations ?? []).map((organization) => organization.id as string).filter(Boolean), [myOrganizations], ); const practiceStaff = usePracticeStaff(myOrganizationIds, currentOrganization?.id, practitionersList); // The patient's OWN related people, which they may always read. It is one cached fetch for the // whole list rather than a lookup per team member, and it is what turns a bare name in a team // roster into "your daughter" - the thing that actually tells a patient who is in the room. const { relatedPersonsList } = useFhirRelatedPersons(); const { isOnline } = usePresenceContext(); const { patientCommunicationRequests, loading: communicationLoading, error: communicationError, errorRetryable: communicationErrorRetryable, getMessagesForRequest, getUnreadCountForRequest, setActiveThread, clearError: clearCommunicationError, openDirectThread, openFrontDeskThread, // The inbox refresh Try again triggers, on a listing that is not known-complete. loadCommunicationData, } = useCommunicationManager(); // One ranking the list below reads from, so the search term never changes which notice applies - // see `rankThreadListing`. Retried the same way everywhere: a fresh read for this scope. const listingRank = rankThreadListing({ state: threadListingState, refused: threadListingRefused, helperGrantState, }); const retryListing = () => void loadCommunicationData(true); const [selectedCommunicationRequest, setSelectedCommunicationRequest] = useState(null); const [chatLoading, setChatLoading] = useState(false); const [practitioner, setPractitioner] = useState(null); const [conversationOrganizationName, setConversationOrganizationName] = useState(); const [conversationRoleLabel, setConversationRoleLabel] = useState< string | undefined >(); const [ selectedCommunicationRequestForDisplay, setSelectedCommunicationRequestForDisplay, ] = useState(null); const [showDisplayModal, setShowDisplayModal] = useState(false); const [showConversationMobile, setShowConversationMobile] = useState(false); const [activeTab, setActiveTab] = useState< "inbox" | "collaboration_teams" | "providers" >("inbox"); const [searchTerm, setSearchTerm] = useState(""); const [error, setError] = useState(); const [expandedOrganizations, setExpandedOrganizations] = useState>({}); // C2 - Collaboration Teams tab state. Teams load independently of the CommunicationRequest-anchored // inbox/providers data above; `selectedTeam` drives the group-chat right-hand pane the same way // `practitioner`/`selectedCommunicationRequest` drive the direct-thread pane. const { teams, openTeams, teamsLoading, teamsError, teamSummaries, inboxUnread, reloadTeams, setOpenTeamId, } = usePatientInbox(); const [selectedTeam, setSelectedTeam] = useState< (CollaborationTeam & { members: TeamMembership[] }) | null >(null); // The open team as the inbox knows it now. The selection says which team; its own copy is from when it was opened. const currentTeam = React.useMemo( () => (selectedTeam ? teams.find((team) => team.teamId === selectedTeam.teamId) ?? selectedTeam : null), [teams, selectedTeam], ); const [privatePeer, setPrivatePeer] = useState<{ memberRef: string; name?: string } | null>(null); /** * Names for authors this thread's roster already knows, the same map the staff panel passes. * * A cross-org sender's `Communication` carries no display, so their messages rendered as * "Participant" on the patient's own team - beside a roster that names them. The server resolves * this and the client must not: these are read straight off the membership it already holds. */ const teamRosterNames = React.useMemo>(() => { const names: Record = {}; for (const member of currentTeam?.members ?? []) { if (member.displayName) names[member.memberRef] = member.displayName; } return names; }, [currentTeam]); const [searchParams] = useSearchParams(); const patient = currentUser?.fhir as Patient; const spacerMargin = "24px"; // Refreshed on arrival so a team made elsewhere is listed; the frame shares the list and count. useEffect(() => { void reloadTeams(); }, [reloadTeams]); // The open team's messages are being read, so the shared count leaves them out. useEffect(() => { setOpenTeamId(selectedTeam?.teamId ?? null); return () => setOpenTeamId(null); }, [selectedTeam?.teamId, setOpenTeamId]); useFhirCommunicationRequestTargetedBroadcast({ communicationRequest: selectedCommunicationRequestForDisplay || undefined, isOpen: showDisplayModal, onUpdate: (updatedCommunicationRequest) => { setSelectedCommunicationRequestForDisplay(updatedCommunicationRequest); }, onDelete: () => { setShowDisplayModal(false); setSelectedCommunicationRequestForDisplay(null); }, }); const getLatestRequestTimestamp = (request: CommunicationRequest): number => { const value = request.authoredOn || request.meta?.lastUpdated || request.occurrenceDateTime || undefined; return value ? new Date(value).getTime() : 0; }; // "Me", for working out who the OTHER party in a thread is: the DISPLAYED patient, not the login // identity (finding B12). Shares one implementation with dashboard/Messages.tsx - they were // copy-pasted siblings, which is how they drifted. const currentUserReference = toSelfReference(messagingIdentity); const rolePriority = React.useMemo>( () => ({ [USER_ROLES.PRACTITIONER]: 1, [USER_ROLES.RECEPTIONIST]: 2, [USER_ROLES.ADMIN]: 3, [USER_ROLES.PATIENT]: 4, }), [USER_ROLES], ); const toDisplayRoleLabel = React.useCallback( (role: string): string => { switch (role) { case USER_ROLES.PRACTITIONER: return "Practitioner"; case USER_ROLES.RECEPTIONIST: return "Receptionist"; case USER_ROLES.ADMIN: return "Admin"; case USER_ROLES.PATIENT: return "Patient"; default: return role.charAt(0).toUpperCase() + role.slice(1).toLowerCase(); } }, [USER_ROLES], ); const isLikelyIdentifier = React.useCallback((value?: string | null): boolean => { const normalized = value?.trim(); if (!normalized) { return false; } if (normalized.includes("/")) { return true; } const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; if (uuidPattern.test(normalized)) { return true; } return !normalized.includes(" "); }, []); const getCounterpartyReference = React.useCallback( (request: CommunicationRequest): Reference | undefined => resolveCounterpartyReference(request, currentUserReference), [currentUserReference], ); const getCounterpartyId = React.useCallback( (request: CommunicationRequest): string | undefined => { return extractReferenceId(getCounterpartyReference(request)) as | string | undefined; }, [extractReferenceId, getCounterpartyReference], ); const getPractitionerWithRolesById = React.useCallback( (practitionerId?: string): PractitionerWithRoles | undefined => { if (!practitionerId) { return undefined; } return practitionersList.find( (item) => item.practitioner.id === practitionerId, ); }, [practitionersList], ); const getPractitionerNameFromRoster = React.useCallback( (practitionerId?: string): string | undefined => { const practitionerWithRoles = getPractitionerWithRolesById(practitionerId); if (!practitionerWithRoles) { return undefined; } return ( formatFhirHumanName(practitionerWithRoles.practitioner.name) || practitionerId ); }, [getPractitionerWithRolesById], ); const getRoleLabelsForPractitioner = React.useCallback( (practitionerId?: string): string[] => { const practitionerWithRoles = getPractitionerWithRolesById(practitionerId); if (!practitionerWithRoles?.roles?.length) { return []; } const normalizedRoles = practitionerWithRoles.roles .filter((role: PractitionerRole) => role.active !== false) .map((role: PractitionerRole) => getCognitoGroup(role)) .filter((group): group is string => !!group) .map((group) => { const singularRole = group.endsWith("s") ? group.slice(0, -1) : group; return singularRole.toLowerCase(); }); const uniqueRoles = Array.from(new Set(normalizedRoles)); /* * THE PATIENT'S PATH, AND IT IS THE ONLY ONE THAT REACHES HERE * Every mapping above went through `getCognitoGroup`, which reads `role.meta.tag` - the field the * patient-facing projection STRIPS (/228). So for a patient this list is ALWAYS empty and * the caller's `"Staff"` floor was labelling their own physician, with the clipboard icon. * A staff caller has the tag, so `uniqueRoles` is non-empty and this branch is never taken - * which is what keeps their labels byte-identical rather than re-sourced. * Do not "simplify" this by preferring the org-role labels unconditionally: that changes the * staff vocabulary too, which is the bigger change declined to make. */ if (uniqueRoles.length === 0) { return organizationRoleLabelsForPatient(practitionerWithRoles.roles); } return uniqueRoles .sort((a, b) => { const aPriority = rolePriority[a] ?? 999; const bPriority = rolePriority[b] ?? 999; return aPriority - bPriority; }) .map(toDisplayRoleLabel); }, [getCognitoGroup, getPractitionerWithRolesById, rolePriority, toDisplayRoleLabel], ); const getPractitionerRoleSummary = React.useCallback( (practitionerId?: string): string => { const roleLabels = getRoleLabelsForPractitioner(practitionerId); if (roleLabels.length === 0) { return "Staff"; } if (roleLabels.length === 1) { return roleLabels[0]; } return `${roleLabels[0]} +${roleLabels.length - 1}`; }, [getRoleLabelsForPractitioner], ); const getPractitionerAvatarUrl = React.useCallback( (practitionerResource?: Practitioner | null): string => { return getFhirPhotoUrl( practitionerResource?.photo?.[0], "practitioner", ); }, [], ); /** * Practice names, keyed by organization id - built from the roles already in hand, so it costs no read. * * - a FRONT-DESK thread has no practitioner to hang a practice off, so the row cannot get * the name the way every other row does. `currentOrganization` alone is not enough: a patient can hold * threads at more than one practice, and naming them all after the current one would be worse than * naming none. */ const organizationNameById = React.useMemo(() => { const names = new Map(); practitionersList.forEach((practitionerWithRoles) => { (practitionerWithRoles.roles || []).forEach((role: PractitionerRole) => { const organizationId = extractReferenceId(role.organization) as string | undefined; const organizationName = role.organization?.display?.trim(); if (organizationId && organizationName && !names.has(organizationId)) { names.set(organizationId, organizationName); } }); }); if (currentOrganization?.id && currentOrganization.name && !names.has(currentOrganization.id)) { names.set(currentOrganization.id, currentOrganization.name); } // Every practice the patient belongs to, so the front desk of a practice they are not signed // into is named rather than described. Its logo was already read from the same list. for (const organization of myOrganizations ?? []) { const name = organization.name?.trim(); if (organization.id && name && !names.has(organization.id)) { names.set(organization.id, name); } } return names; }, [currentOrganization, extractReferenceId, practitionersList, myOrganizations]); const getThreadOrganizationName = React.useCallback( (request: CommunicationRequest): string | undefined => { const organizationId = getThreadOrganizationId(request); return organizationId ? organizationNameById.get(organizationId) : undefined; }, [organizationNameById], ); // The rule is a pure function beside senderLabel rather than a closure here: a role word derived // in the page is how the two counterparty helpers drifted apart in the first place, which is the // reason utils/messaging/counterparty.ts exists at all. const counterpartyRoleWord = React.useCallback( (counterparty?: Reference): string => counterpartyRoleWordFor(counterparty, getPractitionerRoleSummary), [getPractitionerRoleSummary], ); const getCounterpartyDisplay = React.useCallback( (request: CommunicationRequest): string => { // - the office is named FIRST, before anything reaches for a person. A pool thread has // no counterparty reference at all, so every branch below it resolves to "Unknown" - which is what // a patient would have read beside their own message to the practice. if (isPoolThreadRequest(request)) { return poolThreadLabel(getThreadPoolKey(request)); } // A VISIT'S THREAD IS NAMED THE SAME WAY AND FOR THE SAME REASON: its roster is whoever was // admitted to the room, so there is no counterparty reference to resolve and every branch below // falls to "Unknown" - which is what a patient read beside the messages from their own visit. if (isEncounterRoomThreadRequest(request)) { return ROOM_THREAD_LABEL; } const counterparty = getCounterpartyReference(request); const counterpartyId = extractReferenceId(counterparty) as string | undefined; const resourceType = counterparty?.reference?.split("/")[0]; const directDisplay = counterparty?.display?.trim(); if ( selectedCommunicationRequest?.id === request.id && practitioner && practitioner.id && (!counterpartyId || practitioner.id === counterpartyId) ) { return ( formatFhirHumanName(practitioner.name) || directDisplay || counterpartyRoleWord(counterparty) ); } if (resourceType === "Practitioner") { const rosterName = getPractitionerNameFromRoster(counterpartyId); if (rosterName) { return rosterName; } if (directDisplay && !isLikelyIdentifier(directDisplay)) { return directDisplay; } } else if (directDisplay && !isIdentifierDisplay(directDisplay, counterpartyId)) { return directDisplay; } return counterpartyRoleWord(counterparty); }, [ extractReferenceId, getCounterpartyReference, counterpartyRoleWord, getPractitionerNameFromRoster, isLikelyIdentifier, practitioner, selectedCommunicationRequest?.id, ], ); const getCounterpartyLabel = React.useCallback( (request: CommunicationRequest): string => { if (isPoolThreadRequest(request)) { return poolThreadDescription(getThreadPoolKey(request)); } if (isEncounterRoomThreadRequest(request)) { return ROOM_THREAD_DESCRIPTION; } const counterparty = getCounterpartyReference(request); const resourceType = counterparty?.reference?.split("/")[0]; const counterpartyId = extractReferenceId(counterparty) as string | undefined; if (resourceType === "Practitioner") { return getPractitionerRoleSummary(counterpartyId); } if (resourceType === "Patient") { return "Patient"; } return "Staff"; }, [extractReferenceId, getCounterpartyReference, getPractitionerRoleSummary], ); const dedupedPatientCommunicationRequests = React.useMemo(() => { const requestMap = new Map(); patientCommunicationRequests.forEach((request) => { const counterpartyId = getCounterpartyId(request); // - a POOL thread keys on ITSELF, matching the staff hook. It has no counterparty here // so it would fall back to its own id anyway; saying so explicitly is what stops a later change // that gives the office a reference from silently collapsing it into a person's thread. const dedupeKey = isPoolThreadRequest(request) ? (request.id as string) : counterpartyId || (request.id as string); if (!dedupeKey) { return; } const existing = requestMap.get(dedupeKey); if (!existing) { requestMap.set(dedupeKey, request); return; } const existingTime = getLatestRequestTimestamp(existing); const currentTime = getLatestRequestTimestamp(request); if (currentTime >= existingTime) { requestMap.set(dedupeKey, request); } }); return Array.from(requestMap.values()).sort((a, b) => { return getLatestRequestTimestamp(b) - getLatestRequestTimestamp(a); }); }, [patientCommunicationRequests, getCounterpartyId]); const getPractitionerOrganizationId = React.useCallback( (practitionerWithRoles: PractitionerWithRoles): string | undefined => { const activeRoles = (practitionerWithRoles.roles || []).filter( (role: PractitionerRole) => role.active !== false, ); for (const role of activeRoles) { const organizationId = extractReferenceId(role.organization) as | string | undefined; if (organizationId) { return organizationId; } } return undefined; }, [extractReferenceId], ); const getPractitionerOrganizationName = React.useCallback( (practitionerWithRoles: PractitionerWithRoles): string | undefined => { const activeRoles = (practitionerWithRoles.roles || []).filter( (role: PractitionerRole) => role.active !== false, ); for (const role of activeRoles) { const organizationDisplay = role.organization?.display?.trim(); if (organizationDisplay) { return organizationDisplay; } const organizationId = extractReferenceId(role.organization) as | string | undefined; if (organizationId) { return organizationId; } } return undefined; }, [extractReferenceId], ); const getPractitionerOrganizationNameById = React.useCallback( (practitionerId?: string): string | undefined => { const practitionerWithRoles = getPractitionerWithRolesById(practitionerId); if (!practitionerWithRoles) { return undefined; } return getPractitionerOrganizationName(practitionerWithRoles); }, [getPractitionerOrganizationName, getPractitionerWithRolesById], ); const setConversationContextFromPractitioner = React.useCallback( (practitionerWithRoles?: PractitionerWithRoles | null) => { if (!practitionerWithRoles?.practitioner?.id) { setConversationOrganizationName(undefined); setConversationRoleLabel(undefined); return; } setConversationOrganizationName( getPractitionerOrganizationName(practitionerWithRoles), ); setConversationRoleLabel( getPractitionerRoleSummary(practitionerWithRoles.practitioner.id as string), ); }, [getPractitionerOrganizationName, getPractitionerRoleSummary], ); const isPractitionerEligibleForPatientMessaging = React.useCallback( (practitionerWithRoles: PractitionerWithRoles): boolean => { if ( !practitionerWithRoles.roles || practitionerWithRoles.roles.length === 0 ) { return false; } const activeRoles = practitionerWithRoles.roles.filter( (role: PractitionerRole) => role.active !== false, ); if (activeRoles.length === 0) { return false; } // - THIS USED TO READ `getCognitoGroup(role)`, i.e. `role.meta.tag`, AND THE PATIENT // PROJECTION STRIPS `meta`. So for a patient caller the group was ALWAYS undefined, every // practitioner failed, `availablePractitioners` was empty, and the Providers tab read "(0)" at // every practice - **a patient could not start a conversation with anyone.** // Both halves were individually right: stripping a Cognito group from a patient's payload is // correct (/228), and classifying by it is correct for a STAFF token. The SEAM was the bug. // The fix is never to put the tag back in the patient projection - it reads the role's `code` // codings, which the patient already receives. See `utils/patientMessagingDirectory.ts`. // // ยง5 is preserved: a RECEPTIONIST is not individually messageable - a patient writing // to the office reaches the front desk as a ROLE, not the one person who happens to be listed. // Someone holding BOTH roles keeps their listing: the test is whether they hold a patient-facing // role, not whether they lack a receptionist one. return isPractitionerMessageableByPatient(practitionerWithRoles); }, [], ); /** * Practices with at least one active receptionist. * * DERIVED, never authored. Offering "Front desk" at a practice that has no receptionist is a * door onto nobody: the pool resolves its members BY ROLE at read time, so with none the message * would sit unread with no one it could notify. Same rule the patient home's door list follows - * the label is written by us, its visibility is read from the data. */ // - same defect, second instance: this required a `Receptionists` TAG, which a patient // never receives, so the set was ALWAYS empty and "Front desk" could never render for a patient. The // whole front-desk pool had no patient-side door. const orgIdsWithFrontDesk = React.useMemo(() => { const ids = new Set( organizationIdsWithFrontDesk(practiceStaff, (role) => { const organizationId = extractReferenceId(role.organization) as string | undefined; return organizationId || role.organization?.display?.trim(); }), ); // A practice whose desk has already written to this patient has a desk, whether or not a // receptionist is visible on the roster she can read. for (const request of patientCommunicationRequests ?? []) { if (getThreadPoolKey(request) !== FRONT_DESK_POOL_KEY) continue; const organizationId = getThreadOrganizationId(request); if (organizationId) ids.add(organizationId); } return ids; }, [practiceStaff, extractReferenceId, patientCommunicationRequests]); const availablePractitioners = React.useMemo(() => { return practiceStaff.filter((practitionerWithRoles) => { const practitionerId = practitionerWithRoles.practitioner.id as string; if (!practitionerId) return false; return isPractitionerEligibleForPatientMessaging(practitionerWithRoles); }); }, [practiceStaff, isPractitionerEligibleForPatientMessaging]); // ONE PRACTICE PER GROUP, WHETHER OR NOT ANYBODY THERE CAN BE MESSAGED, and the assembly lives in // `utils/patientProviderGroups` rather than here. It was a memo in this file, and the only tests that // could reach it read this file's SOURCE TEXT - which stayed green throughout the defect it now // fixes, because a source read cannot tell a rule that works from a rule that is written down. const filteredOrganizationGroups = React.useMemo( () => buildPatientProviderGroups({ organizations: myOrganizations, allPractitioners: availablePractitioners, currentOrganization, searchTerm, organizationIdOf: (reference) => extractReferenceId(reference) as string | undefined, }), [ availablePractitioners, currentOrganization, extractReferenceId, myOrganizations, searchTerm, ], ); const inboxRequests = React.useMemo(() => { return dedupedPatientCommunicationRequests // Card 356 โ€” the SAME predicate the staff inbox uses, not a second copy of the same sentence. // Two copies is exactly why the front desk was missing from both inboxes at once, and why fixing // either one alone would have read as done. .filter((request) => threadBelongsInInbox(request, getMessagesForRequest(request).length)) .sort((a, b) => { const aUnread = getUnreadCountForRequest(a.id as string) > 0 ? 1 : 0; const bUnread = getUnreadCountForRequest(b.id as string) > 0 ? 1 : 0; return bUnread - aUnread; }); }, [dedupedPatientCommunicationRequests, getMessagesForRequest, getUnreadCountForRequest]); const filteredInboxRequests = React.useMemo(() => { if (!searchTerm.trim()) return inboxRequests; return inboxRequests.filter((request) => { const counterpartyName = getCounterpartyDisplay(request).toLowerCase(); return counterpartyName.includes(searchTerm.toLowerCase()); }); }, [inboxRequests, searchTerm, getCounterpartyDisplay]); /** * A PRACTICE'S LOGO, BY ITS ID, for the front-desk rows in the inbox. * * Built from `myOrganizations` and NOT from `filteredOrganizationGroups`, which narrows as somebody * types: an avatar that disappears mid-search would look like the row losing its practice rather * than like a search doing its job. */ const organizationLogoById = React.useMemo(() => { const logos = new Map>>(); for (const organization of myOrganizations ?? []) { const logo = organizationLogoOf(organization); if (organization.id && logo) logos.set(organization.id, logo); } return logos; }, [myOrganizations]); /** * WHICH TEAMS BELONG ON THE MESSAGES PAGE, AND IN WHAT ORDER. * * Spencer, 2026-09-11: *"Under the teams tab I don't want another tab for open/closed. Just * show the active first, dormant second and don't even show closed here since you can't message a * closed team."* * * A DISSOLVED TEAM IS NOT A THING YOU CAN WRITE TO, so on the page whose whole purpose is * writing to people it is not an option that needs refusing - it is not an option. Offering it and * then declining is the shape this portal keeps having to remove. * * DORMANT STAYS AND IS SECOND. A team goes dormant after ninety days of silence, and a message * is exactly the thing that wakes it - so it is still a destination, just not the likely one. * Sorting rather than filtering is what keeps that true. * * THIS IS THE MESSAGES PAGE ONLY. The Collaboration Teams page still lists every team including * the dissolved ones, because that page is where a patient looks at the record of their teams * rather than picks one to talk to. Do not 'tidy' the two into one filter. */ const filteredTeams = React.useMemo(() => { const RANK: Record = { active: 0, dormant: 1 }; // Ranked on the displayed status: a team quiet past the dormancy window reads dormant everywhere. const now = Date.now(); const bucket = [...openTeams].sort( (a, b) => (RANK[teamDisplayStatus(a, now)] ?? 2) - (RANK[teamDisplayStatus(b, now)] ?? 2), ); if (!searchTerm.trim()) return bucket; const normalizedSearch = searchTerm.toLowerCase(); return bucket.filter((team) => team.name.toLowerCase().includes(normalizedSearch)); }, [openTeams, searchTerm]); /** * THE INBOX IS EVERY CONVERSATION, GROUPS INCLUDED. * * Spencer, 2026-09-10: *"We need to model our collab team messages the way facebook does their * group messages. New messages display in the 'all' list which would be our 'Inbox'."* * * THE TABS ARE FILTERS OVER ONE LIST, NOT THREE SEPARATE LISTS. That is the whole of the * borrowed model: All holds everything, and Groups narrows it. A team thread that only appeared * under its own tab meant a patient with an unread team message saw nothing new in the place they * look for new messages - the inbox - which is the same defect as burying it. * * Sorted by last activity across both kinds, because a list ordered by recency inside each kind and * grouped by kind is not ordered by recency at all. */ const inboxEntries = React.useMemo(() => { type Entry = | { kind: 'thread'; at: number; request: CommunicationRequest } | { kind: 'team'; at: number; team: CollaborationTeam & { members: TeamMembership[] } }; const entries: Entry[] = filteredInboxRequests.map((request) => ({ kind: 'thread', at: threadLastActivity(getMessagesForRequest(request)), request, })); for (const team of openTeams) { const summary = teamSummaries.get(team.teamId); // A team nobody has written in yet has no place in a list of conversations. It is still on the // Teams tab, which lists teams rather than messages. if (!summary?.timestamp) continue; if (searchTerm.trim() && !team.name?.toLowerCase().includes(searchTerm.toLowerCase())) continue; entries.push({ kind: 'team', at: summary.timestamp, team }); } return entries.sort((a, b) => b.at - a.at); }, [filteredInboxRequests, openTeams, teamSummaries, searchTerm, getMessagesForRequest]); /** * Is this patient already in a conversation with this practitioner? * * Lifted out of `handlePractitionerClick`, which has always computed it, because the ROW now needs the * same answer: a practitioner who declines new threads is still fully clickable when one already exists. * Two copies of this test would eventually disagree, and the way it would show is a patient looking at a * greyed-out row for a conversation they are already having. */ const existingThreadWith = React.useCallback( (practitionerId: string | undefined) => practitionerId ? dedupedPatientCommunicationRequests.find( (request) => (getCounterpartyId(request) as string | undefined) === practitionerId, ) : undefined, [dedupedPatientCommunicationRequests, getCounterpartyId], ); const handlePractitionerClick = async ( practitionerWithRoles: PractitionerWithRoles, ) => { setChatLoading(true); setError(undefined); try { const existingRequest = existingThreadWith( practitionerWithRoles.practitioner.id as string | undefined, ); if (existingRequest) { setPractitioner(practitionerWithRoles.practitioner); setConversationContextFromPractitioner(practitionerWithRoles); if (existingRequest.id) { setActiveThread(existingRequest.id); } setSelectedCommunicationRequest(existingRequest); setShowConversationMobile(true); } else { const organizationId = getPractitionerOrganizationId( practitionerWithRoles, ); if (!organizationId) { throw new Error("Practitioner organization context is missing"); } // (act-as): the thread belongs to the PATIENT being messaged about - the subject the // caregiver switched into - not the login identity. `messagingIdentity` is that displayed patient // (the act-as subject, or self otherwise), the same value CommunicationService scopes threads by, so // the new thread lands in the list the user is actually looking at instead of silently vanishing. // Message-level sender attribution stays on `currentUser.fhir` (in CommunicationService) - "in // coordination with" the patient, not impersonation. const subjectPatientId = messagingIdentity?.id; if (!subjectPatientId) { throw new Error("Patient context is missing"); } const thread = await openDirectThread( subjectPatientId, practitionerWithRoles.practitioner.id as string, "practitioner", organizationId, ); setPractitioner(practitionerWithRoles.practitioner); setConversationContextFromPractitioner(practitionerWithRoles); if (thread.threadId) { setActiveThread(thread.threadId); } setSelectedCommunicationRequest(thread.request); setShowConversationMobile(true); setActiveTab("inbox"); } } catch (openError: unknown) { // Retired with the practice or person it was opened for: nothing is selected, said or offered again. if (isSaveRetired(openError)) return; console.error("Error opening practitioner chat:", openError); // A REFUSAL IS NOT A FAILURE, AND "PLEASE TRY AGAIN" IS ADVICE THAT CANNOT WORK. // // Measured on the local mount: a related person switched into the patient they help meets // two different 403s here, and both used to read "Failed to open chat. Please try again." // - the authority tier refusing the write, when the patient has not allowed that helper to // message providers; // - "You are not a participant in this conversation", refusing the read of the thread that // was just created for them. // Neither moves on a retry. `describeWriteFailure` relays the server's own sentence and // drops the retry advice for a permanent denial, which is what it was written for. const openMessage = describeBlockedAction(openError, "Failed to open chat"); setError(openMessage); toast.error(openMessage, { position: "top-right", }); } finally { setChatLoading(false); } }; /** * - open the practice's FRONT DESK thread. * * No practitioner is passed to the conversation context, and that is deliberate rather than an * omission: a pool thread has no owner, and naming whichever receptionist happens to answer would * tell the patient they are writing to a person. The practice's own name is the correct heading. */ // One practice with a desk: open its desk thread (the server reuses an existing one). Several: the // patient chooses on Practices, because guessing would write to the wrong practice. const openFrontDeskDoor = () => { const deskPractices = (myOrganizations ?? []).filter( (organization) => !!organization.id && orgIdsWithFrontDesk.has(organization.id), ); if (deskPractices.length === 1) { void handleFrontDeskClick(deskPractices[0].id as string, deskPractices[0].name); return; } setActiveTab("providers"); }; const handleFrontDeskClick = async (organizationId: string, organizationName?: string) => { setChatLoading(true); try { const subjectPatientId = messagingIdentity?.id; if (!subjectPatientId) { throw new Error("Patient context is missing"); } const thread = await openFrontDeskThread(subjectPatientId, organizationId); // `null`, not `undefined`: the state is `Practitioner | null`, and a pool thread genuinely has // no practitioner to show. setPractitioner(null); setConversationContextFromPractitioner(null); setConversationOrganizationName(organizationName); setConversationRoleLabel(poolThreadLabel(FRONT_DESK_POOL_KEY)); if (thread.threadId) { setActiveThread(thread.threadId); } setSelectedCommunicationRequest(thread.request); setShowConversationMobile(true); setActiveTab("inbox"); } catch (openError: unknown) { if (isSaveRetired(openError)) return; console.error("Error opening front desk chat:", openError); // Same reasoning as the practitioner thread above. The pool thread is reached by the same // helper acting for the same patient, so it meets the same refusals. const openMessage = describeBlockedAction(openError, "Failed to open chat"); setError(openMessage); toast.error(openMessage, { position: "top-right" }); } finally { setChatLoading(false); } }; const openCommunicationRequest = React.useCallback(async ( communicationRequest: CommunicationRequest, ) => { // Defensive: this can be triggered by the counterpartyId search-param effect below independent of // the active tab, so make sure a lingering group-chat selection never hides the direct thread it opens. setSelectedTeam(null); setSelectedCommunicationRequest(communicationRequest); setShowConversationMobile(true); setChatLoading(true); try { if (communicationRequest.id) { setActiveThread(communicationRequest.id); } // - the office branch comes FIRST. Below this, "no practitioner reference" is treated // as a broken thread and throws; a pool thread legitimately has none, so opening one from the inbox // fell into the catch, left `practitioner` at whatever the last conversation set, and the pane // either showed nothing or showed the WRONG conversation's header. if (isPoolThreadRequest(communicationRequest)) { setPractitioner(null); setConversationContextFromPractitioner(null); setConversationOrganizationName(getThreadOrganizationName(communicationRequest)); setConversationRoleLabel(poolThreadLabel(getThreadPoolKey(communicationRequest))); return; } // AND THE ROOM BRANCH SITS BESIDE IT, for the reason the pool branch records: below this, "no // practitioner reference" is treated as a broken thread and throws. A visit's thread legitimately // has none, so opening one from the inbox fell into the catch - and because the row had already // been marked read on the way in, the patient was left with no unread badge and nothing on screen. if (isEncounterRoomThreadRequest(communicationRequest)) { setPractitioner(null); setConversationContextFromPractitioner(null); setConversationOrganizationName(getThreadOrganizationName(communicationRequest)); setConversationRoleLabel(ROOM_THREAD_DESCRIPTION); return; } const counterparty = getCounterpartyReference(communicationRequest); const resourceType = counterparty?.reference?.split("/")[0]; const id = extractReferenceId(counterparty) as string | undefined; if (!id || resourceType !== "Practitioner") { throw new Error("Unable to resolve practitioner for selected chat"); } const practitionerWithRoles: PractitionerWithRoles = await fetchPractitioner(id); setPractitioner(practitionerWithRoles.practitioner); setConversationContextFromPractitioner(practitionerWithRoles); } catch (chatError: unknown) { console.error("Error opening chat:", chatError); } finally { setChatLoading(false); } }, [ extractReferenceId, fetchPractitioner, getCounterpartyReference, getThreadOrganizationName, setActiveThread, setConversationContextFromPractitioner, ]); const clearError = () => { clearCommunicationError(); setError(undefined); }; // - `SyntheticEvent` so a key press can open a thread as well as a click. Both of the calls // below (`preventDefault`, `stopPropagation`) exist on every synthetic event, so widening costs nothing. const chat = async ( event: React.SyntheticEvent, communicationRequest: CommunicationRequest, ) => { event.preventDefault(); event.stopPropagation(); await openCommunicationRequest(communicationRequest); }; const handleSendMessage = async ( message: string, attachment?: Attachment, references?: Reference[], send?: SendOperation, ) => { // - THIS GUARD USED TO THROW A PATIENT'S MESSAGE AWAY AND REPORT SUCCESS. // It required a named `practitioner`, and a POOL thread has none by design. So a patient wrote to the // front desk, the composer cleared, the message appeared under a TODAY divider with a green double // tick - and nothing was written. Measured in a browser: the thread held ZERO messages server-side // while an ordinary thread the same minute held three. const hasContent = !!message.trim() || !!attachment || (references?.length ?? 0) > 0; // Kept as its own line so the compiler narrows the thread for the rest of the function; the decision // that actually changed lives in `shouldSendOnThread` below. // A message with content that cannot be sent yet must fail loudly: returning here told the composer // it was sent, and the words were discarded. if (!selectedCommunicationRequest) { if (hasContent) throw new Error(NOT_READY_TO_SEND); return; } if (!shouldSendOnThread(hasContent, selectedCommunicationRequest, practitioner)) { if (hasContent) throw new Error(NOT_READY_TO_SEND); return; } // The patient the message is about: the act-as subject while switched in, never the helper's own // record. The server anchors the message to the Patient it names and checks the helper's link and // grant against that patient, so the helper's own id is refused as "not linked". Until the subject's // record has loaded there is nobody to write as. const self = (actingAs ? messagingIdentity : currentUser?.fhir) as Patient | undefined; if (!self) { if (hasContent) throw new Error(NOT_READY_TO_SEND); return; } try { // On a pool thread this is the PATIENT'S OWN record, not the practitioner and not nothing. // The office is reached by the pool key (the server's front-desk notice), while the recipient list // carries the patient - the shape `Communication/post.ts` already documents and requires, since it // 400s any non-collab message with an empty recipient list. // And never the practitioner: `practitioner` can be STALE from a provider chat the patient had // open a moment ago, so a pooled message would be addressed to one named clinician. const recipient = recipientForThread( selectedCommunicationRequest, practitioner, self, ); const recipientReferences: Reference[] = recipient ? [ createReferenceObject( recipient.resourceType, recipient.id as string, formatFhirHumanName(recipient.name), ), ] : []; const transformedCommunication: Communication = createCommunicationForThread( message, selectedCommunicationRequest.id as string, self, recipientReferences, attachment, references, ); // Who really wrote it is not said here: the server stamps the helper from their own act-as identity // and drops any copy a client sends, so the sender stays the patient, display and all. // THE SENDER'S OWN MESSAGE, PUT ON SCREEN FROM THE WRITE RATHER THAN FROM A RE-READ. // // MEASURED at the front desk on the pooled Patient One thread. The reply returned `201` carrying the // full resource with the same `thread-id` the client then searched on - and that search came back with // six messages, none of them the one just written. Re-opening a minute later showed it, in place, with // `Sent` beside it. So it is a RACE, not a filter: HealthLake's search index is eventually consistent, // and a read issued immediately after a write frequently does not contain it. Re-reading harder is the // same coin tossed again. // // What the reader saw was worse than a missing line: the pooled thread also renders "You haven't // replied to this message yet" with a Dismiss beside it, directly above the composer that had just // sent a reply - so a desk under pressure answers twice, in a thread several colleagues are watching. // // `addCommunicationToState` already existed and already does this: it derives the thread from the // message, is idempotent by id so the live channel and the next open cannot double it, and lifts the // conversation to the top of the list. Only the broadcast hook was calling it. addCommunicationToState(await createCommunication(namedSend(transformedCommunication, send), send)); } catch (sendError: unknown) { console.error("Error sending message:", sendError); // RETHROW, and this half is not optional - shipping the send without it is worse than shipping // neither. `Chat.tsx` rolls its optimistic message back and toasts ON A THROW; swallowing here is // exactly how a discarded message came to render as delivered. A silent failure that LOOKS sent is // the defect this card exists for, and it would survive a fix that only made the happy path work. throw sendError; } }; const handleUpdateMessages = async (updatedMessages: Communication[], transport?: SaveTransport): Promise => { try { return await saveCommunicationBundle(updatedMessages, transport); } catch (updateError: unknown) { console.error("Failed to update message receipt status:", updateError); return []; } }; const handleCloseChat = () => { setActiveThread(null); setPractitioner(null); setSelectedCommunicationRequest(null); setConversationOrganizationName(undefined); setConversationRoleLabel(undefined); }; /** * WHO A TEAM MEMBER IS, TO THIS PATIENT - a name, what they are to them, and a face if there is one. * * THE SUBTITLE ANSWERS A DIFFERENT QUESTION FOR EACH KIND OF MEMBER, because "who is this" has a * different answer for each: a practitioner is their practice, a related person is their relationship, * and an independent practitioner belongs to no practice at all - which IS the answer, not a gap. * * Spencer, 2026-09-10: *"maybe we call them 'independent practitioners' instead of orgless or * external, seems like a little less harsh that way."* The internal words for that member class stay * as they are; this is what a patient reads. * * NOTHING HERE IS INVENTED. A photograph and a practice can only be resolved for somebody this * patient is allowed to read, so an unresolved one is simply absent and the row says less rather than * guessing. That is not an edge case: an independent practitioner is by definition outside every * organization the patient belongs to, so it is the normal path for exactly the member a patient most * needs to recognise. */ const describeTeamMember = React.useCallback((member: TeamMembership) => { const name = teamRosterNames[member.memberRef] ?? member.displayName; if (member.memberClass === "external-practitioner") { return { name, subtitle: "Independent practitioner" }; } if (member.memberClass === "related-person") { const related = relatedPersonsList?.find( (candidate) => `RelatedPerson/${candidate.id}` === member.memberRef, ); const relationship = related?.relationship?.[0]?.text?.trim() || related?.relationship?.[0]?.coding?.[0]?.display?.trim(); return { name, // The relationship if the record carries one; otherwise what they are on this team, which is // still true. Never a bare "Related person", which tells the patient nothing they did not know. subtitle: relationship ?? member.role ?? undefined, }; } const practitionerId = member.memberRef.split("/")[1]; const withRoles = practitionerId ? getPractitionerWithRolesById(practitionerId) : undefined; const practice = withRoles?.roles ?.filter((role) => role.active !== false) .map((role) => role.organization?.display?.trim()) .find(Boolean); return { name, subtitle: practice ?? member.role ?? undefined, avatarUrl: withRoles?.practitioner ? getPractitionerAvatarUrl(withRoles.practitioner) : undefined, }; }, [teamRosterNames, relatedPersonsList, getPractitionerWithRolesById]); const handleTeamClick = (team: CollaborationTeam & { members: TeamMembership[] }) => { setSelectedTeam(team); setPrivatePeer(null); setShowConversationMobile(true); }; const handleTeamSend = async (text: string, attachment?: Attachment, send?: SendOperation) => { if (!selectedTeam || (!text.trim() && !attachment)) return; const self = currentUser?.fhir as Patient; // Patient or RelatedPerson resource const built = createCommunicationForThread( text, privateThreadId ?? buildCollabTeamThreadId(selectedTeam.teamId), self, [], attachment, ); built.sender = { reference: `${self.resourceType}/${self.id}`, display: formatFhirHumanName(self.name), }; // Thrown on to the chat, which keeps the typed message and marks it failed; swallowing it here // left a refused send looking delivered. const saved = await createCommunication(namedSend(built, send), send); appendOptimistic(saved); }; // `?desk=1` is the home's "Message the front desk": open the desk, once, after the practices and their // staff have loaded, so one practice with a desk is not mistaken for none while they are still arriving. const deskRequestHandled = React.useRef(false); useEffect(() => { if (deskRequestHandled.current || searchParams.get("desk") !== "1") return; if (myOrganizationsLoading || practitionersLoading) return; deskRequestHandled.current = true; openFrontDeskDoor(); }); useEffect(() => { const counterpartyId = searchParams.get("counterpartyId"); if (!counterpartyId || dedupedPatientCommunicationRequests.length === 0) { return; } const matchingRequest = dedupedPatientCommunicationRequests.find((request) => { return getCounterpartyId(request) === counterpartyId; }); if (!matchingRequest) { return; } if (selectedCommunicationRequest?.id === matchingRequest.id && practitioner) { return; } void openCommunicationRequest(matchingRequest); }, [ dedupedPatientCommunicationRequests, getCounterpartyId, openCommunicationRequest, practitioner, searchParams, selectedCommunicationRequest?.id, ]); /** * - ARRIVING FROM A NAMED PRACTICE. `/dashboard/messages?organizationId=` opens the * Providers tab with that practice already expanded, so a patient who pressed *Send a message* on My * Providers does not have to remember which row they were on and find it again in a list. * * THE CONTROL IS WHY THIS IS A URL PARAMETER RATHER THAN STORED STATE, and it is the half with teeth. * The card's requirement is that arriving at Messages ANY OTHER WAY must not pre-select anything - a * pre-selection that persisted would let a patient compose to one practice and send to another, and * they would not know, because the compose screen would look right. * A parameter of the navigation cannot leak into a navigation that does not carry it. There is no * stored "selected practice" to go stale, so the wrong-practice case is unreachable by construction * rather than prevented by a reset somebody has to remember to write. * * `groupId` in `filteredOrganizationGroups` IS the organisation id whenever the role carries one, so * the parameter keys the expansion map directly - no second lookup that could disagree with it. * * APPLIED ONCE PER VALUE, via the ref. Without it the effect re-expands on every render that touches * the groups, so a patient who collapses the practice watches it spring open again and cannot close it. */ const appliedOrganizationParam = useRef(undefined); useEffect(() => { const organizationId = searchParams.get("organizationId"); if (!organizationId || appliedOrganizationParam.current === organizationId) return; // DO NOT DECIDE WHILE THE PRACTICES ARE STILL ARRIVING, and this is not the same guard as the one it // replaces. The old one waited for the list to be non-empty and then latched, which was correct // while every group came from one source. Groups now come from two - the patient's practices and the // practitioners they can message - and they resolve at different moments. So the list goes non-empty // holding only the practice whose practitioners loaded first, the ref latches on that render, and the // practice the patient actually asked for is added a moment later with nothing left to open it. // // Measured in a browser: the group appeared, named and correct, and stayed shut. if (myOrganizationsLoading) return; appliedOrganizationParam.current = organizationId; setActiveTab("providers"); // Only expand a group that EXISTS. A practice the patient does not belong to has no group here, and // writing its key would leave them on Providers with nothing opened and no reason given - the tab's // own empty state is the honest answer to that. if (filteredOrganizationGroups.some((group) => group.id === organizationId)) { setExpandedOrganizations((previous) => ({ ...previous, [organizationId]: true })); } }, [searchParams, filteredOrganizationGroups, myOrganizationsLoading]); /** * ARRIVING FROM A TEAM'S OWN PAGE. `/dashboard/messages?teamId=` opens the Collaboration * Teams tab with that team's conversation already open. * * A TEAM PAGE THAT CANNOT REACH ITS OWN CONVERSATION IS THE DEFECT THIS CLOSES. The team's row * in the list showed an unread count and a preview; opening the team took you to a page with * members, invites and a way to dissolve it, and no route at all to the thread that count was * about. The only way through was back out to the list and across to Messages. * * A URL PARAMETER RATHER THAN STORED STATE, for the reason spelled out on the `organizationId` * effect above: a pre-selection that persisted would let somebody compose to one team and send * to another, with a compose screen that looked right either way. A parameter of the navigation * cannot leak into a navigation that does not carry it. * * AND NOT WHILE THE TEAMS ARE STILL ARRIVING. Latching on an empty list would leave the tab open * on nothing, with the team the patient actually asked for landing a moment later and no longer * anything to open it. * * THE TAB ONCE, THE TEAM WHEN IT ARRIVES. A settled list can still predate the team (one just * created), so the tab is switched once per team asked for and the team is opened when a list * carries it. Leaving the tab or opening another team first gives up the wait, and an address * without a team forgets both, so asking again opens it again. */ const tabAppliedFor = useRef(undefined); const selectionAppliedFor = useRef(undefined); useEffect(() => { const teamId = searchParams.get("teamId"); if (!teamId) { tabAppliedFor.current = undefined; selectionAppliedFor.current = undefined; return; } if (teamsLoading) return; if (tabAppliedFor.current !== teamId) { tabAppliedFor.current = teamId; selectionAppliedFor.current = undefined; setActiveTab("collaboration_teams"); } if (selectionAppliedFor.current === teamId) return; // Only open a team that EXISTS. A team the patient is not on has no row here, and selecting // nothing leaves them on the tab with its own empty state, which is the honest answer. const team = teams.find((candidate) => candidate.teamId === teamId); if (team) { selectionAppliedFor.current = teamId; setSelectedTeam(team); setShowConversationMobile(true); } }, [searchParams, teams, teamsLoading]); // The patient's own move while the asked-for team is still on its way - to another tab, or into // another team - ends the wait for it, tab and team both, even before the first list has arrived. const tabBefore = useRef(activeTab); const teamBefore = useRef(selectedTeam?.teamId); useEffect(() => { const movedTab = activeTab !== tabBefore.current && activeTab !== "collaboration_teams"; const openedTeam = !!selectedTeam && selectedTeam.teamId !== teamBefore.current; tabBefore.current = activeTab; teamBefore.current = selectedTeam?.teamId; const asked = searchParams.get("teamId"); if (!asked || selectionAppliedFor.current === asked) return; if (movedTab || (openedTeam && selectedTeam?.teamId !== asked)) { tabAppliedFor.current = asked; selectionAppliedFor.current = asked; } }, [activeTab, selectedTeam, searchParams]); useEffect(() => { return () => { setActiveThread(null); }; }, [setActiveThread]); const currentChatMessages = selectedCommunicationRequest ? getMessagesForRequest(selectedCommunicationRequest) : []; /** * - a read receipt carries a REFERENCE; a patient must be shown a NAME. * * Built from the receipts actually present on this thread rather than from the whole roster, so the * map is exactly as large as the question being asked. * A reference that does not resolve is simply ABSENT from the map, never mapped to a placeholder. * The status line then falls back to the practice name - true, and the fact the patient wanted - * where *"Viewed by Unknown"* would read as though the practice cannot name its own staff. */ const readerNameByReference = React.useMemo(() => { const map: Record = {}; for (const message of currentChatMessages) { for (const extension of message.extension ?? []) { const reference = extension.extension?.find( (child) => child.url === "recipient", )?.valueReference?.reference; if (!reference?.startsWith("Practitioner/")) continue; const name = getPractitionerNameFromRoster(reference.split("/")[1]); if (name) map[reference] = name; } } return map; }, [currentChatMessages, getPractitionerNameFromRoster]); // - read off the SELECTED THREAD, never off a flag this page sets when the office is // opened. Every route in (the provider picker, an inbox row, a deep link) then behaves identically, // and a route added later cannot forget to say what kind of conversation it just opened. const selectedThreadPoolKey = selectedCommunicationRequest ? getThreadPoolKey(selectedCommunicationRequest) : undefined; // Read off the selected thread for the same reason as the line above it, and the pane needs telling // separately from the open path: opening one now works, and the pane still refused to draw it. const selectedThreadIsRoom = isEncounterRoomThreadRequest( selectedCommunicationRequest ?? undefined, ); // Read from the practice's staff roles the patient already receives; unknown stays unsaid. const poolAnswererCount = React.useMemo(() => { if (selectedThreadPoolKey !== FRONT_DESK_POOL_KEY) return undefined; const organizationId = getThreadOrganizationId(selectedCommunicationRequest ?? undefined); if (!organizationId) return undefined; return frontDeskAnswererCount( practitionersList, organizationId, (role) => extractReferenceId(role.organization) as string | undefined, ); }, [selectedThreadPoolKey, selectedCommunicationRequest, practitionersList, extractReferenceId]); // Hooks must run unconditionally - pass null when no team is selected/active rather than skipping // the call. The hook itself no-ops (empty messages) when its id argument is null. // A TEAM IS ACTIVE BECAUSE ONE IS SELECTED, not because of which tab opened it. This also required // activeTab === "collaboration_teams", which was true while a team could only be reached from its // own tab. Teams now appear in the Inbox too, and opening one from there left this null: the pane // read "No messages yet" over a thread whose own row was showing a preview and three unread. The // tab is where you FOUND it; the selection is what you are reading. const activeTeamId = selectedTeam?.teamId ?? null; const selectedTeamRefusal = teams.find((team) => team.teamId === activeTeamId)?.messagingRefusal; // Closed as the inbox knows it now, not as it was when the thread was opened. const selectedTeamClosed = currentTeam?.status === "dissolved"; // A private 1:1 with one member, opened from the team's Conversation details. The patient who owns the team // offers it, as themselves; the server decides who may read and send. const selfFhir = currentUser?.fhir as Patient | undefined; const selfReference = selfFhir?.resourceType && selfFhir.id ? `${selfFhir.resourceType}/${selfFhir.id}` : null; const ownsSelectedTeam = !!selectedTeam && selfReference === `Patient/${selectedTeam.patientId}`; // A private conversation belongs to one team; any other way of changing team leaves it. useEffect(() => { setPrivatePeer(null); }, [activeTeamId]); const privateThreadId = privatePeer && selectedTeam && ownsSelectedTeam && selfReference ? buildCollabTeamPrivateThreadId(selectedTeam.teamId, selfReference, privatePeer.memberRef) : null; const { messages: teamMessages, appendOptimistic, error: teamLoadError, canRetry: teamCanRetry, refetch: refetchTeam, } = useCollaborationTeamThreadMessages(activeTeamId, 'public', privateThreadId); const renderInboxList = () => { // A REFUSAL IS NOT AN EMPTY INBOX, AND A PATIENT MUST NOT BE TOLD IT IS. Measured on the // org-less portal: every message read was refused and this said "No messages yet", so // somebody the practice had written to was told nothing was sent. The staff panel already // says this; the patient is the surface it never reached. // Team conversations count too: a patient whose only messages are in a team is not empty. // A refusal is settled: retrying it is refused again, so it says no access and offers nothing to retry. Why is // named only from the grant on the helper's own link, never assumed from the refusal. if (inboxEntries.length === 0 && listingRank === 'refused') { return (
); } if (inboxEntries.length === 0 && listingRank === 'pending') { return (
); } if (inboxEntries.length === 0 && listingRank === 'unknown') { return (
); } if (inboxEntries.length === 0 && listingRank === 'failed') { return (
Try again} data-testid="patient-inbox-unavailable" />
); } if (inboxEntries.length === 0 && listingRank === 'partial') { return (
Try again} data-testid="patient-inbox-partial" />
); } if (inboxEntries.length === 0) { // A practice with no receptionist has no front desk to promise: point at the practice // instead. Only once staff have loaded, since an empty list while loading is not "none". const noFrontDesk = practiceStaff.length > 0 && orgIdsWithFrontDesk.size === 0; return ( /* * An empty inbox is a patient's normal first state, not a failure, so it says what the door * is for and offers the one action rather than describing where to go and stopping. * * The action lands on Providers rather than opening a thread, because that is where a * practice is chosen. A patient can have several, and guessing which front desk they meant * would start a conversation with the wrong practice - which they would not notice, because * the composer looks the same either way. * * A SEARCH that found nothing is a different fact and keeps its own words: there, the list is * empty because of what was typed, and an invitation to message the front desk would be a * non-sequitur. */
setActiveTab("providers")}> {noFrontDesk ? "Message your practice" : "Message the front desk"} )} data-testid="patient-inbox-empty" />
); } // THE DOOR STAYS once there are conversations: it used to live only in the empty state, so a patient // with any thread had no way to start one with the desk. const noFrontDeskAnywhere = practiceStaff.length > 0 && orgIdsWithFrontDesk.size === 0; const rowsNotice = rowsNoticeFor(listingRank); return (
{rowsNotice && ( )} {!searchTerm && (
)} {inboxEntries.map((entry) => { if (entry.kind === 'team') { const team = entry.team; const summary = teamSummaries.get(team.teamId); // A GROUP GETS THE GROUP'S OWN MARK, never one member's face, and its second line is // who is in it rather than a practice: a team spans practices and can hold related // persons who belong to none. const activeMembers = team.members.filter((member) => member.state === "active"); const named = activeMembers .map((member) => describeTeamMember(member).name) .filter((name): name is string => !!name && !!name.trim()); const unnamed = activeMembers.length - named.length; const roster = [ ...named, ...(unnamed > 0 ? [`${unnamed} unnamed`] : []), "you", ].join(" ยท "); return ( handleTeamClick(team)} selected={selectedTeam?.teamId === team.teamId} avatar={} name={team.name} sub={roster} preview={summary?.preview} time={formatRelativeTime(entry.at, Date.now()) || '--'} // The open thread is read by definition: a team's unread comes from read receipts, // which land only after the messages have been seen, so the badge would otherwise sit // on the very row being read. unread={selectedTeam?.teamId !== team.teamId ? summary?.unread ?? 0 : 0} /> ); } const communicationRequest = entry.request; const unreadCount = getUnreadCountForRequest( communicationRequest.id as string, ); const counterpartyPractitioner = getPractitionerWithRolesById( getCounterpartyId(communicationRequest), )?.practitioner; const isPoolRow = isPoolThreadRequest(communicationRequest); const isRoomRow = isEncounterRoomThreadRequest(communicationRequest); // Named against the same "me" the counterparty logic uses, so the preview and the thread // pane can never disagree about who sent a message. const preview = threadPreview( getMessagesForRequest(communicationRequest), currentUserReference ?? null, { othersUnnamed: isPoolRow }, ); const counterparty = getCounterpartyReference(communicationRequest); const counterpartyId = extractReferenceId(counterparty) as string | undefined; const displayName = getCounterpartyDisplay(communicationRequest); const roleLabel = getCounterpartyLabel(communicationRequest); const threadOrganizationName = isPoolRow || isRoomRow ? getThreadOrganizationName(communicationRequest) : getPractitionerOrganizationNameById(counterpartyId); // The practice on every row, so a patient can tell which practice each person is with. A // person's row leads with what they do there; the front desk and a visit are the practice's // own, so the practice alone says it, and the description stands in when it is unknown. const sub = isPoolRow || isRoomRow ? threadOrganizationName || roleLabel : [roleLabel, threadOrganizationName].filter(Boolean).join(" ยท "); const logo = isPoolRow ? organizationLogoById.get(getThreadOrganizationId(communicationRequest) ?? "") : undefined; const photo = counterpartyPractitioner?.photo?.[0]; const photoUrl = !isPoolRow && !isRoomRow ? scannedPhotoSrc(photo) : undefined; const presenceRef = counterparty?.reference; return ( void chat(event, communicationRequest)} // Keyed on the selected thread, not on there being a practitioner: a pool thread has none. selected={selectedCommunicationRequest?.id === communicationRequest.id} avatar={ : undefined} /> } // No presence on the office or a visit: "online" is a fact about a person. online={!isPoolRow && !isRoomRow && !!presenceRef && isOnline(presenceRef)} name={displayName} sub={sub} preview={preview} // The last message's time, not the conversation's `authoredOn`, and relative, so an // old thread never shows a time of day that reads as later than now. time={formatRelativeTime( threadLastActivity(getMessagesForRequest(communicationRequest)), Date.now(), ) || "--"} unread={unreadCount} /> ); })}
); }; const renderCollaborationTeamsList = () => { return (
{/* THE SETTLED SEGMENTED CONTROL, not a pair of hand-styled pills. This was two buttons carrying their own padding, their own 6px corner and their own selected colour - a ninth button against a standard of eight, and one that missed the patient mount's 44px touch floor because the floor is attached to `.btn` and these were not buttons by that name. `.btn-segmented` joins them, and `aria-pressed` is what paints the active one, so the selected state is announced to a screen reader instead of only being a colour. */} {teamsLoading ? (
{Array.from({ length: 4 }).map((_, i) => (
))}
) : teamsError ? (

{teamsError}

) : filteredTeams.length === 0 ? ( // The shared empty state - the conversations list beside it already used it and this // one did not, so two panels of the same screen disagreed about what an empty list looks like.
) : (
{filteredTeams.map((team) => (
handleTeamClick(team)} onKeyDown={(event: React.KeyboardEvent) => { if (event.key !== 'Enter' && event.key !== ' ') return; event.preventDefault(); handleTeamClick(team); }} /* Spencer, 2026-09-10: "Teams list should match the Practices list cards style."* Two lists on one screen, one tab apart, drew as two different objects - a bordered card on Practices and a bare row with a right-hand rail here. A reader switching tabs had to work out that the same kind of thing was being listed. The selected state moves inside the card as a border and a tint rather than the rail, because a rail hanging off the edge of a bordered card reads as damage. */ className={clsx( "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2", "ahr-list-card flex cursor-pointer items-start justify-between gap-2 overflow-hidden text-left transition-all", selectedTeam?.teamId === team.teamId && "ahr-list-card--on", )} > {/* THE ANATOMY OF THE HOME PAGE'S ATTENTION CARD, not an approximation of it. ๐Ÿ—ฃ๏ธ Spencer, 2026-09-11, after the box alone was matched: *"They are still not right."* He was right, and measuring the two side by side said why - the shell agreed on every property and nothing INSIDE it did. The title was 15px against 13, in --ink rather than --ink-strong; the supporting line 12px/300 against 11.5/400; and the mark was a 48px grey square where that card has a 15px glyph. The square was doing most of the damage: a third of the card's width, on every row, saying nothing that the list's own tab does not already say. โ‡’ A 15px glyph beside the title on the first line, the count on the second, and the chevron the deck paints on every row that opens. */}

{team.members.length} member{team.members.length === 1 ? "" : "s"}

))}
)}
); }; const renderProvidersList = () => { if (filteredOrganizationGroups.length === 0) { return ( { retryMyOrganizations(); void loadPractitioners(); }} /> ); } return (
{filteredOrganizationGroups.map((organizationGroup) => { // Open while searching, so each practice shows who matched or says no one did. const isExpanded = expandedOrganizations[organizationGroup.id] ?? !!searchTerm.trim(); return (
{isExpanded && (
{/* NOBODY TO MESSAGE HERE YET, AND IT IS AN ABSENCE RATHER THAN A DENIAL. The patient belongs to this practice and is entitled to message it; there is simply nobody there. Borrowing the denial voice would tell them they are locked out of their own practice, which is worse than the silence it replaces. It sits INSIDE the practice's own group, because a line above the list detaches the explanation from the practice it is about and a patient with four providers would not know which one it meant. One way through, not two: calling is the closest substitute for what they came here to do, and booking is a different intent already one tap away on the card they pressed to get here. */} {!organizationGroup.hasMessageablePeople && !orgIdsWithFrontDesk.has(organizationGroup.id) && (

No one here to message yet

{organizationGroup.name} has not added anyone you can message. You can still call them, or book an appointment and ask at the visit.

{organizationGroup.phone && ( )}
)} {!!searchTerm.trim() && organizationGroup.hasMessageablePeople && organizationGroup.practitioners.length === 0 && ( )}
{/* CARD 257 ยง5 โ€” the front desk is FIRST and is a role, not a person. It renders only where a receptionist actually works (see orgIdsWithFrontDesk): a door onto nobody is worse than no door. */} {orgIdsWithFrontDesk.has(organizationGroup.id) && (
handleFrontDeskClick(organizationGroup.id, organizationGroup.name) } onKeyDown={(event: React.KeyboardEvent) => { if (event.key !== 'Enter' && event.key !== ' ') return; event.preventDefault(); handleFrontDeskClick(organizationGroup.id, organizationGroup.name); }} className={clsx( "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2", "ahr-person-row ahr-person-row--desk flex justify-between items-center cursor-pointer", )} >
{/* The same round tile and ground as the inbox's front desk row: the patient comes through this door and must recognise the row it created. */} {/* FD initials on the sunken ground (standard 3.10; the deck's blue is superseded). */}
{/* Card 356 โ€” the same words the INBOX row uses, from one source. The patient clicks "Front desk" here and then has to recognise the conversation it created in their inbox; two literals drift and the row they land on stops looking like the door they came through. */}

{poolThreadLabel(FRONT_DESK_POOL_KEY)}

{poolThreadDescription(FRONT_DESK_POOL_KEY)}

)} {organizationGroup.practitioners.map( (practitionerWithRoles: PractitionerWithRoles) => { const practitionerName = formatFhirHumanName( practitionerWithRoles.practitioner.name, ); const roleSummary = getPractitionerRoleSummary( practitionerWithRoles.practitioner.id as string, ); // THREE STATES, NOT TWO, AND THE MIDDLE ONE IS THE EASY ONE TO LOSE. // // ordinary clickable, writes a new thread // declines but we HAVE one clickable - this is how the patient reaches a // conversation their clinician started // declines and we have none listed, explained, inert // // Spencer ruled the third state is LISTED rather than hidden, which runs against // the usual rule that a door which opens and then refuses reads as broken. It is // right here because this door does not open and says so: a hidden practitioner // reads as "they do not work here" and sends the patient looking for somebody // standing in front of them, where a disabled one with a sentence reads as "not // right now", which is the truth. The explanation IS the feature. const rowState = patientPickerRowState( practitionerWithRoles.practitioner, !!existingThreadWith( practitionerWithRoles.practitioner.id as string | undefined, ), ); const inert = rowState === 'declined'; return (
handlePractitionerClick(practitionerWithRoles)} onKeyDown={(event: React.KeyboardEvent) => { if (inert) return; if (event.key !== 'Enter' && event.key !== ' ') return; event.preventDefault(); handlePractitionerClick(practitionerWithRoles); }} className={clsx( "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2", "ahr-person-row flex justify-between items-center", inert ? "cursor-default opacity-60" : "cursor-pointer", )} >
{/* The inbox rows' avatar: their photo, else their initials. A stock silhouette stood in for everyone without a photo. */}

{practitionerName || "Unknown"}

{roleSummary}

{inert && ( // Not truncated, deliberately: this sentence is the only thing telling // the patient why the row does nothing, and half of it explains nothing.

{declinesNewMessagesNotice(practitionerName)}

)}
); }, )}
)}
); })}
); }; const renderContent = () => { return ( <> {error && ( <> {error} )} {communicationError && ( <> {communicationError} )} {communicationError ? (

{communicationErrorRetryable === false ? "You don't have access to these messages" : "Could not load messages"}

{communicationErrorRetryable === false ? "Your access to these messages was refused." : "Try again later."}

{communicationErrorRetryable !== false && ( )}
) : communicationLoading ? ( {/* The same hidden heading as the loaded branch below - whichever branch paints is the one a reader gets, so a screen that is still loading still has a name. */} Messages {/* THE SCROLL LIVES HERE, inside the card, under the title and the tab row - which stay put while the list moves under them. `min-h-0` is load-bearing: a flex child refuses to shrink below its content without it, so the card would grow instead of scrolling. */} {/* THE LIST SITS ON THE PAPER GROUND, not on the card's white. Spencer, 2026-09-10: "Cards don't look consistent with the rest of the app." Everywhere else - the home page's attention list, the practices list on the account pages - a white card sits on `--paper` and is legible as a raised thing. Here the identical card was drawn on white, so all that showed was its hairline and it read as an outlined box inside a bigger box. DESIGN.md section 1 says exactly this: painting the ground is what makes a white card a card. The conversation pane on the other half of this same screen was already on paper, so the two halves of one screen disagreed about what the ground is. */} {/* โš ๏ธ THE GROUND IS PER TAB, and that is a consequence of the two lists being different objects rather than an inconsistency. Teams and Practices are CARDS: a white card on a white panel shows only its own hairline and reads as an outlined box, so they sit on the paper. The inbox is bare rows with a rail on the selected one - nothing to raise, nothing to raise it off - and it was refined and settled that way. */} {/* NO SCROLLER AND NO HEIGHT OF ITS OWN. This carried `overflow-y-auto` plus four `max-h-[calc(100vh-N)]` breakpoints, so the list scrolled inside a box that scrolled inside the card - two bars side by side in one column. The caps were also a second guess at the chrome above, which is the very thing the page now measures instead of assuming. The card decides the height; this just holds the rows. */} {Array.from({ length: 8 }).map((_, i) => (
))}
) : ( {/* ๐Ÿ—ฃ๏ธ Spencer, 2026-09-11: *"Remove the Messages heading from the left column above the tabs, it's just taking up needless space, it's obvious you're on the messages page here without it."* He is right about the screen: the breadcrumb above says Messages, the tabs below say which list, and this is the only column on the left. โš ๏ธ IT IS HIDDEN, NOT DELETED, and the difference is why this comment exists. "Obvious you're on the messages page" is true of somebody LOOKING at it. A reader moving by heading hears the heading order and nothing else - a breadcrumb is navigation, not a title, and is not in that order at all. Deleting this left the screen with no name, which the page-chrome guard caught on the next run. โ‡’ `sr-only` takes the row back without taking the name away. `text-start` and `as` stay: they are what that guard reads to tell a page title from a section label. */} Messages
{/* Search above the tabs on a laptop, as the laptop board draws it; under them on a phone, as the phone board does. The list scrolls under both. */}
setSearchTerm(e.target.value)} />
{/* THE SCROLL LIVES HERE, inside the card, under the title and the tab row - which stay put while the list moves under them. `min-h-0` is load-bearing: a flex child refuses to shrink below its content without it, so the card would grow instead of scrolling. */} {/* THE LIST SITS ON THE PAPER GROUND, not on the card's white. Spencer, 2026-09-10: "Cards don't look consistent with the rest of the app." Everywhere else - the home page's attention list, the practices list on the account pages - a white card sits on `--paper` and is legible as a raised thing. Here the identical card was drawn on white, so all that showed was its hairline and it read as an outlined box inside a bigger box. DESIGN.md section 1 says exactly this: painting the ground is what makes a white card a card. The conversation pane on the other half of this same screen was already on paper, so the two halves of one screen disagreed about what the ground is. */} {/* โš ๏ธ THE GROUND IS PER TAB, and that is a consequence of the two lists being different objects rather than an inconsistency. Teams and Practices are CARDS: a white card on a white panel shows only its own hairline and reads as an outlined box, so they sit on the paper. The inbox is bare rows with a rail on the selected one - nothing to raise, nothing to raise it off - and it was refined and settled that way. */} {/* NO SCROLLER AND NO HEIGHT OF ITS OWN. This carried `overflow-y-auto` plus four `max-h-[calc(100vh-N)]` breakpoints, so the list scrolled inside a box that scrolled inside the card - two bars side by side in one column. The caps were also a second guess at the chrome above, which is the very thing the page now measures instead of assuming. The card decides the height; this just holds the rows. */} {activeTab === "inbox" ? renderInboxList() : activeTab === "collaboration_teams" ? renderCollaborationTeamsList() : renderProvidersList()}
)} {selectedCommunicationRequestForDisplay && ( { setShowDisplayModal(false); setSelectedCommunicationRequestForDisplay(null); }} /> )} ); }; return ( // The split is the whole of this page, so the page height and the split's own height were the same // number and the split carried it. It is stated here instead, which renders the same and stops this // screen depending on a constant the staff shell needs to mean something else.
{renderContent()}
{selectedTeam && privateThreadId && privatePeer ? ( <>
Private conversation with {privatePeer.name ?? "this member"} in {selectedTeam.name}
setShowConversationMobile(false)} messages={teamMessages} loadError={teamLoadError} onRetryLoad={teamCanRetry ? () => void refetchTeam() : undefined} patient={patient} isGroupThread groupName={privatePeer.name ?? selectedTeam.name} participantCount={2} participants={[{ name: 'You' }, { name: privatePeer.name }]} privateWithName={privatePeer.name ?? ""} onUpdateMessages={handleUpdateMessages} onReadMessages={readCommunications} requestId={privateThreadId} readOnly={selectedTeamClosed || !!selectedTeamRefusal} readOnlyReason={selectedTeamRefusal} senderNameByReference={teamRosterNames} onSendMessage={handleTeamSend} sendRecovery={sendRecovery} onClose={() => setPrivatePeer(null)} />
) : selectedTeam ? ( setShowConversationMobile(false)} messages={teamMessages} // A refused read is a denial, not an empty thread; only a failure that could pass is offered a retry. loadError={teamLoadError} onRetryLoad={teamCanRetry ? () => void refetchTeam() : undefined} patient={patient} isGroupThread groupName={selectedTeam.name} participantCount={ 1 + (currentTeam ?? selectedTeam).members.filter((member) => member.state === "active").length } // ACTIVE MEMBERS PLUS THE PATIENT - the same set the count above is of, because two // derivations of one roster eventually disagree with each other on the same screen. // // The name comes from the roster the server resolved, with the member's own displayName // as the fallback. Neither is invented: a member whose name did not resolve is passed // through unnamed and the panel says so, because on a cross-organization team the person // a patient cannot look up is the one they most need to know is in the room. participants={[ // The reader's own row. 'You' rather than their name: this is their own screen, // and the thread already labels their own messages that way. { name: 'You' }, ...(currentTeam ?? selectedTeam).members .filter((member) => member.state === "active") // A member with a resource reference can be messaged privately; an invite-only one cannot. .map((member) => ({ ...describeTeamMember(member), ...(/^(Practitioner|RelatedPerson)\//.test(member.memberRef) ? { memberRef: member.memberRef } : {}), })), ]} onMessagePrivately={ownsSelectedTeam ? (person) => setPrivatePeer(person) : undefined} // WITHOUT THIS A TEAM THREAD NEVER MARKS ITSELF READ. // // Spencer, 2026-09-10: the new-message count never goes away after viewing. A thread // writes a read receipt for the reader once the messages have been on screen a moment, // and the writing is done by this callback - which only the direct thread was given. So // the team's unread was recomputed from receipts that could not exist, and came back every // time the reader left and returned. // // The handler is generic - it saves the Communications it is handed and cares nothing for // which kind of thread they belong to - so the direct thread having it and the team not // was an omission rather than a decision. // // The badge disappearing while the thread is OPEN is a separate thing and already done; // that is derived from the open thread rather than from a receipt. This is what makes it // stay gone afterwards. onUpdateMessages={handleUpdateMessages} onReadMessages={readCommunications} // Each conversation keeps its own draft; without a key every team shared one. requestId={buildCollabTeamThreadId(selectedTeam.teamId)} // Read live from the inbox: the viewer's links may land after the team was selected. readOnly={selectedTeamClosed || !!selectedTeamRefusal} readOnlyReason={selectedTeamRefusal} senderNameByReference={teamRosterNames} onSendMessage={handleTeamSend} sendRecovery={sendRecovery} onClose={() => setSelectedTeam(null)} /> ) : ( setShowConversationMobile(false)} messages={currentChatMessages} patient={patient} practitioner={practitioner as Practitioner} onSendMessage={(message, attachment, send) => handleSendMessage(message, attachment, undefined, send)} sendRecovery={sendRecovery} onUpdateMessages={handleUpdateMessages} onReadMessages={readCommunications} // The open thread, so a draft typed to one person or practice never shows in another. requestId={selectedCommunicationRequest?.id} onClose={handleCloseChat} loading={chatLoading} conversationOrganizationName={conversationOrganizationName} conversationRoleLabel={conversationRoleLabel} poolThreadKey={selectedThreadPoolKey} poolAnswererCount={poolAnswererCount} isRoomThread={selectedThreadIsRoom} readerNameByReference={readerNameByReference} participantCount={2} /> )}
); }; export default MessagesPage;