import { ReactNode, createContext, useCallback, useContext, useEffect, useRef, useState, } from 'react'; import { environment } from '../environments/environment'; import { HttpError, useHttpService } from './HttpService'; import { authSessionId } from './authStorage'; import { useWebSocketServiceContext } from './WebSocketService'; import { useAuthServiceContext } from './AuthService'; import { isStaffOfInactivePractice } from './PracticeClosureService'; import { PRESENCE_STALE_SECONDS } from './presenceConstants'; import { useRereadOnJoinRecovery } from '../hooks/useRereadOnJoinRecovery'; type PresenceResponse = { online: string[] }; interface PresenceContextType { isOnline: (reference: string) => boolean; onlineReferences: Set; } const PresenceServiceContext = createContext(undefined); export const PresenceServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const { get } = useHttpService(); const { subscribe, unsubscribe, isConnected } = useWebSocketServiceContext(); const { currentUser, currentOrganization, joinRecovery } = useAuthServiceContext(); const [onlineReferences, setOnlineReferences] = useState>(new Set()); // useHttpService returns a fresh `get` each render; hold it in a ref so `refresh` // stays stable and the seed/poll effect doesn't re-fire on every render. const getRef = useRef(get); getRef.current = get; // /presence is authenticated. If it fires without a session (e.g. on the /login page - this // provider wraps the whole app), getValidToken() refreshAccessToken() logout() // window.location.reload(), which re-mounts this provider and loops the login page forever. // Gate every fetch on an established session; a ref keeps `refresh` stable for the poll effect. // A closed practice refuses its staff this read, so it is not made at all. const sessionUserId = isStaffOfInactivePractice(currentUser, currentOrganization) ? undefined : currentUser?.fhir?.id; // Presence is a practice's own list, read by the practice the request names; a patient with no practice has none. // The sign-in is part of the scope: a new one, even by the same person, is asked again; a record update is not. const scope = sessionUserId && currentOrganization?.id ? `${authSessionId() ?? ''}|${sessionUserId}|${currentOrganization.id}` : undefined; const scopeRef = useRef(scope); scopeRef.current = scope; // Each sign-in, practice and confirmed join starts a new generation of reads. A refusal stops only its own // generation, and an answer or refusal from an older one changes nothing: its request is cancelled. const generation = useRef({ count: 0, cancel: new AbortController() }); const refusedGeneration = useRef(undefined); const nextGeneration = useCallback(() => { generation.current.cancel.abort(); generation.current = { count: generation.current.count + 1, cancel: new AbortController() }; }, []); const refresh = useCallback(async () => { const { count, cancel } = generation.current; if (!scopeRef.current || refusedGeneration.current === count) { return; } try { const res = await getRef.current(`${environment.apiUrl}/presence`, { signal: cancel.signal }); if (generation.current.count !== count) return; setOnlineReferences(new Set(res.online ?? [])); } catch (error) { if (generation.current.count !== count) return; if (error instanceof HttpError && error.status === 403) { refusedGeneration.current = count; return; } // Best-effort: keep the last known set; the next interval retries. console.error('Failed to fetch presence:', error); } }, []); // Seed on mount + self-heal poll (catches hard-crashes whose offline delta never fired). // Gated on a session in a practice so it (re)starts on login or a practice change, and never fires on /login. useEffect(() => { nextGeneration(); if (!scope) { return; } void refresh(); const interval = window.setInterval(() => { void refresh(); }, PRESENCE_STALE_SECONDS * 1000); return () => window.clearInterval(interval); }, [refresh, nextGeneration, scope]); // And once a just-joined member's Check again confirms their access: a refusal while it settled is asked again. const rereadAfterJoin = useCallback(() => { nextGeneration(); void refresh(); }, [refresh, nextGeneration]); useRereadOnJoinRecovery(joinRecovery, rereadAfterJoin); // Re-seed whenever the socket (re)connects, so a reconnect resyncs presence. useEffect(() => { if (isConnected) { void refresh(); } }, [isConnected, refresh]); // Live deltas. useEffect(() => { const handler = (data: { reference?: string; online?: boolean }) => { if (!data?.reference) { return; } setOnlineReferences((prev) => { const next = new Set(prev); if (data.online) { next.add(data.reference as string); } else { next.delete(data.reference as string); } return next; }); }; subscribe('presence.changed', handler); return () => unsubscribe('presence.changed', handler); }, [subscribe, unsubscribe]); const isOnline = useCallback( (reference: string) => onlineReferences.has(reference), [onlineReferences], ); return ( {children} ); }; export const usePresenceContext = () => { const context = useContext(PresenceServiceContext); if (context === undefined) { throw new Error('usePresenceContext must be used within a PresenceServiceProvider'); } return context; };