"use client"; import { useAuth } from "@clerk/nextjs"; import { useEffect, useMemo, useRef, useState } from "react"; import { io, type Socket } from "socket.io-client"; import axios from "axios"; import { PageHeader } from "@/components/ui/PageHeader"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; interface ChatThread { id: string; type: "gym" | "dm"; unreadCount: number; lastMessageBody: string | null; lastMessageAt: string | null; } interface ChatMessage { id: string; threadId: string; senderUserId: string; body: string; createdAt: string; clientMessageId: string | null; kind?: "text" | "system"; attachments?: Array<{ url: string; type: string; name?: string }>; editedAt?: string | null; deletedAt?: string | null; } interface ReadEvent { threadId?: string; userId?: string; lastReadMessageId?: string | null; } export default function AdminChatPage() { const { getToken, userId } = useAuth(); const [threads, setThreads] = useState([]); const [activeThreadId, setActiveThreadId] = useState(null); const [messagesByThreadId, setMessagesByThreadId] = useState< Record >({}); const [draft, setDraft] = useState(""); const [loadingThreads, setLoadingThreads] = useState(false); const [loadingMessages, setLoadingMessages] = useState(false); const [socketConnected, setSocketConnected] = useState(false); const [typingByThreadId, setTypingByThreadId] = useState< Record >({}); const [readByThreadId, setReadByThreadId] = useState< Record> >({}); const typingTimeoutRef = useRef | null>(null); const socketRef = useRef(null); const getTokenRef = useRef(getToken); const activeThreadIdRef = useRef(null); const subscribedThreadIdsRef = useRef>(new Set()); const loadingThreadsRef = useRef(false); const loadingMessagesRef = useRef>({}); const activeMessages = useMemo( () => (activeThreadId ? (messagesByThreadId[activeThreadId] ?? []) : []), [activeThreadId, messagesByThreadId], ); const typingUsers = useMemo( () => (activeThreadId ? (typingByThreadId[activeThreadId] ?? []) : []), [activeThreadId, typingByThreadId], ); useEffect(() => { getTokenRef.current = getToken; }, [getToken]); useEffect(() => { activeThreadIdRef.current = activeThreadId; }, [activeThreadId]); const loadThreads = async (showLoader = true) => { if (loadingThreadsRef.current) { return; } const token = await getTokenRef.current(); if (!token) { return; } loadingThreadsRef.current = true; try { if (showLoader) { setLoadingThreads(true); } const response = await axios.get<{ threads: ChatThread[] }>( "/api/chat/threads", { headers: { Authorization: `Bearer ${token}` }, }, ); setThreads(response.data.threads); setActiveThreadId((prev) => prev ?? response.data.threads[0]?.id ?? null); setReadByThreadId({}); } finally { if (showLoader) { setLoadingThreads(false); } loadingThreadsRef.current = false; } }; const loadMessages = async (threadId: string, showLoader = true) => { if (loadingMessagesRef.current[threadId]) { return; } const token = await getTokenRef.current(); if (!token) { return; } loadingMessagesRef.current[threadId] = true; try { if (showLoader) { setLoadingMessages(true); } const response = await axios.get<{ messages: ChatMessage[] }>( `/api/chat/threads/${threadId}/messages`, { headers: { Authorization: `Bearer ${token}` }, }, ); setMessagesByThreadId((prev) => ({ ...prev, [threadId]: response.data.messages, })); } finally { if (showLoader) { setLoadingMessages(false); } loadingMessagesRef.current[threadId] = false; } }; useEffect(() => { void loadThreads(true); }, []); useEffect(() => { if (!activeThreadId) { return; } void loadMessages(activeThreadId, true); }, [activeThreadId]); useEffect(() => { let mounted = true; const setupSocket = async () => { const token = await getTokenRef.current(); if (!token || !mounted) { return; } const realtimeBase = process.env.NEXT_PUBLIC_REALTIME_URL ?? window.location.origin; const socket = io(realtimeBase, { path: process.env.NEXT_PUBLIC_REALTIME_PATH ?? "/socket.io", transports: ["websocket"], auth: { token }, }); socketRef.current = socket; socket.on("connect", () => { setSocketConnected(true); subscribedThreadIdsRef.current.clear(); void loadThreads(false); }); socket.on("disconnect", () => { setSocketConnected(false); setTypingByThreadId({}); subscribedThreadIdsRef.current.clear(); }); socket.on( "chat:message:new", (event: { threadId: string; message: ChatMessage }) => { setMessagesByThreadId((prev) => { const current = prev[event.threadId] ?? []; if (current.some((message) => message.id === event.message.id)) { return prev; } return { ...prev, [event.threadId]: [...current, event.message], }; }); setThreads((prev) => prev .map((thread) => thread.id === event.threadId ? { ...thread, lastMessageAt: event.message.createdAt, lastMessageBody: event.message.body, unreadCount: event.threadId === activeThreadIdRef.current || event.message.senderUserId === userId ? thread.unreadCount : thread.unreadCount + 1, } : thread, ) .sort((a, b) => { const aTime = a.lastMessageAt ?? "1970-01-01T00:00:00.000Z"; const bTime = b.lastMessageAt ?? "1970-01-01T00:00:00.000Z"; return new Date(bTime).getTime() - new Date(aTime).getTime(); }), ); }, ); socket.on( "chat:message:ack", (event: { threadId: string; message: ChatMessage; clientMessageId?: string | null; }) => { setMessagesByThreadId((prev) => ({ ...prev, [event.threadId]: (prev[event.threadId] ?? []).map((message) => event.clientMessageId ? message.clientMessageId === event.clientMessageId ? event.message : message : message.id === event.message.id ? event.message : message, ), })); }, ); socket.on( "chat:typing", (event: { threadId?: string; userId?: string; isTyping?: boolean }) => { if (!event.threadId || !event.userId || event.userId === userId) { return; } setTypingByThreadId((prev) => { const current = prev[event.threadId!] ?? []; if (event.isTyping) { if (current.includes(event.userId!)) { return prev; } return { ...prev, [event.threadId!]: [...current, event.userId!], }; } return { ...prev, [event.threadId!]: current.filter((id) => id !== event.userId), }; }); }, ); socket.on("chat:read:update", (event: ReadEvent) => { if (!event.threadId || !event.userId || !event.lastReadMessageId) { return; } setReadByThreadId((prev) => ({ ...prev, [event.threadId!]: { ...(prev[event.threadId!] ?? {}), [event.userId!]: event.lastReadMessageId!, }, })); void loadThreads(false); }); socket.on("chat:error", (event: { code?: string; message?: string }) => { if (event.code === "rate_limited") { console.warn(event.message ?? "Chat action rate-limited"); } }); }; void setupSocket(); return () => { mounted = false; if (typingTimeoutRef.current) { clearTimeout(typingTimeoutRef.current); } if (socketRef.current) { socketRef.current.disconnect(); socketRef.current = null; } }; }, [userId]); useEffect(() => { if (!socketRef.current || !socketConnected) { return; } const socket = socketRef.current; const nextIds = new Set(threads.map((thread) => thread.id)); threads.forEach((thread) => { if (!subscribedThreadIdsRef.current.has(thread.id)) { socket.emit("chat:subscribe", { threadId: thread.id }); subscribedThreadIdsRef.current.add(thread.id); } }); Array.from(subscribedThreadIdsRef.current).forEach((threadId) => { if (!nextIds.has(threadId)) { socket.emit("chat:unsubscribe", { threadId }); subscribedThreadIdsRef.current.delete(threadId); } }); }, [socketConnected, threads]); useEffect(() => { if (!activeThreadId) { return; } setThreads((prev) => prev.map((thread) => thread.id === activeThreadId ? { ...thread, unreadCount: 0 } : thread, ), ); }, [activeThreadId]); useEffect(() => { if (!socketConnected) { const interval = setInterval(() => { void loadThreads(false); if (activeThreadIdRef.current) { void loadMessages(activeThreadIdRef.current, false); } }, 5000); return () => { clearInterval(interval); }; } }, [socketConnected]); useEffect(() => { if (!activeThreadId) { return; } const messages = messagesByThreadId[activeThreadId] ?? []; const lastMessage = messages[messages.length - 1]; if (!lastMessage || lastMessage.senderUserId === userId) { return; } const markRead = async () => { const token = await getTokenRef.current(); if (!token) { return; } await axios.post( `/api/chat/threads/${activeThreadId}/read`, { lastReadMessageId: lastMessage.id }, { headers: { Authorization: `Bearer ${token}` }, }, ); if (socketRef.current && socketConnected) { socketRef.current.emit("chat:read", { threadId: activeThreadId, lastReadMessageId: lastMessage.id, }); } if (userId) { setReadByThreadId((prev) => ({ ...prev, [activeThreadId]: { ...(prev[activeThreadId] ?? {}), [userId]: lastMessage.id, }, })); } }; void markRead(); }, [activeThreadId, messagesByThreadId, socketConnected, userId]); const onChangeDraft = (value: string) => { setDraft(value); if (!activeThreadId || !socketRef.current || !socketConnected) { return; } const isTyping = value.trim().length > 0; socketRef.current.emit("chat:typing", { threadId: activeThreadId, isTyping, }); if (typingTimeoutRef.current) { clearTimeout(typingTimeoutRef.current); } typingTimeoutRef.current = setTimeout(() => { if (activeThreadId && socketRef.current) { socketRef.current.emit("chat:typing", { threadId: activeThreadId, isTyping: false, }); } }, 1200); }; const onSend = async () => { if (!activeThreadId) { return; } const body = draft.trim(); if (!body) { return; } const clientMessageId = `admin_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const optimistic: ChatMessage = { id: clientMessageId, threadId: activeThreadId, senderUserId: userId ?? "", body, createdAt: new Date().toISOString(), clientMessageId, kind: "text", attachments: [], editedAt: null, deletedAt: null, }; setMessagesByThreadId((prev) => ({ ...prev, [activeThreadId]: [...(prev[activeThreadId] ?? []), optimistic], })); setDraft(""); if (socketRef.current && socketConnected) { socketRef.current.emit("chat:send", { threadId: activeThreadId, body, clientMessageId, }); return; } const token = await getTokenRef.current(); if (!token) { return; } try { const response = await axios.post<{ message: ChatMessage }>( `/api/chat/threads/${activeThreadId}/messages`, { body, clientMessageId }, { headers: { Authorization: `Bearer ${token}` }, }, ); setMessagesByThreadId((prev) => ({ ...prev, [activeThreadId]: (prev[activeThreadId] ?? []).map((message) => message.id === clientMessageId ? response.data.message : message, ), })); } catch { setMessagesByThreadId((prev) => ({ ...prev, [activeThreadId]: (prev[activeThreadId] ?? []).filter( (message) => message.id !== clientMessageId, ), })); } }; return (
{socketConnected ? "Realtime connected" : "Realtime disconnected"}
Threads {loadingThreads ? (
Loading threads...
) : ( threads.map((thread) => ( )) )}
Messages {!activeThreadId ? (

Select a thread to start chatting.

) : (
{loadingMessages ? (

Loading messages...

) : ( activeMessages.map((message) => { const own = message.senderUserId === userId; const threadReads = activeThreadId ? (readByThreadId[activeThreadId] ?? {}) : {}; const seen = Object.entries(threadReads).some( ([readerUserId, readMessageId]) => readerUserId !== userId && readMessageId === message.id, ); return (
{message.body}
{own && seen && (
Seen
)}
); }) )}
{typingUsers.length > 0 && (

{typingUsers.length === 1 ? "Someone is typing..." : `${typingUsers.length} people are typing...`}

)}
onChangeDraft(event.target.value)} placeholder="Type a message" />
)}
); }