fitaiProto/apps/admin/src/app/chat/page.tsx
2026-04-03 05:09:55 +02:00

638 lines
18 KiB
TypeScript

"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<ChatThread[]>([]);
const [activeThreadId, setActiveThreadId] = useState<string | null>(null);
const [messagesByThreadId, setMessagesByThreadId] = useState<
Record<string, ChatMessage[]>
>({});
const [draft, setDraft] = useState("");
const [loadingThreads, setLoadingThreads] = useState(false);
const [loadingMessages, setLoadingMessages] = useState(false);
const [socketConnected, setSocketConnected] = useState(false);
const [typingByThreadId, setTypingByThreadId] = useState<
Record<string, string[]>
>({});
const [readByThreadId, setReadByThreadId] = useState<
Record<string, Record<string, string>>
>({});
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const socketRef = useRef<Socket | null>(null);
const getTokenRef = useRef(getToken);
const activeThreadIdRef = useRef<string | null>(null);
const subscribedThreadIdsRef = useRef<Set<string>>(new Set());
const loadingThreadsRef = useRef(false);
const loadingMessagesRef = useRef<Record<string, boolean>>({});
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 (
<div className="space-y-6">
<PageHeader
title="Chat"
description="Gym room and private trainer-client messaging"
breadcrumbs={[{ label: "Chat" }]}
/>
<div className="text-sm text-slate-500">
{socketConnected ? "Realtime connected" : "Realtime disconnected"}
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[320px_1fr]">
<Card>
<CardHeader>
<CardTitle>Threads</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{loadingThreads ? (
<div className="text-sm text-slate-500">Loading threads...</div>
) : (
threads.map((thread) => (
<button
key={thread.id}
type="button"
onClick={() => setActiveThreadId(thread.id)}
className={`w-full rounded-lg border p-3 text-left transition ${
thread.id === activeThreadId
? "border-blue-500 bg-blue-50"
: "border-slate-200 hover:bg-slate-50"
}`}
>
<div className="flex items-center justify-between">
<p className="font-medium text-slate-900">
{thread.type === "gym" ? "Gym Room" : "Trainer DM"}
</p>
{thread.unreadCount > 0 && (
<span className="rounded-full bg-red-500 px-2 py-0.5 text-xs font-semibold text-white">
{thread.unreadCount > 99 ? "99+" : thread.unreadCount}
</span>
)}
</div>
<p className="mt-1 truncate text-sm text-slate-500">
{thread.lastMessageBody ?? "No messages yet"}
</p>
</button>
))
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Messages</CardTitle>
</CardHeader>
<CardContent>
{!activeThreadId ? (
<p className="text-sm text-slate-500">
Select a thread to start chatting.
</p>
) : (
<div className="space-y-3">
<div className="max-h-[460px] space-y-2 overflow-y-auto rounded-md border border-slate-200 p-3">
{loadingMessages ? (
<p className="text-sm text-slate-500">
Loading messages...
</p>
) : (
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 (
<div
key={message.id}
className={`max-w-[80%] rounded-lg px-3 py-2 text-sm ${
own
? "ml-auto bg-blue-600 text-white"
: "bg-slate-100 text-slate-900"
}`}
>
<div>{message.body}</div>
{own && seen && (
<div className="mt-1 text-[10px] text-blue-100">
Seen
</div>
)}
</div>
);
})
)}
</div>
{typingUsers.length > 0 && (
<p className="text-xs text-slate-500">
{typingUsers.length === 1
? "Someone is typing..."
: `${typingUsers.length} people are typing...`}
</p>
)}
<div className="flex items-center gap-2">
<Input
value={draft}
onChange={(event) => onChangeDraft(event.target.value)}
placeholder="Type a message"
/>
<Button onClick={onSend}>Send</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}