Vitron-Front/app/components/thread/MessageList.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

221 lines
6.9 KiB
TypeScript
Raw Permalink 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 { isDifferentDay } from "../../utils/helpers";
import { DateSeparator, MessageBubble } from "./MessageBubble";
import {
MessageList as MessageListType,
ThreadParticipant,
} from "../../../src/api/types/models";
import { Loader2, ArrowDown } from "lucide-react";
import {
useState,
useRef,
useEffect,
forwardRef,
ForwardedRef,
useImperativeHandle,
} from "react";
// Message List component
const MessageList = forwardRef(
(
{
messages,
isLoading,
isFetching,
userId,
teamUserIds,
viewerIsStore = false,
onReply,
onDelete,
onScroll,
otherParticipant,
}: {
messages: MessageListType[];
isLoading: boolean;
isFetching: boolean;
userId: string | undefined;
// Senders on the store team. Only treated as "our side" when the VIEWER
// is store-side (viewerIsStore) — otherwise a customer would wrongly see
// the store's staff messages on their own (right) side.
teamUserIds?: readonly string[];
// True when the current viewer is on this thread's store team. Gates all
// team-grouping so the customer view stays "my messages right, store left".
viewerIsStore?: boolean;
onReply: (message: MessageListType) => void;
onDelete?: (messageId: string) => void;
onScroll?: (event: React.UIEvent<HTMLDivElement>) => void;
otherParticipant: ThreadParticipant | undefined;
},
ref: ForwardedRef<{
scrollToBottom: () => void;
getContainer: () => HTMLDivElement | null;
}>
) => {
const [highlightedMessageId, setHighlightedMessageId] = useState<
string | null
>(null);
const messageRefs = useRef<Record<string, HTMLDivElement | null>>({});
const [showScrollButton, setShowScrollButton] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Expose methods to parent component
useImperativeHandle(ref, () => ({
scrollToBottom: () => {
if (containerRef.current) {
containerRef.current.scrollTo({
top: containerRef.current.scrollHeight,
});
}
},
getContainer: () => containerRef.current,
}));
// Handle scroll events to show/hide the scroll button
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleScroll = (e: Event) => {
// Show button when scrolled up more than 300px from bottom
const isScrolledUp =
container.scrollHeight -
container.scrollTop -
container.clientHeight >
300;
setShowScrollButton(isScrolledUp);
// Call parent's onScroll handler if provided
if (onScroll && e instanceof UIEvent) {
onScroll(e as unknown as React.UIEvent<HTMLDivElement>);
}
};
container.addEventListener("scroll", handleScroll);
return () => container.removeEventListener("scroll", handleScroll);
}, [onScroll]);
const scrollToBottom = () => {
if (containerRef.current) {
containerRef.current.scrollTo({
top: containerRef.current.scrollHeight + 100,
behavior: "smooth",
});
}
};
if (isLoading && !messages.length) {
return (
<p className="text-GRAY text-center">درحال بارگذاری پیامها...</p>
);
}
if (messages.length === 0) {
return (
<p className="text-GRAY text-center">
هنوز پیامی ارسال نشده. مکالمه را شروع کنید!
</p>
);
}
// Handle reply click to scroll to original message
const handleReplyClick = (replyToId: string) => {
const targetMessage = messages.find((m) => m.id === replyToId);
if (targetMessage) {
// Reset the highlighted message ID first to ensure the animation can trigger again
setHighlightedMessageId(null);
// Use setTimeout to ensure the state update has processed
setTimeout(() => {
// Then set the highlighted message ID
setHighlightedMessageId(replyToId);
// Scroll to the message
const messageElement = messageRefs.current[replyToId];
if (messageElement) {
messageElement.scrollIntoView({
behavior: "smooth",
block: "center",
});
}
}, 300);
}
};
return (
<div
className="flex flex-col pt-[60px] relative h-[calc(100vh-64px)] overflow-auto px-2 pb-8"
style={{ scrollbarWidth: "inherit" }}
ref={containerRef}
onScroll={onScroll}
>
{isFetching && (
<div className="flex items-center justify-center py-2">
<Loader2 className="w-4 h-4 animate-spin" />
</div>
)}
{messages.map((message, index) => (
<div
key={message.id || index}
ref={(el) => {
if (message.id) {
messageRefs.current[message.id] = el;
}
}}
>
{/* Date separator - show when day changes between messages */}
{(index === 0 ||
isDifferentDay(
message.createdAt || "",
messages[index - 1]?.createdAt
)) && <DateSeparator date={message.createdAt || ""} />}
{/* Message bubble */}
<MessageBubble
otherParticipant={otherParticipant}
message={message}
isCurrentUser={
message.sender === userId ||
(viewerIsStore &&
!!message.sender &&
!!teamUserIds?.includes(message.sender))
}
isCoworker={
viewerIsStore &&
message.sender !== userId &&
!!message.sender &&
!!teamUserIds?.includes(message.sender)
}
onReply={onReply}
onDelete={onDelete}
replyToMessageContent={message.replyContent || ""}
onReplyClick={handleReplyClick}
isHighlighted={highlightedMessageId === message.id}
/>
</div>
))}
{/* Floating scroll to bottom button with animation */}
<div
className={`fixed bottom-16 right-4 z-30 transition-all duration-300 ${
showScrollButton
? "opacity-100 translate-y-0"
: "opacity-0 translate-y-10 pointer-events-none"
}`}
>
<button
onClick={scrollToBottom}
className="w-8 h-8 bg-primary text-WHITE rounded-full flex items-center justify-center shadow-lg hover:bg-primary/90 transition-all z-10"
aria-label="رفتن به پایین"
>
<ArrowDown className="w-4 h-4" />
</button>
</div>
</div>
);
}
);
// Add display name
MessageList.displayName = "MessageList";
export default MessageList;