638 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|