# Mobile Phase 3 — Voice Calls Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax. Implementer typechecks before every commit. **Goal:** A mobile user can place + answer voice calls (1:1 and group) against the same LiveKit + Supabase signaling backend the desktop already uses. **Architecture:** Add `@livekit/react-native` + `@livekit/react-native-webrtc`; build a small `CallProvider` (state machine + signal subscription + LiveKit room lifecycle); render an `IncomingCallModal` at root and a `/call` full-screen route for the connected state; add a phone-icon entry point in the conversation header. No CallKit / VoIP-push wake-up in this phase. **Tech Stack:** Expo SDK 52, RN 0.76, expo-router 4, `@livekit/react-native` (added Task 1), `@livekit/react-native-webrtc` (added Task 1), `@chat-app/shared` (rtc + chat). **Spec:** `docs/superpowers/specs/2026-05-14-mobile-phase-3-voice-calls-design.md` **Testing note:** No device emulator. Each task gate is `pnpm --filter @chat-app/mobile typecheck`. End-to-end verification is the user's hardware test. --- ## File structure (touchpoints) | File | Status | |---|---| | `apps/mobile/package.json` | MODIFIED — add `@livekit/react-native`, `@livekit/react-native-webrtc` | | `apps/mobile/app.json` | MODIFIED — mic permission, audio background mode, LiveKit plugin | | `apps/mobile/lib/callSignal.ts` | NEW | | `apps/mobile/lib/callContext.tsx` | NEW | | `apps/mobile/components/IncomingCallModal.tsx` | NEW | | `apps/mobile/app/_layout.tsx` | MODIFIED — mount CallProvider, render IncomingCallModal | | `apps/mobile/app/(app)/_layout.tsx` | MODIFIED — register `call` Stack.Screen | | `apps/mobile/app/(app)/call.tsx` | NEW | | `apps/mobile/app/(app)/conversations/[id].tsx` | MODIFIED — phone-icon header button | --- ## Task 1: Add LiveKit RN deps + permissions/plugin **Files:** - Modify: `apps/mobile/package.json` (via `pnpm add`) - Modify: `apps/mobile/app.json` - [ ] **Step 1: Install the deps** ```bash pnpm --filter @chat-app/mobile add @livekit/react-native @livekit/react-native-webrtc ``` - [ ] **Step 2: Replace `apps/mobile/app.json` with** ```json { "expo": { "name": "Netralax", "slug": "netralax", "version": "0.1.0", "orientation": "portrait", "icon": "./assets/icon.png", "scheme": "netralax", "userInterfaceStyle": "automatic", "newArchEnabled": true, "splash": { "image": "./assets/splash.png", "resizeMode": "contain", "backgroundColor": "#0b0b0f" }, "assetBundlePatterns": ["**/*"], "ios": { "supportsTablet": true, "bundleIdentifier": "cloud.netralax.app", "infoPlist": { "ITSAppUsesNonExemptEncryption": false, "UIBackgroundModes": ["audio"], "NSMicrophoneUsageDescription": "Netralax nutzt das Mikrofon für Sprachanrufe." } }, "android": { "package": "cloud.netralax.app", "permissions": ["RECORD_AUDIO"], "adaptiveIcon": { "foregroundImage": "./assets/adaptive-icon.png", "backgroundColor": "#0b0b0f" } }, "plugins": [ "expo-router", "expo-secure-store", "expo-sqlite", [ "expo-notifications", { "color": "#0b0b0f" } ], [ "expo-image-picker", { "photosPermission": "Netralax greift auf deine Fotos zu, damit du sie in Nachrichten teilen kannst.", "cameraPermission": "Netralax nutzt die Kamera für Fotos in Nachrichten." } ], "@livekit/react-native-webrtc", "@livekit/react-native" ], "experiments": { "typedRoutes": true }, "extra": { "eas": { "projectId": "REPLACE_WITH_EAS_PROJECT_ID" } } } } ``` - [ ] **Step 3: Typecheck + commit** ```bash pnpm --filter @chat-app/mobile typecheck git add apps/mobile/package.json pnpm-lock.yaml apps/mobile/app.json git commit -m "chore(mobile): add LiveKit RN deps + mic permission + audio bg mode" ``` --- ## Task 2: `lib/callSignal.ts` **Files:** - Create: `apps/mobile/lib/callSignal.ts` - [ ] **Step 1: Create the file** ```ts import { rtc } from '@chat-app/shared'; import type { CallSignal } from '@chat-app/shared/rtc'; import type { AppSupabaseClient } from '@chat-app/shared/supabase'; // Thin wrapper around Supabase realtime broadcast for call signaling. // One channel per peer userId. Subscriptions live for the lifetime of // the AuthProvider's session; teardown returns a no-arg unsubscribe. export type SignalListener = (signal: CallSignal) => void; export function subscribeCallSignals( client: AppSupabaseClient, myUserId: string, onSignal: SignalListener, ): () => void { const ch = client.channel(rtc.signalTopic(myUserId)); ch.on('broadcast', { event: 'signal' }, (msg) => { if (msg.payload && typeof msg.payload === 'object') { onSignal(msg.payload as CallSignal); } }); void ch.subscribe(); return () => { void client.removeChannel(ch); }; } export async function sendCallSignal( client: AppSupabaseClient, toUserId: string, payload: CallSignal, ): Promise { const ch = client.channel(rtc.signalTopic(toUserId)); await ch.subscribe(); await ch.send({ type: 'broadcast', event: 'signal', payload }); await client.removeChannel(ch); } ``` - [ ] **Step 2: Typecheck + commit** ```bash pnpm --filter @chat-app/mobile typecheck git add apps/mobile/lib/callSignal.ts git commit -m "feat(mobile): callSignal subscribe + broadcast helpers" ``` --- ## Task 3: `lib/callContext.tsx` **Files:** - Create: `apps/mobile/lib/callContext.tsx` - [ ] **Step 1: Create the file** ```tsx import { AudioSession, Room, RoomEvent, Track } from '@livekit/react-native'; import { chat, rtc } from '@chat-app/shared'; import type { CallSignal } from '@chat-app/shared/rtc'; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react'; import { useAuth } from './authContext'; import { sendCallSignal, subscribeCallSignals } from './callSignal'; import { supabase } from './supabase'; type Identity = string; export interface RemoteParticipantSummary { identity: Identity; name: string; speaking: boolean; } export type CallState = | { kind: 'idle' } | { kind: 'outgoing'; callId: string; conversationId: string; peers: Identity[] } | { kind: 'incoming'; callId: string; conversationId: string; fromUserId: Identity } | { kind: 'connecting'; callId: string; conversationId: string } | { kind: 'connected'; callId: string; conversationId: string; muted: boolean; speakerOn: boolean; participants: RemoteParticipantSummary[]; } | { kind: 'ended'; reason: 'normal' | 'rejected' | 'cancelled' | 'error' }; interface CallContextValue { state: CallState; startCall: (conversationId: string) => Promise; acceptIncoming: () => Promise; rejectIncoming: () => Promise; cancelOutgoing: () => Promise; endCall: () => Promise; toggleMute: () => Promise; toggleSpeaker: () => Promise; } const Ctx = createContext(null); export function useCall(): CallContextValue { const v = useContext(Ctx); if (!v) throw new Error('useCall() called outside '); return v; } function randomCallId(): string { return 'call-' + Math.random().toString(36).slice(2, 10) + '-' + Date.now().toString(36); } export function CallProvider({ children }: { children: React.ReactNode }) { const { user } = useAuth(); const myUserId = user?.id ?? null; const [state, setState] = useState({ kind: 'idle' }); const roomRef = useRef(null); const participantsRef = useRef>(new Map()); const peerIdsFor = useCallback( async (conversationId: string): Promise => { if (!myUserId) return []; const all = await chat.listConversations(supabase); const conv = all.find((c) => c.id === conversationId); if (!conv) return []; return conv.members.map((m) => m.userId).filter((id) => id !== myUserId); }, [myUserId], ); const teardownRoom = useCallback(async () => { const r = roomRef.current; roomRef.current = null; participantsRef.current.clear(); if (r) { try { await r.disconnect(); } catch (err) { console.warn('[call] room.disconnect failed', err); } } try { await AudioSession.stopAudioSession(); } catch { /* already stopped */ } }, []); const updateParticipantsState = useCallback(() => { setState((prev) => { if (prev.kind !== 'connected') return prev; return { ...prev, participants: Array.from(participantsRef.current.values()), }; }); }, []); const joinRoom = useCallback( async (conversationId: string, callId: string): Promise => { const token = await rtc.fetchLivekitToken(supabase, conversationId); await AudioSession.startAudioSession(); const room = new Room(); roomRef.current = room; room .on(RoomEvent.ParticipantConnected, (p) => { participantsRef.current.set(p.identity, { identity: p.identity, name: p.name || p.identity, speaking: p.isSpeaking, }); updateParticipantsState(); }) .on(RoomEvent.ParticipantDisconnected, (p) => { participantsRef.current.delete(p.identity); updateParticipantsState(); }) .on(RoomEvent.ActiveSpeakersChanged, (speakers) => { for (const [id, entry] of participantsRef.current) { entry.speaking = speakers.some((sp) => sp.identity === id); participantsRef.current.set(id, entry); } updateParticipantsState(); }); await room.connect(token.url, token.token); await room.localParticipant.setMicrophoneEnabled(true); for (const p of room.remoteParticipants.values()) { participantsRef.current.set(p.identity, { identity: p.identity, name: p.name || p.identity, speaking: p.isSpeaking, }); } setState({ kind: 'connected', callId, conversationId, muted: false, speakerOn: false, participants: Array.from(participantsRef.current.values()), }); }, [updateParticipantsState], ); // Signal subscription. Routes incoming invites / cancels / rejects to // state transitions. eslint-disable on deps because handleIncomingSignal // is defined inline below; re-subscribing on every render would churn // the realtime channel. useEffect(() => { if (!myUserId) return; const unsub = subscribeCallSignals(supabase, myUserId, (sig) => { handleIncomingSignal(sig); }); return unsub; // eslint-disable-next-line react-hooks/exhaustive-deps }, [myUserId]); function handleIncomingSignal(sig: CallSignal) { setState((prev) => { switch (sig.type) { case 'invite': if (prev.kind === 'idle') { return { kind: 'incoming', callId: sig.callId, conversationId: sig.conversationId, fromUserId: sig.fromUserId, }; } return prev; case 'cancel': if ( (prev.kind === 'incoming' || prev.kind === 'connecting') && prev.callId === sig.callId ) { return { kind: 'ended', reason: 'cancelled' }; } return prev; case 'reject': if (prev.kind === 'outgoing' && prev.callId === sig.callId) { void teardownRoom(); return { kind: 'ended', reason: 'rejected' }; } return prev; case 'accept': return prev; case 'end': if ( (prev.kind === 'connected' || prev.kind === 'connecting') && prev.callId === sig.callId ) { void teardownRoom(); return { kind: 'ended', reason: 'normal' }; } return prev; } }); } const startCall = useCallback( async (conversationId: string): Promise => { if (!myUserId) throw new Error('not authenticated'); const callId = randomCallId(); const peers = await peerIdsFor(conversationId); setState({ kind: 'outgoing', callId, conversationId, peers }); try { await Promise.all( peers.map((peerId) => sendCallSignal(supabase, peerId, { type: 'invite', callId, conversationId, fromUserId: myUserId, kind: 'audio', sentAt: new Date().toISOString(), }), ), ); await joinRoom(conversationId, callId); } catch (err) { console.warn('[call] startCall failed', err); await teardownRoom(); setState({ kind: 'ended', reason: 'error' }); } }, [myUserId, peerIdsFor, joinRoom, teardownRoom], ); const acceptIncoming = useCallback(async (): Promise => { if (!myUserId) return; if (state.kind !== 'incoming') return; const { callId, conversationId, fromUserId } = state; setState({ kind: 'connecting', callId, conversationId }); try { await sendCallSignal(supabase, fromUserId, { type: 'accept', callId, byUserId: myUserId, }); await joinRoom(conversationId, callId); } catch (err) { console.warn('[call] acceptIncoming failed', err); await teardownRoom(); setState({ kind: 'ended', reason: 'error' }); } }, [myUserId, state, joinRoom, teardownRoom]); const rejectIncoming = useCallback(async (): Promise => { if (!myUserId) return; if (state.kind !== 'incoming') return; const { callId, fromUserId } = state; setState({ kind: 'ended', reason: 'rejected' }); try { await sendCallSignal(supabase, fromUserId, { type: 'reject', callId, byUserId: myUserId, }); } catch (err) { console.warn('[call] rejectIncoming send failed', err); } }, [myUserId, state]); const cancelOutgoing = useCallback(async (): Promise => { if (!myUserId) return; if (state.kind !== 'outgoing') return; const { callId, peers } = state; setState({ kind: 'ended', reason: 'cancelled' }); try { await Promise.all( peers.map((peerId) => sendCallSignal(supabase, peerId, { type: 'cancel', callId, byUserId: myUserId, }), ), ); } catch (err) { console.warn('[call] cancelOutgoing send failed', err); } await teardownRoom(); }, [myUserId, state, teardownRoom]); const endCall = useCallback(async (): Promise => { if (!myUserId) return; if (state.kind !== 'connected') { await teardownRoom(); setState({ kind: 'idle' }); return; } const { callId, conversationId } = state; const peers = await peerIdsFor(conversationId); setState({ kind: 'ended', reason: 'normal' }); try { await Promise.all( peers.map((peerId) => sendCallSignal(supabase, peerId, { type: 'end', callId, byUserId: myUserId, }), ), ); } catch (err) { console.warn('[call] endCall send failed', err); } await teardownRoom(); }, [myUserId, state, peerIdsFor, teardownRoom]); const toggleMute = useCallback(async (): Promise => { if (state.kind !== 'connected') return; const r = roomRef.current; if (!r) return; const nextMuted = !state.muted; try { await r.localParticipant.setMicrophoneEnabled(!nextMuted); setState((prev) => prev.kind === 'connected' ? { ...prev, muted: nextMuted } : prev, ); } catch (err) { console.warn('[call] toggleMute failed', err); } }, [state]); const toggleSpeaker = useCallback(async (): Promise => { if (state.kind !== 'connected') return; const nextSpeaker = !state.speakerOn; try { await AudioSession.selectAudioOutput(nextSpeaker ? 'speaker' : 'earpiece'); setState((prev) => prev.kind === 'connected' ? { ...prev, speakerOn: nextSpeaker } : prev, ); } catch (err) { console.warn('[call] toggleSpeaker failed', err); } }, [state]); // Drift `ended` back to `idle` after a short pause so the UI can show // a brief status pill ("Anruf abgelehnt") before snapping back. useEffect(() => { if (state.kind !== 'ended') return; const t = setTimeout(() => { setState({ kind: 'idle' }); }, 2500); return () => clearTimeout(t); }, [state]); const value: CallContextValue = useMemo( () => ({ state, startCall, acceptIncoming, rejectIncoming, cancelOutgoing, endCall, toggleMute, toggleSpeaker, }), [state, startCall, acceptIncoming, rejectIncoming, cancelOutgoing, endCall, toggleMute, toggleSpeaker], ); return {children}; } export { Track }; ``` - [ ] **Step 2: Typecheck + commit** ```bash pnpm --filter @chat-app/mobile typecheck git add apps/mobile/lib/callContext.tsx git commit -m "feat(mobile): CallProvider with state machine + LiveKit room lifecycle" ``` --- ## Task 4: `components/IncomingCallModal.tsx` **Files:** - Create: `apps/mobile/components/IncomingCallModal.tsx` - [ ] **Step 1: Create the file** ```tsx import { chat } from '@chat-app/shared'; import type { ConversationSummary } from '@chat-app/shared/chat'; import { useEffect, useState } from 'react'; import { Modal, Pressable, StyleSheet, Text, View } from 'react-native'; import { Avatar } from './Avatar'; import { useCall } from '../lib/callContext'; import { supabase } from '../lib/supabase'; import { colors } from '../theme/colors'; // Full-screen modal that surfaces over any route when the CallContext // reports an `incoming` state. Resolves the caller's display name from // the conversation membership; falls back to "Anrufer" otherwise. export function IncomingCallModal() { const { state, acceptIncoming, rejectIncoming } = useCall(); const visible = state.kind === 'incoming'; const [conversation, setConversation] = useState(null); useEffect(() => { if (state.kind !== 'incoming') { setConversation(null); return; } let cancelled = false; void (async () => { try { const all = await chat.listConversations(supabase); if (cancelled) return; setConversation(all.find((c) => c.id === state.conversationId) ?? null); } catch { /* swallow */ } })(); return () => { cancelled = true; }; }, [state]); if (state.kind !== 'incoming') return null; const callerName = (() => { if (!conversation) return 'Anrufer'; const m = conversation.members.find((mm) => mm.userId === state.fromUserId); return m?.profile?.displayName ?? m?.profile?.username ?? 'Anrufer'; })(); const conversationTitle = conversation?.type === 'group' ? (conversation.name ?? 'Gruppe') : callerName; return ( Eingehender Anruf {callerName} {conversation?.type === 'group' && ( in {conversationTitle} )} { void rejectIncoming(); }} > Ablehnen { void acceptIncoming(); }} > Annehmen ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: colors.bg, alignItems: 'center', justifyContent: 'center', gap: 20, paddingHorizontal: 32, }, title: { color: colors.text, fontSize: 28, fontWeight: '700' }, subtitle: { color: colors.textMuted, fontSize: 14 }, actions: { flexDirection: 'row', gap: 24, marginTop: 32, }, button: { paddingHorizontal: 28, paddingVertical: 16, borderRadius: 14, minWidth: 140, alignItems: 'center', }, buttonAccept: { backgroundColor: colors.success }, buttonReject: { backgroundColor: colors.danger }, buttonText: { color: colors.text, fontWeight: '700', fontSize: 16 }, }); ``` - [ ] **Step 2: Typecheck + commit** ```bash pnpm --filter @chat-app/mobile typecheck git add apps/mobile/components/IncomingCallModal.tsx git commit -m "feat(mobile): IncomingCallModal with Annehmen/Ablehnen + name resolution" ``` --- ## Task 5: Mount `CallProvider` + render `IncomingCallModal` at root **Files:** - Modify: `apps/mobile/app/_layout.tsx` - [ ] **Step 1: Replace the file** ```tsx import { crypto } from '@chat-app/shared'; import { Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { ErrorBoundary } from '../components/ErrorBoundary'; import { IncomingCallModal } from '../components/IncomingCallModal'; import { AuthProvider } from '../lib/authContext'; import { CallProvider } from '../lib/callContext'; import { createLibsodiumBackend } from '../lib/cryptoBackend'; crypto.setCryptoBackend(createLibsodiumBackend()); export default function RootLayout() { return ( ); } ``` - [ ] **Step 2: Typecheck + commit** ```bash pnpm --filter @chat-app/mobile typecheck git add apps/mobile/app/_layout.tsx git commit -m "feat(mobile): mount CallProvider + global IncomingCallModal" ``` --- ## Task 6: Register `/call` route **Files:** - Modify: `apps/mobile/app/(app)/_layout.tsx` - [ ] **Step 1: Replace the file** ```tsx import { Redirect, Stack } from 'expo-router'; import { useAuth } from '../../lib/authContext'; export default function AppLayout() { const { session, loading } = useAuth(); if (loading) return null; if (!session) return ; return ( ); } ``` - [ ] **Step 2: Typecheck + commit** ```bash pnpm --filter @chat-app/mobile typecheck git add 'apps/mobile/app/(app)/_layout.tsx' git commit -m "feat(mobile): register /call full-screen modal route" ``` --- ## Task 7: `app/(app)/call.tsx` **Files:** - Create: `apps/mobile/app/(app)/call.tsx` - [ ] **Step 1: Create the file** ```tsx import { useRouter } from 'expo-router'; import { useEffect, useMemo, useState } from 'react'; import { Pressable, StyleSheet, Text, View } from 'react-native'; import { Avatar } from '../../components/Avatar'; import { useCall } from '../../lib/callContext'; import { colors } from '../../theme/colors'; export default function CallScreen() { const router = useRouter(); const { state, toggleMute, toggleSpeaker, endCall } = useCall(); const [seconds, setSeconds] = useState(0); useEffect(() => { if (state.kind !== 'connected') return; setSeconds(0); const id = setInterval(() => setSeconds((s) => s + 1), 1000); return () => clearInterval(id); }, [state.kind]); useEffect(() => { if (state.kind === 'idle') { router.back(); } }, [state.kind, router]); const duration = useMemo(() => { const m = Math.floor(seconds / 60); const s = seconds % 60; return [m, s].map((n) => String(n).padStart(2, '0')).join(':'); }, [seconds]); if (state.kind === 'idle' || state.kind === 'ended') { return ( Anruf beendet ); } if (state.kind === 'incoming') { return null; } const connecting = state.kind === 'connecting' || state.kind === 'outgoing'; const participants = state.kind === 'connected' ? state.participants : []; const muted = state.kind === 'connected' ? state.muted : false; const speakerOn = state.kind === 'connected' ? state.speakerOn : false; return ( {connecting ? 'Verbinde …' : 'Anruf läuft'} {state.kind === 'connected' && {duration}} {participants.length === 0 && ( Warten auf Teilnehmer … )} {participants.map((p) => ( {p.name} {p.speaking ? 'Spricht …' : 'Stumm'} ))} { void toggleMute(); }} /> { void toggleSpeaker(); }} /> { void endCall(); }} > Auflegen ); } function ToolbarButton({ label, active, onPress, }: { label: string; active: boolean; onPress: () => void; }) { return ( {label} ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: colors.bg, padding: 24 }, header: { alignItems: 'center', marginTop: 24, gap: 6 }, title: { color: colors.text, fontSize: 24, fontWeight: '700' }, duration: { color: colors.textMuted, fontSize: 16, fontVariant: ['tabular-nums'] }, participantList: { flex: 1, marginTop: 24, gap: 12 }, empty: { color: colors.textMuted, textAlign: 'center', marginTop: 32 }, participantRow: { flexDirection: 'row', alignItems: 'center', gap: 12, backgroundColor: colors.surface, padding: 12, borderRadius: 12, borderColor: colors.border, borderWidth: 1, }, participantText: { flex: 1 }, participantName: { color: colors.text, fontSize: 16, fontWeight: '600' }, participantStatus: { color: colors.textMuted, fontSize: 12, marginTop: 2 }, participantSpeaking: { color: colors.success, fontWeight: '700' }, toolbar: { flexDirection: 'row', gap: 12, paddingBottom: 16 }, toolbarButton: { flex: 1, paddingVertical: 14, borderRadius: 14, backgroundColor: colors.surface, borderColor: colors.border, borderWidth: 1, alignItems: 'center', }, toolbarButtonActive: { backgroundColor: colors.accentMuted, borderColor: colors.accent }, toolbarButtonText: { color: colors.text, fontWeight: '600' }, toolbarButtonTextActive: { color: colors.accent }, hangup: { flex: 1, paddingVertical: 14, borderRadius: 14, backgroundColor: colors.danger, alignItems: 'center', }, hangupText: { color: colors.text, fontWeight: '700' }, }); ``` - [ ] **Step 2: Typecheck + commit** ```bash pnpm --filter @chat-app/mobile typecheck git add 'apps/mobile/app/(app)/call.tsx' git commit -m "feat(mobile): in-call screen with participants + toolbar" ``` --- ## Task 8: Phone-icon header button in conversation detail **Files:** - Modify: `apps/mobile/app/(app)/conversations/[id].tsx` The change is additive — keeping the existing imports/state/effect blocks intact: - [ ] **Step 1: Add imports** At the top, add (alphabetical with existing imports): ```tsx import { useRouter } from 'expo-router'; import { useCall } from '../../../lib/callContext'; ``` `useRouter` may already be unused-style imported; if `useLocalSearchParams` is already from expo-router, fold it into the same import. - [ ] **Step 2: Wire `useCall` + a navigation effect inside `ConversationDetail`** Right after `const { user, device, ownPrivateKey } = useAuth();`, add: ```tsx const { state: callState, startCall } = useCall(); const router = useRouter(); // When a call moves into `connected`, jump to the in-call screen so // the user can see participants + controls. The /call screen pops // itself when callState returns to `idle`. useEffect(() => { if (callState.kind === 'connected') { router.push('/(app)/call'); } }, [callState.kind, router]); ``` - [ ] **Step 3: Replace the `Stack.Screen` options** Find the existing `` block. Replace with: ```tsx ( { if (!id) return; void startCall(id); }} hitSlop={10} style={styles.callBtn} > 📞 ), }} /> ``` - [ ] **Step 4: Add styles** Append to the existing `StyleSheet.create({ ... })`: ```ts callBtn: { paddingHorizontal: 10, paddingVertical: 4, }, callBtnText: { fontSize: 18 }, ``` - [ ] **Step 5: Typecheck + commit** ```bash pnpm --filter @chat-app/mobile typecheck git add 'apps/mobile/app/(app)/conversations/[id].tsx' git commit -m "feat(mobile): phone-icon header button + navigate to /call on connect" ``` --- ## Task 9: Workspace typecheck - [ ] **Step 1: Full typecheck** ```bash pnpm typecheck ``` Expected: exit 0 across all packages. - [ ] **Step 2: No commit at this step.** --- ## Self-Review Notes **Spec coverage:** §1 deps→T1, §2 perms→T1, §3 signaling→T2, §4 state machine→T3, §5 routing→T3, §6 call screen→T7, §7 incoming modal→T4+T5, §8 entry→T8. **Type consistency:** `CallState` discriminated union is the single source of truth across `callContext.tsx`, `IncomingCallModal.tsx`, `call.tsx`, and `[id].tsx`. `Room`, `Track`, `AudioSession` come from `@livekit/react-native` only. **Known follow-ups (Phase 3.5):** native CallKit / ConnectionService, VoIP push wake-up, video tracks, missed-call list, Bluetooth routing menu.