177a4c059f
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>
1096 lines
32 KiB
Markdown
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.
|