diff --git a/docs/superpowers/specs/2026-04-15-expo-chat-migration-design.md b/docs/superpowers/specs/2026-04-15-expo-chat-migration-design.md new file mode 100644 index 0000000..9a493dd --- /dev/null +++ b/docs/superpowers/specs/2026-04-15-expo-chat-migration-design.md @@ -0,0 +1,232 @@ +# Expo App: Chat Migration to Custom Backend + +**Date:** 2026-04-15 +**Scope:** Replace Stream Chat with the custom chat backend (chat.xtablo.com) in the Expo mobile app. Core messaging with typing indicators, presence, unread counts, and message history. + +## Context + +The web app uses a custom chat backend built on Cloudflare Workers + Durable Objects at `chat.xtablo.com`. It communicates via WebSocket for real-time events (messages, typing, presence) and REST API for history, unread counts, and mark-as-read. Authentication uses Supabase JWT directly. + +The Expo app currently uses `stream-chat-expo` which is a different backend entirely. This spec migrates the mobile app to the same custom backend the web uses. + +### Backend API + +**Base URLs:** +- WebSocket: `wss://chat.xtablo.com` +- REST: `https://chat.xtablo.com` + +**Authentication:** Supabase JWT (`session.access_token`) +- WebSocket: passed as query param `?token=` +- REST: `Authorization: Bearer ` header + +### REST Endpoints + +**GET `/chat/channels/:channelId/messages`** +- Query params: `limit` (default 50), `before` (ISO8601 cursor) +- Response: `{ messages: RawApiMessage[], hasMore: boolean }` + +**POST `/chat/channels/:channelId/read`** +- Empty body, uses JWT to identify user +- Response: `{ ok: true }` + +**GET `/chat/unread`** +- Response: `{ unread: { channel_id: string, unread_count: number }[] }` + +### WebSocket Protocol + +**Connection:** `wss://chat.xtablo.com/chat/ws/:channelId?token=` + +**Client -> Server:** +| Type | Payload | +|------|---------| +| `message.send` | `{ text: string, clientId: string }` | +| `typing.start` | `{}` | +| `typing.stop` | `{}` | +| `presence.ping` | `{}` | + +**Server -> Client:** +| Type | Payload | +|------|---------| +| `message.new` | `{ id, userId, text, createdAt, clientId }` | +| `typing` | `{ userId, isTyping: boolean }` | +| `presence.update` | `{ userId, status: "online" \| "offline" }` | +| `error` | `{ code, message }` | + +### Message Types + +```typescript +// Internal app type (camelCase) +interface ChatMessage { + id: string; + userId: string; + text: string; + createdAt: string; + clientId: string; + optimistic?: boolean; +} + +// Raw API response (snake_case from Postgres) +interface RawApiMessage { + id: string; + user_id: string; + text: string; + created_at: string; + client_id?: string; +} +``` + +## Data Layer + +### `useChat(channelId)` Hook + +Port of the web app's `apps/main/src/hooks/useChat.ts`, adapted for React Native's WebSocket API. + +**Returns:** +```typescript +{ + messages: ChatMessage[]; + sendMessage: (text: string) => void; + sendTyping: () => void; + isConnected: boolean; + typingUsers: string[]; + onlineUsers: string[]; + loadMoreMessages: () => void; + hasMoreMessages: boolean; + markAsRead: () => void; +} +``` + +**Key behaviors:** +- **Optimistic updates:** Generate `clientId` via `expo-crypto`'s `Crypto.randomUUID()` (already installed in the project). Add message with `optimistic: true` immediately. On `message.new` from server, replace optimistic entry by matching `clientId`. +- **Typing debounce:** Send `typing.start` on first keystroke. Reset timer on each keystroke. Send `typing.stop` after 2 seconds of inactivity. Filter out own user from `typingUsers`. +- **Presence:** Track `onlineUsers` array from `presence.update` events. +- **Message history:** REST call with pagination via `before` cursor (oldest message's `createdAt`). Prepend older messages. +- **Reconnection:** Exponential backoff: `min(1000 * 2^attempt, 30000)`. Reset attempt counter on successful connection. +- **Mark as read:** POST to `/chat/channels/:channelId/read`. +- **Cleanup:** Close WebSocket on unmount. Clear typing timers. + +### `useChatUnread()` Hook + +React Query hook polling `/chat/unread` every 30 seconds. + +```typescript +{ + unreadCounts: { channel_id: string, unread_count: number }[]; + getUnreadCount: (channelId: string) => number; +} +``` + +Uses `useAuthStore` for the JWT. `refetchInterval: 30_000`. Invalidated when `markAsRead()` is called (via query client). + +## UI + +### Channel List (Discussions Tab) + +Rewrite `app/(app)/(tabs)/index.tsx`. Replace Stream Chat's `` with a custom list. + +**Data sources:** +- `useTablosList()` — channel list (channels = tablos) +- `useChatUnread()` — unread badges + +**Each row:** +- Tablo color indicator (circle or square) +- Tablo name +- Unread count badge (blue circle with number, hidden when 0) + +**Behavior:** +- Tap row -> navigate to `/channel/:tabloId` +- Pull-to-refresh refetches tablos and unread counts +- Channels with unread messages sorted to top, then by last updated +- Loading state with skeleton placeholders + +### Channel Screen + +Rewrite `app/(app)/channel/[cid].tsx`. Replace Stream Chat's ``, ``, ``. + +**Header:** +- Back button +- Tablo name (from `useTablosList()` cache) +- Online user count ("N en ligne") + +**Message list:** +- Inverted `FlatList` +- Messages grouped by sender + 2-minute interval (consecutive messages from same user within 2min share a single header) +- First message in group: sender name + avatar +- Subsequent messages in group: just the text +- Timestamps shown per group +- Own messages right-aligned, others left-aligned + +**Typing indicator:** +- Below the message list, above the input +- Shows "[Name] est en train d'écrire..." with animated dots +- Multiple users: "[Name1] et [Name2] sont en train d'écrire..." +- Hidden when no one is typing + +**Message input:** +- Text input with send button (disabled when empty) +- Calls `sendTyping()` on text change +- Calls `sendMessage(text)` on send, clears input +- `KeyboardAvoidingView` for iOS + +**Pagination:** +- `onEndReached` (top of inverted list) triggers `loadMoreMessages()` +- Spinner shown while loading older messages + +**Auto mark-as-read:** +- Call `markAsRead()` when screen gains focus (`useFocusEffect`) +- Invalidate `["chat-unread"]` query after marking + +**Connection status:** +- Small banner at top when `isConnected === false`: "Reconnexion..." +- Hides when reconnected + +### User Name Resolution + +Messages from the backend only contain `userId`. To display names and avatars, use `useTabloMembers(tabloId)` (already created in the tasks feature). Build a lookup map `userId -> { name, avatar }` for rendering. + +## Changes + +### New Files + +| File | Purpose | +|------|---------| +| `types/chat.types.ts` | ChatMessage, RawApiMessage, WebSocket event types | +| `hooks/chat.ts` | `useChat(channelId)` — WebSocket + REST chat client | +| `hooks/chatUnread.ts` | `useChatUnread()` — Unread count polling | + +### Rewritten Files + +| File | Purpose | +|------|---------| +| `app/(app)/(tabs)/index.tsx` | Channel list using tablos + unread counts | +| `app/(app)/channel/[cid].tsx` | Channel screen using `useChat()` | + +### Modified Files + +| File | Change | +|------|--------| +| `app/(app)/_layout.tsx` | Remove `ChatProvider` wrapper | +| `.env` | Add `EXPO_PUBLIC_CHAT_WS_URL`, `EXPO_PUBLIC_CHAT_API_URL` | + +### Deleted Files + +| File | Reason | +|------|--------| +| `providers/ChatProvider.tsx` | Stream Chat provider no longer needed | + +### Dependencies + +- **Remove:** `stream-chat-expo`, `stream-chat`, and any Stream-related packages +- **Add:** None (React Native has built-in WebSocket) + +## Out of Scope + +- Reactions, replies, pins +- Message editing and deleting +- File/image attachments +- Voice messages +- Threaded conversations +- Push notifications for new messages +- Message search +- Read receipts (who read which message) +- Message status indicators (sent/delivered/read)