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