import React from 'react'; import { usePractitionerServiceContext, useAuthServiceContext } from '../../../services'; import { useFhirPractitioners } from '../../../hooks'; import { ToggleControl } from '../../../components/ui/toggle-control'; import { PATIENT_INITIATED_MESSAGES_EXTENSION, allowsPatientInitiatedMessages, } from '../../../utils/messaging/patientInitiatedMessages'; import { isPractitionerMessageableByPatient, workspaceMayHavePatientConversations, } from '../../../utils/patientMessagingDirectory'; /** * "Let patients start new conversations with you" - the practitioner's own answer. * * Spencer, 2026-09-04: "In the practitioner's messages page UI let's add a toggle to allow new direct * messages from patients or not." A control belongs where its effect is felt: somebody notices they are * getting more patient messages than they want while looking at their inbox. * * IT LIVES ON THE PAGE, NOT INSIDE THE PATIENTS PANEL, AND THAT IS THE SECOND PLACEMENT NOT THE FIRST. * * Built inside the Patients panel and caught in a browser: a Medical Assistant is offered to patients in * the provider picker - the picker admits every non-support role - but their Messages page renders * COLLEAGUES ONLY, because the Patients tab is gated to practitioners and receptionists. So the one * control that could stop patients writing to them was on a screen their role does not have. * * The set of people a patient may message and the set of people with a patient INBOX are different sets, * and the toggle belongs to the first. Rendering it at page level makes it independent of which tabs a * role happens to get, which is the only way it can follow the rule that decides its audience. */ export interface PatientMessagingToggleProps { /** * The hat the person is wearing right now, resolved by the page rather than here. The coalesce * `activeWorkspace ?? selectedWorkspace ?? userRole` already exists in two places and a third copy * is how those two would come apart. */ workspace?: string; } export const PatientMessagingToggle: React.FC = ({ workspace }) => { const { currentUser } = useAuthServiceContext(); const { practitionersList, updatePractitioner } = usePractitionerServiceContext(); // The roster is context state somebody has to populate; this component reads it, so it asks for it // rather than relying on a sibling screen having been visited first. useFhirPractitioners(); const myEntry = React.useMemo( () => practitionersList.find((entry) => entry.practitioner.id === currentUser?.fhir?.id), [practitionersList, currentUser?.fhir?.id], ); /** * TWO QUESTIONS, AND THEY ARE NOT THE SAME ONE. * * WHO the person is: exactly the people a patient's picker can list, read from the SAME rule the * picker uses. Not "is a practitioner" and not "is not a receptionist". The picker admits every * non-support role - which is why a laboratory professional and a radiology technician appear in it * beside the physicians - and every one of them has the same reason to decline unsolicited patient * threads. A receptionist is never listed individually, so the setting would govern nothing for * them and offering it would lie. * * WHICH HAT they are wearing: this rendered on the Administrator's Messages page because the * signed-in person also held a practitioner role, on a workspace where only colleagues can be * messaged at all. The person half cannot see that, because it is the same answer in every * workspace. */ const mayDecline = !!myEntry && isPractitionerMessageableByPatient(myEntry) && workspaceMayHavePatientConversations(workspace); const [accepts, setAccepts] = React.useState(true); const [saving, setSaving] = React.useState(false); const [saveFailed, setSaveFailed] = React.useState(false); React.useEffect(() => { if (myEntry) setAccepts(allowsPatientInitiatedMessages(myEntry.practitioner)); }, [myEntry]); const handleChange = React.useCallback( async (checked: boolean) => { const practitioner = myEntry?.practitioner; if (!practitioner) return; // Optimistic, then reverted on failure. A switch that does not move under the finger reads as // broken; one that moves and silently did not save is worse. setAccepts(checked); setSaving(true); setSaveFailed(false); try { // Read-modify-write on the whole stored resource, with the extension REPLACED rather than // appended - turning the setting off and on again would otherwise leave two of them and whichever // was found first would win. await updatePractitioner({ ...practitioner, extension: [ ...(practitioner.extension ?? []).filter( (extension) => extension.url !== PATIENT_INITIATED_MESSAGES_EXTENSION, ), { url: PATIENT_INITIATED_MESSAGES_EXTENSION, valueBoolean: checked }, ], }); } catch (saveError) { console.error('Could not save the patient-messaging setting:', saveError); setAccepts(!checked); setSaveFailed(true); } finally { setSaving(false); } }, [myEntry, updatePractitioner], ); if (!mayDecline) return null; return (

Let patients start new conversations with you

{/* States the consequence of turning it OFF, because that is the direction with one. The reply half is what people assume wrongly, and either wrong assumption is bad: believing replies stop makes this feel harsher than it is, and a patient actually losing the ability to answer would be the defect the whole control is written around. */}

Off, patients see that you are not accepting new messages and are pointed to the front desk. Anyone you have already written to can still reply.

{saveFailed && (

The setting could not be saved, so it has not changed. Try again.

)}
); };