035b5f078d
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>
1310 lines
41 KiB
Markdown
1310 lines
41 KiB
Markdown
# 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<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**
|
|
|
|
```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<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`**
|
|
|
|
```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`**
|
|
|
|
```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 <Redirect href="/" />;
|
|
return <Stack screenOptions={{ headerShown: true }} />;
|
|
}
|
|
```
|
|
|
|
- [ ] **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<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**
|
|
|
|
```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<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**
|
|
|
|
```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 (
|
|
<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`**
|
|
|
|
```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**
|
|
|
|
```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<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**
|
|
|
|
```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<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**
|
|
|
|
```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).
|