w-lab-app/src/components/domains/chat/ChatMessagesWrapper/ChatMessagesWrapper.tsx
gofnnp f821fea322 AW-496-chat-improvement
virtualization & optimization
2025-07-29 19:26:03 +04:00

182 lines
4.9 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { Button, Icon, IconName, Spinner } from "@/components/ui";
import { useChat } from "@/providers/chat-provider";
import { ChatMessage, Suggestions } from "..";
import styles from "./ChatMessagesWrapper.module.scss";
export default function ChatMessagesWrapper() {
const messagesWrapperRef = useRef<HTMLDivElement>(null);
const [isScrolledUp, setIsScrolledUp] = useState(false);
const {
messages: socketMessages,
isLoadingAdvisorMessage,
hasMoreOlderMessages,
isLoadingOlder,
// unreadMessagesCount,
loadOlder,
send,
} = useChat();
const messages = useMemo(() => {
const msgs = [...socketMessages];
if (isLoadingAdvisorMessage) {
msgs.unshift({
id: "typing",
type: "text",
text: "…",
role: "assistant",
isRead: false,
createdDate: new Date().toISOString(),
});
}
return msgs;
}, [isLoadingAdvisorMessage, socketMessages]);
const virtualizer = useVirtualizer({
enabled: messages.length > 0,
count: hasMoreOlderMessages ? messages.length + 1 : messages.length,
getScrollElement: () => messagesWrapperRef.current,
measureElement: el => el.getBoundingClientRect().height,
getItemKey: idx => messages[idx]?.id ?? idx,
estimateSize: _i => 100,
overscan: 5,
paddingStart: 36,
paddingEnd: 36,
gap: 8,
});
const items = virtualizer.getVirtualItems();
const scrollToBottom = useCallback(() => {
virtualizer.scrollToOffset(0);
}, [virtualizer]);
useEffect(() => {
const handleScroll = (e: WheelEvent) => {
e.preventDefault();
const currentTarget = e.currentTarget as HTMLElement;
if (currentTarget) {
currentTarget.scrollTop -= e.deltaY;
}
};
messagesWrapperRef.current?.addEventListener("wheel", handleScroll, {
passive: false,
});
return () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
messagesWrapperRef.current?.removeEventListener("wheel", handleScroll);
};
}, []);
useEffect(() => {
const [lastItem] = [...items].reverse();
if (!lastItem) {
return;
}
if (
lastItem.index >= messages.length - 1 &&
hasMoreOlderMessages &&
!isLoadingOlder
) {
loadOlder();
}
}, [hasMoreOlderMessages, loadOlder, messages.length, isLoadingOlder, items]);
useEffect(() => {
if (!messagesWrapperRef.current || messages.length === 0) return;
setIsScrolledUp((virtualizer.scrollOffset || 0) > 600);
}, [virtualizer.scrollOffset, messages.length, messagesWrapperRef]);
return (
<>
{isScrolledUp && (
<Button
className={styles.scrollToBottomButton}
onClick={scrollToBottom}
aria-label="Scroll to bottom"
style={{
top: `${messagesWrapperRef.current?.clientHeight}px`,
}}
>
<Icon
name={IconName.Chevron}
style={{ transform: "rotate(-90deg)" }}
/>
{/* {!!unreadMessagesCount && (
<Badge className={styles.badge}>
<Typography weight="semiBold" size="xs" color="black">
{unreadMessagesCount > 99 ? "99+" : unreadMessagesCount}
</Typography>
</Badge>
)} */}
</Button>
)}
<div ref={messagesWrapperRef} className={styles.messagesWrapper}>
<Suggestions
className={styles.suggestions}
onSuggestionClick={suggestion => {
send(suggestion);
}}
/>
<div
style={{
height: virtualizer.getTotalSize(),
width: "100%",
position: "relative",
}}
>
{items.map(virtualRow => {
const message = messages[virtualRow.index];
const isLoaderRow = virtualRow.index > messages.length - 1;
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
paddingInline: "16px",
transform: `translateY(${virtualRow.start}px) scaleY(-1)`,
}}
>
{!isLoaderRow && (
<ChatMessage key={message.id} message={message} />
)}
{isLoaderRow && (
<div className={styles.loaderTop}>
<Spinner size={16} />
</div>
)}
</div>
);
})}
</div>
</div>
</>
);
}
export const ChatMessagesWrapperLoader = () => {
return (
<div className={styles.loaderContainer}>
<Spinner />
</div>
);
};