288 lines
8.6 KiB
TypeScript
288 lines
8.6 KiB
TypeScript
import {
|
||
useGetThreadDetails,
|
||
useGetThreadMessages,
|
||
setMessagesCache,
|
||
deleteMessageFromCache,
|
||
} from "../../requestHandler/use-chat-hooks";
|
||
import { useState, useRef, useEffect } from "react";
|
||
import { Loader2 } from "lucide-react";
|
||
import { useServiceGetProfile } from "../../requestHandler/use-profile-hooks";
|
||
import MessageList from "./MessageList";
|
||
import ChatHeader from "./ChatHeader";
|
||
import MessageInput from "./MessageInput";
|
||
import {
|
||
MessageList as MessageListType,
|
||
ThreadDetail,
|
||
ThreadParticipant,
|
||
} from "../../../src/api/types/models";
|
||
import useWebSocket from "../../hooks/useWebSocket";
|
||
import { useQueryClient } from "@tanstack/react-query";
|
||
import { toast } from "../../hooks/use-toast";
|
||
|
||
interface ThreadChatPageProps {
|
||
threadId: string;
|
||
}
|
||
|
||
export default function ThreadChatPage({ threadId }: ThreadChatPageProps) {
|
||
// Scroll to bottom on initial load
|
||
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
||
const { data: threadDetails, isLoading: isLoadingThread } =
|
||
useGetThreadDetails(threadId);
|
||
const [replyToMessage, setReplyToMessage] = useState<
|
||
MessageListType | undefined
|
||
>(undefined);
|
||
|
||
// Load messages using infinite query
|
||
const {
|
||
data: messagesData,
|
||
fetchNextPage,
|
||
hasNextPage,
|
||
isFetchingNextPage,
|
||
isLoading: isLoadingMessages,
|
||
} = useGetThreadMessages(threadId);
|
||
|
||
// Flatten all pages of messages into a single array
|
||
const allMessages = messagesData?.pages
|
||
? messagesData.pages.flatMap((page) => page.results)
|
||
: [];
|
||
|
||
const messageListRef = useRef<{
|
||
scrollToBottom: () => void;
|
||
getContainer: () => HTMLDivElement | null;
|
||
}>(null);
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
const [messageText, setMessageText] = useState("");
|
||
const { data: user } = useServiceGetProfile();
|
||
const [prevScrollHeight, setPrevScrollHeight] = useState(0);
|
||
const queryClient = useQueryClient();
|
||
|
||
// WebSocket implementation
|
||
const {
|
||
isConnected,
|
||
sendWebSocketMessage,
|
||
markMessagesAsRead,
|
||
deleteMessage,
|
||
newMessage,
|
||
readReceipt,
|
||
messageDeleted,
|
||
resetReadReceipt,
|
||
resetNewMessage,
|
||
resetMessageDeleted,
|
||
} = useWebSocket(threadId, user?.id);
|
||
|
||
// Handle new messages from WebSocket
|
||
useEffect(() => {
|
||
if (newMessage) {
|
||
setMessagesCache(queryClient, threadId, {
|
||
id: newMessage.message_id,
|
||
content: newMessage.content,
|
||
sender: newMessage.sender_id,
|
||
createdAt: new Date().toISOString(),
|
||
replyTo: newMessage.reply_to_id,
|
||
replyContent: newMessage.reply_content || "",
|
||
});
|
||
resetNewMessage();
|
||
setTimeout(() => {
|
||
if (messageListRef.current) {
|
||
messageListRef.current.scrollToBottom();
|
||
}
|
||
}, 100);
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [newMessage]);
|
||
|
||
const otherParticipant =
|
||
threadDetails?.participants?.find(
|
||
(participant) => participant.username !== user?.username
|
||
) || undefined;
|
||
|
||
useEffect(() => {
|
||
if (isConnected && !isLoadingMessages && allMessages.length > 0) {
|
||
markMessagesAsRead();
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [isConnected, isLoadingMessages, allMessages.length]);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
queryClient.removeQueries({
|
||
queryKey: ["getThreadMessages", threadId],
|
||
});
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (readReceipt) {
|
||
queryClient.setQueryData(
|
||
["getThreadDetails", threadId],
|
||
(oldData: ThreadDetail) => {
|
||
if (!oldData) return oldData;
|
||
|
||
return {
|
||
...oldData,
|
||
participants: oldData.participants?.map(
|
||
(participant: ThreadParticipant) => {
|
||
if (
|
||
participant.username &&
|
||
participant.username.toString() ===
|
||
readReceipt.reader_username
|
||
) {
|
||
return {
|
||
...participant,
|
||
lastReadAt: new Date().toISOString(),
|
||
};
|
||
}
|
||
return participant;
|
||
}
|
||
),
|
||
};
|
||
}
|
||
);
|
||
resetReadReceipt();
|
||
}
|
||
}, [readReceipt, threadId, queryClient, resetReadReceipt]);
|
||
|
||
useEffect(() => {
|
||
if (
|
||
!isLoadingMessages &&
|
||
messageListRef.current &&
|
||
allMessages.length > 0 &&
|
||
isConnected
|
||
) {
|
||
if (isInitialLoad) {
|
||
setIsInitialLoad(false);
|
||
setTimeout(() => {
|
||
if (messageListRef.current) {
|
||
messageListRef.current.scrollToBottom();
|
||
}
|
||
}, 100);
|
||
}
|
||
}
|
||
}, [isLoadingMessages, allMessages.length, isInitialLoad, isConnected]);
|
||
|
||
// Add effect to handle message deleted events
|
||
useEffect(() => {
|
||
if (messageDeleted) {
|
||
deleteMessageFromCache(queryClient, threadId, messageDeleted.message_id);
|
||
resetMessageDeleted();
|
||
const isOwner = messageDeleted.sender_id === user?.id;
|
||
if (isOwner) {
|
||
toast({
|
||
title: "پیام با موفقیت حذف شد",
|
||
description: "پیام شما با موفقیت حذف شد.",
|
||
});
|
||
}
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [messageDeleted]);
|
||
|
||
// Handle scroll to load more messages
|
||
const handleScroll = () => {
|
||
if (!messageListRef.current || isInitialLoad) return;
|
||
const container = messageListRef.current.getContainer();
|
||
if (!container) return;
|
||
|
||
const { scrollTop } = container;
|
||
// Load more messages when scrolled near the top
|
||
if (scrollTop < 100 && hasNextPage && !isFetchingNextPage) {
|
||
// Save the current scroll height before loading more messages
|
||
setPrevScrollHeight(container.scrollHeight);
|
||
fetchNextPage();
|
||
}
|
||
};
|
||
|
||
// Maintain scroll position after loading more messages
|
||
useEffect(() => {
|
||
if (!isFetchingNextPage && prevScrollHeight > 0 && messageListRef.current) {
|
||
const container = messageListRef.current.getContainer();
|
||
if (!container) return;
|
||
|
||
// Calculate the new position by finding the difference in height
|
||
const newScrollHeight = container.scrollHeight;
|
||
const scrollDifference = newScrollHeight - prevScrollHeight;
|
||
|
||
// Adjust scroll position to maintain the view
|
||
container.scrollTop = scrollDifference;
|
||
|
||
// Reset the previous height
|
||
setPrevScrollHeight(0);
|
||
}
|
||
}, [isFetchingNextPage, prevScrollHeight]);
|
||
|
||
// Handle sending message
|
||
const handleSendMessage = (text: string) => {
|
||
if (isConnected) {
|
||
sendWebSocketMessage(text, replyToMessage?.id, replyToMessage?.content);
|
||
}
|
||
setReplyToMessage(undefined);
|
||
setMessageText("");
|
||
};
|
||
|
||
// Handle reply to message
|
||
const handleReplyClick = (message: MessageListType) => {
|
||
setReplyToMessage(message);
|
||
// Focus on the input field
|
||
setTimeout(() => {
|
||
if (inputRef.current) {
|
||
inputRef.current.focus();
|
||
}
|
||
}, 0);
|
||
};
|
||
|
||
// Handle message deletion
|
||
const handleDeleteMessage = (messageId: string) => {
|
||
if (isConnected) {
|
||
deleteMessage(messageId);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="flex flex-col">
|
||
<ChatHeader
|
||
isConnected={isConnected}
|
||
otherParticipant={otherParticipant}
|
||
threadDetails={threadDetails}
|
||
/>
|
||
{!isConnected ? (
|
||
<div className="flex flex-col items-center justify-center h-[300px] w-full">
|
||
<Loader2 size={24} className="animate-spin mt-10" />
|
||
<p className="text-R12 text-GRAY">
|
||
در حال اتصال به سرور پیامرسانی...
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div
|
||
className={`hide-scrollbar flex-grow w-full max-h-[calc(100vh-${replyToMessage ? "120px" : "75px"})] flex flex-col`}
|
||
>
|
||
{/* Messages list */}
|
||
<MessageList
|
||
otherParticipant={otherParticipant}
|
||
ref={messageListRef}
|
||
messages={[...allMessages].reverse()}
|
||
isLoading={isLoadingMessages || isLoadingThread}
|
||
isFetching={isFetchingNextPage}
|
||
userId={user?.id}
|
||
onReply={handleReplyClick}
|
||
onDelete={handleDeleteMessage}
|
||
onScroll={handleScroll}
|
||
/>
|
||
</div>
|
||
{/* Message input component */}
|
||
<MessageInput
|
||
setReplyToMessage={setReplyToMessage}
|
||
isSendingMessage={false}
|
||
messageText={messageText}
|
||
setMessageText={setMessageText}
|
||
onSend={handleSendMessage}
|
||
replyToMessage={replyToMessage}
|
||
onCancelReply={() => setReplyToMessage(undefined)}
|
||
inputRef={inputRef}
|
||
/>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|