From b516568b74818cdefc6362e778bc810f836b1ac2 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Wed, 15 Apr 2026 17:03:47 +0200 Subject: [PATCH] docs: add expo chat migration implementation plan Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-04-15-expo-chat-migration.md | 1219 +++++++++++++++++ 1 file changed, 1219 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-15-expo-chat-migration.md diff --git a/docs/superpowers/plans/2026-04-15-expo-chat-migration.md b/docs/superpowers/plans/2026-04-15-expo-chat-migration.md new file mode 100644 index 0000000..eefc9dc --- /dev/null +++ b/docs/superpowers/plans/2026-04-15-expo-chat-migration.md @@ -0,0 +1,1219 @@ +# 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 `` with a tablo-based list using `useTablosList()` + `useChatUnread()`. Replace Stream Chat's `` / `` / `` 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([]); + const [isConnected, setIsConnected] = useState(false); + const [typingUsers, setTypingUsers] = useState([]); + const [onlineUsers, setOnlineUsers] = useState([]); + const [hasMoreMessages, setHasMoreMessages] = useState(true); + + const wsRef = useRef(null); + const reconnectAttemptRef = useRef(0); + const reconnectTimerRef = useRef>(undefined); + const typingTimerRef = useRef>(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 => { + 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 ( + + + + + + + + + ); +} +``` + +- [ ] **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 `` 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 ( + router.push(`/channel/${item.id}`)} + activeOpacity={0.7} + > + + 0 && styles.channelNameBold, + ]} + numberOfLines={1} + > + {item.name} + + {unread > 0 && ( + + {unread > 99 ? "99+" : unread} + + )} + + ); + }; + + if (isLoading && !tablos) { + return ( + + + Chargement... + + + ); + } + + return ( + + + Discussions + + item.id} + renderItem={renderChannel} + refreshControl={ + + } + ListEmptyComponent={ + + + + Aucune discussion + + + } + /> + + ); +} + +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(null); + + // Build user lookup map + const userMap = useMemo(() => { + const map: Record = {}; + 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 ( + + {/* Avatar + name for other users */} + {!item.isOwn && ( + + + {initial} + + {senderName} + {timestamp} + + )} + {item.isOwn && ( + + {timestamp} + + )} + + {/* Message bubbles */} + {item.messages.map((msg) => ( + + + {msg.text} + + + ))} + + ); + }; + + return ( + + {/* Header */} + + router.back()} style={styles.backButton}> + + + + + {tablo?.name ?? "Discussion"} + + {onlineUsers.length > 0 && ( + + {onlineUsers.length} en ligne + + )} + + + + {/* Connection banner */} + {!isConnected && ( + + Reconnexion... + + )} + + + {/* Message list */} + {messages.length === 0 ? ( + + + + Commencez la conversation + + + Soyez le premier à envoyer un message ! + + + ) : ( + item.key} + renderItem={renderMessageGroup} + inverted + onEndReached={loadMoreMessages} + onEndReachedThreshold={0.3} + ListFooterComponent={ + hasMoreMessages ? ( + + ) : null + } + contentContainerStyle={styles.messageListContent} + /> + )} + + {/* Typing indicator */} + {typingText && ( + + {typingText} + + )} + + {/* Input */} + + + + + + + + + ); +} + +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