Files
ChatApp/docs/superpowers/plans/2026-05-14-mobile-phase-3-voice-calls.md
byGalax 177a4c059f docs(mobile): phase 3 spec + plan — voice calls
9 tasks adding voice calls (1:1 + group) to the mobile app over the
same LiveKit + Supabase signaling stack the desktop uses:

  1. @livekit/react-native + @livekit/react-native-webrtc deps, mic
     permission strings, audio background mode, LiveKit Expo plugin.
  2. callSignal.ts subscribe/publish helpers over Supabase realtime.
  3. callContext.tsx state machine (idle/outgoing/incoming/connecting/
     connected/ended) + LiveKit room lifecycle + audio routing.
  4. IncomingCallModal at root with Annehmen/Ablehnen.
  5. Mount CallProvider + global IncomingCallModal in _layout.tsx.
  6. Register /call full-screen modal route.
  7. In-call screen with participants list + mute/speaker/hangup.
  8. Phone-icon header button on conversation detail + push to /call
     on connect.
  9. Workspace typecheck pass.

Out of scope: video, CallKit / ConnectionService native UI, VoIP push
wake-up, screen sharing, call history. Those are Phase 3.5 / 4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 06:04:20 +02:00

1096 lines
32 KiB
Markdown

# 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<void> {
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<void>;
acceptIncoming: () => Promise<void>;
rejectIncoming: () => Promise<void>;
cancelOutgoing: () => Promise<void>;
endCall: () => Promise<void>;
toggleMute: () => Promise<void>;
toggleSpeaker: () => Promise<void>;
}
const Ctx = createContext<CallContextValue | null>(null);
export function useCall(): CallContextValue {
const v = useContext(Ctx);
if (!v) throw new Error('useCall() called outside <CallProvider>');
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<CallState>({ kind: 'idle' });
const roomRef = useRef<Room | null>(null);
const participantsRef = useRef<Map<Identity, RemoteParticipantSummary>>(new Map());
const peerIdsFor = useCallback(
async (conversationId: string): Promise<Identity[]> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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 <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
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<ConversationSummary | null>(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 (
<Modal visible={visible} transparent animationType="fade" onRequestClose={rejectIncoming}>
<View style={styles.container}>
<Text style={styles.subtitle}>Eingehender Anruf</Text>
<Avatar name={callerName} size={96} />
<Text style={styles.title}>{callerName}</Text>
{conversation?.type === 'group' && (
<Text style={styles.subtitle}>in {conversationTitle}</Text>
)}
<View style={styles.actions}>
<Pressable
style={[styles.button, styles.buttonReject]}
onPress={() => {
void rejectIncoming();
}}
>
<Text style={styles.buttonText}>Ablehnen</Text>
</Pressable>
<Pressable
style={[styles.button, styles.buttonAccept]}
onPress={() => {
void acceptIncoming();
}}
>
<Text style={styles.buttonText}>Annehmen</Text>
</Pressable>
</View>
</View>
</Modal>
);
}
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 (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<ErrorBoundary>
<AuthProvider>
<CallProvider>
<StatusBar style="auto" />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="(app)" />
<Stack.Screen name="auth/callback" />
</Stack>
<IncomingCallModal />
</CallProvider>
</AuthProvider>
</ErrorBoundary>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}
```
- [ ] **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 <Redirect href="/" />;
return (
<Stack screenOptions={{ headerShown: true }}>
<Stack.Screen name="chats" />
<Stack.Screen name="conversations/[id]" />
<Stack.Screen
name="call"
options={{ headerShown: false, presentation: 'fullScreenModal' }}
/>
</Stack>
);
}
```
- [ ] **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 (
<View style={styles.container}>
<Text style={styles.title}>Anruf beendet</Text>
</View>
);
}
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 (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>{connecting ? 'Verbinde …' : 'Anruf läuft'}</Text>
{state.kind === 'connected' && <Text style={styles.duration}>{duration}</Text>}
</View>
<View style={styles.participantList}>
{participants.length === 0 && (
<Text style={styles.empty}>Warten auf Teilnehmer </Text>
)}
{participants.map((p) => (
<View key={p.identity} style={styles.participantRow}>
<Avatar name={p.name} size={48} />
<View style={styles.participantText}>
<Text style={styles.participantName}>{p.name}</Text>
<Text style={[styles.participantStatus, p.speaking && styles.participantSpeaking]}>
{p.speaking ? 'Spricht …' : 'Stumm'}
</Text>
</View>
</View>
))}
</View>
<View style={styles.toolbar}>
<ToolbarButton
label={muted ? 'Stumm' : 'Mikro'}
active={muted}
onPress={() => {
void toggleMute();
}}
/>
<ToolbarButton
label={speakerOn ? 'Lautsprecher' : 'Hörer'}
active={speakerOn}
onPress={() => {
void toggleSpeaker();
}}
/>
<Pressable
style={styles.hangup}
onPress={() => {
void endCall();
}}
>
<Text style={styles.hangupText}>Auflegen</Text>
</Pressable>
</View>
</View>
);
}
function ToolbarButton({
label,
active,
onPress,
}: {
label: string;
active: boolean;
onPress: () => void;
}) {
return (
<Pressable
onPress={onPress}
style={[styles.toolbarButton, active && styles.toolbarButtonActive]}
>
<Text style={[styles.toolbarButtonText, active && styles.toolbarButtonTextActive]}>
{label}
</Text>
</Pressable>
);
}
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 `<Stack.Screen options={{ title, headerStyle: ..., headerTitleStyle: ..., headerBackTitle: 'Chats' }} />` block. Replace with:
```tsx
<Stack.Screen
options={{
title,
headerStyle: { backgroundColor: colors.bg },
headerTitleStyle: { color: colors.text },
headerBackTitle: 'Chats',
headerRight: () => (
<Pressable
onPress={() => {
if (!id) return;
void startCall(id);
}}
hitSlop={10}
style={styles.callBtn}
>
<Text style={styles.callBtnText}>📞</Text>
</Pressable>
),
}}
/>
```
- [ ] **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.