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

32 KiB

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

pnpm --filter @chat-app/mobile add @livekit/react-native @livekit/react-native-webrtc
  • Step 2: Replace apps/mobile/app.json with
{
  "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
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

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
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

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
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

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
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

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
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

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
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

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
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):

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:

  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:

      <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({ ... }):

  callBtn: {
    paddingHorizontal: 10,
    paddingVertical: 4,
  },
  callBtnText: { fontSize: 18 },
  • Step 5: Typecheck + commit
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
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.