feat(expo): add useChat hook with WebSocket and REST integration
This commit is contained in:
parent
f57185cd50
commit
68194d445f
1 changed files with 225 additions and 0 deletions
225
xtablo-expo/hooks/chat.ts
Normal file
225
xtablo-expo/hooks/chat.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue