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

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

View File

@@ -53,19 +53,19 @@ const Message = ({ message }: Props) => {
title: "Повторить отправку",
icon: <IconBrandTelegram />,
},
])
]);
const getContext = () => {
if (!isMine) return;
if (!isMine || message.file) return;
if (isSuccess) return contextMenuSuccessMsg();
if (isError) return contextMenuErrorMsg();
}
};
const getStatusIcon = () => {
const size = em(18);
if (message.status == MessageStatuses.ERROR) {
return <IconAlertCircle size={size} />;
return <IconAlertCircle size={size} color={"red"}/>;
}
if (message.status == MessageStatuses.SENDING) {
return <IconClock size={size} />;
@@ -86,7 +86,9 @@ const Message = ({ message }: Props) => {
<div>{message.tgSender!.lastName} {message.tgSender!.firstName}</div>
)}
{message.file && (
<ChatFile file={message.file} />
<Group>
<ChatFile file={message.file} />
</Group>
)}
<div>{message.text}</div>
<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 { useChatContext } from "../../../../pages/ClientsPage/contexts/ChatContext.tsx";
import ActionIconCopy from "../../../ActionIconCopy/ActionIconCopy.tsx";
import SelectedFile from "../SelectedFile/SelectedFile.tsx";
import { useMemo } from "react";
const MessageInput = () => {
@@ -13,20 +12,19 @@ const MessageInput = () => {
form,
files,
fileDialog,
isMessageSending,
} = useChatContext();
const getFiles = useMemo(() => {
return files.map(file => (
<SelectedFile key={file.name} file={file} />
));
}, [files]);
const getFiles = files.map(file => (
<SelectedFile key={file.name} file={file} />
));
return (
<form onSubmit={form.onSubmit(values => submitMessage(values))}>
<Stack gap={"xs"}>
<Divider />
{getFiles}
<Group wrap={"nowrap"} align={"center"}>
<Group wrap={"nowrap"} align={"flex-end"}>
{chat?.tgGroup?.tgInviteLink && (
<ActionIconCopy
onCopiedLabel={"Ссылка на чат скопирована в буфер обмена"}
@@ -42,9 +40,12 @@ const MessageInput = () => {
<IconPaperclip />
</ActionIcon>
</Tooltip>
<TextInput
<Textarea
{...form.getInputProps("message")}
w={"100%"}
minRows={1}
maxRows={4}
autosize
/>
{form.values.messageId && (
<Button
@@ -57,6 +58,7 @@ const MessageInput = () => {
<Button
variant={"default"}
type="submit"
disabled={isMessageSending}
>
{form.values.messageId ? (
<IconCheck />