import { useCallback, useRef, useState } from "react"; import { Camera } from "lucide-react"; import { Attachment, Patient } from "fhir/r4"; import { toast } from "sonner"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../ui/dialog"; import { initialsOf } from "../ui/org-badge.tsx"; import { useAuthServiceContext, usePatientServiceContext, usePhotoUploadService } from "../../services"; import { saveOverCurrent } from "../../utils/saveOverCurrent"; import { isRecordChanged } from "../../utils/recordChanged"; import { isSaveRetired, isSaveTimedOut, useSaveScope, type SaveOperation } from "../../utils/saveOperation"; import type { SectionUpdatePatient } from "./useSectionEditor"; import { usePhotoUrl } from "../../hooks/usePhotoUrl"; import { describeWriteFailure } from "../../utils/fetchFailureMessage"; type ProfilePhotoControlProps = { data: Patient | undefined; name: string; photo: Attachment | undefined; setPhoto: (photo: Attachment | undefined) => void; updatePatient: SectionUpdatePatient; submitting: boolean; setSubmitting: (submitting: boolean) => void; /** The picture alone, with no camera and nothing to open. */ readOnly?: boolean; }; const MAX_PHOTO_BYTES = 5 * 1024 * 1024; // The image types a profile photo upload accepts; anything else is refused before it is sent. const PHOTO_TYPES = ["image/jpeg", "image/png", "image/webp"]; /** An upload and its save, together: longer than a plain save, since the image travels first. */ const PHOTO_DEADLINE_MS = 60_000; // Smaller than this on either side is not a picture of anything (the inbox avatar's rule). const SMALLEST_USABLE_IMAGE = 8; /** The photo when there is a usable one; otherwise, and when it fails to load, the initials. */ const Avatar = ({ url, name, large = false }: { url: string | null; name: string; large?: boolean }) => { const [unusable, setUnusable] = useState(null); const showImage = Boolean(url) && unusable !== url; return ( {showImage && url ? ( setUnusable(url)} onLoad={(event) => { const { naturalWidth, naturalHeight } = event.currentTarget; if (naturalWidth < SMALLEST_USABLE_IMAGE || naturalHeight < SMALLEST_USABLE_IMAGE) setUnusable(url); }} /> ) : ( initialsOf(name) )} ); }; /** The profile photo beside the name; the camera opens the view where it is changed or removed. */ export const ProfilePhotoControl = ({ data, name, photo, setPhoto, updatePatient, submitting, setSubmitting, readOnly = false, }: ProfilePhotoControlProps) => { const { currentUser, setCurrentUser } = useAuthServiceContext(); const { uploadPatientPhoto } = usePhotoUploadService(); // Whose photo this is in words: the viewer's own, or the person shown, for a helper or staff. const own = Boolean(data?.id) && currentUser?.fhir?.resourceType === "Patient" && currentUser.fhir.id === data?.id; const whose = own ? "Your" : `${name}'s`; const { fetchPatient } = usePatientServiceContext(); // The photo as it was when the editor opened: a change is saved only while it still is. const openedPhoto = useRef(undefined); const { url } = usePhotoUrl(photo); const [open, setOpen] = useState(false); const [error, setError] = useState(null); const fileInputRef = useRef(null); const shownUrl = photo ? url : null; // The editor's lifetime: opening or closing it, another patient, another person signed in, or losing the right // to change the photo retires what it started, so an upload or save answered late changes nothing on screen. const scope = useSaveScope(data?.id && `${data.id}|${readOnly ? "view" : "edit"}`, () => { setOpen(false); setError(null); setSubmitting(false); }); const openEditor = () => { scope.renew(); openedPhoto.current = data?.photo; setError(null); setOpen(true); }; // Only the photo changes, on the record as it is now; never the rest of a record loaded earlier. const savePhoto = (photoNext: Patient["photo"], operation: SaveOperation) => saveOverCurrent({ operation, read: () => fetchPatient(data!.id!, false, undefined, true, operation.transport), stillHolds: (current) => JSON.stringify(current.photo ?? null) === JSON.stringify(openedPhoto.current ?? null), apply: (current) => ({ ...current, photo: photoNext }), write: (candidate, basedOn) => updatePatient(candidate, basedOn, operation), }); const PHOTO_CHANGED = "The photo was changed elsewhere while this was open. Close it to see the latest, then try again."; /** What a failed save says; undefined when the save no longer belongs to what is on screen. */ const failureOf = (failure: unknown, fallback: string): string | undefined => { if (isSaveRetired(failure)) return undefined; if (isSaveTimedOut(failure)) return failure.message; if (isRecordChanged(failure)) return failure.outOfDate ? failure.message : PHOTO_CHANGED; return describeWriteFailure(failure, fallback); }; const handleImageChange = useCallback( async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; // Cleared first so choosing the same file again still fires a change. e.target.value = ""; if (readOnly || submitting || !data || !file) return; setError(null); if (!file.type.startsWith("image/")) { setError("That file is not an image."); return; } if (!PHOTO_TYPES.includes(file.type)) { setError("Choose a JPEG, PNG or WebP image."); return; } if (file.size > MAX_PHOTO_BYTES) { setError("Choose an image smaller than 5 MB."); return; } const operation = scope.start(PHOTO_DEADLINE_MS); try { setSubmitting(true); const { key, contentType, size } = await operation.step(() => uploadPatientPhoto(file, data.id!, operation.transport)); const newPhoto: Attachment = { // Named by key only: the server fills the bytes once this exact upload scans clean. contentType, url: key, title: file.name, size, creation: new Date().toISOString(), }; const updatedPatient = await savePhoto([newPhoto], operation); setPhoto(newPhoto); // A proxy changing a dependent's photo must not put the dependent's face in the guardian's own header. setCurrentUser((signedIn) => signedIn?.fhir?.id && signedIn.fhir.id === updatedPatient.id ? { ...signedIn, fhir: { ...signedIn.fhir, photo: [newPhoto] } as typeof signedIn.fhir } : signedIn); setOpen(false); toast.success(own ? "Profile photo updated." : `${whose} photo was updated.`, { position: "top-right" }); } catch (uploadError) { const message = failureOf(uploadError, `${whose} photo was not saved`); if (message !== undefined) setError(message); } finally { if (operation.belongs()) setSubmitting(false); } }, [data, readOnly, setPhoto, setSubmitting, submitting, uploadPatientPhoto, setCurrentUser, savePhoto, scope, own, whose], ); const removePhoto = useCallback(async () => { if (readOnly || submitting || !data) return; setError(null); const operation = scope.start(PHOTO_DEADLINE_MS); try { setSubmitting(true); const updatedPatient = await savePhoto(undefined, operation); setPhoto(undefined); setCurrentUser((signedIn) => signedIn?.fhir?.id && signedIn.fhir.id === updatedPatient.id ? { ...signedIn, fhir: { ...signedIn.fhir, photo: undefined } as typeof signedIn.fhir } : signedIn); setOpen(false); toast.success(own ? "Profile photo removed." : `${whose} photo was removed.`, { position: "top-right" }); } catch (removeError) { const message = failureOf(removeError, `${whose} photo was not removed`); if (message !== undefined) setError(message); } finally { if (operation.belongs()) setSubmitting(false); } }, [data, readOnly, setPhoto, setSubmitting, submitting, setCurrentUser, savePhoto, scope, own, whose]); if (readOnly) { return ; } return ( <> { if (!next && !submitting) { scope.renew(); setOpen(false); } }}> {whose} photo {own ? "Change or remove your profile photo." : `Change or remove ${name}'s profile photo.`}
{error &&

{error}

}
{photo && ( )}
void handleImageChange(event)} disabled={submitting} />
); }; export default ProfilePhotoControl;