diff --git a/docs/superpowers/plans/2026-05-14-mobile-phase-3-voice-calls.md b/docs/superpowers/plans/2026-05-14-mobile-phase-3-voice-calls.md new file mode 100644 index 0000000..281b14e --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-mobile-phase-3-voice-calls.md @@ -0,0 +1,1095 @@ +# 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. diff --git a/docs/superpowers/specs/2026-05-14-mobile-phase-3-voice-calls-design.md b/docs/superpowers/specs/2026-05-14-mobile-phase-3-voice-calls-design.md new file mode 100644 index 0000000..47dcb4e --- /dev/null +++ b/docs/superpowers/specs/2026-05-14-mobile-phase-3-voice-calls-design.md @@ -0,0 +1,167 @@ +# Mobile Phase 3 — Voice Calls + +**Date:** 2026-05-14 +**Scope:** `apps/mobile` +**Roadmap context:** `2026-05-13-mobile-deployment-roadmap.md` + +--- + +## Problem + +Phases 1+2 cover messaging. The desktop ships voice/video calls via LiveKit; without parity on mobile the app feels incomplete. Phase 3 brings audio calls (1:1 DM + groups) into the mobile client over the same LiveKit + Supabase-realtime signaling stack the desktop already uses. + +## Goal + +After Phase 3, a Netralax mobile user can: + +1. Initiate a voice call from a conversation header — "Anrufen" button next to the title. +2. Receive an incoming-call modal when the peer / a group member starts a call, with **Annehmen** / **Ablehnen** buttons. +3. Join the LiveKit room on accept, hear other participants, and be heard. +4. Toggle mute + lautsprecher (speaker/earpiece), and end the call with the red hangup button. +5. See a participant list in the in-call screen so they know who's on the line. + +## Non-goals + +- **Video calls** — voice-only MVP. Camera toggle UI is reserved for Phase 3.5 because it requires extra permission strings, camera previews, and view tracks that double the surface. +- **CallKit / ConnectionService native UI** — the OS-level "incoming call" screen requires `react-native-callkeep` + native config + APNS VoIP / FCM data-only payloads. Phase 3 ships an in-app modal; native CallKit is Phase 3.5. +- **Push-based wakeup** — if the app is killed, the user doesn't get notified of an incoming call. Realtime subscription only works while the app is open. +- **Screen sharing** — explicitly out of scope on mobile. +- **Call recording, captions, soundboard** — desktop-only conveniences, post-Phase-4. +- **Call-stream end-to-end encryption beyond what the LiveKit token mint already enforces** — server-side RLS + short-lived JWT handle authorisation. + +## Design + +### 1. New dependencies + +- `@livekit/react-native` — the LiveKit RN SDK; provides `Room`, `LocalParticipant`, `RemoteParticipant`, `AudioSession`. +- `@livekit/react-native-webrtc` — peer dep that ships the actual WebRTC stack as native modules. + +Both LiveKit packages require native code, so the workspace already runs through EAS Dev Client (Phase 0 set up the dev profile). The `@livekit/react-native` Expo plugin needs registration in `app.json`. + +### 2. Permissions + +`app.json` gains `NSMicrophoneUsageDescription` (via plugin config) plus the Android `RECORD_AUDIO` permission. No camera string in Phase 3 since we don't capture video. Also add iOS `audio` background mode so the LiveKit room stays alive when the user backgrounds the app mid-call. + +### 3. Call signaling + +`apps/mobile/lib/callSignal.ts` — Supabase realtime channel subscription: + +- `subscribeCallSignals(client, userId, onSignal)` subscribes to `signalTopic(userId)`, parses `CallSignal` payloads, returns an unsubscribe function. +- `sendCallSignal(client, toUserId, payload)` broadcasts to the peer's channel. + +Mirrors the desktop pattern but in a much smaller surface — the desktop's CallContext is ~3000 lines because of features we're not shipping (active speaker, captions, screen share, soundboard, stats overlay, etc.). The mobile Phase 3 equivalent is ~400 lines. + +### 4. Call state machine + +`apps/mobile/lib/callContext.tsx` — React context holding: + +```ts +type CallState = + | { kind: 'idle' } + | { kind: 'outgoing'; callId: string; conversationId: string; peers: string[] } + | { kind: 'incoming'; callId: string; conversationId: string; fromUserId: string } + | { kind: 'connecting'; callId: string; conversationId: string } + | { kind: 'connected'; callId: string; conversationId: string; room: Room; muted: boolean; speakerOn: boolean } + | { kind: 'ended'; reason: 'normal' | 'rejected' | 'cancelled' | 'error' }; +``` + +Exposed actions: + +- `startCall(conversationId)` — sends `invite` signals to every other member, transitions to `outgoing`, then joins the LiveKit room immediately so the user is "in" once the first peer accepts. +- `acceptIncoming()` — fetches LiveKit token, connects, sends `accept`, transitions to `connected`. +- `rejectIncoming()` — sends `reject`, transitions to `idle`. +- `cancelOutgoing()` — sends `cancel` to each invited peer, transitions to `idle`. +- `endCall()` — disconnects from LiveKit, sends `end` to participants, transitions to `idle`. +- `toggleMute()` — un/publishes the mic track. +- `toggleSpeaker()` — flips between speaker and earpiece via LiveKit's `AudioSession`. + +The provider subscribes to call signals via §3 and surfaces incoming invites to whichever screen is currently mounted. + +### 5. Audio routing + +LiveKit RN's `AudioSession.startAudioSession()` + `selectAudioOutput()` handle the platform plumbing: + +- iOS: AVAudioSession category `playAndRecord`, with the speaker on/off based on the toggle. +- Android: AudioManager speakerphone flag. + +On `acceptIncoming()` and `startCall()` we call `AudioSession.startAudioSession()`; on `endCall()` we call `AudioSession.stopAudioSession()`. + +### 6. Call screen UI + +`apps/mobile/app/(app)/call.tsx` — full-screen route shown when `state.kind === 'connected'`. + +Layout: + +- Top: conversation name + call duration ("00:42"). +- Middle: vertical list of participants — own + remotes — with avatars + names. "verbindet…" until they join, "spricht" emerald dot when active speaker. +- Bottom: 3-button toolbar — Mute, Speaker, Hangup. Hangup is a red circle, the others pill-style. + +Routes are gated: + +- If `state.kind === 'connected'` and we're not on `/call` → router pushes `/call`. +- If `state.kind === 'idle'` and we are on `/call` → router pops. +- If `state.kind === 'incoming'` → `IncomingCallModal` (§7) renders over the current screen. + +### 7. Incoming-call modal + +`apps/mobile/components/IncomingCallModal.tsx` — full-screen modal mounted at the root layout so it surfaces over any screen. + +- Visible when `state.kind === 'incoming'`. +- Shows caller name (resolved via the conversation members), big avatar, ringing animation. +- Two buttons: **Annehmen** (green) and **Ablehnen** (red). +- Tap Annehmen → `acceptIncoming()` → routes to `/call` on success. + +### 8. Conversation entry points + +Add a phone-icon button in the conversation-detail header (`[id].tsx` Stack.Screen `headerRight`). On tap → `startCall(id)`. The icon is a simple PNG-character `📞` for the MVP — a vector icon set is a polish-pass. + +### 9. Edge cases handled + +- **App backgrounding mid-call:** LiveKit stays connected; audio continues. iOS `audio` background mode is required in `app.json` — added. +- **Network loss:** LiveKit auto-reconnects. We surface "Verbindung verloren — Wiederverbinden…" in the call screen. +- **Caller hangs up before answer:** the modal listens for `cancel` and auto-dismisses. +- **Both sides hang up simultaneously:** dual `end` signals are idempotent. +- **Joining a call that's already started in a group:** every member who got an invite can join the same LiveKit room. + +## File structure (deltas) + +| File | Status | Responsibility | +|---|---|---| +| `apps/mobile/package.json` | MODIFIED | Add `@livekit/react-native`, `@livekit/react-native-webrtc` | +| `apps/mobile/app.json` | MODIFIED | Microphone permission, iOS `audio` background mode, LiveKit plugin | +| `apps/mobile/lib/callSignal.ts` | NEW | Realtime subscribe/publish helpers | +| `apps/mobile/lib/callContext.tsx` | NEW | Call state machine, signal dispatch, LiveKit room lifecycle | +| `apps/mobile/components/IncomingCallModal.tsx` | NEW | Annehmen / Ablehnen modal | +| `apps/mobile/app/_layout.tsx` | MODIFIED | Mount `CallProvider` under `AuthProvider`; render `IncomingCallModal` at root | +| `apps/mobile/app/(app)/call.tsx` | NEW | In-call full-screen UI | +| `apps/mobile/app/(app)/_layout.tsx` | MODIFIED | Register the `call` route | +| `apps/mobile/app/(app)/conversations/[id].tsx` | MODIFIED | Phone-icon header button → `startCall(id)` | + +## Risks + +- **`@livekit/react-native-webrtc` + New Architecture.** Supported but a moving target. If a runtime crash surfaces under `newArchEnabled: true`, drop to `false` for the next dev build. +- **Audio session conflicts.** Other apps holding the audio session may interrupt. LiveKit's `AudioSession` API requests focus; we accept brief interruptions. +- **Realtime channel dies on app sleep.** Backgrounded app → iOS budgets out the JS bridge after ~30s. Phase 3 accepts this; Phase 3.5 with CallKit + VoIP push fixes it. +- **Token endpoint dependency.** The `mint-livekit-token` edge function must accept the mobile session JWT — same as desktop, server-side RLS already in place. + +## Verification + +1. `pnpm --filter @chat-app/mobile typecheck` exits 0. +2. With the mobile dev client built + desktop signed into the same account, on real hardware: + - Mobile starts a call → desktop sees incoming-call panel. + - Desktop accepts → audio flows both ways. + - Mute / speaker toggles work. + - Hangup ends the call cleanly on both sides. +3. Reverse direction. +4. Reject + cancel flows. +5. Group: any one member starts → all others get the modal; multiple can join. + +## Out of scope (Phase 3.5 / 4) + +- Video calls (camera + view tracks). +- Native CallKit / ConnectionService. +- VoIP push to wake the app from killed/background. +- Call history / missed-call UI on the chat list. +- Screen sharing. +- Active-speaker reorder, captions, in-call soundboard. +- Bluetooth headset routing menu beyond the simple toggle.