docs(mobile): phase 1 spec + plan — Auth + Chat MVP

11 tasks taking the mobile app from Phase 0 shell to a working
end-to-end magic-link login + conversation list + decrypted detail +
text-send. All renderer-only, plumbed through @chat-app/shared's
CryptoBackend / SecretStore / Supabase-session-storage interfaces:

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
byGalax
2026-05-13 23:52:37 +02:00
parent 13c6ad68f0
commit 035b5f078d
2 changed files with 1505 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -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<void>`
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).