Vitron-Front/app/components/thread/MessageList.tsx
2026-04-29 01:44:16 +03:30

201 lines
6.0 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 { 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,
onReply,
onDelete,
onScroll,
otherParticipant,
}: {
messages: MessageListType[];
isLoading: boolean;
isFetching: boolean;
userId: string | undefined;
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}
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;