merge main into branch
This commit is contained in:
@@ -4,9 +4,69 @@ import dynamic from "next/dynamic";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { createStompClient } from "@/lib/chatSocket";
|
||||
|
||||
const API_BASE = "";
|
||||
const POLL_INTERVAL = 2500;
|
||||
|
||||
function isImageFilename(name) {
|
||||
return /\.(jpe?g|png|gif|webp|bmp|svg)$/i.test(name || "");
|
||||
}
|
||||
|
||||
function AttachmentPreview({ url, name, token }) {
|
||||
const [blobUrl, setBlobUrl] = useState(null);
|
||||
const isImage = isImageFilename(name);
|
||||
|
||||
useEffect(() => {
|
||||
if (!url || !token) return;
|
||||
let objectUrl;
|
||||
fetch(url, { headers: { Authorization: `Bearer ${token}` } })
|
||||
.then((r) => (r.ok ? r.blob() : null))
|
||||
.then((blob) => {
|
||||
if (blob) {
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setBlobUrl(objectUrl);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { if (objectUrl) URL.revokeObjectURL(objectUrl); };
|
||||
}, [url, token]);
|
||||
|
||||
if (isImage) {
|
||||
return (
|
||||
<div style={{ marginTop: "0.5rem" }}>
|
||||
{blobUrl ? (
|
||||
<img
|
||||
src={blobUrl}
|
||||
alt={name || "attachment"}
|
||||
style={{ maxWidth: "220px", maxHeight: "220px", borderRadius: "8px", cursor: "pointer", display: "block" }}
|
||||
onClick={() => window.open(blobUrl, "_blank")}
|
||||
/>
|
||||
) : (
|
||||
<span style={{ fontSize: "0.8rem", opacity: 0.7 }}>📎 Loading image…</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: "0.4rem" }}>
|
||||
<a
|
||||
href="#"
|
||||
style={{ color: "inherit", fontSize: "0.85rem", opacity: 0.85 }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (!blobUrl) return;
|
||||
const a = document.createElement("a");
|
||||
a.href = blobUrl;
|
||||
a.download = name || "attachment";
|
||||
a.click();
|
||||
}}
|
||||
>
|
||||
📎 {name || "Attachment"}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatPage() {
|
||||
const { user, token, loading: authLoading } = useAuth();
|
||||
@@ -22,15 +82,18 @@ function ChatPage() {
|
||||
const [error, setError] = useState(null);
|
||||
const [conversations, setConversations] = useState([]);
|
||||
const [convsLoading, setConvsLoading] = useState(false);
|
||||
const [closedExpanded, setClosedExpanded] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
const [switchingConv, setSwitchingConv] = useState(false);
|
||||
|
||||
const messagesEndRef = useRef(null);
|
||||
const messagesAreaRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
const pollRef = useRef(null);
|
||||
const stompRef = useRef(null);
|
||||
const lastMessageIdRef = useRef(null);
|
||||
const fileInputRef = useRef(null);
|
||||
const lastScrolledIdRef = useRef(null);
|
||||
const initialLoadDoneRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
@@ -42,17 +105,17 @@ function ChatPage() {
|
||||
if (messages.length === 0) return;
|
||||
const lastMsg = messages[messages.length - 1];
|
||||
if (lastMsg.id === lastScrolledIdRef.current) return;
|
||||
if (!initialLoadDoneRef.current) return;
|
||||
lastScrolledIdRef.current = lastMsg.id;
|
||||
const area = messagesAreaRef.current;
|
||||
if (!area) return;
|
||||
const nearBottom = area.scrollHeight - area.scrollTop - area.clientHeight < 80;
|
||||
if (nearBottom) {
|
||||
area.scrollTop = area.scrollHeight;
|
||||
}
|
||||
const nearBottom = area.scrollHeight - area.scrollTop - area.clientHeight < 150;
|
||||
if (nearBottom) area.scrollTop = area.scrollHeight;
|
||||
}, [messages]);
|
||||
|
||||
const fetchMessages = useCallback(async (convId) => {
|
||||
if (!token || !convId) return;
|
||||
initialLoadDoneRef.current = false;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
@@ -67,10 +130,16 @@ function ChatPage() {
|
||||
|
||||
if (data.length > 0) {
|
||||
lastMessageIdRef.current = data[data.length - 1].id;
|
||||
lastScrolledIdRef.current = data[data.length - 1].id;
|
||||
}
|
||||
setTimeout(() => {
|
||||
const area = messagesAreaRef.current;
|
||||
if (area) area.scrollTop = area.scrollHeight;
|
||||
initialLoadDoneRef.current = true;
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
catch {
|
||||
setError("Failed to load messages.");
|
||||
}
|
||||
@@ -114,40 +183,34 @@ function ChatPage() {
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const startPolling = useCallback((convId) => {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
pollRef.current = setInterval(async () => {
|
||||
if (!token || !convId) return;
|
||||
try {
|
||||
const [msgsRes, convRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}),
|
||||
fetch(`${API_BASE}/api/v1/chat/conversations/${convId}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}),
|
||||
]);
|
||||
if (msgsRes.ok) {
|
||||
const data = await msgsRes.json();
|
||||
if (Array.isArray(data)) {
|
||||
const lastId = data.length > 0 ? data[data.length - 1].id : null;
|
||||
if (lastId !== lastMessageIdRef.current) {
|
||||
lastMessageIdRef.current = lastId;
|
||||
setMessages(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (convRes.ok) {
|
||||
const convData = await convRes.json();
|
||||
setConversation(convData);
|
||||
}
|
||||
}
|
||||
|
||||
catch {
|
||||
//Silent
|
||||
}
|
||||
}, POLL_INTERVAL);
|
||||
}, [token]);
|
||||
const connectStomp = useCallback((convId) => {
|
||||
if (stompRef.current) {
|
||||
stompRef.current.deactivate();
|
||||
stompRef.current = null;
|
||||
}
|
||||
const client = createStompClient(token);
|
||||
client.onConnect = () => {
|
||||
client.subscribe(`/topic/chat/conversations/${convId}`, (frame) => {
|
||||
try {
|
||||
const msg = JSON.parse(frame.body);
|
||||
setMessages((prev) => prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]);
|
||||
lastMessageIdRef.current = msg.id;
|
||||
} catch { /* silent */ }
|
||||
});
|
||||
const convTopic = user?.role === "CUSTOMER"
|
||||
? `/user/queue/chat/conversations`
|
||||
: `/topic/chat/conversations`;
|
||||
client.subscribe(convTopic, (frame) => {
|
||||
try {
|
||||
const conv = JSON.parse(frame.body);
|
||||
if (conv.id === convId) setConversation(conv);
|
||||
setConversations((prev) => prev.map((c) => c.id === conv.id ? conv : c));
|
||||
} catch { /* silent */ }
|
||||
});
|
||||
};
|
||||
stompRef.current = client;
|
||||
client.activate();
|
||||
}, [token, user?.role]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || authLoading) return;
|
||||
@@ -194,16 +257,16 @@ function ChatPage() {
|
||||
]);
|
||||
if (stale) return;
|
||||
setLoadingConv(false);
|
||||
startPolling(convId);
|
||||
connectStomp(convId);
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
return () => {
|
||||
stale = true;
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
if (stompRef.current) { stompRef.current.deactivate(); stompRef.current = null; }
|
||||
};
|
||||
}, [token, authLoading, conversationIdParam, fetchConversation, fetchMessages, startPolling, fetchConversations]);
|
||||
}, [token, authLoading, conversationIdParam, fetchConversation, fetchMessages, connectStomp, fetchConversations]);
|
||||
|
||||
async function handleSend(e) {
|
||||
e?.preventDefault();
|
||||
@@ -347,7 +410,7 @@ function ChatPage() {
|
||||
setConversation(conv);
|
||||
await Promise.all([fetchMessages(conv.id), fetchConversations()]);
|
||||
setLoadingConv(false);
|
||||
startPolling(conv.id);
|
||||
connectStomp(conv.id);
|
||||
router.replace(`/chat?id=${conv.id}`, { scroll: false });
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
@@ -356,7 +419,7 @@ function ChatPage() {
|
||||
}
|
||||
|
||||
async function switchConversation(convId) {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
if (stompRef.current) { stompRef.current.deactivate(); stompRef.current = null; }
|
||||
setMessages([]);
|
||||
setError(null);
|
||||
router.replace(`/chat?id=${convId}`, { scroll: false });
|
||||
@@ -426,7 +489,7 @@ function ChatPage() {
|
||||
<p style={s.sidebarEmpty}>No conversations yet.</p>
|
||||
)}
|
||||
<div style={{ overflowY: "auto", flex: 1 }}>
|
||||
{conversations.map((conv) => (
|
||||
{conversations.filter(c => c.status !== "CLOSED").map((conv) => (
|
||||
<button
|
||||
key={conv.id}
|
||||
style={{ ...s.convItem, ...(conv.id === conversation?.id ? s.convItemActive : {}) }}
|
||||
@@ -434,9 +497,7 @@ function ChatPage() {
|
||||
>
|
||||
<div style={s.convItemTop}>
|
||||
<span style={s.convItemSubject}>{conv.subject || `Conversation #${conv.id}`}</span>
|
||||
<span style={{ ...s.convStatusBadge, ...(conv.status === "OPEN" ? s.convStatusOpen : s.convStatusClosed) }}>
|
||||
{conv.status}
|
||||
</span>
|
||||
<span style={{ ...s.convStatusBadge, ...s.convStatusOpen }}>{conv.status}</span>
|
||||
</div>
|
||||
<div style={s.convItemBottom}>
|
||||
<span style={s.convItemMode}>{conv.mode === "HUMAN" ? "👤 Live" : "🤖 AI"}</span>
|
||||
@@ -445,6 +506,30 @@ function ChatPage() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{conversations.some(c => c.status === "CLOSED") && (
|
||||
<>
|
||||
<button style={s.closedSectionToggle} onClick={() => setClosedExpanded(p => !p)}>
|
||||
<span>Closed ({conversations.filter(c => c.status === "CLOSED").length})</span>
|
||||
<span>{closedExpanded ? "▲" : "▼"}</span>
|
||||
</button>
|
||||
{closedExpanded && conversations.filter(c => c.status === "CLOSED").map((conv) => (
|
||||
<button
|
||||
key={conv.id}
|
||||
style={{ ...s.convItem, ...s.convItemClosed, ...(conv.id === conversation?.id ? s.convItemActive : {}) }}
|
||||
onClick={() => switchConversation(conv.id)}
|
||||
>
|
||||
<div style={s.convItemTop}>
|
||||
<span style={s.convItemSubject}>{conv.subject || `Conversation #${conv.id}`}</span>
|
||||
<span style={{ ...s.convStatusBadge, ...s.convStatusClosed }}>CLOSED</span>
|
||||
</div>
|
||||
<div style={s.convItemBottom}>
|
||||
<span style={s.convItemMode}>{conv.mode === "HUMAN" ? "👤 Live" : "🤖 AI"}</span>
|
||||
<span style={s.convItemDate}>{conv.createdAt ? new Date(conv.createdAt).toLocaleDateString() : ""}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<button style={s.newConvSidebarBtn} onClick={() => handleNewConversation()}>
|
||||
+ New Conversation
|
||||
</button>
|
||||
@@ -488,13 +573,15 @@ function ChatPage() {
|
||||
Close Chat
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
style={s.aiBtn}
|
||||
onClick={() => router.push("/ai-chat")}
|
||||
title="Back to AI Assistant"
|
||||
>
|
||||
AI Assistant
|
||||
</button>
|
||||
{!isHuman && (
|
||||
<button
|
||||
style={s.aiBtn}
|
||||
onClick={() => router.push("/ai-chat")}
|
||||
title="Back to AI Assistant"
|
||||
>
|
||||
AI Assistant
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -540,16 +627,7 @@ function ChatPage() {
|
||||
</span>
|
||||
))}
|
||||
{msg.attachmentUrl && (
|
||||
<div style={s.attachment}>
|
||||
<a
|
||||
href={msg.attachmentUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={s.attachmentLink}
|
||||
>
|
||||
📎 {msg.attachmentName || "Attachment"}
|
||||
</a>
|
||||
</div>
|
||||
<AttachmentPreview url={msg.attachmentUrl} name={msg.attachmentName} token={token} />
|
||||
)}
|
||||
<div style={{ ...s.timestamp, ...(isOwn ? s.timestampUser : {}) }}>
|
||||
{msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : ""}
|
||||
@@ -563,6 +641,11 @@ function ChatPage() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{switchingConv && (
|
||||
<div style={{ textAlign: "center", padding: "1rem", color: "#aaa", fontSize: "0.85rem" }}>
|
||||
Loading messages…
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
@@ -1102,6 +1185,21 @@ const s = {
|
||||
},
|
||||
convStatusOpen: { background: "#e6f9ee", color: "#1a7a3c" },
|
||||
convStatusClosed: { background: "#f0f0f0", color: "#888" },
|
||||
convItemClosed: { opacity: 0.7 },
|
||||
closedSectionToggle: {
|
||||
width: "100%",
|
||||
background: "#f5f5f5",
|
||||
border: "none",
|
||||
borderTop: "1px solid #e8e8e8",
|
||||
padding: "0.5rem 1rem",
|
||||
fontSize: "0.78rem",
|
||||
fontWeight: 600,
|
||||
color: "#666",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
},
|
||||
newConvSidebarBtn: {
|
||||
margin: "0.65rem 1rem",
|
||||
background: "#333",
|
||||
|
||||
Reference in New Issue
Block a user