import type { RoomPublicationState, VideoContext } from './videoContract'; export type CallPhase = 'idle' | 'creating' | 'joining' | 'active' | 'ending'; /** A meeting that exists for this user and which they have NOT joined. * * Deliberately SEPARATE from `phase`: in a meeting model an available meeting is a standing fact * about the world, not a state of the user's own call. Modelling it as a phase is what produced the * old ring model's hijack problem - two meetings could contest one slot. Here they simply coexist. */ export interface AvailableMeeting { sessionId: string; context: VideoContext; initiator?: string; createdAt?: string; /** (R9) - true when this person has already been in this visit, so the offer reads "Rejoin" * rather than inviting them into something they were just in. Server-derived; see `videoContract.ts` * for why the client cannot work it out for itself. */ returning?: boolean; } export interface CallState { phase: CallPhase; sessionId?: string; context?: VideoContext; /** The practitioner who started this session. */ initiator?: string; /** When the current meeting was created, so an older offer cannot surface over it. */ createdAt?: string; error?: string; /** Something true and NOT a failure that has to outlive the call - today only being removed from * one. Separate from `error` because that renders as an alert, and the person on the other end of * a removal may be a patient in distress rather than a bad actor. */ notice?: string; available?: AvailableMeeting; /** The room (encounter-room thread) behind this call, as the create/join response said. `pending` * means the call stays usable while the room entitlement catches up - see MeetingWindow. Absent * from an older server, which must not be read as either guarantee. */ room?: RoomPublicationState; /** Whether the roster announced when this host created the call can find it. Absent from an older server. */ findability?: Findability; } export interface Findability { discovery?: 'known' | 'pending' | 'unconfirmed'; noticeUnconfirmed?: string[]; } export type CallEvent = | { type: 'START'; context: VideoContext } | { type: 'CREATED'; sessionId: string; initiator?: string; createdAt?: string; room?: RoomPublicationState; findability?: Findability } | { type: 'AVAILABLE'; sessionId: string; context: VideoContext; initiator?: string; returning?: boolean; createdAt?: string } /** The offered-but-not-yet-joined meeting ended server-side before the user acted on it. Distinct * from `ENDED`: it clears `available` and NEVER touches `phase` — symmetric with `AVAILABLE` * itself never touching it. A no-op if `sessionId` isn't the one currently sitting in `available` * (most commonly: it's the user's OWN call, which `ENDED` already handles). */ | { type: 'AVAILABLE_ENDED'; sessionId: string } | { type: 'JOIN_AVAILABLE' } | { type: 'JOINED'; room?: RoomPublicationState } | { type: 'LEAVING' } | { type: 'ENDED' } /** Somebody ELSE ended the meeting this user is in, learned from the server push. Distinct * from `ENDED`, which is this user ending their own call and needs no explanation because * they just gave it. Walked 2026-09-14 from both chairs: the clinician pressed Finish and * the patient's window simply vanished, leaving two readings that demand opposite * behaviour - the visit is over, or the connection dropped and they should rejoin within * ten minutes. The product knows which; this is how it says so. */ | { type: 'ENDED_REMOTELY' } /** The host removed THIS user from the meeting. Distinct from `ENDED`: the meeting carries on * without them, they are barred from the grace-window rejoin everyone else gets, and they are owed * an explanation - a silent drop invites a rejoin loop of 403s they cannot interpret. */ | { type: 'REMOVED'; sessionId: string } | { type: 'FAILED'; error: string; /** A join refused only because the visit has not started: the offer stays. */ reoffer?: AvailableMeeting }; export const INITIAL_CALL_STATE: CallState = { phase: 'idle' }; /** Plain, and deliberately not a rebuke. A vague message invites the rejoin loop this exists to end, * but the person removed may be a patient in crisis rather than a bad actor. */ export const REMOVED_FROM_VISIT_NOTICE = 'You were removed from this visit and cannot rejoin it. ' + 'Contact the practice if you still need to be seen.'; /** Said to whoever did NOT end the call. It names the one thing a vanished window cannot: that the * visit was ENDED rather than DROPPED - because a dropped call is rejoinable for ten minutes and * an ended one is not, and waiting for the wrong one costs the patient their afternoon. Neutral * about who ended it: in a case conference the person on this end may be a second clinician. */ export const VISIT_ENDED_NOTICE = 'This visit has ended, so there is nothing to rejoin - the call ' + 'was ended rather than dropped. Contact the practice if you still need to be seen.'; /** The same two notices for a case conference, where the reader is a clinician rather than a patient: * there is no visit and nobody to be seen. */ export const REMOVED_FROM_CONFERENCE_NOTICE = 'You were removed from this case conference and cannot rejoin it.'; /** A colleague's join refused because the visit is not in progress yet: said as waiting, not as a failure. */ export const VISIT_NOT_STARTED_NOTICE = 'This video visit has not started yet. You can join once it does.'; export const CONFERENCE_ENDED_NOTICE = 'This case conference has ended, so there is nothing to rejoin - it ' + 'was ended rather than dropped.'; const isConference = (context?: string): boolean => !!context?.startsWith('collabteam#'); export function callReducer(state: CallState, event: CallEvent): CallState { switch (event.type) { case 'START': return { ...state, phase: 'creating', context: event.context, error: undefined, notice: undefined, findability: undefined }; case 'CREATED': // The host joins their own meeting immediately - they do not "ring out" and wait. Being // alone in a live meeting is a normal state; the UI reports it as "waiting for patient" // by observing zero remote tiles, which is also more honest than a dedicated phase. // The meeting's own time is the baseline an older offer is measured against. return { ...state, phase: 'joining', sessionId: event.sessionId, initiator: event.initiator, createdAt: event.createdAt, room: event.room, findability: event.findability }; case 'AVAILABLE': { // One ordered selection for pushes and lookups alike, measured against the offer held and // the meeting the user is in. An older offer is dropped, and so is one of unknown age once // a timed meeting is held: the authorized list resolves those. const held = ([ state.available, state.phase !== 'idle' && state.sessionId ? { sessionId: state.sessionId, createdAt: state.createdAt } : undefined, ] as ({ sessionId: string; createdAt?: string } | undefined)[]).filter((m): m is { sessionId: string; createdAt?: string } => !!m && m.sessionId !== event.sessionId); if (held.some((m) => !!m.createdAt && (!event.createdAt || event.createdAt < m.createdAt))) { return state; } const sameOffer = state.available?.sessionId === event.sessionId ? state.available : undefined; // Never touches `phase`. A meeting becoming available cannot disturb a call in progress, // and equally is not lost while the user is busy - which is exactly what the old ring // guard got wrong. return { ...state, available: { sessionId: event.sessionId, context: event.context, initiator: event.initiator, returning: event.returning, createdAt: event.createdAt ?? sameOffer?.createdAt, }, }; } case 'AVAILABLE_ENDED': if (!state.available || state.available.sessionId !== event.sessionId) return state; return { ...state, available: undefined }; case 'JOIN_AVAILABLE': if (!state.available) return state; return { phase: 'joining', sessionId: state.available.sessionId, context: state.available.context, initiator: state.available.initiator, createdAt: state.available.createdAt, }; case 'REMOVED': { // Clears `available` for the same session as well as the live call. The removed person is // barred from rejoining, so leaving a Rejoin banner up would offer them a 403. const available = state.available && state.available.sessionId === event.sessionId ? undefined : state.available; if (state.sessionId !== event.sessionId) { return { ...state, available, notice: REMOVED_FROM_VISIT_NOTICE }; } return { phase: 'idle', available, notice: isConference(state.context) ? REMOVED_FROM_CONFERENCE_NOTICE : REMOVED_FROM_VISIT_NOTICE, }; } case 'JOINED': return { ...state, phase: 'active', error: undefined, room: event.room ?? state.room }; case 'LEAVING': return { ...state, phase: 'ending' }; case 'ENDED_REMOTELY': { // The same teardown as `ENDED`, carrying a notice. `notice` rather than `error`, for // the reason the field exists: this is true and is not a failure, and the person // reading it may be a patient who has just watched their appointment disappear. const available = state.available && state.available.sessionId === state.sessionId ? undefined : state.available; return { phase: 'idle', available, notice: isConference(state.context) ? CONFERENCE_ENDED_NOTICE : VISIT_ENDED_NOTICE }; } case 'ENDED': { // Clear the user's own call. An available meeting is only cleared if it was THIS one - // otherwise ending a call would silently discard a different meeting still waiting. const available = state.available && state.available.sessionId === state.sessionId ? undefined : state.available; return { phase: 'idle', available }; } case 'FAILED': { // The refused meeting is offered again only where nothing else is: a meeting offered since is kept as admitted. if (event.reoffer) return { phase: 'idle', error: event.error, available: state.available ?? event.reoffer }; const available = state.available && state.available.sessionId === state.sessionId ? undefined : state.available; return { phase: 'idle', error: event.error, available }; } default: return state; } }