Files
ChatApp/docs/superpowers/plans/2026-05-13-mobile-phase-1-auth-chat-mvp.md
T
byGalax 035b5f078d docs(mobile): phase 1 spec + plan — Auth + Chat MVP
11 tasks taking the mobile app from Phase 0 shell to a working
end-to-end magic-link login + conversation list + decrypted detail +
text-send. All renderer-only, plumbed through @chat-app/shared's
CryptoBackend / SecretStore / Supabase-session-storage interfaces:

  1. Foundation deps (AsyncStorage), env reader, theme constants.
  2. Crypto + secret-store + session-storage adapters.
  3. Supabase client.
  4. AuthProvider + cleanup of Phase-0 sharedSmoke canary.
  5. Session-gated (app) layout.
  6. Magic-link login screen.
  7. Auth-callback deep-link screen.
  8. Avatar + ConversationRow + timeFormat primitives.
  9. Conversation list with pull-to-refresh + logout.
 10. Conversation detail with decrypt + send.
 11. Workspace-wide typecheck pass.

Push notifications, realtime, attachments, voice messages, edits,
reactions, calls are explicitly deferred to later phases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:52:37 +02:00

41 KiB

Mobile Phase 1 — Auth + Chat MVP Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (- [ ]) syntax for tracking. Implementer subagents typecheck before every commit.

Goal: A user on iOS/Android can log in with a magic link, see their conversations, open one, read the message history, and send a text message that the desktop receives.

Architecture: Plug react-native-libsodium + expo-secure-store + @react-native-async-storage/async-storage into packages/shared's CryptoBackend / SecretStore / Supabase-session-storage interfaces; mount an AuthProvider that drives session + device-key registration; rewrite the Expo Router screens around it.

Tech Stack: Expo SDK 52, React Native 0.76 (New Arch), expo-router 4, expo-secure-store, expo-application, react-native-libsodium 1.7, @react-native-async-storage/async-storage (added Task 1), @chat-app/shared, TypeScript 5.6.

Spec: docs/superpowers/specs/2026-05-13-mobile-phase-1-auth-chat-mvp-design.md

Testing note: No device emulator in this loop. Each task's gate is pnpm --filter @chat-app/mobile typecheck + a code-level self-check that the file matches the spec section. Manual on-device verification is the user's job at the end.


File structure (touchpoints in this plan)

File Status Responsibility
apps/mobile/package.json MODIFIED Add @react-native-async-storage/async-storage
apps/mobile/.env.example NEW Documents EXPO_PUBLIC_* vars
apps/mobile/theme/colors.ts NEW Centralised hex constants
apps/mobile/lib/env.ts NEW EXPO_PUBLIC_* reader
apps/mobile/lib/secretStore.ts NEW SecretStore via expo-secure-store
apps/mobile/lib/sessionStorage.ts NEW AsyncStorage re-export for Supabase
apps/mobile/lib/cryptoBackend.ts NEW CryptoBackend via react-native-libsodium
apps/mobile/lib/supabase.ts NEW Configured AppSupabaseClient
apps/mobile/lib/timeFormat.ts NEW Relative time helper ("vor 5 Min")
apps/mobile/lib/authContext.tsx NEW Session + device provider, hook
apps/mobile/components/Avatar.tsx NEW Initial-letter avatar circle
apps/mobile/components/ConversationRow.tsx NEW Chat list row
apps/mobile/app/_layout.tsx MODIFIED Register crypto, mount AuthProvider, drop sharedSmoke
apps/mobile/app/index.tsx MODIFIED Login screen with magic-link flow
apps/mobile/app/auth/callback.tsx NEW Deep-link landing
apps/mobile/app/(app)/_layout.tsx MODIFIED Session-gated Stack
apps/mobile/app/(app)/chats.tsx MODIFIED Real conversation list + logout button
apps/mobile/app/(app)/conversations/[id].tsx NEW Conversation detail + send
apps/mobile/lib/sharedSmoke.ts DELETED Phase-0 canary served its purpose

Task 1: Foundation deps + env + theme

Files:

  • Modify: apps/mobile/package.json (via pnpm add)

  • Create: apps/mobile/.env.example, apps/mobile/theme/colors.ts, apps/mobile/lib/env.ts

  • Step 1: Add the AsyncStorage dependency

pnpm --filter @chat-app/mobile add @react-native-async-storage/async-storage
  • Step 2: Create apps/mobile/.env.example
# Copy to `.env.local` and fill in. Expo bundles only EXPO_PUBLIC_* vars
# into the JS, which is what we want for these — they are public Supabase
# project keys (anon key + URL) protected by RLS on the server.

EXPO_PUBLIC_SUPABASE_URL=https://your-supabase-host.example
EXPO_PUBLIC_SUPABASE_ANON_KEY=sb_publishable_...
EXPO_PUBLIC_AUTH_REDIRECT_URL=netralax://auth/callback
  • Step 3: Create apps/mobile/theme/colors.ts
// Centralised hex constants. Anything new screen / component should
// import from here instead of inlining a literal so we have a single
// place to swap brand tones later. The Phase-0 review flagged five
// hex codes duplicated across components — this is the fix.
export const colors = {
  // Surfaces / chrome
  bg: '#0b0b0f',
  surface: '#16161c',
  border: '#27272e',

  // Text
  text: '#ffffff',
  textMuted: '#9ca3af',
  textDim: '#6b7280',

  // Accents
  accent: '#5865f2',
  accentMuted: 'rgba(88,101,242,0.15)',

  // Status
  danger: '#ef4444',
  success: '#10b981',
} as const;

export type ColorKey = keyof typeof colors;
  • Step 4: Create apps/mobile/lib/env.ts
// EXPO_PUBLIC_* vars are inlined at bundle time by Expo's Babel plugin.
// We pull them through a single typed module so a missing var is a loud
// startup error rather than a confusing Supabase 401 later.

function required(name: string): string {
  const v = process.env[name];
  if (!v || v.length === 0) {
    throw new Error(
      'Missing required env var ' + name + '. Set it in apps/mobile/.env.local — see .env.example.',
    );
  }
  return v;
}

export const env = {
  supabaseUrl: required('EXPO_PUBLIC_SUPABASE_URL'),
  supabaseAnonKey: required('EXPO_PUBLIC_SUPABASE_ANON_KEY'),
  authRedirectUrl: process.env.EXPO_PUBLIC_AUTH_REDIRECT_URL ?? 'netralax://auth/callback',
} as const;
  • Step 5: Typecheck + commit
pnpm --filter @chat-app/mobile typecheck
git add apps/mobile/package.json pnpm-lock.yaml apps/mobile/.env.example apps/mobile/theme/colors.ts apps/mobile/lib/env.ts
git commit -m "feat(mobile): add AsyncStorage dep + env reader + theme constants"

Task 2: Adapters (crypto, secret store, session storage)

Files:

  • Create: apps/mobile/lib/cryptoBackend.ts, apps/mobile/lib/secretStore.ts, apps/mobile/lib/sessionStorage.ts

  • Step 1: Create apps/mobile/lib/cryptoBackend.ts

import * as s from 'react-native-libsodium';
import type { CryptoBackend } from '@chat-app/shared/crypto';

// react-native-libsodium re-exports libsodium-wrappers' API shape, so
// this adapter is the synchronous twin of the desktop one
// (`apps/desktop/src/lib/cryptoBackend.ts`). No WASM warm-up gate is
// needed — the native module is ready as soon as the module loads.
export function createLibsodiumBackend(): CryptoBackend {
  return {
    name: 'react-native-libsodium',
    nonceLength: s.crypto_box_NONCEBYTES,
    publicKeyLength: s.crypto_box_PUBLICKEYBYTES,
    privateKeyLength: s.crypto_box_SECRETKEYBYTES,
    secretboxKeyLength: s.crypto_secretbox_KEYBYTES,
    secretboxNonceLength: s.crypto_secretbox_NONCEBYTES,
    randomBytes: (n) => s.randombytes_buf(n),
    generateKeyPair: () => {
      const kp = s.crypto_box_keypair();
      return { publicKey: kp.publicKey, privateKey: kp.privateKey };
    },
    box: (plaintext, nonce, recipientPublicKey, senderPrivateKey) =>
      s.crypto_box_easy(plaintext, nonce, recipientPublicKey, senderPrivateKey),
    boxOpen: (ciphertext, nonce, senderPublicKey, recipientPrivateKey) =>
      s.crypto_box_open_easy(ciphertext, nonce, senderPublicKey, recipientPrivateKey),
    secretbox: (plaintext, nonce, key) => s.crypto_secretbox_easy(plaintext, nonce, key),
    secretboxOpen: (ciphertext, nonce, key) => s.crypto_secretbox_open_easy(ciphertext, nonce, key),
  };
}
  • Step 2: Create apps/mobile/lib/secretStore.ts
import { Buffer } from 'buffer';
import * as SecureStore from 'expo-secure-store';
import type { SecretStore } from '@chat-app/shared/auth';

// SecretStore contract uses Uint8Array values; SecureStore only takes
// strings, so we base64 at the boundary. iOS Keychain max value size
// is generous (a few MB); private keys are 32 bytes so we are well
// within limits.
export const secretStore: SecretStore = {
  async getSecret(key) {
    const v = await SecureStore.getItemAsync(key);
    return v ? new Uint8Array(Buffer.from(v, 'base64')) : null;
  },
  async setSecret(key, value) {
    await SecureStore.setItemAsync(key, Buffer.from(value).toString('base64'));
  },
  async removeSecret(key) {
    await SecureStore.deleteItemAsync(key);
  },
};
  • Step 3: Create apps/mobile/lib/sessionStorage.ts
import AsyncStorage from '@react-native-async-storage/async-storage';

// supabase-js v2 accepts any object with async getItem / setItem /
// removeItem returning Promise<string | null> / Promise<void>. RN's
// AsyncStorage matches that shape for shape; we just re-export it
// under a name that signals intent at the call site.
export const sessionStorage = AsyncStorage;
  • Step 4: Typecheck + commit
pnpm --filter @chat-app/mobile typecheck
git add apps/mobile/lib/cryptoBackend.ts apps/mobile/lib/secretStore.ts apps/mobile/lib/sessionStorage.ts
git commit -m "feat(mobile): crypto + secret-store + session-storage adapters"

Task 3: Supabase client

Files:

  • Create: apps/mobile/lib/supabase.ts

  • Step 1: Create apps/mobile/lib/supabase.ts

import { createClient } from '@chat-app/shared/supabase';

import { env } from './env';
import { sessionStorage } from './sessionStorage';

// Single Supabase client instance for the mobile app. Token storage goes
// to AsyncStorage so the session survives reboots. detectSessionInUrl is
// false because the magic-link callback is handled by our own
// app/auth/callback.tsx screen — Expo Router routes the deep link to
// that file, and we parse it explicitly.
export const supabase = createClient({
  url: env.supabaseUrl,
  anonKey: env.supabaseAnonKey,
  sessionStorage,
  detectSessionInUrl: false,
});
  • Step 2: Typecheck + commit
pnpm --filter @chat-app/mobile typecheck
git add apps/mobile/lib/supabase.ts
git commit -m "feat(mobile): supabase client wired through async-storage session"

Task 4: Auth context provider + cleanup of sharedSmoke

Files:

  • Create: apps/mobile/lib/authContext.tsx

  • Modify: apps/mobile/app/_layout.tsx

  • Delete: apps/mobile/lib/sharedSmoke.ts

  • Step 1: Create apps/mobile/lib/authContext.tsx

import type { Session, User } from '@supabase/supabase-js';
import { auth, crypto, type DeviceRecord } from '@chat-app/shared';
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { Platform } from 'react-native';

import { secretStore } from './secretStore';
import { supabase } from './supabase';

// Locally-stored secrets keyed by stable names. Mirrors the desktop
// convention so the migration tests (later) can compare snapshots.
const KEY_DEVICE_ID = 'device.id';
const KEY_DEVICE_PRIVKEY = 'device.privateKey';

interface AuthContextValue {
  session: Session | null;
  user: User | null;
  device: DeviceRecord | null;
  ownPrivateKey: Uint8Array | null;
  loading: boolean;
  signOut: () => Promise<void>;
}

const Ctx = createContext<AuthContextValue | null>(null);

export function useAuth(): AuthContextValue {
  const v = useContext(Ctx);
  if (!v) throw new Error('useAuth() called outside <AuthProvider>');
  return v;
}

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [session, setSession] = useState<Session | null>(null);
  const [device, setDevice] = useState<DeviceRecord | null>(null);
  const [ownPrivateKey, setOwnPrivateKey] = useState<Uint8Array | null>(null);
  const [loading, setLoading] = useState(true);

  // Resolve or create the device record for this install given an active
  // session. Stores the private key in expo-secure-store on first run.
  const ensureDevice = useCallback(async (currentSession: Session): Promise<void> => {
    const userId = currentSession.user.id;
    const savedDeviceId = await secretStore.getSecret(KEY_DEVICE_ID);
    const savedPrivKey = await secretStore.getSecret(KEY_DEVICE_PRIVKEY);

    if (savedDeviceId && savedPrivKey) {
      const devices = await auth.listOwnDevices(supabase);
      const deviceIdStr = new TextDecoder().decode(savedDeviceId);
      const match = devices.find((d) => d.id === deviceIdStr);
      if (match) {
        setDevice(match);
        setOwnPrivateKey(savedPrivKey);
        return;
      }
      // Stored id no longer matches any device on the server (revoked,
      // wiped). Fall through to register a fresh one.
    }

    const backend = crypto.getCryptoBackend();
    const kp = backend.generateKeyPair();
    const platform = Platform.OS === 'ios' ? 'ios' : Platform.OS === 'android' ? 'android' : 'web';
    const record = await auth.registerDevice(supabase, {
      name: 'Netralax Mobile (' + Platform.OS + ')',
      platform,
      publicKey: kp.publicKey,
    });
    await secretStore.setSecret(KEY_DEVICE_ID, new TextEncoder().encode(record.id));
    await secretStore.setSecret(KEY_DEVICE_PRIVKEY, kp.privateKey);
    setDevice(record);
    setOwnPrivateKey(kp.privateKey);
    void userId;
  }, []);

  useEffect(() => {
    let cancelled = false;
    void (async () => {
      const { data } = await supabase.auth.getSession();
      if (cancelled) return;
      setSession(data.session);
      if (data.session) {
        try {
          await ensureDevice(data.session);
        } catch (err) {
          console.warn('[auth] ensureDevice failed', err);
        }
      }
      setLoading(false);
    })();

    const { data: sub } = supabase.auth.onAuthStateChange((_event, nextSession) => {
      setSession(nextSession);
      if (!nextSession) {
        setDevice(null);
        setOwnPrivateKey(null);
      } else {
        void ensureDevice(nextSession).catch((err) =>
          console.warn('[auth] ensureDevice (state change) failed', err),
        );
      }
    });

    return () => {
      cancelled = true;
      sub.subscription.unsubscribe();
    };
  }, [ensureDevice]);

  const signOut = useCallback(async (): Promise<void> => {
    await supabase.auth.signOut();
    await secretStore.removeSecret(KEY_DEVICE_ID);
    await secretStore.removeSecret(KEY_DEVICE_PRIVKEY);
    setDevice(null);
    setOwnPrivateKey(null);
  }, []);

  const value: AuthContextValue = {
    session,
    user: session?.user ?? null,
    device,
    ownPrivateKey,
    loading,
    signOut,
  };
  return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
  • Step 2: Replace apps/mobile/app/_layout.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 { AuthProvider } from '../lib/authContext';
import { createLibsodiumBackend } from '../lib/cryptoBackend';

// Register the crypto backend exactly once, before any code that calls
// crypto.getCryptoBackend(). Doing it at module load (not inside the
// component) avoids a race where a child component renders before the
// effect fires.
crypto.setCryptoBackend(createLibsodiumBackend());

export default function RootLayout() {
  return (
    <GestureHandlerRootView style={{ flex: 1 }}>
      <SafeAreaProvider>
        <ErrorBoundary>
          <AuthProvider>
            <StatusBar style="auto" />
            <Stack screenOptions={{ headerShown: false }}>
              <Stack.Screen name="index" />
              <Stack.Screen name="(app)" />
              <Stack.Screen name="auth/callback" />
            </Stack>
          </AuthProvider>
        </ErrorBoundary>
      </SafeAreaProvider>
    </GestureHandlerRootView>
  );
}
  • Step 3: Delete apps/mobile/lib/sharedSmoke.ts
rm apps/mobile/lib/sharedSmoke.ts
  • Step 4: Typecheck + commit
pnpm --filter @chat-app/mobile typecheck
git add apps/mobile/lib/authContext.tsx apps/mobile/app/_layout.tsx apps/mobile/lib/sharedSmoke.ts
git commit -m "feat(mobile): AuthProvider + crypto-backend registration; drop sharedSmoke canary"

Task 5: Session-gated (app) layout

Files:

  • Modify: apps/mobile/app/(app)/_layout.tsx

  • Step 1: Replace the file

import { Redirect, Stack } from 'expo-router';

import { useAuth } from '../../lib/authContext';

// Guard for the authenticated route group. While the AuthProvider is
// hydrating from AsyncStorage we render nothing (a brief blank frame)
// rather than flashing the login screen and then snapping into the chat
// list — that's a worse UX than a 200ms blank.
export default function AppLayout() {
  const { session, loading } = useAuth();
  if (loading) return null;
  if (!session) return <Redirect href="/" />;
  return <Stack screenOptions={{ headerShown: true }} />;
}
  • Step 2: Typecheck + commit
pnpm --filter @chat-app/mobile typecheck
git add 'apps/mobile/app/(app)/_layout.tsx'
git commit -m "feat(mobile): session-gated (app) layout redirects to login when signed out"

Files:

  • Modify: apps/mobile/app/index.tsx

  • Step 1: Replace apps/mobile/app/index.tsx

import { auth } from '@chat-app/shared';
import { Redirect } from 'expo-router';
import { useState } from 'react';
import {
  ActivityIndicator,
  Pressable,
  StyleSheet,
  Text,
  TextInput,
  View,
} from 'react-native';

import { useAuth } from '../lib/authContext';
import { env } from '../lib/env';
import { supabase } from '../lib/supabase';
import { colors } from '../theme/colors';

// Phase 1 landing — magic-link login. Signup with invite code is not
// part of this MVP (accounts come from desktop / admin). After a
// successful request the user sees a "Check your inbox" state until
// they tap the email link and Expo Router routes the deep link to
// app/auth/callback.tsx.
export default function Landing() {
  const { session, loading } = useAuth();
  const [email, setEmail] = useState('');
  const [submitting, setSubmitting] = useState(false);
  const [sent, setSent] = useState(false);
  const [error, setError] = useState<string | null>(null);

  if (loading) return null;
  if (session) return <Redirect href="/(app)/chats" />;

  async function handleSubmit() {
    if (!email.includes('@')) {
      setError('Bitte gültige E-Mail eingeben');
      return;
    }
    setSubmitting(true);
    setError(null);
    try {
      await auth.loginWithMagicLink(supabase, email.trim(), env.authRedirectUrl);
      setSent(true);
    } catch (err: unknown) {
      setError(err instanceof Error ? err.message : 'Login fehlgeschlagen');
    } finally {
      setSubmitting(false);
    }
  }

  if (sent) {
    return (
      <View style={styles.container}>
        <Text style={styles.title}>Netralax</Text>
        <Text style={styles.subtitle}>Check deine Mails</Text>
        <Text style={styles.help}>
          Wir haben dir einen Anmelde-Link an {email} geschickt. Tipp auf den Link, um
          dich anzumelden.
        </Text>
        <Pressable
          style={[styles.button, styles.buttonGhost]}
          onPress={() => {
            setSent(false);
            setEmail('');
          }}
        >
          <Text style={styles.buttonText}>Andere E-Mail verwenden</Text>
        </Pressable>
      </View>
    );
  }

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Netralax</Text>
      <Text style={styles.subtitle}>Mit Magic-Link anmelden</Text>

      <TextInput
        value={email}
        onChangeText={setEmail}
        placeholder="du@beispiel.de"
        placeholderTextColor={colors.textDim}
        autoCapitalize="none"
        autoCorrect={false}
        keyboardType="email-address"
        textContentType="emailAddress"
        style={styles.input}
        editable={!submitting}
      />

      {error && <Text style={styles.error}>{error}</Text>}

      <Pressable
        style={[styles.button, submitting && styles.buttonDisabled]}
        onPress={handleSubmit}
        disabled={submitting}
      >
        {submitting ? (
          <ActivityIndicator color={colors.text} />
        ) : (
          <Text style={styles.buttonText}>Magic Link senden</Text>
        )}
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
    backgroundColor: colors.bg,
    padding: 24,
  },
  title: { color: colors.text, fontSize: 32, fontWeight: '700', letterSpacing: 1, marginBottom: 8 },
  subtitle: { color: colors.textMuted, fontSize: 14, marginBottom: 24 },
  help: {
    color: colors.textMuted,
    fontSize: 14,
    textAlign: 'center',
    marginHorizontal: 16,
    marginBottom: 24,
    lineHeight: 20,
  },
  input: {
    width: '100%',
    maxWidth: 360,
    backgroundColor: colors.surface,
    borderColor: colors.border,
    borderWidth: 1,
    borderRadius: 12,
    color: colors.text,
    paddingHorizontal: 16,
    paddingVertical: 14,
    fontSize: 15,
    marginBottom: 12,
  },
  error: { color: colors.danger, fontSize: 13, marginBottom: 12 },
  button: {
    width: '100%',
    maxWidth: 360,
    backgroundColor: colors.accent,
    paddingHorizontal: 24,
    paddingVertical: 14,
    borderRadius: 12,
    alignItems: 'center',
  },
  buttonGhost: { backgroundColor: 'transparent', borderWidth: 1, borderColor: colors.border },
  buttonDisabled: { opacity: 0.6 },
  buttonText: { color: colors.text, fontWeight: '600', fontSize: 15 },
});
  • Step 2: Typecheck + commit
pnpm --filter @chat-app/mobile typecheck
git add apps/mobile/app/index.tsx
git commit -m "feat(mobile): magic-link login screen"

Files:

  • Create: apps/mobile/app/auth/callback.tsx

  • Step 1: Create apps/mobile/app/auth/callback.tsx

import { useLocalSearchParams, useRouter } from 'expo-router';
import { useEffect, useState } from 'react';
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';

import { supabase } from '../../lib/supabase';
import { colors } from '../../theme/colors';

// Supabase magic-link emails redirect to netralax://auth/callback with
// the tokens in either the URL fragment (#access_token=...&refresh_token=...)
// or the query string depending on the provider. Expo Router parses the
// query string into useLocalSearchParams. The hash portion would require
// expo-linking; we accept both shapes for safety.
export default function AuthCallback() {
  const params = useLocalSearchParams<{
    access_token?: string;
    refresh_token?: string;
    error?: string;
    error_description?: string;
  }>();
  const router = useRouter();
  const [status, setStatus] = useState<'working' | 'error'>('working');
  const [message, setMessage] = useState<string>('');

  useEffect(() => {
    void (async () => {
      if (params.error) {
        setStatus('error');
        setMessage(params.error_description ?? params.error);
        return;
      }
      if (!params.access_token || !params.refresh_token) {
        setStatus('error');
        setMessage('Magic-Link-URL enthielt keine Tokens.');
        return;
      }
      const { error } = await supabase.auth.setSession({
        access_token: params.access_token,
        refresh_token: params.refresh_token,
      });
      if (error) {
        setStatus('error');
        setMessage(error.message);
        return;
      }
      router.replace('/(app)/chats');
    })();
  }, [params, router]);

  return (
    <View style={styles.container}>
      {status === 'working' ? (
        <>
          <ActivityIndicator color={colors.accent} size="large" />
          <Text style={styles.text}>Du wirst angemeldet</Text>
        </>
      ) : (
        <>
          <Text style={styles.errorTitle}>Anmeldung fehlgeschlagen</Text>
          <Text style={styles.text}>{message}</Text>
        </>
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
    backgroundColor: colors.bg,
    padding: 24,
    gap: 16,
  },
  text: { color: colors.textMuted, fontSize: 14, textAlign: 'center' },
  errorTitle: { color: colors.danger, fontSize: 18, fontWeight: '600' },
});
  • Step 2: Typecheck + commit
pnpm --filter @chat-app/mobile typecheck
git add apps/mobile/app/auth/callback.tsx
git commit -m "feat(mobile): auth callback screen consumes magic-link tokens"

Task 8: UI primitives — Avatar, ConversationRow, timeFormat

Files:

  • Create: apps/mobile/components/Avatar.tsx, apps/mobile/components/ConversationRow.tsx, apps/mobile/lib/timeFormat.ts

  • Step 1: Create apps/mobile/lib/timeFormat.ts

// Minimal relative-time formatter for chat lists. Matches the desktop's
// terse style ("Vor 5 Min", "Gestern", "12.05.").
export function formatRelativeTime(iso: string | null): string {
  if (!iso) return '';
  const then = new Date(iso).getTime();
  if (!Number.isFinite(then)) return '';
  const diffSec = (Date.now() - then) / 1000;
  if (diffSec < 60) return 'Gerade eben';
  if (diffSec < 3600) return 'Vor ' + Math.floor(diffSec / 60) + ' Min';
  if (diffSec < 86400) return 'Vor ' + Math.floor(diffSec / 3600) + ' Std';
  if (diffSec < 86400 * 2) return 'Gestern';
  const d = new Date(iso);
  return [
    String(d.getDate()).padStart(2, '0'),
    String(d.getMonth() + 1).padStart(2, '0'),
    String(d.getFullYear()).slice(-2),
  ].join('.');
}
  • Step 2: Create apps/mobile/components/Avatar.tsx
import { StyleSheet, Text, View } from 'react-native';

import { colors } from '../theme/colors';

// Initial-letter avatar circle. Phase 2 will swap in an image-aware
// version that prefers profile.avatarUrl when present.
export function Avatar({ name, size = 40 }: { name: string; size?: number }) {
  const letter = (name.trim().charAt(0) || '?').toUpperCase();
  return (
    <View style={[styles.circle, { width: size, height: size, borderRadius: size / 2 }]}>
      <Text style={[styles.letter, { fontSize: size * 0.45 }]}>{letter}</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  circle: {
    backgroundColor: colors.accentMuted,
    alignItems: 'center',
    justifyContent: 'center',
  },
  letter: { color: colors.accent, fontWeight: '700' },
});
  • Step 3: Create apps/mobile/components/ConversationRow.tsx
import type { ConversationSummary } from '@chat-app/shared/chat';
import { Pressable, StyleSheet, Text, View } from 'react-native';

import { formatRelativeTime } from '../lib/timeFormat';
import { colors } from '../theme/colors';
import { Avatar } from './Avatar';

interface Props {
  conversation: ConversationSummary;
  lastMessagePreview: string;
  onPress: () => void;
}

export function ConversationRow({ conversation, lastMessagePreview, onPress }: Props) {
  const title =
    conversation.type === 'group'
      ? (conversation.name ?? 'Gruppe')
      : (conversation.peer?.displayName ?? '—');
  const subtitle =
    conversation.type === 'group'
      ? conversation.members.length + ' Mitglieder'
      : '@' + (conversation.peer?.username ?? '');

  return (
    <Pressable
      style={({ pressed }) => [styles.row, pressed && { backgroundColor: colors.surface }]}
      onPress={onPress}
    >
      <Avatar name={title} />
      <View style={styles.center}>
        <View style={styles.titleLine}>
          <Text style={styles.title} numberOfLines={1}>
            {title}
          </Text>
          <Text style={styles.time}>{formatRelativeTime(conversation.lastMessageAt)}</Text>
        </View>
        <Text style={styles.preview} numberOfLines={1}>
          {lastMessagePreview || subtitle}
        </Text>
      </View>
    </Pressable>
  );
}

const styles = StyleSheet.create({
  row: {
    flexDirection: 'row',
    alignItems: 'center',
    paddingHorizontal: 16,
    paddingVertical: 12,
    gap: 12,
  },
  center: { flex: 1, minWidth: 0 },
  titleLine: { flexDirection: 'row', alignItems: 'baseline', gap: 8 },
  title: { color: colors.text, fontSize: 15, fontWeight: '600', flex: 1 },
  time: { color: colors.textDim, fontSize: 11 },
  preview: { color: colors.textMuted, fontSize: 13, marginTop: 2 },
});
  • Step 4: Typecheck + commit
pnpm --filter @chat-app/mobile typecheck
git add apps/mobile/lib/timeFormat.ts apps/mobile/components/Avatar.tsx apps/mobile/components/ConversationRow.tsx
git commit -m "feat(mobile): Avatar + ConversationRow + relative-time helper"

Task 9: Real chat list screen with logout

Files:

  • Modify: apps/mobile/app/(app)/chats.tsx

  • Step 1: Replace apps/mobile/app/(app)/chats.tsx

import { chat, type ConversationSummary } from '@chat-app/shared';
import { Stack, useRouter } from 'expo-router';
import { useCallback, useEffect, useState } from 'react';
import {
  ActivityIndicator,
  Alert,
  FlatList,
  Pressable,
  RefreshControl,
  StyleSheet,
  Text,
  View,
} from 'react-native';

import { ConversationRow } from '../../components/ConversationRow';
import { useAuth } from '../../lib/authContext';
import { supabase } from '../../lib/supabase';
import { colors } from '../../theme/colors';

// Phase 1 conversation list. The last-message preview is the static
// fallback ('…') — fetching + decrypting last-messages per conversation
// is a Phase 1.5 follow-up. Tap → conversation detail.
export default function Chats() {
  const router = useRouter();
  const { signOut } = useAuth();
  const [list, setList] = useState<ConversationSummary[] | null>(null);
  const [refreshing, setRefreshing] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const load = useCallback(async () => {
    setError(null);
    try {
      const result = await chat.listConversations(supabase);
      setList(result.filter((c) => !c.archived));
    } catch (err: unknown) {
      setError(err instanceof Error ? err.message : 'Konversationen konnten nicht geladen werden');
    }
  }, []);

  useEffect(() => {
    void load();
  }, [load]);

  const onRefresh = useCallback(async () => {
    setRefreshing(true);
    await load();
    setRefreshing(false);
  }, [load]);

  const confirmLogout = () => {
    Alert.alert('Abmelden', 'Diese Sitzung beenden?', [
      { text: 'Abbrechen', style: 'cancel' },
      {
        text: 'Abmelden',
        style: 'destructive',
        onPress: () => {
          void signOut();
        },
      },
    ]);
  };

  return (
    <View style={styles.container}>
      <Stack.Screen
        options={{
          title: 'Chats',
          headerStyle: { backgroundColor: colors.bg },
          headerTitleStyle: { color: colors.text },
          headerRight: () => (
            <Pressable onPress={confirmLogout} hitSlop={10}>
              <Text style={styles.logoutLink}>Abmelden</Text>
            </Pressable>
          ),
        }}
      />

      {list === null && !error && (
        <View style={styles.loading}>
          <ActivityIndicator color={colors.accent} />
        </View>
      )}

      {error && <Text style={styles.error}>{error}</Text>}

      {list && list.length === 0 && !error && (
        <View style={styles.empty}>
          <Text style={styles.emptyText}>Noch keine Konversationen.</Text>
          <Text style={styles.emptyHint}>
            Lege dir auf dem Desktop einen Chat an  er taucht hier auf, sobald du
            herunterziehst, um zu aktualisieren.
          </Text>
        </View>
      )}

      {list && list.length > 0 && (
        <FlatList
          data={list}
          keyExtractor={(item) => item.id}
          renderItem={({ item }) => (
            <ConversationRow
              conversation={item}
              lastMessagePreview="…"
              onPress={() => router.push('/(app)/conversations/' + item.id)}
            />
          )}
          refreshControl={
            <RefreshControl
              refreshing={refreshing}
              onRefresh={onRefresh}
              tintColor={colors.accent}
            />
          }
          ItemSeparatorComponent={() => <View style={styles.separator} />}
        />
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: colors.bg },
  loading: { flex: 1, alignItems: 'center', justifyContent: 'center' },
  empty: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32, gap: 8 },
  emptyText: { color: colors.text, fontSize: 16, fontWeight: '600' },
  emptyHint: { color: colors.textMuted, fontSize: 13, textAlign: 'center', lineHeight: 18 },
  error: { color: colors.danger, padding: 16, textAlign: 'center' },
  separator: { height: 1, backgroundColor: colors.border, marginLeft: 68 },
  logoutLink: { color: colors.accent, fontSize: 14, fontWeight: '600', paddingHorizontal: 8 },
});
  • Step 2: Typecheck + commit
pnpm --filter @chat-app/mobile typecheck
git add 'apps/mobile/app/(app)/chats.tsx'
git commit -m "feat(mobile): real chat list with pull-to-refresh + logout"

Task 10: Conversation detail (read + send)

Files:

  • Create: apps/mobile/app/(app)/conversations/[id].tsx

  • Step 1: Create apps/mobile/app/(app)/conversations/[id].tsx

import {
  chat,
  type ConversationSummary,
  type DecryptedMessage,
} from '@chat-app/shared';
import { Stack, useLocalSearchParams } from 'expo-router';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
  ActivityIndicator,
  FlatList,
  KeyboardAvoidingView,
  Platform,
  Pressable,
  StyleSheet,
  Text,
  TextInput,
  View,
} from 'react-native';

import { useAuth } from '../../../lib/authContext';
import { supabase } from '../../../lib/supabase';
import { colors } from '../../../theme/colors';

// Phase 1 conversation detail: fetch + decrypt last 50 messages, render
// them newest-at-bottom, and let the user send a text message. No
// realtime subscription, no attachments, no edit/delete — those are
// later phases. Pull-to-refresh re-fetches.
export default function ConversationDetail() {
  const { id } = useLocalSearchParams<{ id: string }>();
  const { user, device, ownPrivateKey } = useAuth();
  const [conversation, setConversation] = useState<ConversationSummary | null>(null);
  const [messages, setMessages] = useState<DecryptedMessage[] | null>(null);
  const [text, setText] = useState('');
  const [sending, setSending] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const listRef = useRef<FlatList<DecryptedMessage>>(null);

  const load = useCallback(async () => {
    if (!id || !device || !ownPrivateKey) return;
    setError(null);
    try {
      const all = await chat.listConversations(supabase);
      const conv = all.find((c) => c.id === id) ?? null;
      setConversation(conv);

      const ciphers = await chat.fetchConversationMessages(supabase, id, 50);
      const decrypted = await chat.decryptMessages({
        client: supabase,
        messages: ciphers,
        ownDeviceId: device.id,
        ownPrivateKey,
      });
      setMessages(decrypted);
    } catch (err: unknown) {
      setError(err instanceof Error ? err.message : 'Nachrichten konnten nicht geladen werden');
    }
  }, [id, device, ownPrivateKey]);

  useEffect(() => {
    void load();
  }, [load]);

  const members = useMemo(() => conversation?.members ?? [], [conversation]);
  const senderName = useCallback(
    (senderId: string) => {
      if (senderId === user?.id) return 'Du';
      const m = members.find((mm) => mm.userId === senderId);
      return m?.profile?.displayName ?? m?.profile?.username ?? 'Unbekannt';
    },
    [members, user],
  );

  const title =
    conversation?.type === 'group'
      ? (conversation.name ?? 'Gruppe')
      : (conversation?.peer?.displayName ?? '…');

  async function handleSend() {
    if (!text.trim() || !user || !device || !ownPrivateKey || !id || sending) return;
    setSending(true);
    setError(null);
    try {
      await chat.sendEncryptedMessage({
        client: supabase,
        conversationId: id,
        plaintext: text.trim(),
        senderUserId: user.id,
        senderDeviceId: device.id,
        senderPrivateKey: ownPrivateKey,
      });
      setText('');
      await load();
    } catch (err: unknown) {
      setError(err instanceof Error ? err.message : 'Senden fehlgeschlagen');
    } finally {
      setSending(false);
    }
  }

  return (
    <KeyboardAvoidingView
      style={styles.container}
      behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
      keyboardVerticalOffset={Platform.OS === 'ios' ? 80 : 0}
    >
      <Stack.Screen
        options={{
          title,
          headerStyle: { backgroundColor: colors.bg },
          headerTitleStyle: { color: colors.text },
          headerBackTitle: 'Chats',
        }}
      />

      {messages === null && !error && (
        <View style={styles.loading}>
          <ActivityIndicator color={colors.accent} />
        </View>
      )}

      {error && <Text style={styles.error}>{error}</Text>}

      {messages && (
        <FlatList
          ref={listRef}
          data={messages}
          keyExtractor={(m) => m.id}
          contentContainerStyle={styles.listContent}
          renderItem={({ item }) => (
            <MessageRow
              senderName={senderName(item.senderId)}
              mine={item.senderId === user?.id}
              body={item.plaintext ?? '🔒 [Entschlüsselung fehlgeschlagen]'}
              time={new Date(item.createdAt).toLocaleTimeString('de-DE', {
                hour: '2-digit',
                minute: '2-digit',
              })}
            />
          )}
          onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: false })}
        />
      )}

      <View style={styles.inputRow}>
        <TextInput
          value={text}
          onChangeText={setText}
          placeholder="Nachricht schreiben…"
          placeholderTextColor={colors.textDim}
          style={styles.input}
          multiline
          editable={!sending}
        />
        <Pressable
          style={[styles.sendBtn, (!text.trim() || sending) && styles.sendBtnDisabled]}
          disabled={!text.trim() || sending}
          onPress={handleSend}
        >
          {sending ? (
            <ActivityIndicator color={colors.text} />
          ) : (
            <Text style={styles.sendBtnText}>Senden</Text>
          )}
        </Pressable>
      </View>
    </KeyboardAvoidingView>
  );
}

function MessageRow({
  senderName,
  mine,
  body,
  time,
}: {
  senderName: string;
  mine: boolean;
  body: string;
  time: string;
}) {
  return (
    <View style={[styles.bubbleWrap, mine ? styles.bubbleWrapMine : styles.bubbleWrapOther]}>
      <View style={[styles.bubble, mine ? styles.bubbleMine : styles.bubbleOther]}>
        {!mine && <Text style={styles.sender}>{senderName}</Text>}
        <Text style={styles.body}>{body}</Text>
        <Text style={styles.time}>{time}</Text>
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: colors.bg },
  loading: { flex: 1, alignItems: 'center', justifyContent: 'center' },
  error: { color: colors.danger, padding: 12, textAlign: 'center' },
  listContent: { padding: 12, gap: 6 },
  bubbleWrap: { flexDirection: 'row', marginVertical: 2 },
  bubbleWrapMine: { justifyContent: 'flex-end' },
  bubbleWrapOther: { justifyContent: 'flex-start' },
  bubble: { maxWidth: '78%', padding: 10, borderRadius: 14 },
  bubbleMine: { backgroundColor: colors.accent },
  bubbleOther: { backgroundColor: colors.surface, borderColor: colors.border, borderWidth: 1 },
  sender: { color: colors.textMuted, fontSize: 11, fontWeight: '600', marginBottom: 4 },
  body: { color: colors.text, fontSize: 15, lineHeight: 20 },
  time: { color: colors.textDim, fontSize: 10, marginTop: 4, textAlign: 'right' },
  inputRow: {
    flexDirection: 'row',
    alignItems: 'flex-end',
    padding: 8,
    gap: 8,
    borderTopWidth: 1,
    borderTopColor: colors.border,
    backgroundColor: colors.surface,
  },
  input: {
    flex: 1,
    minHeight: 40,
    maxHeight: 120,
    color: colors.text,
    backgroundColor: colors.bg,
    borderColor: colors.border,
    borderWidth: 1,
    borderRadius: 10,
    paddingHorizontal: 12,
    paddingVertical: 10,
    fontSize: 15,
  },
  sendBtn: {
    backgroundColor: colors.accent,
    borderRadius: 10,
    paddingHorizontal: 16,
    height: 40,
    alignItems: 'center',
    justifyContent: 'center',
  },
  sendBtnDisabled: { opacity: 0.5 },
  sendBtnText: { color: colors.text, fontWeight: '600' },
});
  • Step 2: Typecheck + commit
pnpm --filter @chat-app/mobile typecheck
git add 'apps/mobile/app/(app)/conversations/[id].tsx'
git commit -m "feat(mobile): conversation detail with decrypt + send"

Task 11: End-to-end typecheck pass

Files: None.

  • Step 1: Verify the entire workspace typechecks
pnpm typecheck

Expected: exit 0 across all packages (desktop must not regress).

  • Step 2: Report Phase 1 done

No code commit at this step.


Self-Review Notes

Spec coverage: every §1–§13 has a task. theme→T1, cryptoBackend→T2, secretStore→T2, sessionStorage→T2, env→T1, supabase→T3, authContext→T4, route gating→T5+T6, login screen→T6, callback→T7, conversation list→T9 (T8 supplies primitives), conversation detail→T10, cleanup→T4.

Type consistency: useAuth() returns the same shape everywhere consumers use it. createLibsodiumBackend() is sync. supabase is a singleton. Theme colors is as const for narrow string literals.

Known follow-ups left for future phases:

  • Last-message preview shows instead of the actual decrypted last message (Phase 1.5 hook).
  • No realtime subscription — opening a chat shows the fetched-at-mount state.
  • Push notifications deferred entirely.
  • Mark-read / delivery tracking not wired (Phase 2).