feat: support chat upload file

This commit is contained in:
IlyaDoronin 2024-03-11 19:02:37 +03:00
parent c730993fef
commit 0f91f0037d
11 changed files with 916 additions and 218 deletions

@ -9,26 +9,8 @@
"ecmaVersion": "latest", "ecmaVersion": "latest",
"sourceType": "module" "sourceType": "module"
}, },
"plugins": [ "plugins": ["@typescript-eslint", "react"],
"@typescript-eslint",
"react"
],
"rules": { "rules": {
"indent": [ "quotes": ["error", "double"]
"error",
"tab"
],
"linebreak-style": [
"error",
"unix"
],
"quotes": [
"error",
"double"
],
"semi": [
"error",
"never"
]
} }
} }

@ -1,9 +1,9 @@
import { makeRequest } from "@frontend/kitui" import { makeRequest } from "@frontend/kitui";
import { parseAxiosError } from "@root/utils/parse-error" import { parseAxiosError } from "@root/utils/parse-error";
import { SendTicketMessageRequest } from "@frontend/kitui" import { SendTicketMessageRequest } from "@frontend/kitui";
const apiUrl = process.env.REACT_APP_DOMAIN + "/heruvym" const apiUrl = process.env.REACT_APP_DOMAIN + "/heruvym";
export async function sendTicketMessage( export async function sendTicketMessage(
ticketId: string, ticketId: string,
@ -18,13 +18,13 @@ export async function sendTicketMessage(
method: "POST", method: "POST",
useToken: true, useToken: true,
body: { ticket: ticketId, message: message, lang: "ru", files: [] }, body: { ticket: ticketId, message: message, lang: "ru", files: [] },
}) });
return [sendTicketMessageResponse] return [sendTicketMessageResponse];
} catch (nativeError) { } catch (nativeError) {
const [error] = parseAxiosError(nativeError) const [error] = parseAxiosError(nativeError);
return [null, `Не удалось отправить сообщение. ${error}`] return [null, `Не удалось отправить сообщение. ${error}`];
} }
} }
@ -35,12 +35,12 @@ export async function shownMessage(id: string): Promise<[null, string?]> {
method: "POST", method: "POST",
useToken: true, useToken: true,
body: { id }, body: { id },
}) });
return [shownMessageResponse] return [shownMessageResponse];
} catch (nativeError) { } catch (nativeError) {
const [error] = parseAxiosError(nativeError) const [error] = parseAxiosError(nativeError);
return [null, `Не удалось прочесть сообщение. ${error}`] return [null, `Не удалось прочесть сообщение. ${error}`];
} }
} }

@ -0,0 +1,55 @@
import { Box, SxProps, Theme } from "@mui/material";
interface Props {
color: string;
sx?: SxProps<Theme>;
}
export default function Download({ color, sx }: Props) {
return (
<Box
sx={{
height: "38px",
width: "45px",
display: "flex",
alignItems: "center",
justifyContent: "center",
...sx,
}}
>
<svg
width="47"
height="42"
viewBox="0 0 47 42"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M33.8846 26.8076H44.2692C44.7283 26.8076 45.1685 26.9551 45.4931 27.2177C45.8177 27.4802 46 27.8363 46 28.2076V39.4076C46 39.7789 45.8177 40.135 45.4931 40.3976C45.1685 40.6601 44.7283 40.8076 44.2692 40.8076H2.73077C2.27174 40.8076 1.83151 40.6601 1.50693 40.3976C1.18235 40.135 1 39.7789 1 39.4076V28.2076C1 27.8363 1.18235 27.4802 1.50693 27.2177C1.83151 26.9551 2.27174 26.8076 2.73077 26.8076H13.1154"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M23.5 27V1"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M13.1155 11.3846L23.5001 1L33.8847 11.3846"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M39.135 36.0776C40.3141 36.0776 41.27 35.1217 41.27 33.9426C41.27 32.7635 40.3141 31.8076 39.135 31.8076C37.9559 31.8076 37 32.7635 37 33.9426C37 35.1217 37.9559 36.0776 39.135 36.0776Z"
fill={color}
/>
</svg>
</Box>
);
}

@ -10,32 +10,52 @@ import {
useMediaQuery, useMediaQuery,
useTheme, useTheme,
} from "@mui/material"; } from "@mui/material";
import { TicketMessage } from "@frontend/kitui";
import {
addOrUpdateUnauthMessages,
useUnauthTicketStore,
incrementUnauthMessageApiPage,
setUnauthIsPreventAutoscroll,
setUnauthSessionData,
setIsMessageSending,
setUnauthTicketMessageFetchState,
} from "@root/stores/unauthTicket";
import { enqueueSnackbar } from "notistack";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import ChatMessage from "../ChatMessage";
import SendIcon from "../icons/SendIcon";
import UserCircleIcon from "./UserCircleIcon";
import { throttle } from "@frontend/kitui";
import { import {
TicketMessage,
makeRequest,
useTicketsFetcher,
useTicketMessages, useTicketMessages,
getMessageFromFetchError, getMessageFromFetchError,
useSSESubscription, useSSESubscription,
useEventListener, useEventListener,
createTicket, createTicket,
} from "@frontend/kitui"; } from "@frontend/kitui";
import { enqueueSnackbar } from "notistack";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import ChatMessage from "../ChatMessage";
import SendIcon from "../icons/SendIcon";
import UserCircleIcon from "./UserCircleIcon";
import { throttle } from "@frontend/kitui";
import { sendTicketMessage, shownMessage } from "@root/api/ticket"; import { sendTicketMessage, shownMessage } from "@root/api/ticket";
import { useSSETab } from "@root/utils/hooks/useSSETab"; import { useSSETab } from "@root/utils/hooks/useSSETab";
import {
checkAcceptableMediaType,
MAX_FILE_SIZE,
ACCEPT_SEND_MEDIA_TYPES_MAP,
} from "@utils/checkAcceptableMediaType";
import {
useTicketStore,
setTicketData,
addOrUpdateUnauthMessages,
setUnauthTicketMessageFetchState,
setUnauthIsPreventAutoscroll,
incrementUnauthMessageApiPage,
setIsMessageSending,
} from "@root/stores/tickets";
import { useUserStore } from "@root/stores/user";
import AttachFileIcon from "@mui/icons-material/AttachFile";
import ChatDocument from "./ChatDocument";
import ChatImage from "./ChatImage";
import ChatVideo from "./ChatVideo";
type ModalWarningType =
| "errorType"
| "errorSize"
| "picture"
| "video"
| "audio"
| "document"
| null;
interface Props { interface Props {
open: boolean; open: boolean;
sx?: SxProps<Theme>; sx?: SxProps<Theme>;
@ -45,23 +65,26 @@ export default function Chat({ open = false, sx }: Props) {
const theme = useTheme(); const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md")); const upMd = useMediaQuery(theme.breakpoints.up("md"));
const [messageField, setMessageField] = useState<string>(""); const [messageField, setMessageField] = useState<string>("");
const sessionData = useUnauthTicketStore((state) => state.sessionData); const [disableFileButton, setDisableFileButton] = useState(false);
const messages = useUnauthTicketStore((state) => state.messages); const [modalWarningType, setModalWarningType] =
const messageApiPage = useUnauthTicketStore((state) => state.apiPage); useState<ModalWarningType>(null);
const messagesPerPage = useUnauthTicketStore(
(state) => state.messagesPerPage
);
const isMessageSending = useUnauthTicketStore(
(state) => state.isMessageSending
);
const isPreventAutoscroll = useUnauthTicketStore(
(state) => state.isPreventAutoscroll
);
const lastMessageId = useUnauthTicketStore((state) => state.lastMessageId);
const fetchState = useUnauthTicketStore(
(state) => state.unauthTicketMessageFetchState
);
const chatBoxRef = useRef<HTMLDivElement>(null); const chatBoxRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const user = useUserStore((state) => state.user?._id);
const ticket = useTicketStore(
(state) => state[user ? "authData" : "unauthData"]
);
const {
messages,
sessionData,
isMessageSending,
isPreventAutoscroll,
lastMessageId,
messagesPerPage,
unauthTicketMessageFetchState: fetchState,
apiPage: messageApiPage,
} = ticket;
const { isActiveSSETab, updateSSEValue } = useSSETab<TicketMessage[]>( const { isActiveSSETab, updateSSEValue } = useSSETab<TicketMessage[]>(
"ticket", "ticket",
addOrUpdateUnauthMessages addOrUpdateUnauthMessages
@ -74,8 +97,10 @@ export default function Chat({ open = false, sx }: Props) {
messagesPerPage, messagesPerPage,
messageApiPage, messageApiPage,
onSuccess: useCallback((messages) => { onSuccess: useCallback((messages) => {
if (chatBoxRef.current && chatBoxRef.current.scrollTop < 1) if (chatBoxRef.current && chatBoxRef.current.scrollTop < 1) {
chatBoxRef.current.scrollTop = 1; chatBoxRef.current.scrollTop = 1;
}
addOrUpdateUnauthMessages(messages); addOrUpdateUnauthMessages(messages);
}, []), }, []),
onError: useCallback((error: Error) => { onError: useCallback((error: Error) => {
@ -92,6 +117,7 @@ export default function Chat({ open = false, sx }: Props) {
`/heruvym/ticket?ticket=${sessionData?.ticketId}&s=${sessionData?.sessionId}`, `/heruvym/ticket?ticket=${sessionData?.ticketId}&s=${sessionData?.sessionId}`,
onNewData: (ticketMessages) => { onNewData: (ticketMessages) => {
updateSSEValue(ticketMessages); updateSSEValue(ticketMessages);
addOrUpdateUnauthMessages(ticketMessages); addOrUpdateUnauthMessages(ticketMessages);
}, },
onDisconnect: useCallback(() => { onDisconnect: useCallback(() => {
@ -100,6 +126,34 @@ export default function Chat({ open = false, sx }: Props) {
marker: "ticket", marker: "ticket",
}); });
useTicketsFetcher({
url: process.env.REACT_APP_DOMAIN + "/heruvym/getTickets",
ticketsPerPage: 10,
ticketApiPage: 0,
onSuccess: (result) => {
if (result.data?.length) {
const currentTicket = result.data.find(
({ origin }) => !origin.includes("/support")
);
if (!currentTicket) {
return;
}
setTicketData({
ticketId: currentTicket.id,
sessionId: currentTicket.sess,
});
}
},
onError: (error: Error) => {
const message = getMessageFromFetchError(error);
if (message) enqueueSnackbar(message);
},
onFetchStateChange: () => {},
enabled: Boolean(user),
});
const throttledScrollHandler = useMemo( const throttledScrollHandler = useMemo(
() => () =>
throttle(() => { throttle(() => {
@ -149,7 +203,7 @@ export default function Chat({ open = false, sx }: Props) {
async function handleSendMessage() { async function handleSendMessage() {
if (!messageField || isMessageSending) return; if (!messageField || isMessageSending) return;
if (!sessionData) { if (!sessionData?.ticketId) {
setIsMessageSending(true); setIsMessageSending(true);
createTicket({ createTicket({
url: process.env.REACT_APP_DOMAIN + "/heruvym/create", url: process.env.REACT_APP_DOMAIN + "/heruvym/create",
@ -157,10 +211,10 @@ export default function Chat({ open = false, sx }: Props) {
Title: "Unauth title", Title: "Unauth title",
Message: messageField, Message: messageField,
}, },
useToken: false, useToken: Boolean(user),
}) })
.then((response) => { .then((response) => {
setUnauthSessionData({ setTicketData({
ticketId: response.Ticket, ticketId: response.Ticket,
sessionId: response.sess, sessionId: response.sess,
}); });
@ -210,6 +264,66 @@ export default function Chat({ open = false, sx }: Props) {
} }
}; };
const sendFile = async (file: File) => {
if (file === undefined) return true;
console.log("тут ошибка", modalWarningType);
let data;
if (!ticket.sessionData?.ticketId) {
try {
data = await createTicket({
url: process.env.REACT_APP_DOMAIN + "/heruvym/create",
body: {
Title: "Unauth title",
Message: "",
},
useToken: Boolean(user),
});
setTicketData({
ticketId: data.Ticket,
sessionId: data.sess,
});
} catch (error: any) {
const errorMessage = getMessageFromFetchError(error);
if (errorMessage) enqueueSnackbar(errorMessage);
}
setIsMessageSending(false);
}
const ticketId = ticket.sessionData?.ticketId || data?.Ticket;
if (ticketId !== undefined) {
if (file.size > MAX_FILE_SIZE) return setModalWarningType("errorSize");
try {
const body = new FormData();
body.append(file.name, file);
body.append("ticket", ticketId);
await makeRequest({
url: process.env.REACT_APP_DOMAIN + "/heruvym/sendFiles",
body: body,
method: "POST",
});
} catch (error: any) {
const errorMessage = getMessageFromFetchError(error);
if (errorMessage) enqueueSnackbar(errorMessage);
}
return true;
}
};
const sendFileHC = async (file: File) => {
console.log(file);
const check = checkAcceptableMediaType(file);
if (check.length > 0) {
enqueueSnackbar(check);
return;
}
setDisableFileButton(true);
await sendFile(file);
setDisableFileButton(false);
console.log(disableFileButton);
};
return ( return (
<> <>
{open && ( {open && (
@ -276,16 +390,87 @@ export default function Chat({ open = false, sx }: Props) {
flexGrow: 1, flexGrow: 1,
}} }}
> >
{sessionData && {ticket.sessionData?.ticketId &&
messages.map((message) => ( messages.map((message) => {
const isFileVideo = () => {
if (message.files) {
return ACCEPT_SEND_MEDIA_TYPES_MAP.video.some(
(fileType) =>
message.files[0].toLowerCase().endsWith(fileType)
);
}
};
const isFileImage = () => {
if (message.files) {
return ACCEPT_SEND_MEDIA_TYPES_MAP.picture.some(
(fileType) =>
message.files[0].toLowerCase().endsWith(fileType)
);
}
};
const isFileDocument = () => {
if (message.files) {
return ACCEPT_SEND_MEDIA_TYPES_MAP.document.some(
(fileType) =>
message.files[0].toLowerCase().endsWith(fileType)
);
}
};
if (message.files.length > 0 && isFileImage()) {
return (
<ChatImage
unAuthenticated
key={message.id}
file={message.files[0]}
createdAt={message.created_at}
isSelf={
(ticket.sessionData?.sessionId || user) ===
message.user_id
}
/>
);
}
if (message.files.length > 0 && isFileVideo()) {
return (
<ChatVideo
unAuthenticated
key={message.id}
file={message.files[0]}
createdAt={message.created_at}
isSelf={
(ticket.sessionData?.sessionId || user) ===
message.user_id
}
/>
);
}
if (message.files.length > 0 && isFileDocument()) {
return (
<ChatDocument
unAuthenticated
key={message.id}
file={message.files[0]}
createdAt={message.created_at}
isSelf={
(ticket.sessionData?.sessionId || user) ===
message.user_id
}
/>
);
}
return (
<ChatMessage <ChatMessage
unAuthenticated unAuthenticated
key={message.id} key={message.id}
text={message.message} text={message.message}
createdAt={message.created_at} createdAt={message.created_at}
isSelf={sessionData.sessionId === message.user_id} isSelf={
(ticket.sessionData?.sessionId || user) ===
message.user_id
}
/> />
))} );
})}
</Box> </Box>
<FormControl fullWidth sx={{ borderTop: "1px solid black" }}> <FormControl fullWidth sx={{ borderTop: "1px solid black" }}>
<InputBase <InputBase
@ -314,6 +499,26 @@ export default function Chat({ open = false, sx }: Props) {
onChange={(e) => setMessageField(e.target.value)} onChange={(e) => setMessageField(e.target.value)}
endAdornment={ endAdornment={
<InputAdornment position="end"> <InputAdornment position="end">
<IconButton
disabled={disableFileButton}
onClick={() => {
console.log(disableFileButton);
if (!disableFileButton) fileInputRef.current?.click();
}}
>
<AttachFileIcon />
</IconButton>
<input
ref={fileInputRef}
id="fileinput"
onChange={({ target }) => {
if (target.files?.[0]) {
sendFileHC(target.files?.[0]);
}
}}
style={{ display: "none" }}
type="file"
/>
<IconButton <IconButton
disabled={isMessageSending} disabled={isMessageSending}
onClick={handleSendMessage} onClick={handleSendMessage}

@ -0,0 +1,113 @@
import { Box, Link, Typography, useMediaQuery, useTheme } from "@mui/material";
import { isDateToday } from "../../utils/date";
import Download from "@root/assets/Icons/download";
interface Props {
unAuthenticated?: boolean;
isSelf: boolean;
file: string;
createdAt: string;
}
export default function ChatDocument({
unAuthenticated = false,
isSelf,
file,
createdAt,
}: Props) {
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const messageBackgroundColor = isSelf
? "white"
: unAuthenticated
? "#EFF0F5"
: "#434657";
const date = new Date(createdAt);
const today = isDateToday(date);
const time = date.toLocaleString([], {
hour: "2-digit",
minute: "2-digit",
...(!today && {
year: "2-digit",
month: "2-digit",
day: "2-digit",
}),
});
return (
<Box
sx={{
display: "flex",
gap: "9px",
padding: isSelf ? "0 8px 0 0" : "0 0 0 8px",
}}
>
<Typography
sx={{
alignSelf: "end",
fontWeight: 400,
fontSize: "14px",
lineHeight: "17px",
order: isSelf ? 1 : 2,
margin: isSelf ? "0 0 0 auto" : "0 auto 0 0",
color: "#434657",
mb: "-4px",
whiteSpace: "nowrap",
}}
>
{time}
</Typography>
<Box
sx={{
backgroundColor: messageBackgroundColor,
border: unAuthenticated
? "1px solid #E3E3E3"
: `1px solid ${"#434657"}`,
order: isSelf ? 2 : 1,
p: upMd ? "18px" : "12px",
borderRadius: "8px",
color: isSelf || unAuthenticated ? "#434657" : "white",
position: "relative",
maxWidth: `calc(100% - ${today ? 45 : 110}px)`,
overflowWrap: "break-word",
}}
>
<svg
style={{
position: "absolute",
top: "-1px",
right: isSelf ? "-8px" : undefined,
left: isSelf ? undefined : "-8px",
transform: isSelf ? undefined : "scale(-1, 1)",
}}
xmlns="http://www.w3.org/2000/svg"
width="16"
height="8"
viewBox="0 0 16 8"
fill="none"
>
<path
d="M0.5 0.5L15.5 0.500007
C10 0.500006 7.5 8 7.5 7.5H7.5H0.5V0.5Z"
fill={messageBackgroundColor}
stroke={unAuthenticated ? "#E3E3E3" : theme.palette.gray.main}
/>
<rect y="1" width="8" height="8" fill={messageBackgroundColor} />
</svg>
<Link
download
href={`https://storage.yandexcloud.net/pair/${file}`}
style={{
color: "#7E2AEA",
display: "flex",
gap: "10px",
}}
>
<Download color={theme.palette.purple.main} />
</Link>
</Box>
</Box>
);
}

@ -0,0 +1,119 @@
import {
Box,
ButtonBase,
Link,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import { isDateToday } from "../../utils/date";
import { useNavigate } from "react-router-dom";
interface Props {
unAuthenticated?: boolean;
isSelf: boolean;
file: string;
createdAt: string;
}
export default function ChatImage({
unAuthenticated = false,
isSelf,
file,
createdAt,
}: Props) {
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const navigate = useNavigate();
const messageBackgroundColor = isSelf
? "white"
: unAuthenticated
? "#EFF0F5"
: "#434657";
const date = new Date(createdAt);
const today = isDateToday(date);
const time = date.toLocaleString([], {
hour: "2-digit",
minute: "2-digit",
...(!today && {
year: "2-digit",
month: "2-digit",
day: "2-digit",
}),
});
return (
<Box
sx={{
display: "flex",
gap: "9px",
padding: isSelf ? "0 8px 0 0" : "0 0 0 8px",
}}
>
<Typography
sx={{
alignSelf: "end",
fontWeight: 400,
fontSize: "14px",
lineHeight: "17px",
order: isSelf ? 1 : 2,
margin: isSelf ? "0 0 0 auto" : "0 auto 0 0",
color: "#434657",
mb: "-4px",
whiteSpace: "nowrap",
}}
>
{time}
</Typography>
<Box
sx={{
backgroundColor: messageBackgroundColor,
border: unAuthenticated
? "1px solid #E3E3E3"
: `1px solid ${"#434657"}`,
order: isSelf ? 2 : 1,
p: upMd ? "18px" : "12px",
borderRadius: "8px",
color: isSelf || unAuthenticated ? "#434657" : "white",
position: "relative",
maxWidth: `calc(100% - ${today ? 45 : 110}px)`,
overflowWrap: "break-word",
}}
>
<svg
style={{
position: "absolute",
top: "-1px",
right: isSelf ? "-8px" : undefined,
left: isSelf ? undefined : "-8px",
transform: isSelf ? undefined : "scale(-1, 1)",
}}
xmlns="http://www.w3.org/2000/svg"
width="16"
height="8"
viewBox="0 0 16 8"
fill="none"
>
<path
d="M0.5 0.5L15.5 0.500007
C10 0.500006 7.5 8 7.5 7.5H7.5H0.5V0.5Z"
fill={messageBackgroundColor}
stroke={unAuthenticated ? "#E3E3E3" : "#434657"}
/>
<rect y="1" width="8" height="8" fill={messageBackgroundColor} />
</svg>
<ButtonBase target="_blank" href={`/image/${file}`}>
<Box
component="img"
sx={{
height: "217px",
width: "217px",
}}
src={`https://storage.yandexcloud.net/pair/${file}`}
/>
</ButtonBase>
</Box>
</Box>
);
}

@ -0,0 +1,123 @@
import {
Box,
ButtonBase,
Link,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import { isDateToday } from "../../utils/date";
import { useNavigate } from "react-router-dom";
import { useEffect } from "react";
interface Props {
unAuthenticated?: boolean;
isSelf: boolean;
file: string;
createdAt: string;
}
export default function ChatImage({
unAuthenticated = false,
isSelf,
file,
createdAt,
}: Props) {
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const navigate = useNavigate();
useEffect(() => {
() => console.log("delete");
});
const messageBackgroundColor = isSelf
? "white"
: unAuthenticated
? "#EFF0F5"
: "#434657";
const date = new Date(createdAt);
const today = isDateToday(date);
const time = date.toLocaleString([], {
hour: "2-digit",
minute: "2-digit",
...(!today && {
year: "2-digit",
month: "2-digit",
day: "2-digit",
}),
});
return (
<Box
sx={{
display: "flex",
gap: "9px",
padding: isSelf ? "0 8px 0 0" : "0 0 0 8px",
}}
>
<Typography
sx={{
alignSelf: "end",
fontWeight: 400,
fontSize: "14px",
lineHeight: "17px",
order: isSelf ? 1 : 2,
margin: isSelf ? "0 0 0 auto" : "0 auto 0 0",
color: "#434657",
mb: "-4px",
whiteSpace: "nowrap",
}}
>
{time}
</Typography>
<Box
sx={{
backgroundColor: messageBackgroundColor,
border: unAuthenticated
? "1px solid #E3E3E3"
: `1px solid ${"#434657"}`,
order: isSelf ? 2 : 1,
p: upMd ? "18px" : "12px",
borderRadius: "8px",
color: isSelf || unAuthenticated ? "#434657" : "white",
position: "relative",
maxWidth: `calc(100% - ${today ? 45 : 110}px)`,
overflowWrap: "break-word",
}}
>
<svg
style={{
position: "absolute",
top: "-1px",
right: isSelf ? "-8px" : undefined,
left: isSelf ? undefined : "-8px",
transform: isSelf ? undefined : "scale(-1, 1)",
}}
xmlns="http://www.w3.org/2000/svg"
width="16"
height="8"
viewBox="0 0 16 8"
fill="none"
>
<path
d="M0.5 0.5L15.5 0.500007
C10 0.500006 7.5 8 7.5 7.5H7.5H0.5V0.5Z"
fill={messageBackgroundColor}
stroke={unAuthenticated ? "#E3E3E3" : "#434657"}
/>
<rect y="1" width="8" height="8" fill={messageBackgroundColor} />
</svg>
<Box
component="video"
sx={{
height: "217px",
width: "217px",
}}
controls
>
<source src={`https://storage.yandexcloud.net/pair/${file}`} />
</Box>
</Box>
</Box>
);
}

@ -4,12 +4,16 @@ import { Box, Fab, Typography, Badge, useTheme } from "@mui/material";
import CircleDoubleDown from "./CircleDoubleDownIcon"; import CircleDoubleDown from "./CircleDoubleDownIcon";
import Chat from "./Chat"; import Chat from "./Chat";
import { useUnauthTicketStore } from "@root/stores/unauthTicket"; import { useUserStore } from "@root/stores/user";
import { useTicketStore } from "@root/stores/tickets";
export default function FloatingSupportChat() { export default function FloatingSupportChat() {
const [isChatOpened, setIsChatOpened] = useState<boolean>(false); const [isChatOpened, setIsChatOpened] = useState<boolean>(false);
const theme = useTheme(); const theme = useTheme();
const { messages } = useUnauthTicketStore((state) => state); const user = useUserStore((state) => state.user?._id);
const { messages } = useTicketStore(
(state) => state[user ? "authData" : "unauthData"]
);
const animation = { const animation = {
"@keyframes runningStripe": { "@keyframes runningStripe": {
@ -35,6 +39,7 @@ export default function FloatingSupportChat() {
}, },
}, },
}; };
return ( return (
<Box <Box
sx={{ sx={{

@ -1,7 +1,23 @@
import { FetchState, Ticket } from "@frontend/kitui" import { FetchState, Ticket, TicketMessage } from "@frontend/kitui";
import { create } from "zustand" import { create } from "zustand";
import { devtools } from "zustand/middleware" import { devtools, persist, createJSONStorage } from "zustand/middleware";
import { useUserStore } from "./user";
import { produce } from "immer";
type SessionData = {
ticketId: string;
sessionId: string;
};
interface AuthData {
sessionData: SessionData | null;
isMessageSending: boolean;
messages: TicketMessage[];
apiPage: number;
messagesPerPage: number;
lastMessageId: string | undefined;
isPreventAutoscroll: boolean;
unauthTicketMessageFetchState: FetchState;
}
interface TicketStore { interface TicketStore {
ticketCount: number; ticketCount: number;
@ -9,38 +25,160 @@ interface TicketStore {
apiPage: number; apiPage: number;
ticketsPerPage: number; ticketsPerPage: number;
ticketsFetchState: FetchState; ticketsFetchState: FetchState;
authData: AuthData;
unauthData: AuthData;
} }
const initAuthData = {
sessionData: null,
isMessageSending: false,
messages: [],
apiPage: 0,
messagesPerPage: 10,
lastMessageId: undefined,
isPreventAutoscroll: false,
unauthTicketMessageFetchState: "idle" as FetchState,
};
const initialState: TicketStore = { const initialState: TicketStore = {
ticketCount: 0, ticketCount: 0,
tickets: [], tickets: [],
apiPage: 0, apiPage: 0,
ticketsPerPage: 10, ticketsPerPage: 10,
ticketsFetchState: "idle", ticketsFetchState: "idle",
} authData: initAuthData,
unauthData: initAuthData,
};
export const useTicketStore = create<TicketStore>()( export const useTicketStore = create<TicketStore>()(
devtools( persist(
(set, get) => initialState, devtools((set, get) => initialState, {
name: "Unauth tickets",
}),
{ {
name: "Tickets" version: 0,
name: "unauth-ticket",
storage: createJSONStorage(() => localStorage),
} }
) )
) );
export const setTicketCount = (ticketCount: number) => useTicketStore.setState({ ticketCount }) export const setTicketCount = (ticketCount: number) =>
useTicketStore.setState({ ticketCount });
export const setTicketApiPage = (apiPage: number) => useTicketStore.setState({ apiPage: apiPage }) export const setTicketApiPage = (apiPage: number) =>
useTicketStore.setState({ apiPage: apiPage });
export const updateTickets = (receivedTickets: Ticket[]) => { export const updateTickets = (receivedTickets: Ticket[]) => {
const state = useTicketStore.getState() const state = useTicketStore.getState();
const ticketIdToTicketMap: { [ticketId: string]: Ticket; } = {}; const ticketIdToTicketMap: { [ticketId: string]: Ticket } = {};
[...state.tickets, ...receivedTickets].forEach(ticket => ticketIdToTicketMap[ticket.id] = ticket) [...state.tickets, ...receivedTickets].forEach(
(ticket) => (ticketIdToTicketMap[ticket.id] = ticket)
);
useTicketStore.setState({ tickets: Object.values(ticketIdToTicketMap) }) useTicketStore.setState({ tickets: Object.values(ticketIdToTicketMap) });
};
export const clearTickets = () => useTicketStore.setState({ ...initialState });
export const setTicketsFetchState = (ticketsFetchState: FetchState) =>
useTicketStore.setState({ ticketsFetchState });
export const setTicketData = (sessionData: SessionData) =>
updateTicket((ticket) => {
ticket.sessionData = sessionData;
});
export const updateTicket = <T extends AuthData>(
recipe: (ticket: AuthData) => void
) =>
setProducedState(
(state) => {
//В зависимости от авторизованности вызывается изменение разных объектов
if (Boolean(useUserStore.getState().userId)) {
recipe(state.authData);
} else {
recipe(state.unauthData);
}
},
{
type: "updateTicket",
recipe,
}
);
function setProducedState<A extends string | { type: unknown }>(
recipe: (state: TicketStore) => void,
action?: A
) {
useTicketStore.setState((state) => produce(state, recipe), false, action);
} }
export const clearTickets = () => useTicketStore.setState({ ...initialState }) function filterMessageUncompleteness(messages: TicketMessage[]) {
return messages.filter(
(message) =>
"id" in message &&
"ticket_id" in message &&
"user_id" in message &&
"session_id" in message &&
"message" in message &&
"files" in message &&
"shown" in message &&
"request_screenshot" in message &&
"created_at" in message &&
((message.files !== null && message.files.length > 0) ||
message.message.length > 0)
);
}
export const setTicketsFetchState = (ticketsFetchState: FetchState) => useTicketStore.setState({ ticketsFetchState }) function sortMessagesByTime(ticket1: TicketMessage, ticket2: TicketMessage) {
const date1 = new Date(ticket1.created_at).getTime();
const date2 = new Date(ticket2.created_at).getTime();
return date1 - date2;
}
export const addOrUpdateUnauthMessages = (receivedMessages: TicketMessage[]) =>
updateTicket((ticket) => {
const filtered = filterMessageUncompleteness(receivedMessages);
if (filtered.length === 0) return;
const messageIdToMessageMap: { [messageId: string]: TicketMessage } = {};
[...ticket.messages, ...filtered].forEach(
(message) => (messageIdToMessageMap[message.id] = message)
);
const sortedMessages = Object.values(messageIdToMessageMap).sort(
sortMessagesByTime
);
ticket.messages = sortedMessages;
ticket.lastMessageId = sortedMessages.at(-1)?.id;
});
export const setUnauthTicketMessageFetchState = (
unauthTicketMessageFetchState: FetchState
) =>
updateTicket((ticket) => {
ticket.unauthTicketMessageFetchState = unauthTicketMessageFetchState;
});
export const setUnauthIsPreventAutoscroll = (isPreventAutoscroll: boolean) =>
updateTicket((ticket) => {
ticket.isPreventAutoscroll = isPreventAutoscroll;
});
export const incrementUnauthMessageApiPage = () => {
const state = useTicketStore.getState();
useTicketStore.setState({ apiPage: state.apiPage + 1 });
};
export const setIsMessageSending = (
isMessageSending: AuthData["isMessageSending"]
) => {
updateTicket((ticket) => {
ticket.isMessageSending = isMessageSending;
});
};

@ -1,79 +0,0 @@
import { FetchState, TicketMessage } from "@frontend/kitui"
import { create } from "zustand"
import { createJSONStorage, devtools, persist } from "zustand/middleware"
interface UnauthTicketStore {
sessionData: {
ticketId: string;
sessionId: string;
} | null;
isMessageSending: boolean;
messages: TicketMessage[];
apiPage: number;
messagesPerPage: number;
lastMessageId: string | undefined;
isPreventAutoscroll: boolean;
unauthTicketMessageFetchState: FetchState;
}
export const useUnauthTicketStore = create<UnauthTicketStore>()(
persist(
devtools(
(set, get) => ({
sessionData: null,
isMessageSending: false,
messages: [],
apiPage: 0,
messagesPerPage: 10,
lastMessageId: undefined,
isPreventAutoscroll: false,
unauthTicketMessageFetchState: "idle",
}),
{
name: "Unauth tickets"
}
),
{
version: 0,
name: "unauth-ticket",
storage: createJSONStorage(() => localStorage),
partialize: state => ({
sessionData: state.sessionData,
})
}
)
)
export const setUnauthSessionData = (sessionData: UnauthTicketStore["sessionData"]) => useUnauthTicketStore.setState({ sessionData })
export const setIsMessageSending = (isMessageSending: UnauthTicketStore["isMessageSending"]) => useUnauthTicketStore.setState({ isMessageSending })
export const addOrUpdateUnauthMessages = (receivedMessages: TicketMessage[]) => {
const state = useUnauthTicketStore.getState()
const messageIdToMessageMap: { [messageId: string]: TicketMessage; } = {};
[...state.messages, ...receivedMessages].forEach(message => messageIdToMessageMap[message.id] = message)
const sortedMessages = Object.values(messageIdToMessageMap).sort(sortMessagesByTime)
useUnauthTicketStore.setState({
messages: sortedMessages,
lastMessageId: sortedMessages.at(-1)?.id,
})
}
export const incrementUnauthMessageApiPage = () => {
const state = useUnauthTicketStore.getState()
useUnauthTicketStore.setState({ apiPage: state.apiPage + 1 })
}
export const setUnauthIsPreventAutoscroll = (isPreventAutoscroll: boolean) => useUnauthTicketStore.setState({ isPreventAutoscroll })
export const setUnauthTicketMessageFetchState = (unauthTicketMessageFetchState: FetchState) => useUnauthTicketStore.setState({ unauthTicketMessageFetchState })
function sortMessagesByTime(ticket1: TicketMessage, ticket2: TicketMessage) {
const date1 = new Date(ticket1.created_at).getTime()
const date2 = new Date(ticket2.created_at).getTime()
return date1 - date2
}

@ -0,0 +1,37 @@
export const MAX_FILE_SIZE = 10485760;
const MAX_PHOTO_SIZE = 5242880;
const MAX_VIDEO_SIZE = 52428800;
export const ACCEPT_SEND_MEDIA_TYPES_MAP = {
picture: ["jpg", "png"],
video: ["mp4"],
document: ["doc", "docx", "pdf", "txt", "xlsx", "csv"],
} as const;
const TOO_LARGE_TEXT = "Файл слишком большой";
export const checkAcceptableMediaType = (file: File) => {
if (file === null) return "";
const segments = file?.name.split(".");
const extension = segments[segments.length - 1];
const type = extension.toLowerCase();
console.log(type);
switch (type) {
case ACCEPT_SEND_MEDIA_TYPES_MAP.document.find((name) => name === type):
if (file.size > MAX_FILE_SIZE) return TOO_LARGE_TEXT;
return "";
case ACCEPT_SEND_MEDIA_TYPES_MAP.picture.find((name) => name === type):
if (file.size > MAX_PHOTO_SIZE) return TOO_LARGE_TEXT;
return "";
case ACCEPT_SEND_MEDIA_TYPES_MAP.video.find((name) => name === type):
if (file.size > MAX_VIDEO_SIZE) return TOO_LARGE_TEXT;
return "";
default:
return "Не удалось отправить файл. Недопустимый тип";
}
};