feat: sending and receiving messages with files, editing text messages

This commit is contained in:
2025-04-02 15:27:57 +04:00
parent f6c55012bd
commit 23f0cdc081
29 changed files with 615 additions and 72 deletions

View File

@@ -15,17 +15,13 @@ export const ActionIconCopy: FC<Props> = ({ onCopiedLabel, value }) => {
<Tooltip
label={onCopiedLabel}
offset={5}
position="bottom"
radius="xl"
transitionProps={{ duration: 100, transition: "slide-down" }}
opened={clipboard.copied}>
<ActionIcon
variant={"default"}
size="lg"
onClick={() => {
console.log("AKLSKLSKSM");
return clipboard.copy(value);
}}
onClick={() => clipboard.copy(value)}
>
{clipboard.copied ? (
<IconCheck

View File

@@ -28,9 +28,9 @@ const Chat = () => {
/>
));
}
elements.push((
elements.push(
<Message key={currMessage.id + "msg"} message={currMessage} />
));
);
prevMessage = currMessage;
}
@@ -38,12 +38,13 @@ const Chat = () => {
};
return (
<Stack>
<Stack h={"96vh"}>
<ScrollArea
h={"100%"}
viewportRef={scrollRef}
onScrollPositionChange={onScrollPositionChange}
>
<Stack h={"91vh"} pr={"md"} gap={"sm"}>
<Stack pr={"md"} gap={"sm"}>
{getChatElements()}
</Stack>
</ScrollArea>

View File

@@ -0,0 +1,15 @@
.file-circe {
@mixin light {
background-color: var(--mantine-color-gray-0);
}
@mixin dark {
background-color: var(--mantine-color-dark-4);
}
border-radius: 50%;
align-items: center;
justify-items: center;
padding: 0.8rem;
width: 3rem;
height: 3rem;
cursor: pointer;
}

View File

@@ -0,0 +1,21 @@
import { MessageFileSchema } from "../../../../client";
import ChatDocument from "./components/ChatDocument.tsx";
import ChatPhoto from "./components/ChatPhoto.tsx";
type Props = {
file: MessageFileSchema;
}
const ChatFile = ({ file }: Props) => {
if (file.type === "photo") {
return (
<ChatPhoto file={file} />
);
}
return (
<ChatDocument file={file} />
);
};
export default ChatFile;

View File

@@ -0,0 +1,48 @@
import { Center, Group, rem, Stack, Text } from "@mantine/core";
import { IconFileFilled } from "@tabler/icons-react";
import { MessageFileSchema } from "../../../../../client";
import styles from "../ChatFile.module.css";
import classNames from "classnames";
import downloadFile from "../utils/downloadFile.ts";
type Props = {
file: MessageFileSchema;
}
const ChatDocument = ({ file }: Props) => {
const round = (value: number) => Math.round(value * 10) / 10;
const getFileSize = () => {
const BYTES_IN_KB = 1_024;
const BYTES_IN_MB = 1_048_576;
const BYTES_IN_GB = 1_073_741_824;
if (file.fileSize < BYTES_IN_KB) {
return `${file.fileSize} B`;
}
if (file.fileSize < BYTES_IN_MB) {
return `${round(file.fileSize / BYTES_IN_KB)} KB`;
}
if (file.fileSize < BYTES_IN_GB) {
return `${round(file.fileSize / BYTES_IN_MB)} MB`;
}
return `${round(file.fileSize / BYTES_IN_GB)} GB`;
};
return (
<Group justify={"center"} my={"sm"}>
<Center
className={classNames(styles["file-circe"])}
onClick={() => downloadFile(file)}
>
<IconFileFilled />
</Center>
<Stack gap={rem(1)} my={rem(1)}>
<Text>{file.fileName}</Text>
<Text>{getFileSize()}</Text>
</Stack>
</Group>
);
};
export default ChatDocument;

View File

@@ -0,0 +1,44 @@
import { MessageFileSchema } from "../../../../../client";
import { Box, Image, Modal } from "@mantine/core";
import getDocLink from "../utils/getDocLink.ts";
import { useDisclosure } from "@mantine/hooks";
type Props = {
file: MessageFileSchema;
}
const ChatPhoto = ({ file }: Props) => {
const [opened, { open, close }] = useDisclosure(false);
const image = (
<Image
src={getDocLink(file.id)}
radius={"md"}
/>
);
return (
<>
<Modal
opened={opened}
onClose={close}
size={"auto"}
withCloseButton={false}
>
<Box mah={"90vh"} maw={"90vw"}>
{image}
</Box>
</Modal>
<Box
my={"sm"}
onClick={open}
style={{ cursor: "pointer" }}
>
{image}
</Box>
</>
);
};
export default ChatPhoto;

View File

@@ -0,0 +1,14 @@
import { MessageFileSchema } from "../../../../../client";
import getDocLink from "./getDocLink.ts";
const downloadFile = (file: MessageFileSchema) => {
const link = document.createElement("a");
link.href = getDocLink(file.id);
link.download = file.fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
export default downloadFile;

View File

@@ -0,0 +1,5 @@
const getDocLink = (fileId: number) => {
return `${import.meta.env.VITE_API_URL}/chat/tg-file/${fileId}`;
};
export default getDocLink;

View File

@@ -1,11 +1,12 @@
import { MessageSchema } from "../../../../client";
import { Box, Center, em, Flex, Group, rem } from "@mantine/core";
import { formatDateTime } from "../../../../types/utils.ts";
import { IconAlertCircle, IconCheck, IconClock, IconTrash } from "@tabler/icons-react";
import { IconAlertCircle, IconBrandTelegram, IconCheck, IconClock, IconEdit, IconTrash } from "@tabler/icons-react";
import { useContextMenu } from "mantine-contextmenu";
import useMessage from "./hooks/useMessage.tsx";
import styles from "../../Chat.module.css";
import classNames from "classnames";
import ChatFile from "../ChatFile/ChatFile.tsx";
type Props = {
message: MessageSchema;
@@ -20,18 +21,47 @@ enum MessageStatuses {
const Message = ({ message }: Props) => {
const isMine = !message.tgSender;
const isSuccess = message.status == MessageStatuses.SUCCESS;
const { onDeleteMessageClick } = useMessage();
const isError = message.status == MessageStatuses.ERROR;
const {
onDeleteMessageClick,
onEditMessageClick,
onRepeatSendingClick,
} = useMessage();
const { showContextMenu } = useContextMenu();
const contextMenu = () => showContextMenu([
const contextMenuSuccessMsg = () => showContextMenu([
{
key: "delete",
onClick: () => onDeleteMessageClick(message),
title: "Удалить",
icon: <IconTrash />,
},
{
key: "edit",
onClick: () => onEditMessageClick(message),
title: "Редактировать",
icon: <IconEdit />,
},
]);
const contextMenuErrorMsg = () => showContextMenu([
{
key: "repeatSending",
onClick: () => onRepeatSendingClick(message),
title: "Повторить отправку",
icon: <IconBrandTelegram />,
},
])
const getContext = () => {
if (!isMine) return;
if (isSuccess) return contextMenuSuccessMsg();
if (isError) return contextMenuErrorMsg();
}
const getStatusIcon = () => {
const size = em(18);
if (message.status == MessageStatuses.ERROR) {
@@ -49,11 +79,15 @@ const Message = ({ message }: Props) => {
py={rem(5)}
px={rem(15)}
className={classNames(isMine ? styles["message"] : styles["other-message"])}
onContextMenu={isMine && isSuccess ? contextMenu() : undefined}
onContextMenu={getContext()}
maw={em(600)}
>
{!isMine && (
<div>{message.tgSender!.lastName} {message.tgSender!.firstName}</div>
)}
{message.file && (
<ChatFile file={message.file} />
)}
<div>{message.text}</div>
<Group
gap={em(5)}
@@ -61,6 +95,7 @@ const Message = ({ message }: Props) => {
align={"center"}
wrap={"nowrap"}
>
{message.isEdited && "ред."}
<Center>
{formatDateTime(message.createdAt).substring(11, 16)}
</Center>

View File

@@ -1,7 +1,9 @@
import { ChatService, MessageSchema } from "../../../../../client";
import { notifications } from "../../../../../shared/lib/notifications.ts";
import { useChatContext } from "../../../../../pages/ClientsPage/contexts/ChatContext.tsx";
const useMessage = () => {
const { form } = useChatContext();
const onDeleteMessageClick = (message: MessageSchema) => {
ChatService.deleteMessage({
@@ -15,8 +17,31 @@ const useMessage = () => {
.catch(err => console.log(err));
};
const onEditMessageClick = (message: MessageSchema) => {
form.setValues({
messageId: message.id,
message: message.text,
});
};
const onRepeatSendingClick = (message: MessageSchema) => {
ChatService.repeatSendingTextMessage({
requestBody: {
message,
},
})
.then(({ ok, message }) => {
if (!ok) {
notifications.error({ message });
}
})
.catch(err => console.log(err));
};
return {
onDeleteMessageClick,
onEditMessageClick,
onRepeatSendingClick,
};
};

View File

@@ -1,43 +1,71 @@
import { Button, Group, TextInput, Tooltip } from "@mantine/core";
import { IconSend2 } from "@tabler/icons-react";
import { ActionIcon, Button, Divider, Group, Stack, TextInput, Tooltip } from "@mantine/core";
import { IconCheck, IconPaperclip, IconSend2, IconX } from "@tabler/icons-react";
import { useChatContext } from "../../../../pages/ClientsPage/contexts/ChatContext.tsx";
import { useForm } from "@mantine/form";
import ActionIconCopy from "../../../ActionIconCopy/ActionIconCopy.tsx";
import SelectedFile from "../SelectedFile/SelectedFile.tsx";
import { useMemo } from "react";
export type MessageForm = {
message: string;
}
const MessageInput = () => {
const { sendMessage, chat } = useChatContext();
const form = useForm<MessageForm>({
initialValues: {
message: "",
},
});
const {
submitMessage,
chat,
form,
files,
fileDialog,
} = useChatContext();
const getFiles = useMemo(() => {
return files.map(file => (
<SelectedFile key={file.name} file={file} />
));
}, [files]);
return (
<form onSubmit={form.onSubmit(values => sendMessage(values, form))}>
<Group wrap={"nowrap"} align={"center"}>
{chat?.tgGroup?.tgInviteLink && (
<Tooltip label={"Ссылка-приглашение"}>
<form onSubmit={form.onSubmit(values => submitMessage(values))}>
<Stack gap={"xs"}>
<Divider />
{getFiles}
<Group wrap={"nowrap"} align={"center"}>
{chat?.tgGroup?.tgInviteLink && (
<ActionIconCopy
onCopiedLabel={"Ссылка скопирована в буфер обмена"}
onCopiedLabel={"Ссылка на чат скопирована в буфер обмена"}
value={chat.tgGroup.tgInviteLink}
/>
)}
<Tooltip label={"Прикрепить файлы"}>
<ActionIcon
variant={"default"}
onClick={fileDialog.open}
size="lg"
>
<IconPaperclip />
</ActionIcon>
</Tooltip>
)}
<TextInput
{...form.getInputProps("message")}
w={"100%"}
/>
<Button
variant={"default"}
type="submit"
>
<IconSend2 />
</Button>
</Group>
<TextInput
{...form.getInputProps("message")}
w={"100%"}
/>
{form.values.messageId && (
<Button
variant={"default"}
onClick={form.reset}
>
<IconX />
</Button>
)}
<Button
variant={"default"}
type="submit"
>
{form.values.messageId ? (
<IconCheck />
) : (
<IconSend2 />
)}
</Button>
</Group>
</Stack>
</form>
);
};

View File

@@ -0,0 +1,38 @@
import { Box, Group, Text } from "@mantine/core";
import { IconX } from "@tabler/icons-react";
import { useChatContext } from "../../../../pages/ClientsPage/contexts/ChatContext.tsx";
type Props = {
file: File;
}
const SelectedFile = ({ file }: Props) => {
const { files, filesHandlers } = useChatContext();
const onCancelFileClick = () => {
const idx = files.findIndex(f => f.name === file.name && f.type === file.type);
if (idx < 0) return;
filesHandlers.remove(idx);
};
return (
<Box>
<Group justify={"space-between"}>
<Text>
{file.name}
</Text>
<Box
onClick={onCancelFileClick}
style={{
cursor: "pointer",
}}
>
<IconX />
</Box>
</Group>
</Box>
);
};
export default SelectedFile;