docs: add expo chat migration design spec

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arthur Belleville 2026-04-15 16:59:18 +02:00
parent 66d9ab9ad8
commit 302eb665fd

View file

@ -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=<JWT>`
- REST: `Authorization: Bearer <JWT>` 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=<JWT>`
**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 `<ChannelList>` 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 `<Channel>`, `<MessageList>`, `<MessageInput>`.
**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)