chat
This commit is contained in:
@@ -5,8 +5,9 @@ import Container from "@/components/elements/Container";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { User } from "@/types/types";
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { io } from "socket.io-client";
|
||||
import { SOCKET_URL } from "@/components/main/BaseUrl";
|
||||
import {
|
||||
getChatSocket,
|
||||
} from "@/lib/chat/socketClient";
|
||||
import MessageInput from "@/components/chat/MessageInput";
|
||||
import MultiImageModal from "@/components/chat/MultiImageModal";
|
||||
import ChatActionBar from "@/components/chat/ChatActionBar";
|
||||
@@ -22,12 +23,14 @@ import ForwardMessageModal from "@/components/chat/ForwardMessageModal";
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { chatThreadQueryKey } from "@/lib/chat/queryKeys";
|
||||
import { appendMessageToThreadCache, normalizeThreadId } from "@/lib/chat/threadCache";
|
||||
import { normalizeThreadId, upsertMessageInThreadCache } from "@/lib/chat/threadCache";
|
||||
import { useStableMessageKeys } from "@/hooks/useStableMessageKeys";
|
||||
import { getStoredUserId } from "@/lib/auth/session";
|
||||
import { buildExpiresAtIso } from "@/lib/chat/timedMessages";
|
||||
import { isViewOnceMediaType } from "@/lib/chat/viewOnce";
|
||||
import { filterResolvedPendingMessages } from "@/lib/chat/dedupeMessages";
|
||||
import {
|
||||
findPendingMatchForServer,
|
||||
} from "@/lib/chat/dedupeMessages";
|
||||
|
||||
interface ITicketChatProps {
|
||||
params: Promise<{ id: string; username: string }>;
|
||||
@@ -67,44 +70,61 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
const { linkIds, getStableKey } = useStableMessageKeys();
|
||||
const [receiverId, setReceiverId] = useState(chatPartnerId);
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const socketRef = useRef<ReturnType<typeof io> | null>(null);
|
||||
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const activeReplyRef = useRef<ChatMessage | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?._id || !receiverId) return;
|
||||
const socket = io(SOCKET_URL, {
|
||||
transports: ["websocket", "polling"],
|
||||
reconnection: true,
|
||||
reconnectionAttempts: Infinity,
|
||||
reconnectionDelay: 800,
|
||||
});
|
||||
socketRef.current = socket;
|
||||
const handleIncomingMessage = useCallback(
|
||||
(msg: ChatMessage) => {
|
||||
setPendingMessages((prev) => {
|
||||
const match = findPendingMatchForServer(msg, prev);
|
||||
if (match) linkIds(match._id, msg._id);
|
||||
return prev;
|
||||
});
|
||||
},
|
||||
[linkIds]
|
||||
);
|
||||
|
||||
// عضویت در رومها روی هر اتصال/اتصال مجدد تا بعد از قطعی هم پیامها برسند
|
||||
const joinRooms = () => {
|
||||
socket.emit("joinChat", { userId: user._id, receiverId });
|
||||
socket.emit("joinUser", { userId: user._id });
|
||||
};
|
||||
socket.on("connect", joinRooms);
|
||||
if (socket.connected) joinRooms();
|
||||
const pruneResolvedPending = useCallback((ids: string[]) => {
|
||||
if (!ids.length) return;
|
||||
setPendingMessages((prev) => prev.filter((m) => !ids.includes(m._id)));
|
||||
}, []);
|
||||
|
||||
return () => {
|
||||
socket.off("connect", joinRooms);
|
||||
socket.disconnect();
|
||||
socketRef.current = null;
|
||||
};
|
||||
}, [user?._id, receiverId]);
|
||||
const confirmSentMessage = useCallback(
|
||||
(tempId: string, serverMsg: ChatMessage, replyPayload?: ChatMessage["replyTo"]) => {
|
||||
const sentMsg: ChatMessage = {
|
||||
...serverMsg,
|
||||
status: "sent",
|
||||
replyTo: serverMsg.replyTo ?? replyPayload,
|
||||
};
|
||||
|
||||
const currentUserId = normalizeThreadId(user?._id ?? getStoredUserId());
|
||||
const targetReceiverId = normalizeThreadId(
|
||||
userTwoDetail?._id ?? receiverId ?? chatPartnerId
|
||||
);
|
||||
|
||||
linkIds(tempId, sentMsg._id);
|
||||
|
||||
if (currentUserId && targetReceiverId) {
|
||||
queryClient.setQueryData(
|
||||
chatThreadQueryKey(currentUserId, targetReceiverId),
|
||||
(oldData) => upsertMessageInThreadCache(oldData, sentMsg)
|
||||
);
|
||||
}
|
||||
// pending را اینجا حذف نکن — ChatMessageList بعد از sync شدن cache prune میکند
|
||||
},
|
||||
[user?._id, userTwoDetail?._id, receiverId, chatPartnerId, linkIds, queryClient]
|
||||
);
|
||||
|
||||
const handleTyping = useCallback(() => {
|
||||
if (!socketRef.current || !user?._id || !receiverId) return;
|
||||
socketRef.current.emit("typing", {
|
||||
const socket = getChatSocket();
|
||||
if (!socket || !user?._id || !receiverId) return;
|
||||
socket.emit("typing", {
|
||||
senderId: user._id,
|
||||
receiverId,
|
||||
});
|
||||
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
|
||||
typingTimeoutRef.current = setTimeout(() => {
|
||||
socketRef.current?.emit("stopTyping", {
|
||||
getChatSocket()?.emit("stopTyping", {
|
||||
senderId: user._id,
|
||||
receiverId,
|
||||
});
|
||||
@@ -219,36 +239,6 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
};
|
||||
};
|
||||
|
||||
const handleIncomingMessage = useCallback((msg: ChatMessage) => {
|
||||
setPendingMessages((prev) => filterResolvedPendingMessages([msg], prev));
|
||||
}, []);
|
||||
|
||||
const confirmSentMessage = useCallback(
|
||||
(tempId: string, serverMsg: ChatMessage, replyPayload?: ChatMessage["replyTo"]) => {
|
||||
const sentMsg: ChatMessage = {
|
||||
...serverMsg,
|
||||
status: "sent",
|
||||
replyTo: serverMsg.replyTo ?? replyPayload,
|
||||
};
|
||||
|
||||
const currentUserId = normalizeThreadId(user?._id ?? getStoredUserId());
|
||||
const targetReceiverId = normalizeThreadId(
|
||||
userTwoDetail?._id ?? receiverId ?? chatPartnerId
|
||||
);
|
||||
|
||||
if (currentUserId && targetReceiverId) {
|
||||
linkIds(tempId, sentMsg._id);
|
||||
queryClient.setQueryData(
|
||||
chatThreadQueryKey(currentUserId, targetReceiverId),
|
||||
(oldData) => appendMessageToThreadCache(oldData, sentMsg)
|
||||
);
|
||||
}
|
||||
|
||||
setPendingMessages((prev) => prev.filter((m) => m._id !== tempId));
|
||||
},
|
||||
[user?._id, userTwoDetail?._id, receiverId, chatPartnerId, linkIds, queryClient]
|
||||
);
|
||||
|
||||
const processSendMessage = async (
|
||||
fileToUpload: File | null,
|
||||
fileType?: ChatMessage["fileType"],
|
||||
@@ -486,6 +476,7 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
onToggleDeleteSelect={toggleDeleteSelect}
|
||||
onExpirePending={handleExpirePending}
|
||||
onIncomingMessage={handleIncomingMessage}
|
||||
onPruneResolvedPending={pruneResolvedPending}
|
||||
extraBottomRem={
|
||||
(replyingTo ? 2.75 : 0) +
|
||||
(selfDestructSeconds != null ? 2 : 0) +
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import RoundedInput from "@/components/elements/RoundedInput";
|
||||
import { IMAGE_BASE_URL, SOCKET_URL, buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import VerificationBadge from "@/components/main/VerificationBadge";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import UserDetails from "@/components/settings/UserDetails";
|
||||
@@ -12,8 +12,12 @@ import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { ChatListSkeleton } from "@/components/ui/ChatSkeletons";
|
||||
import { io } from "socket.io-client";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
acquireChatSocket,
|
||||
joinUserRoom,
|
||||
releaseChatSocket,
|
||||
} from "@/lib/chat/socketClient";
|
||||
|
||||
export interface IMessage {
|
||||
first_name: string;
|
||||
@@ -43,21 +47,24 @@ function Chats() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?._id) return;
|
||||
const socket = io(SOCKET_URL);
|
||||
socket.emit("joinUser", { userId: user._id });
|
||||
const socket = acquireChatSocket();
|
||||
const uid = String(user._id);
|
||||
|
||||
socket.on("chatListUpdate", () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["messages"] });
|
||||
});
|
||||
const onConnect = () => joinUserRoom(uid);
|
||||
socket.on("connect", onConnect);
|
||||
if (socket.connected) onConnect();
|
||||
|
||||
socket.on("newMessage", () => {
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["messages"] });
|
||||
});
|
||||
};
|
||||
socket.on("chatListUpdate", invalidate);
|
||||
socket.on("newMessage", invalidate);
|
||||
|
||||
return () => {
|
||||
socket.off("chatListUpdate");
|
||||
socket.off("newMessage");
|
||||
socket.disconnect();
|
||||
socket.off("connect", onConnect);
|
||||
socket.off("chatListUpdate", invalidate);
|
||||
socket.off("newMessage", invalidate);
|
||||
releaseChatSocket();
|
||||
};
|
||||
}, [user?._id, queryClient]);
|
||||
|
||||
@@ -75,82 +82,59 @@ function Chats() {
|
||||
<RoundedInput
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
placeholder="جستجو"
|
||||
className="w-full"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") setSearch(searchText);
|
||||
}}
|
||||
placeholder="جستجو..."
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="gentle-transition absolute left-3 top-[7px] active:scale-90"
|
||||
onClick={() => setSearch(searchText)}
|
||||
aria-label="جستجو"
|
||||
>
|
||||
<Image
|
||||
width={25}
|
||||
height={25}
|
||||
alt="جستجو"
|
||||
src="/images/icons/search-normal.svg"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 w-full">
|
||||
{isLoading ? (
|
||||
<ChatListSkeleton count={7} />
|
||||
) : isEmpty ? (
|
||||
<p className="py-8 text-center text-neutral-500">
|
||||
هنوز مکالمهای ندارید.
|
||||
</p>
|
||||
) : (
|
||||
data?.pages?.map((page, pageIndex) => (
|
||||
<React.Fragment key={pageIndex}>
|
||||
{page?.filteredUsersData?.map((item: IMessage) => (
|
||||
<Link
|
||||
href={`/settings/chats/${item?.user_name}/${item?._id}`}
|
||||
key={item?._id}
|
||||
className="gentle-transition mb-2 block w-full rounded-2xl border-b border-border-primary-light p-4 font-semibold hover:bg-black/[0.03] active:scale-[0.99] dark:hover:bg-white/[0.04]"
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{item?.profile_image ? (
|
||||
<Image
|
||||
width={56}
|
||||
height={56}
|
||||
alt={item?.user_name}
|
||||
src={buildStorageUrl(item?.profile_image)}
|
||||
className="h-14 w-14 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-14 w-14 rounded-full bg-neutral-200 dark:bg-neutral-700" />
|
||||
)}
|
||||
<div>
|
||||
<span className="text-sm">
|
||||
{item.first_name} {item.last_name}
|
||||
</span>
|
||||
<div className="mt-1 flex items-center gap-1 text-neutral-500">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
@{item?.user_name}
|
||||
<VerificationBadge isVerified={item?.is_verified} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<span className="text-[10px] text-neutral-400">
|
||||
{item?.last_online}
|
||||
</span>
|
||||
{item?.unread_messages_count ? (
|
||||
<span className="flex h-5 min-w-[20px] items-center justify-center rounded-full bg-[#2aabee] px-1.5 text-[10px] text-white">
|
||||
{item.unread_messages_count}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<ChatListSkeleton />
|
||||
) : isEmpty ? (
|
||||
<p className="py-12 text-center text-neutral-500">
|
||||
هنوز مکالمهای ندارید.
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-5 flex flex-col gap-3">
|
||||
{data?.pages.map((page) =>
|
||||
page.filteredUsersData?.map((item: IMessage) => (
|
||||
<Link
|
||||
key={item._id}
|
||||
href={`/settings/chats/${item?.user_name}/${item?._id}`}
|
||||
className="flex items-center justify-between rounded-2xl border border-gray-100 p-3 dark:border-gray-800"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{item.profile_image ? (
|
||||
<Image
|
||||
src={buildStorageUrl(item.profile_image)}
|
||||
width={48}
|
||||
height={48}
|
||||
alt={item.user_name}
|
||||
className="rounded-2xl"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-12 w-12 rounded-2xl bg-neutral-200" />
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-semibold">
|
||||
{item.first_name} {item.last_name}
|
||||
</span>
|
||||
<span className="text-neutral-500">{item.user_name}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<VerificationBadge isVerified={item.is_verified} />
|
||||
{item.unread_messages_count ? (
|
||||
<span className="rounded-full bg-[#387E65] px-2 py-0.5 text-xs text-white">
|
||||
{item.unread_messages_count}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useRef, useMemo, useCallback } from "react";
|
||||
import { SOCKET_URL, getApiBaseUrl } from "../main/BaseUrl";
|
||||
import { getApiBaseUrl } from "../main/BaseUrl";
|
||||
import {
|
||||
acquireChatSocket,
|
||||
joinChatRoom,
|
||||
joinUserRoom,
|
||||
releaseChatSocket,
|
||||
} from "@/lib/chat/socketClient";
|
||||
import { apiClient } from "@/hooks/useAxios";
|
||||
import { chatThreadQueryKey } from "@/lib/chat/queryKeys";
|
||||
import {
|
||||
appendMessageToThreadCache,
|
||||
normalizeThreadId,
|
||||
upsertMessageInThreadCache,
|
||||
} from "@/lib/chat/threadCache";
|
||||
import ChatMessageCard, { ChatMessage } from "./ChatMessageCard";
|
||||
import ChatDateSeparator from "./ChatDateSeparator";
|
||||
import { groupMessagesByDate } from "@/lib/chat/groupMessagesByDate";
|
||||
import { dedupeChatMessages, filterResolvedPendingMessages } from "@/lib/chat/dedupeMessages";
|
||||
import { io } from "socket.io-client";
|
||||
import { dedupeChatMessages, filterResolvedPendingMessages, findPendingMatchForServer } from "@/lib/chat/dedupeMessages";
|
||||
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { User } from "@/types/types";
|
||||
import { ChatMessagesSkeleton } from "@/components/ui/ChatSkeletons";
|
||||
@@ -59,6 +65,7 @@ interface ChatMessageListProps {
|
||||
getStableKey?: (id: string) => string;
|
||||
onExpirePending?: (ids: string[]) => void;
|
||||
onIncomingMessage?: (message: ChatMessage) => void;
|
||||
onPruneResolvedPending?: (ids: string[]) => void;
|
||||
/** فضای اضافه وقتی بنر پاسخ/زماندار بالای input است */
|
||||
extraBottomRem?: number;
|
||||
}
|
||||
@@ -82,6 +89,7 @@ const ChatMessageList = ({
|
||||
getStableKey = (id) => id,
|
||||
onExpirePending,
|
||||
onIncomingMessage,
|
||||
onPruneResolvedPending,
|
||||
extraBottomRem = 0,
|
||||
}: ChatMessageListProps) => {
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -97,6 +105,12 @@ const ChatMessageList = ({
|
||||
const [highlightedMessageId, setHighlightedMessageId] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const onIncomingMessageRef = useRef(onIncomingMessage);
|
||||
const onTypingChangeRef = useRef(onTypingChange);
|
||||
const onPruneResolvedPendingRef = useRef(onPruneResolvedPending);
|
||||
onIncomingMessageRef.current = onIncomingMessage;
|
||||
onTypingChangeRef.current = onTypingChange;
|
||||
onPruneResolvedPendingRef.current = onPruneResolvedPending;
|
||||
|
||||
const senderId = normalizeThreadId(userDetail?._id ?? getStoredUserId());
|
||||
const receiverId = normalizeThreadId(
|
||||
@@ -142,7 +156,8 @@ const ChatMessageList = ({
|
||||
getNextPageParam: (lastPage) => lastPage.nextPage,
|
||||
staleTime: 60_000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
refetchOnMount: true,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const hasCachedMessages = Boolean(
|
||||
@@ -186,44 +201,45 @@ const ChatMessageList = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (!threadReady) return;
|
||||
const socket = io(SOCKET_URL, {
|
||||
transports: ["websocket", "polling"],
|
||||
reconnection: true,
|
||||
reconnectionAttempts: Infinity,
|
||||
reconnectionDelay: 800,
|
||||
});
|
||||
const socket = acquireChatSocket();
|
||||
const uid = senderId;
|
||||
const rid = receiverId;
|
||||
|
||||
// عضویت در روم چت روی هر اتصال/اتصال مجدد
|
||||
const joinRoom = () => {
|
||||
socket.emit("joinChat", { userId: senderId, receiverId });
|
||||
socket.emit("joinUser", { userId: senderId });
|
||||
joinUserRoom(uid);
|
||||
joinChatRoom(uid, rid);
|
||||
};
|
||||
socket.on("connect", joinRoom);
|
||||
if (socket.connected) joinRoom();
|
||||
|
||||
const belongs = (msg: ChatMessage) =>
|
||||
belongsToThread(msg, senderId, receiverId) ||
|
||||
(msg.senderId === senderId && msg.receiverId === receiverId) ||
|
||||
(msg.senderId === receiverId && msg.receiverId === senderId);
|
||||
const onConnect = () => {
|
||||
joinRoom();
|
||||
};
|
||||
|
||||
socket.on("newMessage", (message: ChatMessage) => {
|
||||
socket.on("connect", onConnect);
|
||||
if (socket.connected) onConnect();
|
||||
|
||||
const belongs = (msg: ChatMessage) => belongsToThread(msg, senderId, receiverId);
|
||||
|
||||
const onNewMessage = (message: ChatMessage) => {
|
||||
if (!belongs(message)) return;
|
||||
|
||||
onIncomingMessageRef.current?.(message);
|
||||
|
||||
queryClient.setQueryData(
|
||||
chatThreadQueryKey(senderId, receiverId),
|
||||
(oldData) => appendMessageToThreadCache(oldData, message)
|
||||
(oldData) => upsertMessageInThreadCache(oldData, message)
|
||||
);
|
||||
onIncomingMessage?.(message);
|
||||
setToEnd(true);
|
||||
|
||||
if (message.senderId === receiverId) {
|
||||
if (String(message.senderId) === String(receiverId)) {
|
||||
socket.emit("messageSeen", {
|
||||
messageId: message._id,
|
||||
senderId: message.senderId,
|
||||
receiverId: senderId,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
socket.on("newMessage", onNewMessage);
|
||||
|
||||
socket.on(
|
||||
"messageStatusUpdate",
|
||||
@@ -248,17 +264,17 @@ const ChatMessageList = ({
|
||||
|
||||
let typingHideTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
socket.on("userTyping", ({ userId }: { userId: string }) => {
|
||||
if (userId === receiverId) {
|
||||
onTypingChange?.(true);
|
||||
if (String(userId) === String(receiverId)) {
|
||||
onTypingChangeRef.current?.(true);
|
||||
if (typingHideTimer) clearTimeout(typingHideTimer);
|
||||
typingHideTimer = setTimeout(() => onTypingChange?.(false), 3500);
|
||||
typingHideTimer = setTimeout(() => onTypingChangeRef.current?.(false), 3500);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("userStoppedTyping", ({ userId }: { userId: string }) => {
|
||||
if (userId === receiverId) {
|
||||
if (String(userId) === String(receiverId)) {
|
||||
if (typingHideTimer) clearTimeout(typingHideTimer);
|
||||
onTypingChange?.(false);
|
||||
onTypingChangeRef.current?.(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -285,15 +301,15 @@ const ChatMessageList = ({
|
||||
|
||||
return () => {
|
||||
if (typingHideTimer) clearTimeout(typingHideTimer);
|
||||
socket.off("connect", joinRoom);
|
||||
socket.off("newMessage");
|
||||
socket.off("connect", onConnect);
|
||||
socket.off("newMessage", onNewMessage);
|
||||
socket.off("messageStatusUpdate");
|
||||
socket.off("messagesDeleted");
|
||||
socket.off("userTyping");
|
||||
socket.off("userStoppedTyping");
|
||||
socket.disconnect();
|
||||
releaseChatSocket();
|
||||
};
|
||||
}, [threadReady, senderId, receiverId, queryClient, onTypingChange, onIncomingMessage]);
|
||||
}, [threadReady, senderId, receiverId, queryClient]);
|
||||
|
||||
const allMessages = useMemo(() => {
|
||||
const server =
|
||||
@@ -310,6 +326,19 @@ const ChatMessageList = ({
|
||||
);
|
||||
}, [data, pendingMessages, searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingMessages.length || !data?.pages?.length) return;
|
||||
const server = data.pages.flatMap((page) => page.messages);
|
||||
const resolvedIds = pendingMessages
|
||||
.filter((p) =>
|
||||
server.some((s) => s._id === p._id || findPendingMatchForServer(s, [p]))
|
||||
)
|
||||
.map((p) => p._id);
|
||||
if (resolvedIds.length) {
|
||||
onPruneResolvedPendingRef.current?.(resolvedIds);
|
||||
}
|
||||
}, [data, pendingMessages]);
|
||||
|
||||
const grouped = useMemo(
|
||||
() => groupMessagesByDate(allMessages),
|
||||
[allMessages]
|
||||
@@ -523,6 +552,7 @@ const ChatMessageList = ({
|
||||
String(userDetail?._id ?? getStoredUserId());
|
||||
const stableKey = getStableKey(msg._id);
|
||||
const isNew =
|
||||
isIncoming &&
|
||||
hasInitializedRef.current &&
|
||||
!seenMessageKeysRef.current.has(stableKey);
|
||||
|
||||
|
||||
@@ -9,6 +9,13 @@ function matchesPendingServer(server: ChatMessage, pending: ChatMessage): boolea
|
||||
return Boolean(pending.fileType && server.fileType === pending.fileType);
|
||||
}
|
||||
|
||||
export function findPendingMatchForServer(
|
||||
server: ChatMessage,
|
||||
pending: ChatMessage[]
|
||||
): ChatMessage | undefined {
|
||||
return pending.find((p) => matchesPendingServer(server, p));
|
||||
}
|
||||
|
||||
export function filterResolvedPendingMessages(
|
||||
server: ChatMessage[],
|
||||
pending: ChatMessage[]
|
||||
|
||||
53
src/lib/chat/socketClient.ts
Normal file
53
src/lib/chat/socketClient.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import { SOCKET_URL } from "@/components/main/BaseUrl";
|
||||
|
||||
let sharedSocket: Socket | null = null;
|
||||
let refCount = 0;
|
||||
|
||||
export function acquireChatSocket(): Socket {
|
||||
if (!sharedSocket) {
|
||||
sharedSocket = io(SOCKET_URL, {
|
||||
transports: ["websocket", "polling"],
|
||||
reconnection: true,
|
||||
reconnectionAttempts: Infinity,
|
||||
reconnectionDelay: 800,
|
||||
reconnectionDelayMax: 5000,
|
||||
timeout: 20000,
|
||||
withCredentials: true,
|
||||
autoConnect: true,
|
||||
});
|
||||
}
|
||||
refCount += 1;
|
||||
return sharedSocket;
|
||||
}
|
||||
|
||||
/** Use after acquire — does not change ref count */
|
||||
export function getChatSocket(): Socket | null {
|
||||
return sharedSocket;
|
||||
}
|
||||
|
||||
export function releaseChatSocket(): void {
|
||||
refCount = Math.max(0, refCount - 1);
|
||||
if (refCount === 0 && sharedSocket) {
|
||||
sharedSocket.removeAllListeners();
|
||||
sharedSocket.disconnect();
|
||||
sharedSocket = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function joinUserRoom(userId: string): void {
|
||||
const id = String(userId).trim();
|
||||
if (!id || !sharedSocket) return;
|
||||
sharedSocket.emit("joinUser", { userId: id });
|
||||
}
|
||||
|
||||
export function joinChatRoom(userId: string, receiverId: string): void {
|
||||
const uid = String(userId).trim();
|
||||
const rid = String(receiverId).trim();
|
||||
if (!uid || !rid || !sharedSocket) return;
|
||||
sharedSocket.emit("joinChat", { userId: uid, receiverId: rid });
|
||||
}
|
||||
|
||||
export function isChatSocketConnected(): boolean {
|
||||
return Boolean(sharedSocket?.connected);
|
||||
}
|
||||
@@ -34,3 +34,33 @@ export function appendMessageToThreadCache(
|
||||
|
||||
return { ...oldData, pages: newPages };
|
||||
}
|
||||
|
||||
/** Add or update — avoids duplicate / flicker when optimistic + socket + HTTP overlap */
|
||||
export function upsertMessageInThreadCache(
|
||||
oldData: ChatThreadData | undefined,
|
||||
message: ChatMessage
|
||||
): ChatThreadData {
|
||||
if (!oldData?.pages?.length) {
|
||||
return appendMessageToThreadCache(oldData, message);
|
||||
}
|
||||
|
||||
const messageId = String(message._id);
|
||||
let found = false;
|
||||
|
||||
const newPages = oldData.pages.map((page) => ({
|
||||
...page,
|
||||
messages: page.messages.map((m) => {
|
||||
if (String(m._id) === messageId) {
|
||||
found = true;
|
||||
return { ...m, ...message };
|
||||
}
|
||||
return m;
|
||||
}),
|
||||
}));
|
||||
|
||||
if (found) {
|
||||
return { ...oldData, pages: newPages };
|
||||
}
|
||||
|
||||
return appendMessageToThreadCache(oldData, message);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user