diff --git a/docs/superpowers/plans/2026-05-13-mobile-phase-1-auth-chat-mvp.md b/docs/superpowers/plans/2026-05-13-mobile-phase-1-auth-chat-mvp.md new file mode 100644 index 0000000..801978b --- /dev/null +++ b/docs/superpowers/plans/2026-05-13-mobile-phase-1-auth-chat-mvp.md @@ -0,0 +1,1309 @@ +# 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). diff --git a/docs/superpowers/specs/2026-05-13-mobile-phase-1-auth-chat-mvp-design.md b/docs/superpowers/specs/2026-05-13-mobile-phase-1-auth-chat-mvp-design.md new file mode 100644 index 0000000..ade8e59 --- /dev/null +++ b/docs/superpowers/specs/2026-05-13-mobile-phase-1-auth-chat-mvp-design.md @@ -0,0 +1,196 @@ +# Mobile Phase 1 — Auth + Chat MVP + +**Date:** 2026-05-13 +**Scope:** `apps/mobile` +**Roadmap context:** `2026-05-13-mobile-deployment-roadmap.md` + +--- + +## Problem + +Phase 0 delivered a Netralax-branded shell. It boots, renders a placeholder, and is configured for `eas build`, but the app has no actual functionality. To call mobile "deployment-ready with working features" the user must be able to log in, see their chats, and send a text message — the smallest end-to-end vertical slice that's also useful. + +## Goal + +After Phase 1, a Netralax user on iOS or Android can: + +1. Open the app, enter their account email, request a magic link. +2. Tap the link in their email (deep link returns to `netralax://auth/callback`), complete the session, and land on the chat list. +3. See their existing conversations (DMs + groups) with last-message previews, decrypted on-device. +4. Open a conversation, see the message history (decrypted), and send a new text message that the desktop client receives correctly. +5. Log out from a settings entry, returning to the login screen. + +## Non-goals + +- Signup with invite code (login-only MVP — accounts are provisioned via desktop or admin scripts). +- Attachments, voice messages, reactions, edits (Phase 2). +- Calls (Phase 3). +- Push notifications (deferred — chat works without push, the user pulls fresh data on screen focus / pull-to-refresh). +- Realtime subscription on conversations (deferred). Phase 1 polls / refetches on screen focus. +- Read receipts, typing indicator, presence (Phase 2). +- Friend list, profile edit, settings beyond logout. + +## Design + +### 1. Theme constants + +`apps/mobile/theme/colors.ts` exports a flat `colors` object with the hex codes Phase 0 inlined across files (`#0b0b0f`, `#9ca3af`, `#fff`, `#6b7280`, `#5865f2`) plus a couple of new ones we need for Phase 1 (input border, danger). Every Phase 1 screen imports from here; no new hex literals appear in components. + +### 2. Crypto adapter (`apps/mobile/lib/cryptoBackend.ts`) + +A near-mirror of `apps/desktop/src/lib/cryptoBackend.ts` but wrapping `react-native-libsodium` instead of `libsodium-wrappers-sumo`. The two libraries expose the same primitive API (the RN port re-exports the libsodium-wrappers types) — only the import + the absence of `_sodium.ready` differs. Synchronous (no WASM warm-up needed on a native lib). Registered at boot via `crypto.setCryptoBackend(createLibsodiumBackend())` from `_layout.tsx`. + +### 3. Secret store adapter (`apps/mobile/lib/secretStore.ts`) + +Implements `SecretStore` (from `@chat-app/shared/auth/secure-storage`) backed by `expo-secure-store`. Per the contract, values are `Uint8Array`; we base64-encode at the boundary because `SecureStore` only accepts strings. `buffer` polyfill is part of RN's base set, no extra install. + +### 4. Supabase session storage (`apps/mobile/lib/sessionStorage.ts`) + +Supabase's JS client needs an async-storage object for session tokens. Use `@react-native-async-storage/async-storage` (new dep) — supabase-js v2 accepts its `getItem` / `setItem` / `removeItem` API directly. + +### 5. Env config (`apps/mobile/lib/env.ts`) + +Expo exposes vars prefixed `EXPO_PUBLIC_*` to the bundle via `process.env`. Read three: + +- `EXPO_PUBLIC_SUPABASE_URL` +- `EXPO_PUBLIC_SUPABASE_ANON_KEY` +- `EXPO_PUBLIC_AUTH_REDIRECT_URL` — defaults to `netralax://auth/callback`. + +`apps/mobile/.env.example` is added with placeholder values + a README pointer. + +### 6. Supabase client (`apps/mobile/lib/supabase.ts`) + +Mirrors desktop's `apps/desktop/src/lib/supabase.ts`. Calls `createClient` from `@chat-app/shared/supabase` with the mobile `sessionStorage` adapter and `detectSessionInUrl: false` (the callback screen parses the URL manually). + +### 7. Auth context (`apps/mobile/lib/authContext.tsx`) + +React Context provider that wraps the authenticated tree. Exposes: + +- `session: Session | null` +- `user: User | null` +- `device: DeviceRecord | null` — the registered device for this install +- `loading: boolean` — true while hydrating from AsyncStorage on boot +- `signOut(): Promise` + +On mount: + +1. `await supabase.auth.getSession()` — pulls from AsyncStorage. +2. If a session exists, derive the device by `listOwnDevices(supabase)` and matching on a locally-stored device id (in `secretStore` under key `device.id`). +3. If session exists but no device record yet (fresh install, account exists on other devices), call `registerDevice` with a fresh key pair, store the new device id + private key in `secretStore`. + +The provider subscribes to `supabase.auth.onAuthStateChange` so signing out from anywhere updates the tree. + +### 8. Route gating + +- `app/index.tsx`: if `session` is set, redirect to `/(app)/chats`. Otherwise show the login form. +- `app/(app)/_layout.tsx`: if no `session`, redirect to `/`. (Existing file: change from "always render Stack" to "guard on session".) + +### 9. Login screen (`app/index.tsx`) + +Replaces the Phase-0 placeholder. Renders: + +- Netralax wordmark. +- Email `TextInput`. +- "Magic Link senden" button → `auth.loginWithMagicLink(supabase, email, env.authRedirectUrl)`. +- After tap: show "Check deine Mails" state until the deep link fires. +- Error banner on failure. + +### 10. Auth callback (`app/auth/callback.tsx`) + +A new screen reachable via the `netralax://auth/callback` deep link. Reads URL params (Supabase magic-link callback puts `access_token` + `refresh_token` in the URL hash or query), calls `supabase.auth.setSession({...})`, routes to `/(app)/chats`. On failure, routes back to `/` with an error message. + +Expo Router auto-handles the deep link → screen mapping when the scheme in `app.json` matches and the path matches the route file. + +### 11. Conversation list (`app/(app)/chats.tsx`) + +Replaces the Phase-0 placeholder. Uses `chat.listConversations(supabase)` from `@chat-app/shared/chat`. Renders a `FlatList` of `ConversationRow` (new component): + +- DM: peer's display name + Avatar (initial-letter circle). +- Group: group name + "N Mitglieder". +- Last message preview — decrypted via `decryptMessages` if encrypted, else "…". +- Timestamp (relative — "Vor 5 Min"). + +Pull-to-refresh refetches. + +Tap on row → navigates to `/(app)/conversations/[id]`. + +A small icon-button in the header opens a logout modal calling `signOut()`. + +### 12. Conversation detail (`app/(app)/conversations/[id].tsx`) + +Loads: + +- `fetchConversationMessages(supabase, { conversationId, limit: 50 })`. +- `listConversationDeviceKeys(supabase, conversationId)` for decryption. +- `decryptMessages({ messages, deviceKeys, myDeviceId, myPrivateKey })` to get plaintext. + +Renders an inverted `FlatList` (newest at the bottom, like Discord). Each row shows sender name + body + timestamp. Attachments rendered as "📎 [Anhang]" placeholders — Phase 2 adds real rendering. + +Bottom: a `TextInput` + send button. On send: `sendEncryptedMessage`. Optimistically appends to local state, then refetches. + +### 13. Cleanup + +- Delete `apps/mobile/lib/sharedSmoke.ts` and the `void sharedSmoke` import in `_layout.tsx`. + +## File structure (after Phase 1) + +``` +apps/mobile/ +├── app/ +│ ├── _layout.tsx ← MODIFIED: register crypto backend, mount AuthProvider +│ ├── index.tsx ← MODIFIED: Login screen (magic link form) +│ ├── auth/ +│ │ └── callback.tsx ← NEW: deep-link landing +│ └── (app)/ +│ ├── _layout.tsx ← MODIFIED: session-gated stack +│ ├── chats.tsx ← MODIFIED: real conversation list + logout +│ └── conversations/ +│ └── [id].tsx ← NEW: conversation view + send +├── components/ +│ ├── ErrorBoundary.tsx ← unchanged +│ ├── Avatar.tsx ← NEW: initial-letter avatar circle +│ └── ConversationRow.tsx ← NEW: list row +├── lib/ +│ ├── authContext.tsx ← NEW +│ ├── cryptoBackend.ts ← NEW +│ ├── env.ts ← NEW +│ ├── secretStore.ts ← NEW +│ ├── sessionStorage.ts ← NEW +│ ├── supabase.ts ← NEW +│ ├── timeFormat.ts ← NEW +│ └── sharedSmoke.ts ← DELETED +├── theme/ +│ └── colors.ts ← NEW +├── .env.example ← NEW +└── package.json ← MODIFIED: + @react-native-async-storage/async-storage +``` + +## Risks + +- **`react-native-libsodium` New Architecture compatibility.** v1.7 claims Fabric/TurboModule support. If `setCryptoBackend(createLibsodiumBackend())` crashes on app boot under New Arch, fall back to `"newArchEnabled": false` and re-evaluate. +- **`@chat-app/shared` ESM resolution under Metro.** Shared emits ESM with `.js` import specifiers. Metro's default resolver handles this; if it doesn't, set `metro.config.js`'s `resolver.unstable_enablePackageExports: true`. +- **Magic-link deep-link return.** Expo Router auto-maps `netralax://auth/callback` to `app/auth/callback.tsx` since the scheme in `app.json` matches (`netralax`) and `expo-linking` is in the prebuild. +- **Device key persistence.** The private key blob lives in `expo-secure-store` (Keychain on iOS, Keystore on Android). Uninstalling the app wipes the key — same as desktop. Backup/restore is post-Phase-4. + +## Verification (manual on real hardware after typecheck passes) + +1. `pnpm --filter @chat-app/mobile typecheck` → exit 0. +2. `.env.local` populated with the same `SUPABASE_URL` / `SUPABASE_ANON_KEY` the desktop uses → app boots, shows Login. +3. Enter an existing-user email → "Magic Link senden" succeeds → "Check deine Mails" state appears. +4. Open the email link on the device → callback URL fires Expo Router → app navigates to `/(app)/chats`. +5. Chat list loads; existing DMs and groups from the desktop are visible with decrypted previews. +6. Tap a conversation → detail screen opens, history loads, decrypted message bodies render newest-at-bottom. +7. Type "test from mobile" → send → desktop client of the same account sees the new message via Supabase realtime. +8. Pull-to-refresh on chat list re-fetches. +9. Tap settings → "Abmelden" → returns to Login screen, session cleared from AsyncStorage. + +## Out of scope + +- Push notifications (`expo-notifications` + `notify-push` edge function). +- Realtime subscription on the mobile side (relies on focus-refetch). +- Conversation creation (new DM / new group) from mobile. +- Attachments, voice messages, reactions, edits, replies, forwards (Phase 2). +- Read receipts, typing, delivery state (Phase 2). +- Voice/video calls (Phase 3). +- Backup/restore device keys (post-Phase-4). +- Profile editing, friends, admin tools (post-Phase-4).