From f821fea322c955c3358f0843d81253165a970b2c Mon Sep 17 00:00:00 2001 From: gofnnp Date: Tue, 29 Jul 2025 19:26:03 +0400 Subject: [PATCH] AW-496-chat-improvement virtualization & optimization --- package-lock.json | 42 +++- package.json | 1 + .../chat/[assistantId]/page.module.scss | 1 + .../chat/ChatMessage/ChatMessage.module.scss | 1 + .../domains/chat/ChatMessage/ChatMessage.tsx | 56 +++-- .../chat/ChatMessages/ChatMessages.tsx | 8 +- .../ChatMessagesWrapper.module.scss | 31 ++- .../ChatMessagesWrapper.tsx | 203 ++++++++++++------ src/entities/user/types.ts | 8 +- src/hooks/chats/useChatSocket.ts | 60 ++---- src/providers/chat-provider.tsx | 32 +-- 11 files changed, 268 insertions(+), 175 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2b8b666..4994245 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@lottiefiles/dotlottie-react": "^0.14.1", + "@tanstack/react-virtual": "^3.13.12", "client-only": "^0.0.1", "clsx": "^2.1.1", "idb": "^8.0.3", @@ -200,13 +201,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.2.tgz", - "integrity": "sha512-4SaFZCNfJqvk/kenHpI8xvN42DMaoycy4PzKc5otHxRswww1kAt82OlBuwRVLofCACCTZEcla2Ydxv8scMXaTg==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz", + "integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.15.0", + "@eslint/core": "^0.15.1", "levn": "^0.4.1" }, "engines": { @@ -214,9 +215,9 @@ } }, "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.0.tgz", - "integrity": "sha512-b7ePw78tEWWkpgZCDYkbqDOP8dmM6qe+AOC6iuJqlq1R/0ahMAeH3qynpnqKFGkMltrp44ohV4ubGyvLX28tzw==", + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", + "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1308,6 +1309,33 @@ "tslib": "^2.8.0" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.12", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz", + "integrity": "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.12", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz", + "integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", diff --git a/package.json b/package.json index 4e635de..bef9539 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@lottiefiles/dotlottie-react": "^0.14.1", + "@tanstack/react-virtual": "^3.13.12", "client-only": "^0.0.1", "clsx": "^2.1.1", "idb": "^8.0.3", diff --git a/src/app/[locale]/(chat)/chat/[assistantId]/page.module.scss b/src/app/[locale]/(chat)/chat/[assistantId]/page.module.scss index d7b23e8..428e6e8 100644 --- a/src/app/[locale]/(chat)/chat/[assistantId]/page.module.scss +++ b/src/app/[locale]/(chat)/chat/[assistantId]/page.module.scss @@ -2,4 +2,5 @@ display: flex; flex-direction: column; height: 100dvh; + position: relative; } diff --git a/src/components/domains/chat/ChatMessage/ChatMessage.module.scss b/src/components/domains/chat/ChatMessage/ChatMessage.module.scss index 74ec592..303170d 100644 --- a/src/components/domains/chat/ChatMessage/ChatMessage.module.scss +++ b/src/components/domains/chat/ChatMessage/ChatMessage.module.scss @@ -8,5 +8,6 @@ &.own { align-items: flex-end; align-self: flex-end; + margin-left: auto; } } diff --git a/src/components/domains/chat/ChatMessage/ChatMessage.tsx b/src/components/domains/chat/ChatMessage/ChatMessage.tsx index fdac54c..c6b0b51 100644 --- a/src/components/domains/chat/ChatMessage/ChatMessage.tsx +++ b/src/components/domains/chat/ChatMessage/ChatMessage.tsx @@ -3,11 +3,11 @@ import { useEffect } from "react"; import clsx from "clsx"; +import { IChatMessage } from "@/entities/chats/types"; import { useChat } from "@/providers/chat-provider"; +import { formatTime } from "@/shared/utils/date"; -import MessageAudio from "./MessageAudio/MessageAudio"; import MessageBubble from "./MessageBubble/MessageBubble"; -import MessageImage from "./MessageImage/MessageImage"; import MessageMeta from "./MessageMeta/MessageMeta"; import MessageStatus from "./MessageStatus/MessageStatus"; import MessageText from "./MessageText/MessageText"; @@ -16,21 +16,23 @@ import MessageTyping from "./MessageTyping/MessageTyping"; import styles from "./ChatMessage.module.scss"; export interface ChatMessageProps { - message: { - id: string; - type: "text" | "image" | "audio" | "typing"; - content?: string; - imageUrl?: string; - audioUrl?: string; - duration?: number; - time: string | null; - isOwn: boolean; - isRead?: boolean; - }; + // message: { + // id: string; + // type: "text" | "image" | "voice" | "typing"; + // text?: string; + // imageUrl?: string; + // audioUrl?: string; + // duration?: number; + // time: string | null; + // isOwn: boolean; + // isRead?: boolean; + // }; + message: IChatMessage; } export default function ChatMessage({ message }: ChatMessageProps) { const { isConnected, read } = useChat(); + const isOwn = message.role === "user"; useEffect(() => { if ( @@ -45,37 +47,33 @@ export default function ChatMessage({ message }: ChatMessageProps) { }, [message.id, message.isRead, read, isConnected]); return ( -
- - {message.type === "text" && ( - +
+ + {message.type === "text" && message.id !== "typing" && ( + )} - {message.type === "typing" && } + {message.id === "typing" && } - {message.type === "image" && ( + {/* {message.type === "image" && ( <> - {message.content && ( - - )} + {message.text && } )} - {message.type === "audio" && ( + {message.type === "voice" && ( <> - {message.content && ( - - )} + {message.text && } - )} + )} */} - - {message.isOwn && } + + {isOwn && }
); diff --git a/src/components/domains/chat/ChatMessages/ChatMessages.tsx b/src/components/domains/chat/ChatMessages/ChatMessages.tsx index 3dc786f..5123ed7 100644 --- a/src/components/domains/chat/ChatMessages/ChatMessages.tsx +++ b/src/components/domains/chat/ChatMessages/ChatMessages.tsx @@ -17,11 +17,11 @@ export default function ChatMessages({ )} diff --git a/src/components/domains/chat/ChatMessagesWrapper/ChatMessagesWrapper.module.scss b/src/components/domains/chat/ChatMessagesWrapper/ChatMessagesWrapper.module.scss index af59278..7c44795 100644 --- a/src/components/domains/chat/ChatMessagesWrapper/ChatMessagesWrapper.module.scss +++ b/src/components/domains/chat/ChatMessagesWrapper/ChatMessagesWrapper.module.scss @@ -1,18 +1,45 @@ .messagesWrapper { flex: 1 1 0%; overflow-y: auto; - scroll-behavior: smooth; transition: padding-bottom 0.3s ease-in-out; + position: relative; + transform: scaleY(-1); } .loaderTop { display: flex; justify-content: center; - padding-top: 16px; + padding-bottom: 16px; } .suggestions.suggestions { // position: sticky; // bottom: 0; padding: 0 16px 36px; + margin-bottom: 0; + transform: scaleY(-1); +} + +.scrollToBottomButton.scrollToBottomButton { + position: absolute; + right: 16px; + display: flex; + justify-content: center; + align-items: center; + z-index: 444; + padding: 8px; + width: fit-content; + background-color: #fff; + box-shadow: 0 4px 6px #00000017; + + & > .badge { + position: absolute; + top: -8px; + right: -8px; + background-color: #fbbf24; + min-width: 24px; + min-height: 24px; + max-width: 28px; + max-height: 28px; + } } diff --git a/src/components/domains/chat/ChatMessagesWrapper/ChatMessagesWrapper.tsx b/src/components/domains/chat/ChatMessagesWrapper/ChatMessagesWrapper.tsx index 0de2f99..b5650c7 100644 --- a/src/components/domains/chat/ChatMessagesWrapper/ChatMessagesWrapper.tsx +++ b/src/components/domains/chat/ChatMessagesWrapper/ChatMessagesWrapper.tsx @@ -1,93 +1,174 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; -import { Spinner } from "@/components/ui"; +import { Button, Icon, IconName, Spinner } from "@/components/ui"; import { useChat } from "@/providers/chat-provider"; -import { useChatStore } from "@/providers/chat-store-provider"; -import { formatTime } from "@/shared/utils/date"; -import { ChatMessages, Suggestions } from ".."; +import { ChatMessage, Suggestions } from ".."; import styles from "./ChatMessagesWrapper.module.scss"; export default function ChatMessagesWrapper() { + const messagesWrapperRef = useRef(null); + + const [isScrolledUp, setIsScrolledUp] = useState(false); + const { messages: socketMessages, isLoadingAdvisorMessage, hasMoreOlderMessages, isLoadingOlder, - messagesWrapperRef, + // unreadMessagesCount, loadOlder, - scrollToBottom, send, } = useChat(); - const { _hasHydrated } = useChatStore(state => state); + 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 [isLoadOlder, setIsLoadOlder] = useState(false); + 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 handleScroll = useCallback(() => { - const el = messagesWrapperRef.current; - if (!el) return; + const items = virtualizer.getVirtualItems(); - if (el.scrollTop < 100) { - setIsLoadOlder(true); + 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(); } - }, [loadOlder, messagesWrapperRef]); - - const mappedMessages = useMemo(() => { - const msgs = socketMessages.map(m => ({ - id: m.id, - type: "text" as const, - content: m.text, - isOwn: m.role === "user", - isRead: m.isRead, - time: formatTime(m.createdDate), - })); - return msgs; - }, [socketMessages]); + }, [hasMoreOlderMessages, loadOlder, messages.length, isLoadingOlder, items]); useEffect(() => { - if (isLoadOlder) { - setIsLoadOlder(false); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [socketMessages]); + if (!messagesWrapperRef.current || messages.length === 0) return; - useEffect(() => { - if (socketMessages.length > 0 && _hasHydrated && !isLoadOlder) { - const timeout = setTimeout(() => { - scrollToBottom(); - }); - return () => clearTimeout(timeout); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [socketMessages.length, scrollToBottom, _hasHydrated]); + setIsScrolledUp((virtualizer.scrollOffset || 0) > 600); + }, [virtualizer.scrollOffset, messages.length, messagesWrapperRef]); return ( -
- {isLoadingOlder && hasMoreOlderMessages && ( -
- -
+ <> + {isScrolledUp && ( + )} - - { - send(suggestion); - }} - /> -
+
+ { + send(suggestion); + }} + /> +
+ {items.map(virtualRow => { + const message = messages[virtualRow.index]; + const isLoaderRow = virtualRow.index > messages.length - 1; + + return ( +
+ {!isLoaderRow && ( + + )} + {isLoaderRow && ( +
+ +
+ )} +
+ ); + })} +
+
+ ); } diff --git a/src/entities/user/types.ts b/src/entities/user/types.ts index 67f7555..64b7b74 100644 --- a/src/entities/user/types.ts +++ b/src/entities/user/types.ts @@ -30,10 +30,10 @@ const PartnerSchema = z address: z.string(), }) .optional(), - birthdate: z.string(), - gender: z.string(), - age: z.number(), - sign: z.string(), + birthdate: z.string().optional(), + gender: z.string().optional(), + age: z.number().optional(), + sign: z.string().optional(), }) .optional(); diff --git a/src/hooks/chats/useChatSocket.ts b/src/hooks/chats/useChatSocket.ts index e8d70de..3853bc9 100644 --- a/src/hooks/chats/useChatSocket.ts +++ b/src/hooks/chats/useChatSocket.ts @@ -20,15 +20,10 @@ import type { const PAGE_LIMIT = 50; -type UIMessage = Pick< - IChatMessage, - "id" | "role" | "text" | "createdDate" | "isRead" | "suggestions" | "isLast" ->; - interface UseChatSocketOptions { initialMessages?: IChatMessage[]; initialTotal?: number; - onNewMessage?: (message: UIMessage) => void; + onNewMessage?: (message: IChatMessage) => void; } export const useChatSocket = ( @@ -39,18 +34,8 @@ export const useChatSocket = ( const status = useSocketStatus(); const emit = useSocketEmit(); - const mapApiMessage = (m: IChatMessage): UIMessage => ({ - id: m.id, - role: m.role, - text: m.text, - createdDate: m.createdDate, - isRead: m.isRead, - suggestions: m.suggestions, - isLast: m.isLast, - }); - - const [messages, setMessages] = useState(() => - options.initialMessages ? options.initialMessages.map(mapApiMessage) : [] + const [messages, setMessages] = useState( + () => options.initialMessages || [] ); const [page, setPage] = useState(1); const [totalCount, _setTotalCount] = useState( @@ -60,7 +45,6 @@ export const useChatSocket = ( const [balance, setBalance] = useState(null); const [session, setSession] = useState(null); const [isLoadingSelfMessage, setIsLoadingSelfMessage] = useState(false); - // const [isLoadingAdvisorMessage, setIsLoadingAdvisorMessage] = useState(false); const [isSessionExpired, setIsSessionExpired] = useState(false); const [refillModals, setRefillModals] = useState(null); const { suggestions, setSuggestions } = useChatStore(state => state); @@ -73,6 +57,10 @@ export const useChatSocket = ( ); }, [messages]); + const unreadMessagesCount = useMemo(() => { + return messages.filter(m => !m.isRead && m.role === "assistant").length; + }, [messages]); + const joinChat = useCallback( () => emit("join_chat", { chatId }), [emit, chatId] @@ -84,12 +72,13 @@ export const useChatSocket = ( const send = useCallback( (text: string) => { - const sendingMessage = { + const sendingMessage: IChatMessage = { id: `sending-message-${Date.now()}`, role: "user", text, createdDate: new Date().toISOString(), isRead: false, + type: "text", }; setMessages(prev => [sendingMessage, ...prev]); if (options.onNewMessage) { @@ -97,14 +86,18 @@ export const useChatSocket = ( } setIsLoadingSelfMessage(true); - // setIsLoadingAdvisorMessage(true); emit("send_message", { chatId, message: text }); }, [options, emit, chatId] ); const read = useCallback( - (ids: string[]) => emit("read_message", { messages: ids }), + (ids: string[]) => { + emit("read_message", { messages: ids }); + // setMessages(prev => + // prev.map(m => (ids.includes(m.id) ? { ...m, isRead: true } : m)) + // ); + }, [emit] ); const startSession = useCallback( @@ -149,10 +142,7 @@ export const useChatSocket = ( const { messages: msgs } = data; setMessages(prev => { const ids = new Set(prev.map(m => m.id)); - return [ - ...prev, - ...msgs.map(mapApiMessage).filter(m => !ids.has(m.id)), - ]; + return [...prev, ...msgs.filter(m => !ids.has(m.id))]; }); setPage(nextPage); } catch (e) { @@ -167,26 +157,16 @@ export const useChatSocket = ( if (!data?.length) return; if (data[0].role === "user") setIsLoadingSelfMessage(false); - // if (data[0].role === "assistant") setIsLoadingAdvisorMessage(false); setMessages(prev => { - const map = new Map(); + const map = new Map(); prev .filter(m => !m.id.startsWith("sending-message-")) .forEach(m => map.set(m.id, m)); - data.forEach(d => - map.set(d.id, { - id: d.id, - role: d.role, - text: d.text, - createdDate: d.createdDate, - isRead: d.isRead, - suggestions: d.suggestions, - isLast: d.isLast, - }) - ); + data.forEach(d => map.set(d.id, d)); + return Array.from(map.values()).sort( (a, b) => new Date(b.createdDate).getTime() - new Date(a.createdDate).getTime() @@ -275,6 +255,7 @@ export const useChatSocket = ( session, refillModals, suggestions, + unreadMessagesCount, send, read, @@ -296,6 +277,7 @@ export const useChatSocket = ( session, refillModals, suggestions, + unreadMessagesCount, isLoadingSelfMessage, isLoadingAdvisorMessage, isAvailableChatting, diff --git a/src/providers/chat-provider.tsx b/src/providers/chat-provider.tsx index 7310fd8..e2e8130 100644 --- a/src/providers/chat-provider.tsx +++ b/src/providers/chat-provider.tsx @@ -1,20 +1,11 @@ "use client"; -import { - createContext, - ReactNode, - useCallback, - useContext, - useRef, -} from "react"; +import { createContext, ReactNode, useContext } from "react"; import type { IChatMessage } from "@/entities/chats/types"; import { useChatSocket } from "@/hooks/chats/useChatSocket"; -interface ChatContextValue extends ReturnType { - messagesWrapperRef: React.RefObject; - scrollToBottom: (behavior?: ScrollBehavior) => void; -} +type ChatContextValue = ReturnType; const ChatContext = createContext(null); @@ -44,22 +35,5 @@ export function ChatProvider({ initialTotal, }); - const messagesWrapperRef = useRef(null); - - const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => { - if (messagesWrapperRef.current) { - messagesWrapperRef.current.scrollTo({ - top: messagesWrapperRef.current.scrollHeight, - behavior, - }); - } - }, []); - - return ( - - {children} - - ); + return {children}; }