feat(expo): add useChat hook with WebSocket and REST integration

This commit is contained in:
Arthur Belleville 2026-04-15 17:07:16 +02:00
parent f57185cd50
commit 68194d445f

225
xtablo-expo/hooks/chat.ts Normal file
View file

@ -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<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,
};
}