feat: chat with infinite scroll

This commit is contained in:
2025-04-06 11:43:45 +04:00
parent 23f0cdc081
commit 2f4f3c3125
9 changed files with 152 additions and 110 deletions

View File

@@ -54,6 +54,7 @@
"react-imask": "^7.6.1", "react-imask": "^7.6.1",
"react-redux": "^9.1.2", "react-redux": "^9.1.2",
"react-to-print": "^2.15.1", "react-to-print": "^2.15.1",
"react-virtuoso": "^4.12.6",
"reactflow": "^11.11.4", "reactflow": "^11.11.4",
"recharts": "^2.13.3", "recharts": "^2.13.3",
"zod": "^3.23.8" "zod": "^3.23.8"

View File

@@ -4,12 +4,14 @@
/* eslint-disable */ /* eslint-disable */
import type { MessageFileSchema } from './MessageFileSchema'; import type { MessageFileSchema } from './MessageFileSchema';
import type { TgUserSchema } from './TgUserSchema'; import type { TgUserSchema } from './TgUserSchema';
import type { UserSchema } from './UserSchema';
export type MessageSchema = { export type MessageSchema = {
text: string; text: string;
chatId: number; chatId: number;
id: number; id: number;
createdAt: string; createdAt: string;
tgSender: (TgUserSchema | null); tgSender: (TgUserSchema | null);
crmSender: (UserSchema | null);
status: string; status: string;
isEdited: boolean; isEdited: boolean;
file?: (MessageFileSchema | null); file?: (MessageFileSchema | null);

View File

@@ -1,53 +1,82 @@
import { ScrollArea, Stack } from "@mantine/core";
import Message from "./components/Message/Message.tsx"; import Message from "./components/Message/Message.tsx";
import MessageInput from "./components/MessageInput/MessageInput.tsx";
import { useChatContext } from "../../pages/ClientsPage/contexts/ChatContext.tsx"; import { useChatContext } from "../../pages/ClientsPage/contexts/ChatContext.tsx";
import { MessageSchema } from "../../client"; import { MessageSchema } from "../../client";
import { ReactNode } from "react"; import { ReactNode, useCallback } from "react";
import ChatDate from "./components/ChatDate/ChatDate.tsx"; import ChatDate from "./components/ChatDate/ChatDate.tsx";
import MessageInput from "./components/MessageInput/MessageInput.tsx";
import { Virtuoso } from "react-virtuoso";
import { Stack } from "@mantine/core";
const Chat = () => { const Chat = () => {
const { const {
messages, messages,
scrollRef, lastMessage,
onScrollPositionChange, firstItemIndex,
fetchMoreMessages,
} = useChatContext(); } = useChatContext();
const getChatElements = (): ReactNode[] => { const onFollowOutputHandler = useCallback(
const elements: ReactNode[] = []; (atBottom: boolean) => {
let prevMessage: MessageSchema | null = null; if (atBottom || lastMessage?.crmSender) {
return "auto";
for (let i = messages.length - 1; i >= 0; i--) { } else {
const currMessage = messages[i]; return false;
}
},
[lastMessage],
);
const itemContent = useCallback(
(index: number, sessionData: MessageSchema) => {
let dateComponent: ReactNode | null = null;
const msgArrayIdx = index - firstItemIndex;
const currMessage = messages[msgArrayIdx];
let prevMessage = null;
if (msgArrayIdx > 0) {
prevMessage = messages[msgArrayIdx - 1];
}
if (!prevMessage || prevMessage.createdAt.substring(5, 10) != currMessage.createdAt.substring(5, 10)) { if (!prevMessage || prevMessage.createdAt.substring(5, 10) != currMessage.createdAt.substring(5, 10)) {
elements.push(( dateComponent = (
<ChatDate <ChatDate
key={currMessage.id + "date"} key={currMessage.id + "date"}
date={new Date(currMessage.createdAt)} date={new Date(currMessage.createdAt)}
/> />
)); );
} }
elements.push( return (
<Message key={currMessage.id + "msg"} message={currMessage} /> <Stack mb={"xs"} mr={"xs"}>
{dateComponent}
<Message
key={`${sessionData.id}${index}`}
message={sessionData}
/>
</Stack>
); );
prevMessage = currMessage; },
} [messages],
);
return elements;
};
if (messages.length === 0) {
return (
<Stack h={"96vh"} justify={"flex-end"}>
<MessageInput />
</Stack>
);
}
return ( return (
<Stack h={"96vh"}> <Stack h={"96vh"}>
<ScrollArea <Virtuoso
h={"100%"} data={messages}
viewportRef={scrollRef} followOutput={onFollowOutputHandler}
onScrollPositionChange={onScrollPositionChange} firstItemIndex={firstItemIndex}
> initialTopMostItemIndex={messages.length - 1}
<Stack pr={"md"} gap={"sm"}> itemContent={itemContent}
{getChatElements()} startReached={fetchMoreMessages}
</Stack> height={"100%"}
</ScrollArea> increaseViewportBy={100}
alignToBottom
/>
<MessageInput /> <MessageInput />
</Stack> </Stack>
); );

View File

@@ -53,19 +53,19 @@ const Message = ({ message }: Props) => {
title: "Повторить отправку", title: "Повторить отправку",
icon: <IconBrandTelegram />, icon: <IconBrandTelegram />,
}, },
]) ]);
const getContext = () => { const getContext = () => {
if (!isMine) return; if (!isMine || message.file) return;
if (isSuccess) return contextMenuSuccessMsg(); if (isSuccess) return contextMenuSuccessMsg();
if (isError) return contextMenuErrorMsg(); if (isError) return contextMenuErrorMsg();
} };
const getStatusIcon = () => { const getStatusIcon = () => {
const size = em(18); const size = em(18);
if (message.status == MessageStatuses.ERROR) { if (message.status == MessageStatuses.ERROR) {
return <IconAlertCircle size={size} />; return <IconAlertCircle size={size} color={"red"}/>;
} }
if (message.status == MessageStatuses.SENDING) { if (message.status == MessageStatuses.SENDING) {
return <IconClock size={size} />; return <IconClock size={size} />;
@@ -86,7 +86,9 @@ const Message = ({ message }: Props) => {
<div>{message.tgSender!.lastName} {message.tgSender!.firstName}</div> <div>{message.tgSender!.lastName} {message.tgSender!.firstName}</div>
)} )}
{message.file && ( {message.file && (
<ChatFile file={message.file} /> <Group>
<ChatFile file={message.file} />
</Group>
)} )}
<div>{message.text}</div> <div>{message.text}</div>
<Group <Group

View File

@@ -1,9 +1,8 @@
import { ActionIcon, Button, Divider, Group, Stack, TextInput, Tooltip } from "@mantine/core"; import { ActionIcon, Button, Divider, Group, Stack, Textarea, Tooltip } from "@mantine/core";
import { IconCheck, IconPaperclip, IconSend2, IconX } from "@tabler/icons-react"; import { IconCheck, IconPaperclip, IconSend2, IconX } from "@tabler/icons-react";
import { useChatContext } from "../../../../pages/ClientsPage/contexts/ChatContext.tsx"; import { useChatContext } from "../../../../pages/ClientsPage/contexts/ChatContext.tsx";
import ActionIconCopy from "../../../ActionIconCopy/ActionIconCopy.tsx"; import ActionIconCopy from "../../../ActionIconCopy/ActionIconCopy.tsx";
import SelectedFile from "../SelectedFile/SelectedFile.tsx"; import SelectedFile from "../SelectedFile/SelectedFile.tsx";
import { useMemo } from "react";
const MessageInput = () => { const MessageInput = () => {
@@ -13,20 +12,19 @@ const MessageInput = () => {
form, form,
files, files,
fileDialog, fileDialog,
isMessageSending,
} = useChatContext(); } = useChatContext();
const getFiles = useMemo(() => { const getFiles = files.map(file => (
return files.map(file => ( <SelectedFile key={file.name} file={file} />
<SelectedFile key={file.name} file={file} /> ));
));
}, [files]);
return ( return (
<form onSubmit={form.onSubmit(values => submitMessage(values))}> <form onSubmit={form.onSubmit(values => submitMessage(values))}>
<Stack gap={"xs"}> <Stack gap={"xs"}>
<Divider /> <Divider />
{getFiles} {getFiles}
<Group wrap={"nowrap"} align={"center"}> <Group wrap={"nowrap"} align={"flex-end"}>
{chat?.tgGroup?.tgInviteLink && ( {chat?.tgGroup?.tgInviteLink && (
<ActionIconCopy <ActionIconCopy
onCopiedLabel={"Ссылка на чат скопирована в буфер обмена"} onCopiedLabel={"Ссылка на чат скопирована в буфер обмена"}
@@ -42,9 +40,12 @@ const MessageInput = () => {
<IconPaperclip /> <IconPaperclip />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<TextInput <Textarea
{...form.getInputProps("message")} {...form.getInputProps("message")}
w={"100%"} w={"100%"}
minRows={1}
maxRows={4}
autosize
/> />
{form.values.messageId && ( {form.values.messageId && (
<Button <Button
@@ -57,6 +58,7 @@ const MessageInput = () => {
<Button <Button
variant={"default"} variant={"default"}
type="submit" type="submit"
disabled={isMessageSending}
> >
{form.values.messageId ? ( {form.values.messageId ? (
<IconCheck /> <IconCheck />

View File

@@ -8,13 +8,14 @@ import useChatTab from "./hooks/useChatTab.tsx";
const ChatTab = () => { const ChatTab = () => {
const { selectedCard } = useCardPageContext(); const { selectedCard } = useCardPageContext();
const { onChatCreateClick } = useChatTab(); const { onChatCreateClick, isRequestSending } = useChatTab();
if (!selectedCard?.chat) { if (!selectedCard?.chat) {
return ( return (
<Stack> <Stack>
<InlineButton <InlineButton
onClick={onChatCreateClick} onClick={onChatCreateClick}
disabled={isRequestSending}
> >
<IconMessagePlus /> <IconMessagePlus />
Создать чат Создать чат

View File

@@ -3,10 +3,12 @@ import { ChatService } from "../../../../../client";
import { modals } from "@mantine/modals"; import { modals } from "@mantine/modals";
import { Text } from "@mantine/core"; import { Text } from "@mantine/core";
import { useCardPageContext } from "../../../../../pages/CardsPage/contexts/CardPageContext.tsx"; import { useCardPageContext } from "../../../../../pages/CardsPage/contexts/CardPageContext.tsx";
import { useState } from "react";
const useChatTab = () => { const useChatTab = () => {
const { selectedCard, refetchCard } = useCardPageContext(); const { selectedCard, refetchCard } = useCardPageContext();
const [isRequestSending, setIsRequestSending] = useState<boolean>(false);
const createChat = () => { const createChat = () => {
if (!selectedCard?.clientId) { if (!selectedCard?.clientId) {
@@ -14,6 +16,7 @@ const useChatTab = () => {
return; return;
} }
setIsRequestSending(true);
ChatService.createChat({ ChatService.createChat({
requestBody: { requestBody: {
clientId: selectedCard.clientId, clientId: selectedCard.clientId,
@@ -24,7 +27,8 @@ const useChatTab = () => {
notifications.guess(ok, { message }); notifications.guess(ok, { message });
refetchCard(); refetchCard();
}) })
.catch(err => console.log(err)); .catch(err => console.log(err))
.finally(() => setIsRequestSending(false));
}; };
const onChatCreateClick = () => { const onChatCreateClick = () => {
@@ -43,6 +47,7 @@ const useChatTab = () => {
return { return {
onChatCreateClick, onChatCreateClick,
isRequestSending,
}; };
}; };

View File

@@ -4,6 +4,7 @@ import { IconMessage, IconMessagePlus } from "@tabler/icons-react";
import { useChatContext } from "../../contexts/ChatContext.tsx"; import { useChatContext } from "../../contexts/ChatContext.tsx";
import { modals } from "@mantine/modals"; import { modals } from "@mantine/modals";
import { notifications } from "../../../../shared/lib/notifications.ts"; import { notifications } from "../../../../shared/lib/notifications.ts";
import { useState } from "react";
type Props = { type Props = {
client: ClientSchema; client: ClientSchema;
@@ -12,9 +13,10 @@ type Props = {
const ClientChatButton = ({ client, refetch }: Props) => { const ClientChatButton = ({ client, refetch }: Props) => {
const { setChat } = useChatContext(); const { setChat } = useChatContext();
const [isRequestSending, setIsRequestSending] = useState<boolean>(false);
const createChat = () => { const createChat = () => {
console.log("Creating chat") setIsRequestSending(true);
ChatService.createChat({ ChatService.createChat({
requestBody: { requestBody: {
clientId: client.id, clientId: client.id,
@@ -25,7 +27,8 @@ const ClientChatButton = ({ client, refetch }: Props) => {
notifications.guess(ok, { message }); notifications.guess(ok, { message });
refetch(); refetch();
}) })
.catch(err => console.log(err)); .catch(err => console.log(err))
.finally(() => setIsRequestSending(false));
}; };
const onCreateChatClick = () => { const onCreateChatClick = () => {
@@ -58,7 +61,9 @@ const ClientChatButton = ({ client, refetch }: Props) => {
<Tooltip label="Создать чат"> <Tooltip label="Создать чат">
<ActionIcon <ActionIcon
onClick={onCreateChatClick} onClick={onCreateChatClick}
variant={"default"}> variant={"default"}
disabled={isRequestSending}
>
<IconMessagePlus /> <IconMessagePlus />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>

View File

@@ -1,8 +1,8 @@
import React, { createContext, FC, MutableRefObject, useContext, useEffect, useRef, useState } from "react"; import React, { createContext, FC, useCallback, useContext, useEffect, useState } from "react";
import { ChatSchema, ChatService, MessageSchema } from "../../../client"; import { ChatSchema, ChatService, MessageSchema } from "../../../client";
import { notifications } from "../../../shared/lib/notifications.ts"; import { notifications } from "../../../shared/lib/notifications.ts";
import { useForm, UseFormReturnType } from "@mantine/form"; import { useForm, UseFormReturnType } from "@mantine/form";
import { useDebouncedState, useFileDialog, useListState, UseListStateHandlers } from "@mantine/hooks"; import { useFileDialog, useListState, UseListStateHandlers } from "@mantine/hooks";
export type MessageForm = { export type MessageForm = {
message: string; message: string;
@@ -19,13 +19,16 @@ type ChatContextState = {
chat: ChatSchema | null; chat: ChatSchema | null;
setChat: (chat: ChatSchema | null) => void; setChat: (chat: ChatSchema | null) => void;
messages: MessageSchema[]; messages: MessageSchema[];
lastMessage: MessageSchema | null;
firstItemIndex: number;
form: UseFormReturnType<MessageForm>; form: UseFormReturnType<MessageForm>;
onScrollPositionChange: (values: { x: number, y: number }) => void;
scrollRef: MutableRefObject<HTMLDivElement | null>;
submitMessage: (values: MessageForm) => void; submitMessage: (values: MessageForm) => void;
fetchMoreMessages: () => void;
files: Array<File>; files: Array<File>;
filesHandlers: UseListStateHandlers<File>; filesHandlers: UseListStateHandlers<File>;
fileDialog: FileDialog; fileDialog: FileDialog;
isMessageSending: boolean;
}; };
const ChatContext = createContext<ChatContextState | undefined>(undefined); const ChatContext = createContext<ChatContextState | undefined>(undefined);
@@ -33,18 +36,18 @@ const ChatContext = createContext<ChatContextState | undefined>(undefined);
const useChatContextState = () => { const useChatContextState = () => {
const [chat, setChat] = useState<ChatSchema | null>(null); const [chat, setChat] = useState<ChatSchema | null>(null);
const [messages, setMessages] = useState<MessageSchema[]>([]); const [messages, setMessages] = useState<MessageSchema[]>([]);
const [offset, setOffset] = useState(0); const lastMessage = messages?.length ? messages[messages?.length - 1] : null;
const limit = 20; const [firstItemIndex, setFirstItemIndex] = useState(10000);
const [hasMore, setHasMore] = useState(true); const limit: number = 30;
const scrollRef = useRef<HTMLDivElement | null>(null); let offset: number = 0;
const [isScrollToBottom, setIsScrollToBottom] = useState(true); const [hasMore, setHasMore] = useState(false);
const [scrollPosition, setScrollPosition] = useDebouncedState<{ x: number, y: number }>({ x: 0, y: 0 }, 400);
const [files, filesHandlers] = useListState<File>([]); const [files, filesHandlers] = useListState<File>([]);
const fileDialog = useFileDialog(); const fileDialog = useFileDialog();
const [isMessageSending, setIsMessageSending] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
filesHandlers.setState(Array.from(fileDialog.files ?? [])); filesHandlers.append(...Array.from(fileDialog.files ?? []));
}, [fileDialog.files]); }, [fileDialog.files]);
const form = useForm<MessageForm>({ const form = useForm<MessageForm>({
@@ -55,52 +58,49 @@ const useChatContextState = () => {
const setChatValue = (chat: ChatSchema | null) => { const setChatValue = (chat: ChatSchema | null) => {
if (chat) { if (chat) {
setOffset(0); setFirstItemIndex(10000);
offset = 0;
setHasMore(true); setHasMore(true);
setScrollPosition({ x: 0, y: 0 });
filesHandlers.setState([]); filesHandlers.setState([]);
form.reset(); form.reset();
} }
setChat(chat); setChat(chat);
}; };
const fetchMoreMessages = useCallback(() => {
return setTimeout(() => {
if (!chat || !hasMore) return;
offset += limit;
ChatService.getMessages({
requestBody: {
chatId: chat.id,
offset,
limit,
},
})
.then(({ messages: newMessages }) => {
setFirstItemIndex(prev => prev - newMessages.length);
setMessages(prev => [...newMessages.reverse(), ...prev]);
setHasMore(newMessages.length === limit);
})
.catch(err => console.log(err));
}, 500);
}, [setMessages, chat, offset, hasMore, setHasMore]);
const fetchMessages = () => { const fetchMessages = () => {
if (!chat) return; if (!chat) return;
ChatService.getMessages({ ChatService.getMessages({
requestBody: { requestBody: {
chatId: chat.id, chatId: chat.id,
offset, offset: 0,
limit, limit: limit + offset,
}, },
}) })
.then(({ messages: newMessages }) => { .then(({ messages: newMessages }) => {
if (newMessages.length < limit) setHasMore(false); setMessages(newMessages.reverse());
setMessages([...newMessages]);
setOffset((prev) => prev + limit);
})
.catch(err => console.log(err));
};
const fetchMessagesOnScroll = () => {
if (!chat) return;
ChatService.getMessages({
requestBody: {
chatId: chat.id,
offset,
limit,
},
})
.then(({ messages: newMessages }) => {
console.log(newMessages);
if (newMessages.length < limit) setHasMore(false);
setMessages((prev) => [...prev, ...newMessages]);
setOffset((prev) => prev + limit);
if (scrollRef.current) {
const prevPosition = limit / offset * scrollRef.current.scrollHeight;
scrollRef.current.scrollTo({ top: prevPosition, behavior: "instant" });
}
}) })
.catch(err => console.log(err)); .catch(err => console.log(err));
}; };
@@ -113,22 +113,9 @@ const useChatContextState = () => {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [chat]); }, [chat]);
useEffect(() => {
if (scrollRef.current && isScrollToBottom && chat) {
setIsScrollToBottom(false);
scrollRef.current.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
}
}, [messages, isScrollToBottom]);
useEffect(() => {
if (!chat) return;
if (Math.abs(scrollPosition.y - 200) <= 200 && hasMore) {
fetchMessagesOnScroll();
}
}, [scrollPosition]);
const sendMessageWithFiles = (values: MessageForm) => { const sendMessageWithFiles = (values: MessageForm) => {
if (!chat) return; if (!chat) return;
setIsMessageSending(true);
ChatService.sendMessagesWithFiles({ ChatService.sendMessagesWithFiles({
formData: { formData: {
@@ -145,11 +132,13 @@ const useChatContextState = () => {
filesHandlers.setState([]); filesHandlers.setState([]);
form.reset(); form.reset();
}) })
.catch(err => console.log(err)); .catch(err => console.log(err))
.finally(() => setIsMessageSending(false));
}; };
const sendTextMessage = (values: MessageForm) => { const sendTextMessage = (values: MessageForm) => {
if (!chat) return; if (!chat) return;
setIsMessageSending(true);
ChatService.sendTextMessage({ ChatService.sendTextMessage({
requestBody: { requestBody: {
@@ -164,13 +153,15 @@ const useChatContextState = () => {
notifications.error({ message }); notifications.error({ message });
} }
form.reset(); form.reset();
setIsScrollToBottom(true); setIsMessageSending(false);
}) })
.catch(err => console.log(err)); .catch(err => console.log(err))
.finally(() => setIsMessageSending(false));
}; };
const editMessage = (values: MessageForm) => { const editMessage = (values: MessageForm) => {
if (!chat) return; if (!chat) return;
setIsMessageSending(true);
ChatService.editMessage({ ChatService.editMessage({
requestBody: { requestBody: {
@@ -186,9 +177,10 @@ const useChatContextState = () => {
notifications.error({ message }); notifications.error({ message });
} }
form.reset(); form.reset();
setIsScrollToBottom(true); setIsMessageSending(false);
}) })
.catch(err => console.log(err)); .catch(err => console.log(err))
.finally(() => setIsMessageSending(false));
}; };
const submitMessage = (values: MessageForm) => { const submitMessage = (values: MessageForm) => {
@@ -205,13 +197,16 @@ const useChatContextState = () => {
chat, chat,
setChat: setChatValue, setChat: setChatValue,
messages, messages,
lastMessage,
firstItemIndex,
form, form,
onScrollPositionChange: setScrollPosition,
scrollRef,
submitMessage, submitMessage,
fetchMoreMessages,
files, files,
filesHandlers, filesHandlers,
fileDialog, fileDialog,
isMessageSending,
}; };
}; };