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};
}