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