diff --git a/xtablo-expo/hooks/chat.ts b/xtablo-expo/hooks/chat.ts new file mode 100644 index 0000000..9a1e183 --- /dev/null +++ b/xtablo-expo/hooks/chat.ts @@ -0,0 +1,225 @@ +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, + }; +}