From 34cd5ee67edc811a96841078e6db79d973c6ac3a Mon Sep 17 00:00:00 2001 From: Gary Basin Date: Wed, 22 Jul 2026 04:25:03 -0400 Subject: [PATCH] =?UTF-8?q?refactor:=20shared=20call-core=20=E2=80=94=20on?= =?UTF-8?q?e=20tested=20reducer=20behind=20both=20call=20drivers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts the pure call bookkeeping both platforms duplicated (with drift) into @atrium/surface-client call-core: state types, participant/phase bookkeeping, comparator-parameterized sortLiveCalls, merge-aware upsertUser, user enrichment, and the CallEvent reducer as a pure (state, event) -> { state, effects } transition. The web and mobile useCall hooks stay as thin drivers owning the LiveKit Room and (mobile) every CallKit/AudioSession call site — all 28 verified byte-identical and in original order against the design's site table. No livekit-client types cross into shared (web and mobile pin different versions). Deliberate behavior changes: - web ring timeout 45s -> shared CALL_RING_TTL_MS (60s): the old value raced the server sweeper and could show 'No answer' while the server still considered the call ringing - mobile adopts web's merge-aware upsertUser: fallback participants upgrade to resolved users instead of being permanently add-only Also: both drivers advance their state refs immediately after the reducer runs (before React renders), so back-to-back CallEvents can never read a stale snapshot and drop a transition; web's third duplicate (callPresentation.ts) is deleted. Honest accounting: production LOC rises ~187 (1,667 -> 1,854) — the scaffolding to preserve both platforms' real divergences costs more than the removed duplication saved. Shipped for the drift protection: shared/test/callCore.test.ts (28 tests) pins each platform's semantics across all six call lifecycles. Design doc: docs/archive/notes/2026-07-21-call-core-design.md. Validation: lint/typechecks/build:ci green; shared 496, web 860, mobile 233 unit tests; e2e 120/120; two-user browser call smoke vs local LiveKit (start/ring/join/two-party/hangup, zero page errors). CallKit half needs a real-iPhone pass (28-site checklist in the design doc) before reaching the deploy branch. --- .../notes/2026-07-21-call-core-design.md | 636 ++++++++++++++++++ .../mobile/src/components/GlobalCallUI.tsx | 15 +- surface/mobile/src/lib/useCall.ts | 269 +++----- surface/shared/package.json | 1 + surface/shared/src/call-core.ts | 459 +++++++++++++ surface/shared/src/index.ts | 1 + surface/shared/test/callCore.test.ts | 397 +++++++++++ surface/web/src/Chat.tsx | 2 +- surface/web/src/callPresentation.ts | 21 - surface/web/src/useCall.ts | 369 +++------- surface/web/test/callPresentation.test.ts | 2 +- 11 files changed, 1698 insertions(+), 474 deletions(-) create mode 100644 docs/archive/notes/2026-07-21-call-core-design.md create mode 100644 surface/shared/src/call-core.ts create mode 100644 surface/shared/test/callCore.test.ts delete mode 100644 surface/web/src/callPresentation.ts 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(