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>
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(viapnpm 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"
Task 6: Login screen (magic-link)
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"
Task 7: Auth-callback deep-link 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).