# Expo Chat Migration Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Replace Stream Chat with the custom chat backend (chat.xtablo.com) in the Expo mobile app, providing core messaging with typing indicators, presence, unread counts, and message history.

**Architecture:** Port the web app's `useChat` hook (WebSocket + REST) to React Native. Replace Stream Chat's `<ChannelList>` with a tablo-based list using `useTablosList()` + `useChatUnread()`. Replace Stream Chat's `<Channel>` / `<MessageList>` / `<MessageInput>` with custom components using the `useChat()` hook. Remove `stream-chat-expo` dependency entirely.

**Tech Stack:** React Native WebSocket (built-in), React Query v5, Supabase JWT auth, expo-crypto for UUID generation, expo-router for navigation.

---

## File Structure

### Types
- **Create:** `xtablo-expo/types/chat.types.ts` — ChatMessage, RawApiMessage, ServerMessage, UnreadCount

### Hooks
- **Create:** `xtablo-expo/hooks/chat.ts` — `useChat(channelId)` — WebSocket + REST chat client
- **Create:** `xtablo-expo/hooks/chatUnread.ts` — `useChatUnread()` — Unread count polling

### Screens
- **Rewrite:** `xtablo-expo/app/(app)/(tabs)/index.tsx` — Channel list using tablos + unread
- **Rewrite:** `xtablo-expo/app/(app)/channel/[cid].tsx` — Channel screen using useChat

### Modified Files
- **Modify:** `xtablo-expo/app/(app)/_layout.tsx` — Remove ChatProvider wrapper
- **Modify:** `xtablo-expo/.env` — Add chat backend URLs, remove Stream key

### Deleted Files
- **Delete:** `xtablo-expo/providers/ChatProvider.tsx` — Stream Chat provider

### Dependencies
- **Remove:** `stream-chat-expo` from package.json

---

### Task 1: Create Chat Types

**Files:**
- Create: `xtablo-expo/types/chat.types.ts`

- [ ] **Step 1: Create the types file**

Create `xtablo-expo/types/chat.types.ts`:

```typescript
export interface ChatMessage {
  id: string;
  userId: string;
  text: string;
  createdAt: string;
  clientId: string;
  /** True while the message is only local (not yet echoed by server). */
  optimistic?: boolean;
}

/** Raw shape returned by the REST API (snake_case from Postgres). */
export interface RawApiMessage {
  id: string;
  user_id: string;
  text: string;
  created_at: string;
  client_id?: string;
}

export type ServerMessage =
  | { type: "message.new"; id: string; userId: string; text: string; createdAt: string; clientId: string }
  | { type: "typing"; userId: string; isTyping: boolean }
  | { type: "presence.update"; userId: string; status: "online" | "offline" }
  | { type: "error"; code: string; message: string };

export interface UnreadCount {
  channel_id: string;
  unread_count: number;
}

export function normalizeMessage(raw: RawApiMessage): ChatMessage {
  return {
    id: raw.id,
    userId: raw.user_id,
    text: raw.text,
    createdAt: raw.created_at,
    clientId: raw.client_id ?? "",
  };
}
```

- [ ] **Step 2: Commit**

```bash
git add xtablo-expo/types/chat.types.ts
git commit -m "feat(expo): add chat message and WebSocket event types"
```

---

### Task 2: Create useChat Hook

**Files:**
- Create: `xtablo-expo/hooks/chat.ts`

This is a direct port of `apps/main/src/hooks/useChat.ts` adapted for React Native (uses `expo-crypto` instead of `crypto.randomUUID()`, `useAuthStore` instead of `useSession`, and env var prefix `EXPO_PUBLIC_`).

- [ ] **Step 1: Create the hook file**

Create `xtablo-expo/hooks/chat.ts`:

```typescript
import { useCallback, useEffect, useRef, useState } from "react";
import * as Crypto from "expo-crypto";
import { useAuthStore } from "@/stores/auth";
import { ChatMessage, RawApiMessage, ServerMessage, normalizeMessage } from "@/types/chat.types";

const CHAT_WS_BASE = process.env.EXPO_PUBLIC_CHAT_WS_URL as string;
const CHAT_API_BASE = process.env.EXPO_PUBLIC_CHAT_API_URL as string;

export function useChat(channelId: string | undefined) {
  const session = useAuthStore((state) => state.session);
  const token = session?.access_token;
  const userId = session?.user?.id;

  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const [isConnected, setIsConnected] = useState(false);
  const [typingUsers, setTypingUsers] = useState<string[]>([]);
  const [onlineUsers, setOnlineUsers] = useState<string[]>([]);
  const [hasMoreMessages, setHasMoreMessages] = useState(true);

  const wsRef = useRef<WebSocket | null>(null);
  const reconnectAttemptRef = useRef(0);
  const reconnectTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
  const typingTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
  const isTypingRef = useRef(false);

  // Fetch message history from REST endpoint
  const fetchHistory = useCallback(async (before?: string) => {
    if (!channelId || !token) return;

    const params = new URLSearchParams({ limit: "50" });
    if (before) params.set("before", before);

    try {
      const res = await fetch(
        `${CHAT_API_BASE}/chat/channels/${channelId}/messages?${params}`,
        { headers: { Authorization: `Bearer ${token}` } }
      );

      if (!res.ok) return;

      const data = (await res.json()) as { messages: RawApiMessage[]; hasMore: boolean };
      const normalized = data.messages.map(normalizeMessage);
      setHasMoreMessages(data.hasMore);

      if (before) {
        setMessages((prev) => [...normalized, ...prev]);
      } else {
        setMessages(normalized);
      }
    } catch (err) {
      console.error("Failed to fetch chat history:", err);
    }
  }, [channelId, token]);

  // Load more (pagination)
  const loadMoreMessages = useCallback(() => {
    if (messages.length === 0 || !hasMoreMessages) return;
    const oldest = messages[0];
    fetchHistory(oldest.createdAt);
  }, [messages, hasMoreMessages, fetchHistory]);

  // WebSocket connection management
  useEffect(() => {
    if (!channelId || !token) return;

    const connect = () => {
      const wsUrl = `${CHAT_WS_BASE}/chat/ws/${channelId}?token=${encodeURIComponent(token)}`;
      const ws = new WebSocket(wsUrl);

      ws.onopen = () => {
        setIsConnected(true);
        reconnectAttemptRef.current = 0;
      };

      ws.onmessage = (event) => {
        const msg = JSON.parse(event.data) as ServerMessage;

        switch (msg.type) {
          case "message.new":
            setMessages((prev) => {
              const withoutOptimistic = prev.filter(
                (m) => !(m.clientId === msg.clientId && m.optimistic)
              );
              if (withoutOptimistic.some((m) => m.id === msg.id)) {
                return withoutOptimistic;
              }
              return [
                ...withoutOptimistic,
                {
                  id: msg.id,
                  userId: msg.userId,
                  text: msg.text,
                  createdAt: msg.createdAt,
                  clientId: msg.clientId,
                },
              ];
            });
            break;

          case "typing":
            if (msg.userId === userId) break;
            setTypingUsers((prev) =>
              msg.isTyping
                ? prev.includes(msg.userId) ? prev : [...prev, msg.userId]
                : prev.filter((id) => id !== msg.userId)
            );
            break;

          case "presence.update":
            setOnlineUsers((prev) =>
              msg.status === "online"
                ? prev.includes(msg.userId) ? prev : [...prev, msg.userId]
                : prev.filter((id) => id !== msg.userId)
            );
            break;

          case "error":
            console.error("Chat error:", msg.code, msg.message);
            break;
        }
      };

      ws.onclose = () => {
        setIsConnected(false);
        wsRef.current = null;

        const delay = Math.min(1000 * 2 ** reconnectAttemptRef.current, 30000);
        reconnectAttemptRef.current++;
        reconnectTimerRef.current = setTimeout(connect, delay);
      };

      ws.onerror = () => {
        ws.close();
      };

      wsRef.current = ws;
    };

    fetchHistory().then(connect);

    return () => {
      clearTimeout(reconnectTimerRef.current);
      clearTimeout(typingTimerRef.current);
      wsRef.current?.close();
      wsRef.current = null;
      setMessages([]);
      setIsConnected(false);
      setTypingUsers([]);
      setOnlineUsers([]);
    };
  }, [channelId, token, fetchHistory]);

  // Send message
  const sendMessage = useCallback(
    (text: string) => {
      if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;

      const clientId = Crypto.randomUUID();

      setMessages((prev) => [
        ...prev,
        {
          id: `optimistic-${clientId}`,
          userId: userId ?? "",
          text,
          createdAt: new Date().toISOString(),
          clientId,
          optimistic: true,
        },
      ]);

      wsRef.current.send(JSON.stringify({ type: "message.send", text, clientId }));

      if (isTypingRef.current) {
        wsRef.current.send(JSON.stringify({ type: "typing.stop" }));
        isTypingRef.current = false;
        clearTimeout(typingTimerRef.current);
      }
    },
    [userId]
  );

  // Typing indicator
  const sendTyping = useCallback(() => {
    if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;

    if (!isTypingRef.current) {
      isTypingRef.current = true;
      wsRef.current.send(JSON.stringify({ type: "typing.start" }));
    }

    clearTimeout(typingTimerRef.current);
    typingTimerRef.current = setTimeout(() => {
      if (wsRef.current?.readyState === WebSocket.OPEN) {
        wsRef.current.send(JSON.stringify({ type: "typing.stop" }));
      }
      isTypingRef.current = false;
    }, 2000);
  }, []);

  // Mark as read
  const markAsRead = useCallback(async () => {
    if (!channelId || !token) return;
    try {
      await fetch(`${CHAT_API_BASE}/chat/channels/${channelId}/read`, {
        method: "POST",
        headers: { Authorization: `Bearer ${token}` },
      });
    } catch (err) {
      console.error("Failed to mark channel as read:", err);
    }
  }, [channelId, token]);

  return {
    messages,
    sendMessage,
    sendTyping,
    isConnected,
    typingUsers,
    onlineUsers,
    loadMoreMessages,
    hasMoreMessages,
    markAsRead,
  };
}
```

- [ ] **Step 2: Commit**

```bash
git add xtablo-expo/hooks/chat.ts
git commit -m "feat(expo): add useChat hook with WebSocket and REST integration"
```

---

### Task 3: Create useChatUnread Hook

**Files:**
- Create: `xtablo-expo/hooks/chatUnread.ts`

Port of `apps/main/src/hooks/useChatUnread.ts` adapted for Expo.

- [ ] **Step 1: Create the hook file**

Create `xtablo-expo/hooks/chatUnread.ts`:

```typescript
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "@/stores/auth";
import { UnreadCount } from "@/types/chat.types";

const CHAT_API_BASE = process.env.EXPO_PUBLIC_CHAT_API_URL as string;

export function useChatUnread() {
  const session = useAuthStore((state) => state.session);
  const token = session?.access_token;

  const { data, refetch } = useQuery({
    queryKey: ["chat-unread"],
    queryFn: async (): Promise<UnreadCount[]> => {
      const res = await fetch(`${CHAT_API_BASE}/chat/unread`, {
        headers: { Authorization: `Bearer ${token}` },
      });
      if (!res.ok) return [];
      const json = (await res.json()) as { unread: UnreadCount[] };
      return json.unread;
    },
    enabled: !!token,
    refetchInterval: 30_000,
  });

  return {
    unreadCounts: data ?? [],
    getUnreadCount: (channelId: string) =>
      data?.find((u) => u.channel_id === channelId)?.unread_count ?? 0,
    refetch,
  };
}
```

- [ ] **Step 2: Commit**

```bash
git add xtablo-expo/hooks/chatUnread.ts
git commit -m "feat(expo): add useChatUnread hook for unread count polling"
```

---

### Task 4: Remove Stream Chat — Provider, Dependency, Layout

**Files:**
- Delete: `xtablo-expo/providers/ChatProvider.tsx`
- Modify: `xtablo-expo/app/(app)/_layout.tsx`
- Modify: `xtablo-expo/package.json` (uninstall stream-chat-expo)

- [ ] **Step 1: Remove ChatProvider wrapper from app layout**

Replace the full content of `xtablo-expo/app/(app)/_layout.tsx` with:

```typescript
import { UserStoreProvider } from "@/providers/UserProvider";
import { Stack } from "expo-router";

export default function HomeLayout() {
  return (
    <UserStoreProvider>
      <Stack>
        <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
        <Stack.Screen name="channel" options={{ headerShown: false }} />
        <Stack.Screen name="tablo" options={{ headerShown: false }} />
        <Stack.Screen name="task" options={{ headerShown: false }} />
      </Stack>
    </UserStoreProvider>
  );
}
```

- [ ] **Step 2: Delete ChatProvider.tsx**

```bash
rm xtablo-expo/providers/ChatProvider.tsx
```

- [ ] **Step 3: Uninstall stream-chat-expo**

```bash
cd xtablo-expo && npm uninstall stream-chat-expo
```

If there are other stream-chat related packages (check `package.json` for any `stream-chat` entries), uninstall those too.

- [ ] **Step 4: Update .env**

Open `xtablo-expo/.env`. Remove the Stream Chat key and add the custom chat backend URLs:

Remove:
```
EXPO_PUBLIC_STREAM_CHAT_API_KEY="t5vvvddteapa"
```

Add:
```
EXPO_PUBLIC_CHAT_WS_URL=wss://chat.xtablo.com
EXPO_PUBLIC_CHAT_API_URL=https://chat.xtablo.com
```

- [ ] **Step 5: Commit**

```bash
git add xtablo-expo/app/\(app\)/_layout.tsx xtablo-expo/package.json xtablo-expo/package-lock.json xtablo-expo/.env
git rm xtablo-expo/providers/ChatProvider.tsx
git commit -m "feat(expo): remove Stream Chat, switch to custom chat backend"
```

---

### Task 5: Rewrite Channel List (Discussions Tab)

**Files:**
- Rewrite: `xtablo-expo/app/(app)/(tabs)/index.tsx`

Replace the Stream Chat `<ChannelList>` with a custom list using `useTablosList()` and `useChatUnread()`.

- [ ] **Step 1: Rewrite the discussions tab**

Replace the full content of `xtablo-expo/app/(app)/(tabs)/index.tsx` with:

```typescript
import React, { useMemo, useState } from "react";
import {
  View,
  Text,
  FlatList,
  TouchableOpacity,
  RefreshControl,
  StyleSheet,
  SafeAreaView,
} from "react-native";
import { router } from "expo-router";
import { MessageCircle } from "lucide-react-native";
import { useThemeColor } from "@/hooks/useThemeColor";
import { useColorScheme } from "@/hooks/useColorScheme";
import { useTablosList } from "@/hooks/tablos";
import { useChatUnread } from "@/hooks/chatUnread";
import { ColorMap } from "@/constants/colors";
import { UserTablo } from "@/types/tablos.types";

export default function DiscussionsTab() {
  const colorScheme = useColorScheme();
  const isDark = colorScheme === "dark";
  const bgColor = useThemeColor({ light: "#f8fafc", dark: "#111827" }, "background");
  const textColor = useThemeColor({ light: "#1f2937", dark: "#f9fafb" }, "text");
  const subtextColor = useThemeColor({ light: "#6b7280", dark: "#9ca3af" }, "text");
  const borderColor = isDark ? "#374151" : "#e5e7eb";

  const { data: tablos, isLoading, refetch: refetchTablos } = useTablosList();
  const { getUnreadCount, refetch: refetchUnread } = useChatUnread();
  const [refreshing, setRefreshing] = useState(false);

  // Sort: unread first, then by name
  const sortedTablos = useMemo(() => {
    if (!tablos) return [];
    return [...tablos].sort((a, b) => {
      const unreadA = getUnreadCount(a.id);
      const unreadB = getUnreadCount(b.id);
      if (unreadA > 0 && unreadB === 0) return -1;
      if (unreadA === 0 && unreadB > 0) return 1;
      return (a.name ?? "").localeCompare(b.name ?? "");
    });
  }, [tablos, getUnreadCount]);

  const onRefresh = async () => {
    setRefreshing(true);
    await Promise.all([refetchTablos(), refetchUnread()]);
    setRefreshing(false);
  };

  const renderChannel = ({ item }: { item: UserTablo }) => {
    const unread = getUnreadCount(item.id);
    const tabloColor = item.color ? ColorMap[item.color] ?? "#3b82f6" : "#3b82f6";

    return (
      <TouchableOpacity
        style={[styles.channelRow, { borderBottomColor: borderColor }]}
        onPress={() => router.push(`/channel/${item.id}`)}
        activeOpacity={0.7}
      >
        <View style={[styles.colorDot, { backgroundColor: tabloColor }]} />
        <Text
          style={[
            styles.channelName,
            { color: textColor },
            unread > 0 && styles.channelNameBold,
          ]}
          numberOfLines={1}
        >
          {item.name}
        </Text>
        {unread > 0 && (
          <View style={styles.unreadBadge}>
            <Text style={styles.unreadText}>{unread > 99 ? "99+" : unread}</Text>
          </View>
        )}
      </TouchableOpacity>
    );
  };

  if (isLoading && !tablos) {
    return (
      <SafeAreaView style={[styles.container, { backgroundColor: bgColor }]}>
        <View style={styles.centered}>
          <Text style={{ color: subtextColor }}>Chargement...</Text>
        </View>
      </SafeAreaView>
    );
  }

  return (
    <SafeAreaView style={[styles.container, { backgroundColor: bgColor }]}>
      <View style={[styles.header, { borderBottomColor: borderColor }]}>
        <Text style={[styles.headerTitle, { color: textColor }]}>Discussions</Text>
      </View>
      <FlatList
        data={sortedTablos}
        keyExtractor={(item) => item.id}
        renderItem={renderChannel}
        refreshControl={
          <RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
        }
        ListEmptyComponent={
          <View style={styles.centered}>
            <MessageCircle size={48} color={subtextColor} strokeWidth={1.5} />
            <Text style={[styles.emptyText, { color: subtextColor }]}>
              Aucune discussion
            </Text>
          </View>
        }
      />
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  header: {
    paddingHorizontal: 16,
    paddingVertical: 14,
    borderBottomWidth: StyleSheet.hairlineWidth,
  },
  headerTitle: {
    fontSize: 28,
    fontWeight: "800",
  },
  channelRow: {
    flexDirection: "row",
    alignItems: "center",
    paddingVertical: 14,
    paddingHorizontal: 16,
    borderBottomWidth: StyleSheet.hairlineWidth,
    gap: 12,
  },
  colorDot: {
    width: 12,
    height: 12,
    borderRadius: 6,
  },
  channelName: {
    flex: 1,
    fontSize: 16,
  },
  channelNameBold: {
    fontWeight: "700",
  },
  unreadBadge: {
    backgroundColor: "#3b82f6",
    borderRadius: 10,
    minWidth: 20,
    height: 20,
    paddingHorizontal: 6,
    alignItems: "center",
    justifyContent: "center",
  },
  unreadText: {
    color: "#ffffff",
    fontSize: 11,
    fontWeight: "700",
  },
  centered: {
    flex: 1,
    alignItems: "center",
    justifyContent: "center",
    paddingTop: 60,
    gap: 12,
  },
  emptyText: {
    fontSize: 16,
  },
});
```

- [ ] **Step 2: Commit**

```bash
git add xtablo-expo/app/\(app\)/\(tabs\)/index.tsx
git commit -m "feat(expo): rewrite discussions tab with custom chat backend"
```

---

### Task 6: Rewrite Channel Screen

**Files:**
- Rewrite: `xtablo-expo/app/(app)/channel/[cid].tsx`

Replace Stream Chat components with custom UI using `useChat()`.

- [ ] **Step 1: Rewrite the channel screen**

Replace the full content of `xtablo-expo/app/(app)/channel/[cid].tsx` with:

```typescript
import React, { useState, useRef, useCallback, useMemo } from "react";
import {
  View,
  Text,
  FlatList,
  TextInput,
  TouchableOpacity,
  StyleSheet,
  SafeAreaView,
  KeyboardAvoidingView,
  Platform,
  ActivityIndicator,
} from "react-native";
import { useLocalSearchParams, router } from "expo-router";
import { useFocusEffect } from "@react-navigation/native";
import { ArrowLeft, Send, MessageCircle } from "lucide-react-native";
import { useThemeColor } from "@/hooks/useThemeColor";
import { useColorScheme } from "@/hooks/useColorScheme";
import { useChat } from "@/hooks/chat";
import { useTablosList } from "@/hooks/tablos";
import { useTabloMembers, TabloMember } from "@/hooks/members";
import { useQueryClient } from "@tanstack/react-query";
import { ChatMessage } from "@/types/chat.types";
import { useAuthStore } from "@/stores/auth";

const MESSAGE_GROUP_INTERVAL = 2 * 60 * 1000; // 2 minutes in ms

type MessageGroup = {
  key: string;
  userId: string;
  messages: ChatMessage[];
  isOwn: boolean;
};

function groupMessages(messages: ChatMessage[], currentUserId: string): MessageGroup[] {
  const groups: MessageGroup[] = [];

  for (const msg of messages) {
    const lastGroup = groups[groups.length - 1];
    const timeDiff = lastGroup
      ? new Date(msg.createdAt).getTime() -
        new Date(lastGroup.messages[lastGroup.messages.length - 1].createdAt).getTime()
      : Infinity;

    if (lastGroup && lastGroup.userId === msg.userId && timeDiff < MESSAGE_GROUP_INTERVAL) {
      lastGroup.messages.push(msg);
    } else {
      groups.push({
        key: msg.id,
        userId: msg.userId,
        messages: [msg],
        isOwn: msg.userId === currentUserId,
      });
    }
  }

  return groups;
}

export default function ChannelScreen() {
  const { cid } = useLocalSearchParams<{ cid: string }>();
  const channelId = cid;
  const session = useAuthStore((state) => state.session);
  const currentUserId = session?.user?.id ?? "";
  const queryClient = useQueryClient();

  const colorScheme = useColorScheme();
  const isDark = colorScheme === "dark";
  const bgColor = useThemeColor({ light: "#f8fafc", dark: "#111827" }, "background");
  const textColor = useThemeColor({ light: "#1f2937", dark: "#f9fafb" }, "text");
  const subtextColor = useThemeColor({ light: "#6b7280", dark: "#9ca3af" }, "text");
  const inputBg = useThemeColor({ light: "#f1f5f9", dark: "#374151" }, "background");
  const headerBg = useThemeColor({ light: "#ffffff", dark: "#1f2937" }, "background");
  const borderColor = isDark ? "#374151" : "#e5e7eb";
  const ownBubbleBg = "#3b82f6";
  const otherBubbleBg = isDark ? "#374151" : "#e5e7eb";

  const {
    messages,
    sendMessage,
    sendTyping,
    isConnected,
    typingUsers,
    onlineUsers,
    loadMoreMessages,
    hasMoreMessages,
    markAsRead,
  } = useChat(channelId);

  const { data: tablos } = useTablosList();
  const { data: members } = useTabloMembers(channelId);
  const tablo = tablos?.find((t) => t.id === channelId);

  const [inputText, setInputText] = useState("");
  const flatListRef = useRef<FlatList>(null);

  // Build user lookup map
  const userMap = useMemo(() => {
    const map: Record<string, TabloMember> = {};
    members?.forEach((m) => { map[m.id] = m; });
    return map;
  }, [members]);

  // Mark as read when screen gains focus
  useFocusEffect(
    useCallback(() => {
      markAsRead();
      queryClient.invalidateQueries({ queryKey: ["chat-unread"] });
    }, [markAsRead, queryClient])
  );

  // Group messages for display
  const messageGroups = useMemo(
    () => groupMessages(messages, currentUserId).reverse(),
    [messages, currentUserId]
  );

  const handleSend = () => {
    const text = inputText.trim();
    if (!text) return;
    sendMessage(text);
    setInputText("");
  };

  const handleTextChange = (text: string) => {
    setInputText(text);
    sendTyping();
  };

  // Typing indicator text
  const typingText = useMemo(() => {
    if (typingUsers.length === 0) return null;
    const names = typingUsers.map((id) => userMap[id]?.name ?? "Quelqu'un");
    if (names.length === 1) return `${names[0]} est en train d'écrire...`;
    return `${names.join(" et ")} sont en train d'écrire...`;
  }, [typingUsers, userMap]);

  const renderMessageGroup = ({ item }: { item: MessageGroup }) => {
    const user = userMap[item.userId];
    const senderName = user?.name ?? "Utilisateur";
    const initial = senderName.charAt(0).toUpperCase();
    const timestamp = new Date(item.messages[0].createdAt).toLocaleTimeString("fr-FR", {
      hour: "2-digit",
      minute: "2-digit",
    });

    return (
      <View style={[styles.groupContainer, item.isOwn && styles.groupContainerOwn]}>
        {/* Avatar + name for other users */}
        {!item.isOwn && (
          <View style={styles.senderRow}>
            <View style={styles.avatar}>
              <Text style={styles.avatarText}>{initial}</Text>
            </View>
            <Text style={[styles.senderName, { color: subtextColor }]}>{senderName}</Text>
            <Text style={[styles.timestamp, { color: subtextColor }]}>{timestamp}</Text>
          </View>
        )}
        {item.isOwn && (
          <Text style={[styles.timestamp, styles.timestampOwn, { color: subtextColor }]}>
            {timestamp}
          </Text>
        )}

        {/* Message bubbles */}
        {item.messages.map((msg) => (
          <View
            key={msg.id}
            style={[
              styles.bubble,
              item.isOwn
                ? [styles.bubbleOwn, { backgroundColor: ownBubbleBg }]
                : [styles.bubbleOther, { backgroundColor: otherBubbleBg }],
              msg.optimistic && styles.bubbleOptimistic,
            ]}
          >
            <Text style={[styles.messageText, { color: item.isOwn ? "#ffffff" : textColor }]}>
              {msg.text}
            </Text>
          </View>
        ))}
      </View>
    );
  };

  return (
    <SafeAreaView style={[styles.container, { backgroundColor: bgColor }]}>
      {/* Header */}
      <View style={[styles.header, { backgroundColor: headerBg, borderBottomColor: borderColor }]}>
        <TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
          <ArrowLeft size={22} color={textColor} />
        </TouchableOpacity>
        <View style={styles.headerContent}>
          <Text style={[styles.headerTitle, { color: textColor }]} numberOfLines={1}>
            {tablo?.name ?? "Discussion"}
          </Text>
          {onlineUsers.length > 0 && (
            <Text style={[styles.headerSubtitle, { color: subtextColor }]}>
              {onlineUsers.length} en ligne
            </Text>
          )}
        </View>
      </View>

      {/* Connection banner */}
      {!isConnected && (
        <View style={styles.connectionBanner}>
          <Text style={styles.connectionText}>Reconnexion...</Text>
        </View>
      )}

      <KeyboardAvoidingView
        style={styles.content}
        behavior={Platform.OS === "ios" ? "padding" : undefined}
        keyboardVerticalOffset={Platform.OS === "ios" ? 0 : 0}
      >
        {/* Message list */}
        {messages.length === 0 ? (
          <View style={styles.emptyState}>
            <MessageCircle size={48} color={subtextColor} strokeWidth={1.5} />
            <Text style={[styles.emptyTitle, { color: textColor }]}>
              Commencez la conversation
            </Text>
            <Text style={{ color: subtextColor, textAlign: "center" }}>
              Soyez le premier à envoyer un message !
            </Text>
          </View>
        ) : (
          <FlatList
            ref={flatListRef}
            data={messageGroups}
            keyExtractor={(item) => item.key}
            renderItem={renderMessageGroup}
            inverted
            onEndReached={loadMoreMessages}
            onEndReachedThreshold={0.3}
            ListFooterComponent={
              hasMoreMessages ? (
                <ActivityIndicator style={styles.loadingMore} color="#3b82f6" />
              ) : null
            }
            contentContainerStyle={styles.messageListContent}
          />
        )}

        {/* Typing indicator */}
        {typingText && (
          <View style={[styles.typingBar, { borderTopColor: borderColor }]}>
            <Text style={[styles.typingText, { color: subtextColor }]}>{typingText}</Text>
          </View>
        )}

        {/* Input */}
        <View style={[styles.inputContainer, { borderTopColor: borderColor }]}>
          <TextInput
            style={[styles.input, { backgroundColor: inputBg, color: textColor }]}
            value={inputText}
            onChangeText={handleTextChange}
            placeholder="Message..."
            placeholderTextColor="#9ca3af"
            multiline
            maxLength={4000}
          />
          <TouchableOpacity
            style={[styles.sendButton, !inputText.trim() && styles.sendButtonDisabled]}
            onPress={handleSend}
            disabled={!inputText.trim()}
          >
            <Send size={20} color={inputText.trim() ? "#3b82f6" : "#9ca3af"} />
          </TouchableOpacity>
        </View>
      </KeyboardAvoidingView>
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  header: {
    flexDirection: "row",
    alignItems: "center",
    paddingHorizontal: 12,
    paddingVertical: 12,
    borderBottomWidth: StyleSheet.hairlineWidth,
    gap: 10,
  },
  backButton: {
    padding: 4,
  },
  headerContent: {
    flex: 1,
  },
  headerTitle: {
    fontSize: 17,
    fontWeight: "700",
  },
  headerSubtitle: {
    fontSize: 12,
    marginTop: 1,
  },
  connectionBanner: {
    backgroundColor: "#f59e0b",
    paddingVertical: 4,
    alignItems: "center",
  },
  connectionText: {
    color: "#ffffff",
    fontSize: 12,
    fontWeight: "600",
  },
  content: {
    flex: 1,
  },
  messageListContent: {
    paddingHorizontal: 12,
    paddingVertical: 8,
  },
  emptyState: {
    flex: 1,
    alignItems: "center",
    justifyContent: "center",
    gap: 12,
    paddingHorizontal: 40,
  },
  emptyTitle: {
    fontSize: 20,
    fontWeight: "700",
  },
  loadingMore: {
    paddingVertical: 16,
  },
  groupContainer: {
    marginBottom: 12,
    maxWidth: "80%",
    alignSelf: "flex-start",
  },
  groupContainerOwn: {
    alignSelf: "flex-end",
  },
  senderRow: {
    flexDirection: "row",
    alignItems: "center",
    gap: 6,
    marginBottom: 4,
  },
  avatar: {
    width: 24,
    height: 24,
    borderRadius: 12,
    backgroundColor: "#3b82f6",
    alignItems: "center",
    justifyContent: "center",
  },
  avatarText: {
    color: "#ffffff",
    fontSize: 11,
    fontWeight: "700",
  },
  senderName: {
    fontSize: 12,
    fontWeight: "600",
  },
  timestamp: {
    fontSize: 11,
  },
  timestampOwn: {
    textAlign: "right",
    marginBottom: 4,
  },
  bubble: {
    paddingHorizontal: 12,
    paddingVertical: 8,
    borderRadius: 16,
    marginBottom: 2,
  },
  bubbleOwn: {
    borderBottomRightRadius: 4,
  },
  bubbleOther: {
    borderBottomLeftRadius: 4,
  },
  bubbleOptimistic: {
    opacity: 0.7,
  },
  messageText: {
    fontSize: 15,
    lineHeight: 20,
  },
  typingBar: {
    paddingHorizontal: 16,
    paddingVertical: 6,
    borderTopWidth: StyleSheet.hairlineWidth,
  },
  typingText: {
    fontSize: 12,
    fontStyle: "italic",
  },
  inputContainer: {
    flexDirection: "row",
    alignItems: "flex-end",
    paddingHorizontal: 12,
    paddingVertical: 8,
    borderTopWidth: StyleSheet.hairlineWidth,
    gap: 8,
  },
  input: {
    flex: 1,
    borderRadius: 20,
    paddingHorizontal: 16,
    paddingVertical: 10,
    fontSize: 15,
    maxHeight: 100,
  },
  sendButton: {
    padding: 10,
  },
  sendButtonDisabled: {
    opacity: 0.5,
  },
});
```

**Note on inverted FlatList:** The `messages` state stores oldest-first. `groupMessages` builds groups in the same order, then `.reverse()` puts newest first. With `inverted={true}`, data[0] (newest group) renders at the bottom — correct behavior. `onEndReached` fires when scrolling up (to load older messages).

- [ ] **Step 2: Commit**

```bash
git add xtablo-expo/app/\(app\)/channel/\[cid\].tsx
git commit -m "feat(expo): rewrite channel screen with custom chat backend"
```

---

### Task 7: Update Environment Variables

**Files:**
- Modify: `xtablo-expo/.env`

- [ ] **Step 1: Update .env file**

Open `xtablo-expo/.env`. Remove the Stream Chat API key line:

```
EXPO_PUBLIC_STREAM_CHAT_API_KEY="t5vvvddteapa"
```

Add the custom chat backend URLs:

```
EXPO_PUBLIC_CHAT_WS_URL=wss://chat.xtablo.com
EXPO_PUBLIC_CHAT_API_URL=https://chat.xtablo.com
```

- [ ] **Step 2: Commit**

```bash
git add xtablo-expo/.env
git commit -m "chore(expo): update env vars for custom chat backend"
```

---

### Task 8: Verify TypeScript Compiles

No new files. Verification step.

- [ ] **Step 1: Run TypeScript check**

```bash
cd xtablo-expo && npx tsc --noEmit --pretty 2>&1 | head -30
```

Expected: No new errors from the chat migration. Pre-existing errors (react-test-renderer, Collapsible casing) are fine.

- [ ] **Step 2: Fix any import issues**

If there are remaining imports of `stream-chat-expo` anywhere in the codebase, remove them. Check:

```bash
grep -r "stream-chat" xtablo-expo/app/ xtablo-expo/hooks/ xtablo-expo/providers/ xtablo-expo/components/
```

Should return no matches.

- [ ] **Step 3: Commit any fixes**

```bash
git add -A && git commit -m "fix(expo): clean up remaining stream-chat references"
```

---

### Task 9: End-to-End Smoke Test

No new files. Manual verification.

- [ ] **Step 1: Start the app**

```bash
cd xtablo-expo && npx expo start
```

- [ ] **Step 2: Test channel list**

- Open the app, navigate to the Discussions tab
- Verify tablo list loads with color indicators
- Verify unread count badges appear on channels with unread messages
- Pull-to-refresh should refetch

- [ ] **Step 3: Test channel messaging**

- Tap a channel to open it
- Verify message history loads
- Send a message — should appear immediately (optimistic) then solidify
- Verify messages are grouped by sender and 2-minute interval
- Verify own messages are right-aligned, others left-aligned

- [ ] **Step 4: Test typing indicators**

- Open the same channel from the web app in parallel
- Type in the web app — mobile should show "[Name] est en train d'écrire..."
- Type in the mobile app — web should show typing indicator

- [ ] **Step 5: Test presence**

- Verify online user count shows in the channel header
- Close the web app — count should decrease

- [ ] **Step 6: Test pagination**

- In a channel with many messages, scroll up
- Verify older messages load at the top

- [ ] **Step 7: Test mark-as-read**

- Send messages from web to create unread count on mobile
- Open the channel on mobile — unread badge should clear

- [ ] **Step 8: Test reconnection**

- Toggle airplane mode briefly
- Verify "Reconnexion..." banner appears
- Re-enable network — banner should disappear, connection restored
