Files
ChatApp/docs/superpowers/specs/2026-05-13-mobile-phase-1-auth-chat-mvp-design.md
T
byGalax 035b5f078d 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>
2026-05-13 23:52:37 +02:00

10 KiB

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).