Vitron-Front/app/components/thread/ThreadChatPage.tsx
Arda Samadi f8bf002b39 fix(chat): gate store-team grouping behind viewer_is_store
The team-side logic (teamUserIds → right-aligned + coworker username label) was
applied for every viewer, so a CUSTOMER saw the store's staff messages on their
own side and with the staff's username/phone shown. Gate all team grouping on
viewerIsStore (from the thread): customers now see store/staff messages on the
left with no staff identity; the store inbox keeps the team-grouped view.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:05:56 +03:30

313 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 || "",
imageUrl: newMessage.image_url || undefined,
});
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("");
};
// Send an image the user already uploaded. Content stays empty; the bubble
// renders the image with no accompanying text.
const handleSendImage = (imageUrl: string) => {
if (isConnected) {
sendWebSocketMessage(
"",
replyToMessage?.id,
replyToMessage?.content,
imageUrl,
);
}
setReplyToMessage(undefined);
};
// Team membership check: current user in teamUserIds → can attach images.
const currentUserIsTeam = !!(
user?.id &&
threadDetails?.teamUserIds?.includes(user.id)
);
// 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"}_-_env(safe-area-inset-top)_-_env(safe-area-inset-bottom))] flex flex-col`}
>
{/* Messages list */}
<MessageList
otherParticipant={otherParticipant}
ref={messageListRef}
messages={[...allMessages].reverse()}
isLoading={isLoadingMessages || isLoadingThread}
isFetching={isFetchingNextPage}
userId={user?.id}
teamUserIds={threadDetails?.teamUserIds}
viewerIsStore={threadDetails?.viewerIsStore}
onReply={handleReplyClick}
onDelete={handleDeleteMessage}
onScroll={handleScroll}
/>
</div>
{/* Message input component */}
<MessageInput
setReplyToMessage={setReplyToMessage}
isSendingMessage={false}
messageText={messageText}
setMessageText={setMessageText}
onSend={handleSendMessage}
onSendImage={handleSendImage}
canSendImage={currentUserIsTeam}
replyToMessage={replyToMessage}
onCancelReply={() => setReplyToMessage(undefined)}
inputRef={inputRef}
/>
</>
)}
</div>
);
}