feat: sending and receiving messages with files, editing text messages
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
15
src/components/Chat/components/ChatFile/ChatFile.module.css
Normal file
15
src/components/Chat/components/ChatFile/ChatFile.module.css
Normal 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;
|
||||
}
|
||||
21
src/components/Chat/components/ChatFile/ChatFile.tsx
Normal file
21
src/components/Chat/components/ChatFile/ChatFile.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
const getDocLink = (fileId: number) => {
|
||||
return `${import.meta.env.VITE_API_URL}/chat/tg-file/${fileId}`;
|
||||
};
|
||||
|
||||
export default getDocLink;
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
38
src/components/Chat/components/SelectedFile/SelectedFile.tsx
Normal file
38
src/components/Chat/components/SelectedFile/SelectedFile.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user