docs: add expo chat migration design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
66d9ab9ad8
commit
302eb665fd
1 changed files with 232 additions and 0 deletions
232
docs/superpowers/specs/2026-04-15-expo-chat-migration-design.md
Normal file
232
docs/superpowers/specs/2026-04-15-expo-chat-migration-design.md
Normal 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)
|
||||
Loading…
Reference in a new issue