diff --git a/docs/archive/notes/2026-07-21-call-core-design.md b/docs/archive/notes/2026-07-21-call-core-design.md new file mode 100644 index 000000000..01b1895f3 --- /dev/null +++ b/docs/archive/notes/2026-07-21-call-core-design.md @@ -0,0 +1,636 @@ +# Shared "call core" design — consolidating web + mobile `useCall` + +Date: 2026-07-21 +Scope: design only, no code changes. Read-only analysis of +`surface/web/src/useCall.ts` (744 LOC) and `surface/mobile/src/lib/useCall.ts` +(902 LOC), their consumers, and the shared wire types. + +--- + +## 0. Verdict up front + +**Consolidate, but not into a single hooks-driven room engine.** The honest, +high-value seam is a **pure shared module** (`@atrium/surface-client/call-core`) +that owns the platform-independent brain — state types, participant/phase +bookkeeping, `sortLiveCalls`/`upsert`/`remove`, the CallKit-free user enrichment, +and above all the **`CallEvent` reducer** (`handleCallEvent`, ~100 near-identical +lines each side) expressed as a pure `(state, event) → state` transition. The two +`useCall` hooks stay as **thin platform drivers** that hold the LiveKit `Room`, +own their refs, and call the shared reducer. + +I explicitly recommend **against** pushing the LiveKit `Room` lifecycle itself +into shared code behind a fat platform-adapter interface. Section 9 shows why: the +mobile side interleaves ~28 CallKit/audio-session calls at ~11 distinct lifecycle +points, and a hook-based core would have to invent, name, and correctly order all +11 hooks — adding indirection at exactly the seam whose regressions are most +expensive (verified-on-real-iPhone CallKit behavior) while deleting only ~120 LOC +of `Room` wiring that is *already* mostly parallel. The reducer + pure functions + +types are where the duplication actually is and where unification is provably safe. + +The design below specifies BOTH the full adapter approach (so the tradeoff is +concrete and the CallKit interleavings are made fully explicit, per the brief) and +the recommended smaller consolidation, and states which parts of each are worth +shipping. + +--- + +## 1. Side-by-side inventory + +### 1.1 Imports + +| | web (`surface/web/src/useCall.ts`) | mobile (`surface/mobile/src/lib/useCall.ts`) | +|---|---|---| +| React | `useCallback,useEffect,useRef,useState` (L1) | same (L1) | +| LiveKit | `Room,RoomEvent,Track,Participant,RemoteParticipant,RemoteTrack,RemoteTrackPublication` (L2-10) | `Room,RoomEvent,Track,Participant,RemoteParticipant` (L3) — **no RemoteTrack/Publication** | +| RN audio | — | `AudioSession` from `@livekit/react-native` (L2) | +| CallKit | — | 20 named imports from `expo-callkit-telecom` (L4-24) | +| shared | `CallEvent,CallJoin,CallWire,UserRef` (L11) | `+ApiError,CALL_RING_TTL_MS,channelLabel,Api,AppState,Channel` (L25-36) | +| local | `ApiError,api,Channel` from `./api`; `desktopApiOptions` from `./desktop` (L12-13) | `NATIVE_CALL_UI` from `./nativeCallUi` (L37) | + +Key structural difference: **web imports the `api` singleton** (L12); **mobile +takes `api: Api` as a hook argument** (L149). The core must be the mobile shape +(api injected); the web wrapper injects its singleton. + +### 1.2 `ActiveCallState` shape + +| field | web (L20-28) | mobile (L39-47) | +|---|---|---| +| `call: CallWire` | ✓ | ✓ | +| `phase: 'connecting'|'connected'|'ended'` | ✓ | ✓ | +| `participants: UserRef[]` | ✓ | ✓ | +| `activeSpeakerIds: Set` | ✓ | ✓ | +| `muted: boolean` | ✓ | ✓ | +| `error: string | null` | ✓ | ✓ | +| `remoteAudioTracks: RemoteAudioTrackRef[]` | ✓ (web-only) | — | +| `nativeCallId?: string` | — | ✓ (mobile-only) | + +`RemoteAudioTrackRef` (web L15-18): `{ key: string; track: RemoteTrack }`. +Consumed only by `surface/web/src/components/CallUI.tsx` (the `RemoteAudio` +component, L15-32/L205-206) to `track.attach(