Merge pull request #31 from pennyteenycat/AW-496-chat-improvement
AW-496-chat-improvement
This commit is contained in:
commit
d135674e46
42
package-lock.json
generated
42
package-lock.json
generated
@ -9,6 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lottiefiles/dotlottie-react": "^0.14.1",
|
"@lottiefiles/dotlottie-react": "^0.14.1",
|
||||||
|
"@tanstack/react-virtual": "^3.13.12",
|
||||||
"client-only": "^0.0.1",
|
"client-only": "^0.0.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
@ -200,13 +201,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/plugin-kit": {
|
"node_modules/@eslint/plugin-kit": {
|
||||||
"version": "0.3.2",
|
"version": "0.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz",
|
||||||
"integrity": "sha512-4SaFZCNfJqvk/kenHpI8xvN42DMaoycy4PzKc5otHxRswww1kAt82OlBuwRVLofCACCTZEcla2Ydxv8scMXaTg==",
|
"integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint/core": "^0.15.0",
|
"@eslint/core": "^0.15.1",
|
||||||
"levn": "^0.4.1"
|
"levn": "^0.4.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -214,9 +215,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/plugin-kit/node_modules/@eslint/core": {
|
"node_modules/@eslint/plugin-kit/node_modules/@eslint/core": {
|
||||||
"version": "0.15.0",
|
"version": "0.15.1",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz",
|
||||||
"integrity": "sha512-b7ePw78tEWWkpgZCDYkbqDOP8dmM6qe+AOC6iuJqlq1R/0ahMAeH3qynpnqKFGkMltrp44ohV4ubGyvLX28tzw==",
|
"integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -1308,6 +1309,33 @@
|
|||||||
"tslib": "^2.8.0"
|
"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": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.9.0",
|
"version": "0.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz",
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lottiefiles/dotlottie-react": "^0.14.1",
|
"@lottiefiles/dotlottie-react": "^0.14.1",
|
||||||
|
"@tanstack/react-virtual": "^3.13.12",
|
||||||
"client-only": "^0.0.1",
|
"client-only": "^0.0.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
|
|||||||
@ -2,4 +2,5 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,5 +8,6 @@
|
|||||||
&.own {
|
&.own {
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,11 +3,11 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
import { IChatMessage } from "@/entities/chats/types";
|
||||||
import { useChat } from "@/providers/chat-provider";
|
import { useChat } from "@/providers/chat-provider";
|
||||||
|
import { formatTime } from "@/shared/utils/date";
|
||||||
|
|
||||||
import MessageAudio from "./MessageAudio/MessageAudio";
|
|
||||||
import MessageBubble from "./MessageBubble/MessageBubble";
|
import MessageBubble from "./MessageBubble/MessageBubble";
|
||||||
import MessageImage from "./MessageImage/MessageImage";
|
|
||||||
import MessageMeta from "./MessageMeta/MessageMeta";
|
import MessageMeta from "./MessageMeta/MessageMeta";
|
||||||
import MessageStatus from "./MessageStatus/MessageStatus";
|
import MessageStatus from "./MessageStatus/MessageStatus";
|
||||||
import MessageText from "./MessageText/MessageText";
|
import MessageText from "./MessageText/MessageText";
|
||||||
@ -16,21 +16,23 @@ import MessageTyping from "./MessageTyping/MessageTyping";
|
|||||||
import styles from "./ChatMessage.module.scss";
|
import styles from "./ChatMessage.module.scss";
|
||||||
|
|
||||||
export interface ChatMessageProps {
|
export interface ChatMessageProps {
|
||||||
message: {
|
// message: {
|
||||||
id: string;
|
// id: string;
|
||||||
type: "text" | "image" | "audio" | "typing";
|
// type: "text" | "image" | "voice" | "typing";
|
||||||
content?: string;
|
// text?: string;
|
||||||
imageUrl?: string;
|
// imageUrl?: string;
|
||||||
audioUrl?: string;
|
// audioUrl?: string;
|
||||||
duration?: number;
|
// duration?: number;
|
||||||
time: string | null;
|
// time: string | null;
|
||||||
isOwn: boolean;
|
// isOwn: boolean;
|
||||||
isRead?: boolean;
|
// isRead?: boolean;
|
||||||
};
|
// };
|
||||||
|
message: IChatMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ChatMessage({ message }: ChatMessageProps) {
|
export default function ChatMessage({ message }: ChatMessageProps) {
|
||||||
const { isConnected, read } = useChat();
|
const { isConnected, read } = useChat();
|
||||||
|
const isOwn = message.role === "user";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
@ -45,37 +47,33 @@ export default function ChatMessage({ message }: ChatMessageProps) {
|
|||||||
}, [message.id, message.isRead, read, isConnected]);
|
}, [message.id, message.isRead, read, isConnected]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx(styles.message, message.isOwn && styles.own)}>
|
<div className={clsx(styles.message, isOwn && styles.own)}>
|
||||||
<MessageBubble isOwn={message.isOwn}>
|
<MessageBubble isOwn={isOwn}>
|
||||||
{message.type === "text" && (
|
{message.type === "text" && message.id !== "typing" && (
|
||||||
<MessageText text={message.content} isOwn={message.isOwn} />
|
<MessageText text={message.text} isOwn={isOwn} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{message.type === "typing" && <MessageTyping />}
|
{message.id === "typing" && <MessageTyping />}
|
||||||
|
|
||||||
{message.type === "image" && (
|
{/* {message.type === "image" && (
|
||||||
<>
|
<>
|
||||||
<MessageImage src={message.imageUrl || ""} />
|
<MessageImage src={message.imageUrl || ""} />
|
||||||
{message.content && (
|
{message.text && <MessageText text={message.text} isOwn={isOwn} />}
|
||||||
<MessageText text={message.content} isOwn={message.isOwn} />
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{message.type === "audio" && (
|
{message.type === "voice" && (
|
||||||
<>
|
<>
|
||||||
<MessageAudio
|
<MessageAudio
|
||||||
src={message.audioUrl || ""}
|
src={message.audioUrl || ""}
|
||||||
duration={message.duration}
|
duration={message.duration}
|
||||||
/>
|
/>
|
||||||
{message.content && (
|
{message.text && <MessageText text={message.text} isOwn={isOwn} />}
|
||||||
<MessageText text={message.content} isOwn={message.isOwn} />
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)} */}
|
||||||
</MessageBubble>
|
</MessageBubble>
|
||||||
<MessageMeta time={message.time}>
|
<MessageMeta time={formatTime(message.createdDate)}>
|
||||||
{message.isOwn && <MessageStatus isRead={message.isRead} />}
|
{isOwn && <MessageStatus isRead={message.isRead} />}
|
||||||
</MessageMeta>
|
</MessageMeta>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -17,11 +17,11 @@ export default function ChatMessages({
|
|||||||
<ChatMessage
|
<ChatMessage
|
||||||
message={{
|
message={{
|
||||||
id: "typing",
|
id: "typing",
|
||||||
type: "typing",
|
type: "text",
|
||||||
content: "…",
|
text: "…",
|
||||||
isOwn: false,
|
role: "assistant",
|
||||||
isRead: false,
|
isRead: false,
|
||||||
time: "",
|
createdDate: new Date().toISOString(),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,18 +1,45 @@
|
|||||||
.messagesWrapper {
|
.messagesWrapper {
|
||||||
flex: 1 1 0%;
|
flex: 1 1 0%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
scroll-behavior: smooth;
|
|
||||||
transition: padding-bottom 0.3s ease-in-out;
|
transition: padding-bottom 0.3s ease-in-out;
|
||||||
|
position: relative;
|
||||||
|
transform: scaleY(-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.loaderTop {
|
.loaderTop {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding-top: 16px;
|
padding-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestions.suggestions {
|
.suggestions.suggestions {
|
||||||
// position: sticky;
|
// position: sticky;
|
||||||
// bottom: 0;
|
// bottom: 0;
|
||||||
padding: 0 16px 36px;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,93 +1,174 @@
|
|||||||
"use client";
|
"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 { 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";
|
import styles from "./ChatMessagesWrapper.module.scss";
|
||||||
|
|
||||||
export default function ChatMessagesWrapper() {
|
export default function ChatMessagesWrapper() {
|
||||||
|
const messagesWrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const [isScrolledUp, setIsScrolledUp] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
messages: socketMessages,
|
messages: socketMessages,
|
||||||
isLoadingAdvisorMessage,
|
isLoadingAdvisorMessage,
|
||||||
hasMoreOlderMessages,
|
hasMoreOlderMessages,
|
||||||
isLoadingOlder,
|
isLoadingOlder,
|
||||||
messagesWrapperRef,
|
// unreadMessagesCount,
|
||||||
loadOlder,
|
loadOlder,
|
||||||
scrollToBottom,
|
|
||||||
send,
|
send,
|
||||||
} = useChat();
|
} = 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 items = virtualizer.getVirtualItems();
|
||||||
const el = messagesWrapperRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
|
|
||||||
if (el.scrollTop < 100) {
|
const scrollToBottom = useCallback(() => {
|
||||||
setIsLoadOlder(true);
|
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();
|
||||||
}
|
}
|
||||||
}, [loadOlder, messagesWrapperRef]);
|
}, [hasMoreOlderMessages, loadOlder, messages.length, isLoadingOlder, items]);
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoadOlder) {
|
if (!messagesWrapperRef.current || messages.length === 0) return;
|
||||||
setIsLoadOlder(false);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [socketMessages]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
setIsScrolledUp((virtualizer.scrollOffset || 0) > 600);
|
||||||
if (socketMessages.length > 0 && _hasHydrated && !isLoadOlder) {
|
}, [virtualizer.scrollOffset, messages.length, messagesWrapperRef]);
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
scrollToBottom();
|
|
||||||
});
|
|
||||||
return () => clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [socketMessages.length, scrollToBottom, _hasHydrated]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
className={styles.messagesWrapper}
|
{isScrolledUp && (
|
||||||
ref={messagesWrapperRef}
|
<Button
|
||||||
onScroll={handleScroll}
|
className={styles.scrollToBottomButton}
|
||||||
>
|
onClick={scrollToBottom}
|
||||||
{isLoadingOlder && hasMoreOlderMessages && (
|
aria-label="Scroll to bottom"
|
||||||
<div className={styles.loaderTop}>
|
style={{
|
||||||
<Spinner size={16} />
|
top: `${messagesWrapperRef.current?.clientHeight}px`,
|
||||||
</div>
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
)}
|
)}
|
||||||
<ChatMessages
|
<div ref={messagesWrapperRef} className={styles.messagesWrapper}>
|
||||||
messages={mappedMessages}
|
<Suggestions
|
||||||
isLoadingAdvisorMessage={isLoadingAdvisorMessage}
|
className={styles.suggestions}
|
||||||
/>
|
onSuggestionClick={suggestion => {
|
||||||
<Suggestions
|
send(suggestion);
|
||||||
className={styles.suggestions}
|
}}
|
||||||
onSuggestionClick={suggestion => {
|
/>
|
||||||
send(suggestion);
|
<div
|
||||||
}}
|
style={{
|
||||||
/>
|
height: virtualizer.getTotalSize(),
|
||||||
</div>
|
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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -30,10 +30,10 @@ const PartnerSchema = z
|
|||||||
address: z.string(),
|
address: z.string(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
birthdate: z.string(),
|
birthdate: z.string().optional(),
|
||||||
gender: z.string(),
|
gender: z.string().optional(),
|
||||||
age: z.number(),
|
age: z.number().optional(),
|
||||||
sign: z.string(),
|
sign: z.string().optional(),
|
||||||
})
|
})
|
||||||
.optional();
|
.optional();
|
||||||
|
|
||||||
|
|||||||
@ -20,15 +20,10 @@ import type {
|
|||||||
|
|
||||||
const PAGE_LIMIT = 50;
|
const PAGE_LIMIT = 50;
|
||||||
|
|
||||||
type UIMessage = Pick<
|
|
||||||
IChatMessage,
|
|
||||||
"id" | "role" | "text" | "createdDate" | "isRead" | "suggestions" | "isLast"
|
|
||||||
>;
|
|
||||||
|
|
||||||
interface UseChatSocketOptions {
|
interface UseChatSocketOptions {
|
||||||
initialMessages?: IChatMessage[];
|
initialMessages?: IChatMessage[];
|
||||||
initialTotal?: number;
|
initialTotal?: number;
|
||||||
onNewMessage?: (message: UIMessage) => void;
|
onNewMessage?: (message: IChatMessage) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useChatSocket = (
|
export const useChatSocket = (
|
||||||
@ -39,18 +34,8 @@ export const useChatSocket = (
|
|||||||
const status = useSocketStatus();
|
const status = useSocketStatus();
|
||||||
const emit = useSocketEmit();
|
const emit = useSocketEmit();
|
||||||
|
|
||||||
const mapApiMessage = (m: IChatMessage): UIMessage => ({
|
const [messages, setMessages] = useState<IChatMessage[]>(
|
||||||
id: m.id,
|
() => options.initialMessages || []
|
||||||
role: m.role,
|
|
||||||
text: m.text,
|
|
||||||
createdDate: m.createdDate,
|
|
||||||
isRead: m.isRead,
|
|
||||||
suggestions: m.suggestions,
|
|
||||||
isLast: m.isLast,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [messages, setMessages] = useState<UIMessage[]>(() =>
|
|
||||||
options.initialMessages ? options.initialMessages.map(mapApiMessage) : []
|
|
||||||
);
|
);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [totalCount, _setTotalCount] = useState<number | null>(
|
const [totalCount, _setTotalCount] = useState<number | null>(
|
||||||
@ -60,7 +45,6 @@ export const useChatSocket = (
|
|||||||
const [balance, setBalance] = useState<ICurrentBalance | null>(null);
|
const [balance, setBalance] = useState<ICurrentBalance | null>(null);
|
||||||
const [session, setSession] = useState<ISessionStarted | null>(null);
|
const [session, setSession] = useState<ISessionStarted | null>(null);
|
||||||
const [isLoadingSelfMessage, setIsLoadingSelfMessage] = useState(false);
|
const [isLoadingSelfMessage, setIsLoadingSelfMessage] = useState(false);
|
||||||
// const [isLoadingAdvisorMessage, setIsLoadingAdvisorMessage] = useState(false);
|
|
||||||
const [isSessionExpired, setIsSessionExpired] = useState(false);
|
const [isSessionExpired, setIsSessionExpired] = useState(false);
|
||||||
const [refillModals, setRefillModals] = useState<IRefillModals | null>(null);
|
const [refillModals, setRefillModals] = useState<IRefillModals | null>(null);
|
||||||
const { suggestions, setSuggestions } = useChatStore(state => state);
|
const { suggestions, setSuggestions } = useChatStore(state => state);
|
||||||
@ -73,6 +57,10 @@ export const useChatSocket = (
|
|||||||
);
|
);
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
|
const unreadMessagesCount = useMemo(() => {
|
||||||
|
return messages.filter(m => !m.isRead && m.role === "assistant").length;
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
const joinChat = useCallback(
|
const joinChat = useCallback(
|
||||||
() => emit("join_chat", { chatId }),
|
() => emit("join_chat", { chatId }),
|
||||||
[emit, chatId]
|
[emit, chatId]
|
||||||
@ -84,12 +72,13 @@ export const useChatSocket = (
|
|||||||
|
|
||||||
const send = useCallback(
|
const send = useCallback(
|
||||||
(text: string) => {
|
(text: string) => {
|
||||||
const sendingMessage = {
|
const sendingMessage: IChatMessage = {
|
||||||
id: `sending-message-${Date.now()}`,
|
id: `sending-message-${Date.now()}`,
|
||||||
role: "user",
|
role: "user",
|
||||||
text,
|
text,
|
||||||
createdDate: new Date().toISOString(),
|
createdDate: new Date().toISOString(),
|
||||||
isRead: false,
|
isRead: false,
|
||||||
|
type: "text",
|
||||||
};
|
};
|
||||||
setMessages(prev => [sendingMessage, ...prev]);
|
setMessages(prev => [sendingMessage, ...prev]);
|
||||||
if (options.onNewMessage) {
|
if (options.onNewMessage) {
|
||||||
@ -97,14 +86,18 @@ export const useChatSocket = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsLoadingSelfMessage(true);
|
setIsLoadingSelfMessage(true);
|
||||||
// setIsLoadingAdvisorMessage(true);
|
|
||||||
emit("send_message", { chatId, message: text });
|
emit("send_message", { chatId, message: text });
|
||||||
},
|
},
|
||||||
[options, emit, chatId]
|
[options, emit, chatId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const read = useCallback(
|
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]
|
[emit]
|
||||||
);
|
);
|
||||||
const startSession = useCallback(
|
const startSession = useCallback(
|
||||||
@ -149,10 +142,7 @@ export const useChatSocket = (
|
|||||||
const { messages: msgs } = data;
|
const { messages: msgs } = data;
|
||||||
setMessages(prev => {
|
setMessages(prev => {
|
||||||
const ids = new Set(prev.map(m => m.id));
|
const ids = new Set(prev.map(m => m.id));
|
||||||
return [
|
return [...prev, ...msgs.filter(m => !ids.has(m.id))];
|
||||||
...prev,
|
|
||||||
...msgs.map(mapApiMessage).filter(m => !ids.has(m.id)),
|
|
||||||
];
|
|
||||||
});
|
});
|
||||||
setPage(nextPage);
|
setPage(nextPage);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -167,26 +157,16 @@ export const useChatSocket = (
|
|||||||
if (!data?.length) return;
|
if (!data?.length) return;
|
||||||
|
|
||||||
if (data[0].role === "user") setIsLoadingSelfMessage(false);
|
if (data[0].role === "user") setIsLoadingSelfMessage(false);
|
||||||
// if (data[0].role === "assistant") setIsLoadingAdvisorMessage(false);
|
|
||||||
|
|
||||||
setMessages(prev => {
|
setMessages(prev => {
|
||||||
const map = new Map<string, UIMessage>();
|
const map = new Map<string, IChatMessage>();
|
||||||
|
|
||||||
prev
|
prev
|
||||||
.filter(m => !m.id.startsWith("sending-message-"))
|
.filter(m => !m.id.startsWith("sending-message-"))
|
||||||
.forEach(m => map.set(m.id, m));
|
.forEach(m => map.set(m.id, m));
|
||||||
|
|
||||||
data.forEach(d =>
|
data.forEach(d => map.set(d.id, 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,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return Array.from(map.values()).sort(
|
return Array.from(map.values()).sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
new Date(b.createdDate).getTime() - new Date(a.createdDate).getTime()
|
new Date(b.createdDate).getTime() - new Date(a.createdDate).getTime()
|
||||||
@ -275,6 +255,7 @@ export const useChatSocket = (
|
|||||||
session,
|
session,
|
||||||
refillModals,
|
refillModals,
|
||||||
suggestions,
|
suggestions,
|
||||||
|
unreadMessagesCount,
|
||||||
|
|
||||||
send,
|
send,
|
||||||
read,
|
read,
|
||||||
@ -296,6 +277,7 @@ export const useChatSocket = (
|
|||||||
session,
|
session,
|
||||||
refillModals,
|
refillModals,
|
||||||
suggestions,
|
suggestions,
|
||||||
|
unreadMessagesCount,
|
||||||
isLoadingSelfMessage,
|
isLoadingSelfMessage,
|
||||||
isLoadingAdvisorMessage,
|
isLoadingAdvisorMessage,
|
||||||
isAvailableChatting,
|
isAvailableChatting,
|
||||||
|
|||||||
@ -1,20 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { createContext, ReactNode, useContext } from "react";
|
||||||
createContext,
|
|
||||||
ReactNode,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useRef,
|
|
||||||
} from "react";
|
|
||||||
|
|
||||||
import type { IChatMessage } from "@/entities/chats/types";
|
import type { IChatMessage } from "@/entities/chats/types";
|
||||||
import { useChatSocket } from "@/hooks/chats/useChatSocket";
|
import { useChatSocket } from "@/hooks/chats/useChatSocket";
|
||||||
|
|
||||||
interface ChatContextValue extends ReturnType<typeof useChatSocket> {
|
type ChatContextValue = ReturnType<typeof useChatSocket>;
|
||||||
messagesWrapperRef: React.RefObject<HTMLDivElement | null>;
|
|
||||||
scrollToBottom: (behavior?: ScrollBehavior) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ChatContext = createContext<ChatContextValue | null>(null);
|
const ChatContext = createContext<ChatContextValue | null>(null);
|
||||||
|
|
||||||
@ -44,22 +35,5 @@ export function ChatProvider({
|
|||||||
initialTotal,
|
initialTotal,
|
||||||
});
|
});
|
||||||
|
|
||||||
const messagesWrapperRef = useRef<HTMLDivElement>(null);
|
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
|
||||||
|
|
||||||
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
|
|
||||||
if (messagesWrapperRef.current) {
|
|
||||||
messagesWrapperRef.current.scrollTo({
|
|
||||||
top: messagesWrapperRef.current.scrollHeight,
|
|
||||||
behavior,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ChatContext.Provider
|
|
||||||
value={{ ...value, messagesWrapperRef, scrollToBottom }}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</ChatContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user