diff --git a/src/components/FloatingSupportChat/Chat.tsx b/src/components/FloatingSupportChat/Chat.tsx index 091e467..6b5c6e9 100644 --- a/src/components/FloatingSupportChat/Chat.tsx +++ b/src/components/FloatingSupportChat/Chat.tsx @@ -1,13 +1,13 @@ import { Box, FormControl, IconButton, InputAdornment, InputBase, SxProps, Theme, Typography, useMediaQuery, useTheme } from "@mui/material"; import { createTicket, getUnauthTicketMessages, sendTicketMessage, subscribeToUnauthTicketMessages } from "@root/api/tickets"; import { GetMessagesRequest, TicketMessage } from "@root/model/ticket"; -import { useMessageStore } from "@root/stores/messages"; -import { addOrUpdateUnauthMessages, setUnauthTicketFetchState, setUnauthTicketSessionId, useUnauthTicketStore } from "@root/stores/unauthTicket"; +import { addOrUpdateUnauthMessages, setUnauthTicketFetchState, useUnauthTicketStore, incrementUnauthMessageApiPage, setUnauthIsPreventAutoscroll, setUnauthSessionData } from "@root/stores/unauthTicket"; import { enqueueSnackbar } from "notistack"; import { useEffect, useRef, useState } from "react"; import ChatMessage from "../ChatMessage"; import SendIcon from "../icons/SendIcon"; import UserCircleIcon from "./UserCircleIcon"; +import { throttle } from "@root/utils/decorators"; interface Props { @@ -18,20 +18,22 @@ export default function Chat({ sx }: Props) { const theme = useTheme(); const upMd = useMediaQuery(theme.breakpoints.up("md")); const [messageField, setMessageField] = useState(""); - const sessionId = useUnauthTicketStore(state => state.sessionId); + const sessionData = useUnauthTicketStore(state => state.sessionData); const messages = useUnauthTicketStore(state => state.messages); const messageApiPage = useUnauthTicketStore(state => state.apiPage); const messagesPerPage = useUnauthTicketStore(state => state.messagesPerPage); - const messagesFetchStateRef = useRef(useMessageStore.getState().fetchState); + const messagesFetchStateRef = useRef(useUnauthTicketStore.getState().fetchState); + const isPreventAutoscroll = useUnauthTicketStore(state => state.isPreventAutoscroll); + const lastMessageId = useUnauthTicketStore(state => state.lastMessageId); const chatBoxRef = useRef(); useEffect(function fetchTicketMessages() { - if (!sessionId) return; + if (!sessionData) return; const getTicketsBody: GetMessagesRequest = { amt: messagesPerPage, page: messageApiPage, - ticket: sessionId, + ticket: sessionData.ticketId, }; const controller = new AbortController(); @@ -47,19 +49,19 @@ export default function Chat({ sx }: Props) { } else setUnauthTicketFetchState("all fetched"); }).catch(error => { console.log("Error fetching messages", error); - enqueueSnackbar(error.message); + if (error.code !== "ERR_CANCELED") enqueueSnackbar(error.message); }); return () => { controller.abort(); }; - }, [messageApiPage, messagesPerPage, sessionId]); + }, [messageApiPage, messagesPerPage, sessionData]); useEffect(function subscribeToMessages() { - if (!sessionId) return; + if (!sessionData) return; const unsubscribe = subscribeToUnauthTicketMessages({ - sessionId, + sessionId: sessionData.ticketId, onMessage(event) { try { const newMessage = JSON.parse(event.data) as TicketMessage; @@ -79,33 +81,87 @@ export default function Chat({ sx }: Props) { return () => { unsubscribe(); // clearUnauthTicketState(); + setUnauthIsPreventAutoscroll(false); }; - }, [sessionId]); + }, [sessionData]); - useEffect(() => useMessageStore.subscribe(state => (messagesFetchStateRef.current = state.fetchState)), []); + useEffect(function attachScrollHandler() { + if (!chatBoxRef.current) return; + + const chatBox = chatBoxRef.current; + const scrollHandler = () => { + const scrollBottom = chatBox.scrollHeight - chatBox.scrollTop - chatBox.clientHeight; + const isPreventAutoscroll = scrollBottom > chatBox.clientHeight; + setUnauthIsPreventAutoscroll(isPreventAutoscroll); + + if (messagesFetchStateRef.current !== "idle") return; + + if (chatBox.scrollTop < chatBox.clientHeight) { + if (chatBox.scrollTop < 1) chatBox.scrollTop = 1; + incrementUnauthMessageApiPage(); + } + }; + + const throttledScrollHandler = throttle(scrollHandler, 200); + chatBox.addEventListener("scroll", throttledScrollHandler); + + return () => { + chatBox.removeEventListener("scroll", throttledScrollHandler); + }; + }, []); + + useEffect(function scrollOnNewMessage() { + if (!chatBoxRef.current) return; + + if (!isPreventAutoscroll) { + setTimeout(() => { + scrollToBottom(); + }, 50); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lastMessageId]); + + useEffect(() => useUnauthTicketStore.subscribe(state => (messagesFetchStateRef.current = state.fetchState)), []); async function handleSendMessage() { if (!messageField) return; - if (!sessionId) { + if (!sessionData) { const response = await createTicket({ Title: "Unauth title", Message: messageField, }, false); - setUnauthTicketSessionId(response.sess); + setUnauthSessionData({ + ticketId: response.Ticket, + sessionId: response.sess, + }); } else { sendTicketMessage({ - ticket: sessionId, + ticket: sessionData.ticketId, message: messageField, lang: "ru", files: [], - }, true); + }, true).catch(error => { + console.log("Coudn't send message", error); + enqueueSnackbar(error.message); + }); } setMessageField(""); } + function scrollToBottom(behavior?: ScrollBehavior) { + if (!chatBoxRef.current) return; + + const chatBox = chatBoxRef.current; + chatBox.scroll({ + left: 0, + top: chatBox.scrollHeight, + behavior, + }); + } + const handleTextfieldKeyPress: React.KeyboardEventHandler = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); @@ -157,6 +213,7 @@ export default function Chat({ sx }: Props) { sx={{ display: "flex", width: "100%", + flexBasis: 0, flexDirection: "column", gap: upMd ? "20px" : "16px", px: upMd ? "20px" : "5px", @@ -165,13 +222,13 @@ export default function Chat({ sx }: Props) { flexGrow: 1, }} > - {messages.map((message) => ( + {sessionData && messages.map((message) => ( ))} diff --git a/src/stores/unauthTicket.ts b/src/stores/unauthTicket.ts index 4a86eed..957ecf1 100644 --- a/src/stores/unauthTicket.ts +++ b/src/stores/unauthTicket.ts @@ -1,27 +1,32 @@ -import { Ticket, TicketMessage } from "@root/model/ticket"; +import { TicketMessage } from "@root/model/ticket"; import { create } from "zustand"; import { createJSONStorage, devtools, persist } from "zustand/middleware"; interface UnauthTicketStore { - ticket: Ticket | null; // TODO delete if unused - sessionId: string | null; + sessionData: { + ticketId: string; + sessionId: string; + } | null; messages: TicketMessage[]; fetchState: "idle" | "fetching" | "all fetched"; apiPage: number; messagesPerPage: number; + lastMessageId: string | undefined; + isPreventAutoscroll: boolean; } export const useUnauthTicketStore = create()( persist( devtools( (set, get) => ({ - ticket: null, - sessionId: null, + sessionData: null, messages: [], fetchState: "idle", apiPage: 0, messagesPerPage: 10, + lastMessageId: undefined, + isPreventAutoscroll: false, }), { name: "Unauth ticket store" @@ -29,16 +34,16 @@ export const useUnauthTicketStore = create()( ), { version: 0, - name: "session", + name: "unauth-ticket", storage: createJSONStorage(() => localStorage), partialize: state => ({ - sessionId: state.sessionId, + sessionData: state.sessionData, }) } ) ); -export const setUnauthTicket = (ticket: Ticket) => useUnauthTicketStore.setState({ ticket }); +export const setUnauthSessionData = (sessionData: UnauthTicketStore["sessionData"]) => useUnauthTicketStore.setState({ sessionData }); export const addOrUpdateUnauthMessages = (receivedMessages: TicketMessage[]) => { const state = useUnauthTicketStore.getState(); @@ -48,16 +53,12 @@ export const addOrUpdateUnauthMessages = (receivedMessages: TicketMessage[]) => const sortedMessages = Object.values(messageIdToMessageMap).sort(sortMessagesByTime); - useUnauthTicketStore.setState({ messages: sortedMessages }); + useUnauthTicketStore.setState({ + messages: sortedMessages, + lastMessageId: sortedMessages.at(-1)?.id, + }); }; -export const clearUnauthTicketState = () => useUnauthTicketStore.setState({ - ticket: null, - messages: [], - apiPage: 0, - fetchState: "idle", -}); - export const setUnauthTicketFetchState = (fetchState: UnauthTicketStore["fetchState"]) => useUnauthTicketStore.setState({ fetchState }); export const incrementUnauthMessageApiPage = () => { @@ -66,7 +67,7 @@ export const incrementUnauthMessageApiPage = () => { useUnauthTicketStore.setState({ apiPage: state.apiPage + 1 }); }; -export const setUnauthTicketSessionId = (sessionId: string | null) => useUnauthTicketStore.setState({ sessionId }); +export const setUnauthIsPreventAutoscroll = (isPreventAutoscroll: boolean) => useUnauthTicketStore.setState({ isPreventAutoscroll }); function sortMessagesByTime(ticket1: TicketMessage, ticket2: TicketMessage) { const date1 = new Date(ticket1.created_at).getTime();