front-hub/src/components/FloatingSupportChat/Chat.tsx
Nastya cd18cacb7a
All checks were successful
Deploy / CreateImage (push) Successful in 4m3s
Deploy / DeployService (push) Successful in 22s
fix chat
2025-05-18 13:59:01 +03:00

667 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
Box,
FormControl,
IconButton,
InputAdornment,
InputBase,
SxProps,
Theme,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import {
getAuthToken,
getMessageFromFetchError,
throttle,
TicketMessage,
useSSESubscription,
useTicketMessages,
useTicketsFetcher,
} from "@frontend/kitui";
import { enqueueSnackbar } from "notistack";
import {
TouchEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
WheelEvent,
} from "react";
import ChatMessage from "../ChatMessage";
import SendIcon from "../icons/SendIcon";
import ArrowLeft from "@root/assets/Icons/arrowLeft";
import UserCircleIcon from "./UserCircleIcon";
import {
sendTicketMessage,
shownMessage,
sendFile as sendFileRequest,
} from "@root/api/ticket";
import { useSSETab } from "@root/utils/hooks/useSSETab";
import {
ACCEPT_SEND_MEDIA_TYPES_MAP,
checkAcceptableMediaType,
MAX_FILE_SIZE,
} from "@utils/checkAcceptableMediaType";
import {
addOrUpdateUnauthMessages,
clearTickets,
incrementUnauthMessage,
setIsMessageSending,
setTicketData,
setUnauthIsPreventAutoscroll,
setUnauthTicketMessageFetchState,
useTicketStore,
} 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";
import { createTicket } from "@api/ticket";
type ModalWarningType =
| "errorType"
| "errorSize"
| "picture"
| "video"
| "audio"
| "document"
| null;
interface Props {
open: boolean;
sx?: SxProps<Theme>;
onclickArrow?: () => void;
}
export default function Chat({ open = false, onclickArrow, sx }: Props) {
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const isMobile = useMediaQuery(theme.breakpoints.down(800));
const [messageField, setMessageField] = useState<string>("");
const [disableFileButton, setDisableFileButton] = useState<boolean>(false);
const [sseEnabled, setSseEnabled] = useState(true);
const [modalWarningType, setModalWarningType] =
useState<ModalWarningType>(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[]>(
"ticket",
addOrUpdateUnauthMessages
);
const getGreetingMessage: TicketMessage = useMemo(() => {
const workingHoursMessage =
"Здравствуйте, задайте ваш вопрос и наш оператор вам ответит в течение 10 минут";
const offHoursMessage =
"Здравствуйте, к сожалению, сейчас операторы не работают. Задайте ваш вопрос, и вам ответят в 10:00 по московскому времени";
const date = new Date();
const currentHourUTC = date.getUTCHours();
const MscTime = 3; // Москва UTC+3;
const moscowHour = (currentHourUTC + MscTime) % 24;
const greetingMessage =
moscowHour >= 3 && moscowHour < 10
? offHoursMessage
: workingHoursMessage;
return {
created_at: new Date().toISOString(),
files: [],
id: "111",
message: greetingMessage,
request_screenshot: "",
session_id: "greetingMessage",
shown: { me: 1 },
ticket_id: "111",
user_id: "greetingMessage",
};
}, [open]);
useTicketMessages({
url: process.env.REACT_APP_DOMAIN + "/heruvym/v1.0.0/getMessages",
isUnauth: true,
ticketId: sessionData?.ticketId,
messagesPerPage,
messageApiPage,
onSuccess: useCallback((messages) => {
if (chatBoxRef.current && chatBoxRef.current.scrollTop < 1) {
chatBoxRef.current.scrollTop = 1;
}
addOrUpdateUnauthMessages(messages);
}, []),
onError: useCallback((error: Error) => {
const message = getMessageFromFetchError(error);
if (message) enqueueSnackbar(message);
}, []),
onFetchStateChange: setUnauthTicketMessageFetchState,
});
console.log("sessionData")
console.log(sessionData)
useSSESubscription<TicketMessage>({
enabled: sseEnabled && isActiveSSETab && Boolean(sessionData),
url:
process.env.REACT_APP_DOMAIN +
`/heruvym/v1.0.0/ticket?ticket=${sessionData?.ticketId}&s=${sessionData?.sessionId}`,
onNewData: (ticketMessages) => {
console.log("Chat")
console.log("ticketMessages useSSESubscription ")
console.log(ticketMessages)
const isTicketClosed = ticketMessages.some(
(message) => message.session_id === "close"
);
if (isTicketClosed) {
clearTickets();
addOrUpdateUnauthMessages([getGreetingMessage]);
if (!user) {
localStorage.removeItem("unauth-ticket");
}
return;
}
console.log("under checking some close message -----------------------------------------")
updateSSEValue(ticketMessages);
addOrUpdateUnauthMessages(ticketMessages);
},
onDisconnect: useCallback(() => {
console.log("DISCONNECT")
setUnauthIsPreventAutoscroll(false);
setSseEnabled(false);
}, []),
marker: "ticket",
});
useTicketsFetcher({
url: process.env.REACT_APP_DOMAIN + "/heruvym/v1.0.0/getTickets",
ticketsPerPage: 10,
ticketApiPage: 0,
onSuccess: (result) => {
if (result.data?.length) {
const currentTicket = result.data.find(
({ origin, state }) =>
!origin.includes("/support") && state !== "close"
);
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(
() =>
throttle(() => {
const chatBox = chatBoxRef.current;
if (!chatBox) return;
const scrollBottom =
chatBox.scrollHeight - chatBox.scrollTop - chatBox.clientHeight;
const isPreventAutoscroll = scrollBottom > chatBox.clientHeight;
setUnauthIsPreventAutoscroll(isPreventAutoscroll);
if (fetchState !== "idle") return;
if (chatBox.scrollTop < chatBox.clientHeight) {
incrementUnauthMessage();
}
}, 200),
[fetchState]
);
useEffect(() => {
addOrUpdateUnauthMessages([getGreetingMessage]);
scrollToBottom();
}, [open]);
useEffect(
function scrollOnNewMessage() {
if (!chatBoxRef.current) return;
if (!isPreventAutoscroll) {
setTimeout(() => {
scrollToBottom();
}, 50);
}
},
[lastMessageId]
);
useEffect(() => {
if (open) {
console.log("SHOWN")
console.log("messages")
console.log(messages)
console.log("open")
console.log(open)
if (messages.length > 1) {
let last_message
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].ticket_id !== "111") {
last_message = messages[i];
break;
}
}
console.log("last_message")
console.log(last_message)
if (last_message) {
console.log("if")
console.log(((ticket.sessionData?.sessionId || user) !== last_message.user_id) && last_message.shown.me !== 1 && last_message.ticket_id !== "111")
console.log(((ticket.sessionData?.sessionId || user) !== last_message.user_id))
console.log(last_message.shown.me !== 1)
if (((ticket.sessionData?.sessionId || user) !== last_message.user_id) && last_message.shown.me !== 1 && last_message.ticket_id !== "111") {
shownMessage(last_message.id);
}
console.log("user")
console.log(user)
console.log(messages[messages.length - 1])
}
}
}
}, [open, messages]);
const loadNewMessages = (
event: WheelEvent<HTMLDivElement> | TouchEvent<HTMLDivElement>
) => {
event.stopPropagation();
throttledScrollHandler();
};
async function handleSendMessage() {
if (!messageField || isMessageSending) return;
if (!sessionData?.ticketId) {
setIsMessageSending(true);
const [createTicketresult, createTicketerror] = await createTicket(
"Unauth title",
messageField,
//При создании тикета, если нет у клиента токена, то хедер authorization вовсе не нужно слать
Boolean(getAuthToken())
);
if (createTicketerror) {
enqueueSnackbar(createTicketerror);
} else if (createTicketresult) {
setTicketData({
ticketId: createTicketresult.Ticket,
sessionId: createTicketresult.sess,
});
setSseEnabled(true);
}
setMessageField("");
setIsMessageSending(false);
} else {
setIsMessageSending(true);
const [_, sendTicketMessageError] = await sendTicketMessage(
sessionData.ticketId,
messageField
);
if (sendTicketMessageError) {
enqueueSnackbar(sendTicketMessageError);
}
setMessageField("");
setIsMessageSending(false);
}
}
function scrollToBottom(behavior?: ScrollBehavior) {
if (!chatBoxRef.current) return;
const chatBox = chatBoxRef.current;
chatBox.scroll({
left: 0,
top: chatBox.scrollHeight,
behavior,
});
}
const handleTextfieldKeyPress: React.KeyboardEventHandler<
HTMLInputElement | HTMLTextAreaElement
> = (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
};
const sendFile = async (file: File) => {
if (file === undefined) return true;
let data;
if (!ticket.sessionData?.ticketId) {
try {
const [createTicketresult] = await createTicket("Unauth title", "");
if (createTicketresult) {
data = createTicketresult;
}
if (data) {
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");
const [, sendFileError] = await sendFileRequest(ticketId, file);
if (sendFileError) {
enqueueSnackbar(sendFileError);
}
return true;
}
};
const sendFileHC = async (file: File) => {
const check = checkAcceptableMediaType(file);
if (check.length > 0) {
enqueueSnackbar(check);
return;
}
setDisableFileButton(true);
await sendFile(file);
setDisableFileButton(false);
};
return (
<>
{open && (
<Box
sx={{
display: "flex",
flexDirection: "column",
height: isMobile
? "100%"
: "clamp(250px, calc(100vh - 90px), 600px)",
backgroundColor: "#944FEE",
borderRadius: "8px",
...sx,
}}
>
<Box
sx={{
display: "flex",
gap: "9px",
pl: "22px",
pt: "12px",
pb: "20px",
filter: "drop-shadow(0px 3px 12px rgba(37, 39, 52, 0.3))",
}}
>
{isMobile && (
<IconButton onClick={onclickArrow}>
<ArrowLeft color="white" />
</IconButton>
)}
<UserCircleIcon />
<Box
sx={{
mt: "5px",
display: "flex",
flexDirection: "column",
gap: "3px",
}}
>
<Typography>Данила</Typography>
<Typography
sx={{
fontSize: "16px",
lineHeight: "19px",
}}
>
онлайн-консультант
</Typography>
<Typography
sx={{
fontSize: "16px",
lineHeight: "19px",
}}
>
время работы 10:00-3:00 по мск
</Typography>
</Box>
</Box>
<Box
sx={{
flexGrow: 1,
backgroundColor: "white",
borderRadius: "8px",
display: "flex",
flexDirection: "column",
}}
>
<Box
onWheel={loadNewMessages}
onTouchMove={loadNewMessages}
ref={chatBoxRef}
sx={{
display: "flex",
width: "100%",
flexBasis: 0,
flexDirection: "column",
gap: upMd ? "20px" : "16px",
px: upMd ? "20px" : "5px",
py: upMd ? "20px" : "13px",
overflowY: "auto",
flexGrow: 1,
}}
>
{ticket.sessionData?.ticketId &&
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
unAuthenticated
key={message.id}
text={message.message}
createdAt={message.created_at}
isSelf={
(ticket.sessionData?.sessionId || user) ===
message.user_id
}
/>
);
})}
{!ticket.sessionData?.ticketId && (
<ChatMessage
unAuthenticated
text={getGreetingMessage.message}
createdAt={getGreetingMessage.created_at}
isSelf={false}
/>
)}
</Box>
<FormControl fullWidth sx={{ borderTop: "1px solid black" }}>
<InputBase
value={messageField}
fullWidth
placeholder="Введите сообщение..."
id="message"
multiline
onKeyDown={handleTextfieldKeyPress}
sx={{
width: "100%",
p: 0,
}}
inputProps={{
sx: {
fontWeight: 400,
fontSize: "16px",
lineHeight: "19px",
pt: upMd ? "30px" : "28px",
pb: upMd ? "30px" : "24px",
px: "19px",
maxHeight: "calc(19px * 5)",
color: "black",
},
}}
onChange={(e) => setMessageField(e.target.value)}
endAdornment={
<InputAdornment position="end">
<IconButton
disabled={disableFileButton}
onClick={() => {
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
disabled={isMessageSending}
onClick={handleSendMessage}
sx={{
height: "53px",
width: "53px",
mr: "13px",
p: 0,
opacity: isMessageSending ? 0.3 : 1,
}}
>
<SendIcon
style={{
width: "100%",
height: "100%",
}}
/>
</IconButton>
</InputAdornment>
}
/>
</FormControl>
</Box>
</Box>
)}
</>
);
}