# 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** ```bash pnpm --filter @chat-app/mobile add @react-native-async-storage/async-storage ``` - [ ] **Step 2: Create `apps/mobile/.env.example`** ```dotenv # 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`** ```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`** ```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** ```bash 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`** ```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`** ```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`** ```ts import AsyncStorage from '@react-native-async-storage/async-storage'; // supabase-js v2 accepts any object with async getItem / setItem / // removeItem returning Promise / Promise. 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** ```bash 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`** ```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** ```bash 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`** ```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; } const Ctx = createContext(null); export function useAuth(): AuthContextValue { const v = useContext(Ctx); if (!v) throw new Error('useAuth() called outside '); return v; } export function AuthProvider({ children }: { children: React.ReactNode }) { const [session, setSession] = useState(null); const [device, setDevice] = useState(null); const [ownPrivateKey, setOwnPrivateKey] = useState(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 => { 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 => { 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 {children}; } ``` - [ ] **Step 2: Replace `apps/mobile/app/_layout.tsx`** ```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 ( ); } ``` - [ ] **Step 3: Delete `apps/mobile/lib/sharedSmoke.ts`** ```bash rm apps/mobile/lib/sharedSmoke.ts ``` - [ ] **Step 4: Typecheck + commit** ```bash 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** ```tsx 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 ; 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): session-gated (app) layout redirects to login when signed out" ``` --- ## Task 6: Login screen (magic-link) **Files:** - Modify: `apps/mobile/app/index.tsx` - [ ] **Step 1: Replace `apps/mobile/app/index.tsx`** ```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(null); if (loading) return null; if (session) return ; 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 ( Netralax Check deine Mails Wir haben dir einen Anmelde-Link an {email} geschickt. Tipp auf den Link, um dich anzumelden. { setSent(false); setEmail(''); }} > Andere E-Mail verwenden ); } return ( Netralax Mit Magic-Link anmelden {error && {error}} {submitting ? ( ) : ( Magic Link senden )} ); } 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** ```bash pnpm --filter @chat-app/mobile typecheck git add apps/mobile/app/index.tsx git commit -m "feat(mobile): magic-link login screen" ``` --- ## Task 7: Auth-callback deep-link screen **Files:** - Create: `apps/mobile/app/auth/callback.tsx` - [ ] **Step 1: Create `apps/mobile/app/auth/callback.tsx`** ```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(''); 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 ( {status === 'working' ? ( <> Du wirst angemeldet… ) : ( <> Anmeldung fehlgeschlagen {message} )} ); } 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** ```bash 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`** ```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`** ```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 ( {letter} ); } 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`** ```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 ( [styles.row, pressed && { backgroundColor: colors.surface }]} onPress={onPress} > {title} {formatRelativeTime(conversation.lastMessageAt)} {lastMessagePreview || subtitle} ); } 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** ```bash 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`** ```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(null); const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(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 ( ( Abmelden ), }} /> {list === null && !error && ( )} {error && {error}} {list && list.length === 0 && !error && ( Noch keine Konversationen. Lege dir auf dem Desktop einen Chat an — er taucht hier auf, sobald du herunterziehst, um zu aktualisieren. )} {list && list.length > 0 && ( item.id} renderItem={({ item }) => ( router.push('/(app)/conversations/' + item.id)} /> )} refreshControl={ } ItemSeparatorComponent={() => } /> )} ); } 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** ```bash 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`** ```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(null); const [messages, setMessages] = useState(null); const [text, setText] = useState(''); const [sending, setSending] = useState(false); const [error, setError] = useState(null); const listRef = useRef>(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 ( {messages === null && !error && ( )} {error && {error}} {messages && ( m.id} contentContainerStyle={styles.listContent} renderItem={({ item }) => ( )} onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: false })} /> )} {sending ? ( ) : ( Senden )} ); } function MessageRow({ senderName, mine, body, time, }: { senderName: string; mine: boolean; body: string; time: string; }) { return ( {!mine && {senderName}} {body} {time} ); } 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** ```bash 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** ```bash 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).