Merge pull request #31 from pennyteenycat/AW-496-chat-improvement

AW-496-chat-improvement
This commit is contained in:
pennyteenycat 2025-07-30 19:03:24 +03:00 committed by GitHub
commit d135674e46
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 268 additions and 175 deletions

42
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -2,4 +2,5 @@
display: flex;
flex-direction: column;
height: 100dvh;
position: relative;
}

View File

@ -8,5 +8,6 @@
&.own {
align-items: flex-end;
align-self: flex-end;
margin-left: auto;
}
}

View File

@ -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 (
<div className={clsx(styles.message, message.isOwn && styles.own)}>
<MessageBubble isOwn={message.isOwn}>
{message.type === "text" && (
<MessageText text={message.content} isOwn={message.isOwn} />
<div className={clsx(styles.message, isOwn && styles.own)}>
<MessageBubble isOwn={isOwn}>
{message.type === "text" && message.id !== "typing" && (
<MessageText text={message.text} isOwn={isOwn} />
)}
{message.type === "typing" && <MessageTyping />}
{message.id === "typing" && <MessageTyping />}
{message.type === "image" && (
{/* {message.type === "image" && (
<>
<MessageImage src={message.imageUrl || ""} />
{message.content && (
<MessageText text={message.content} isOwn={message.isOwn} />
)}
{message.text && <MessageText text={message.text} isOwn={isOwn} />}
</>
)}
{message.type === "audio" && (
{message.type === "voice" && (
<>
<MessageAudio
src={message.audioUrl || ""}
duration={message.duration}
/>
{message.content && (
<MessageText text={message.content} isOwn={message.isOwn} />
)}
{message.text && <MessageText text={message.text} isOwn={isOwn} />}
</>
)}
)} */}
</MessageBubble>
<MessageMeta time={message.time}>
{message.isOwn && <MessageStatus isRead={message.isRead} />}
<MessageMeta time={formatTime(message.createdDate)}>
{isOwn && <MessageStatus isRead={message.isRead} />}
</MessageMeta>
</div>
);

View File

@ -17,11 +17,11 @@ export default function ChatMessages({
<ChatMessage
message={{
id: "typing",
type: "typing",
content: "…",
isOwn: false,
type: "text",
text: "…",
role: "assistant",
isRead: false,
time: "",
createdDate: new Date().toISOString(),
}}
/>
)}

View File

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

View File

@ -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<HTMLDivElement>(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 (
<div
className={styles.messagesWrapper}
ref={messagesWrapperRef}
onScroll={handleScroll}
>
{isLoadingOlder && hasMoreOlderMessages && (
<div className={styles.loaderTop}>
<Spinner size={16} />
</div>
<>
{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>
)}
<ChatMessages
messages={mappedMessages}
isLoadingAdvisorMessage={isLoadingAdvisorMessage}
/>
<Suggestions
className={styles.suggestions}
onSuggestionClick={suggestion => {
send(suggestion);
}}
/>
</div>
<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>
</>
);
}

View File

@ -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();

View File

@ -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<UIMessage[]>(() =>
options.initialMessages ? options.initialMessages.map(mapApiMessage) : []
const [messages, setMessages] = useState<IChatMessage[]>(
() => options.initialMessages || []
);
const [page, setPage] = useState(1);
const [totalCount, _setTotalCount] = useState<number | null>(
@ -60,7 +45,6 @@ export const useChatSocket = (
const [balance, setBalance] = useState<ICurrentBalance | null>(null);
const [session, setSession] = useState<ISessionStarted | null>(null);
const [isLoadingSelfMessage, setIsLoadingSelfMessage] = useState(false);
// const [isLoadingAdvisorMessage, setIsLoadingAdvisorMessage] = useState(false);
const [isSessionExpired, setIsSessionExpired] = useState(false);
const [refillModals, setRefillModals] = useState<IRefillModals | null>(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<string, UIMessage>();
const map = new Map<string, IChatMessage>();
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,

View File

@ -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<typeof useChatSocket> {
messagesWrapperRef: React.RefObject<HTMLDivElement | null>;
scrollToBottom: (behavior?: ScrollBehavior) => void;
}
type ChatContextValue = ReturnType<typeof useChatSocket>;
const ChatContext = createContext<ChatContextValue | null>(null);
@ -44,22 +35,5 @@ export function ChatProvider({
initialTotal,
});
const messagesWrapperRef = useRef<HTMLDivElement>(null);
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>
);
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
}