diff --git a/src/components/ChatBotBody/ChatBotBody.tsx b/src/components/ChatBotBody/ChatBotBody.tsx index 6241c863..2cc7370d 100644 --- a/src/components/ChatBotBody/ChatBotBody.tsx +++ b/src/components/ChatBotBody/ChatBotBody.tsx @@ -88,7 +88,6 @@ const ChatBotBody = ({ } if (botOptions.chatWindow?.autoJumpToBottom || !isScrolling) { - console.log(!isScrolling); chatBodyRef.current.scrollTop = chatBodyRef.current.scrollHeight; if (botOptions.isOpen) { setUnreadCount(0); @@ -111,7 +110,14 @@ const ChatBotBody = ({ setUnreadCount(0); } } - }, [chatBodyRef.current?.scrollHeight, isScrolling]); + }, [chatBodyRef.current?.scrollHeight]); + + // sets unread count to 0 if not scrolling + useEffect(() => { + if (!isScrolling) { + setUnreadCount(0); + } + }, [isScrolling]); /** * Checks and updates whether a user is scrolling in chat window. @@ -120,11 +126,16 @@ const ChatBotBody = ({ if (!chatBodyRef.current) { return; } - const { scrollTop, clientHeight, scrollHeight } = chatBodyRef.current; setIsScrolling( scrollTop + clientHeight < scrollHeight - (botOptions.chatWindow?.messagePromptOffset || 30) ); + + // workaround to ensure user never truly scrolls to bottom by introducing a 1 pixel offset + // this is necessary to prevent unexpected scroll behaviors of the chat window when user reaches the bottom + if (!isScrolling && scrollTop + clientHeight >= scrollHeight - 1) { + chatBodyRef.current.scrollTop = scrollHeight - clientHeight - 1; + } }; /** @@ -150,7 +161,7 @@ const ChatBotBody = ({ ); }; - + /** * Renders message from the bot. * diff --git a/src/components/ChatBotBody/ChatMessagePrompt/ChatMessagePrompt.tsx b/src/components/ChatBotBody/ChatMessagePrompt/ChatMessagePrompt.tsx index 1878f79a..30525f0f 100644 --- a/src/components/ChatBotBody/ChatMessagePrompt/ChatMessagePrompt.tsx +++ b/src/components/ChatBotBody/ChatMessagePrompt/ChatMessagePrompt.tsx @@ -52,15 +52,44 @@ const ChatMessagePrompt = ({ }; /** - * Handles scrolling to the bottom of the chat window. + * Handles scrolling to the bottom of the chat window with specified duration. */ - const scrollToBottom = () => { - if (chatBodyRef.current) { - chatBodyRef.current.scrollTo({ - top: chatBodyRef.current.scrollHeight, - behavior: "smooth" - }); + function scrollToBottom(duration: number) { + if (!chatBodyRef.current) { + return; } + + const start = chatBodyRef.current.scrollTop; + const end = chatBodyRef.current.scrollHeight - chatBodyRef.current.clientHeight; + const change = end - start; + const increment = 20; + let currentTime = 0; + + function animateScroll() { + if (!chatBodyRef.current) { + return; + } + currentTime += increment; + const val = easeInOutQuad(currentTime, start, change, duration); + chatBodyRef.current.scrollTop = val; + if (currentTime < duration) { + requestAnimationFrame(animateScroll); + } else { + setIsScrolling(false); + } + } + + animateScroll(); + } + + /** + * Helper function for custom scrolling. + */ + const easeInOutQuad = (t: number, b: number, c: number, d: number) => { + t /= d / 2; + if (t < 1) return c / 2 * t * t + b; + t--; + return -c / 2 * (t * (t - 2) - 1) + b; }; /** @@ -82,7 +111,7 @@ const ChatMessagePrompt = ({ style={isHovered ? chatMessagePromptHoveredStyle : botOptions.chatMessagePromptStyle} onMouseDown={(event: MouseEvent) => { event.preventDefault(); - scrollToBottom(); + scrollToBottom(600); }} className="rcb-message-prompt-text" > diff --git a/src/components/ChatBotContainer.tsx b/src/components/ChatBotContainer.tsx index 19045c4f..4412fff6 100644 --- a/src/components/ChatBotContainer.tsx +++ b/src/components/ChatBotContainer.tsx @@ -56,7 +56,9 @@ const ChatBotContainer = ({ flow }: { flow: Flow }) => { const keepVoiceOnRef = useRef(false); // audio to play for notifications - const notificationAudio = useRef(); + const audioContextRef = useRef(null); + const audioBufferRef = useRef(); + const gainNodeRef = useRef(null); // tracks if user has interacted with page const [hasInteracted, setHasInteracted] = useState(false); @@ -265,12 +267,16 @@ const ChatBotContainer = ({ flow }: { flow: Flow }) => { /** * Sets up the notifications feature (initial toggle status and sound). */ - const setUpNotifications = () => { + const setUpNotifications = async () => { setNotificationToggledOn(botOptions.notification?.defaultToggledOn as boolean); let notificationSound = botOptions.notification?.sound; + audioContextRef.current = new AudioContext(); + const gainNode = audioContextRef.current.createGain(); + gainNode.gain.value = botOptions.notification?.volume || 0.2; + gainNodeRef.current = gainNode; - // convert data uri to url if it is base64, true in production + let audioSource; if (notificationSound?.startsWith("data:audio")) { const binaryString = atob(notificationSound.split(",")[1]); const arrayBuffer = new ArrayBuffer(binaryString.length); @@ -278,12 +284,13 @@ const ChatBotContainer = ({ flow }: { flow: Flow }) => { for (let i = 0; i < binaryString.length; i++) { uint8Array[i] = binaryString.charCodeAt(i); } - const blob = new Blob([uint8Array], { type: "audio/wav" }); - notificationSound = URL.createObjectURL(blob); + audioSource = arrayBuffer; + } else { + const response = await fetch(notificationSound as string); + audioSource = await response.arrayBuffer(); } - notificationAudio.current = new Audio(notificationSound); - notificationAudio.current.volume = botOptions.notification?.volume as number; + audioBufferRef.current = await audioContextRef.current.decodeAudioData(audioSource); } /** @@ -292,9 +299,6 @@ const ChatBotContainer = ({ flow }: { flow: Flow }) => { const handleFirstInteraction = () => { setHasInteracted(true); - // load audio on first user interaction - notificationAudio.current?.load(); - // workaround for getting audio to play on mobile const utterance = new SpeechSynthesisUtterance(); utterance.text = ""; @@ -319,21 +323,46 @@ const ChatBotContainer = ({ flow }: { flow: Flow }) => { * Handles notification count update and notification sound. */ const handleNotifications = () => { - // if embedded, or no message found, no need for notifications - if (botOptions.theme?.embedded || messages.length == 0) { + // if no audio context or no messages, return + if (!audioContextRef.current || messages.length === 0) { return; } + const message = messages[messages.length - 1] - if (message != null && message?.sender !== "user" && !isBotTyping - && (!botOptions.isOpen || document.visibilityState !== "visible" - || (botOptions.isOpen && isScrolling))) { - setUnreadCount(prev => prev + 1); - if (!botOptions.notification?.disabled && notificationToggledOn && hasInteracted) { - notificationAudio.current?.play(); - } + // if message is null or sent by user or is bot typing, return + if (message == null || message.sender === "user" || isBotTyping) { + return; + } + + // if chat is open but user is not scrolling, return + if (botOptions.isOpen && !isScrolling) { + return; + } + + setUnreadCount(prev => prev + 1); + if (!botOptions.notification?.disabled && notificationToggledOn && hasInteracted && audioBufferRef.current) { + const source = audioContextRef.current.createBufferSource(); + source.buffer = audioBufferRef.current; + source.connect(gainNodeRef.current as AudioNode).connect(audioContextRef.current.destination); + source.start(); } } + /** + * Helps check if audio should be played. + */ + const shouldPlayAudio = () => { + if (!audioBufferRef.current || !audioContextRef.current) { + return false; + } + + const message = messages[messages.length - 1]; + const isUserMessage = message?.sender === "user"; + const isBotTypingOrInvisible = isBotTyping || !botOptions.isOpen || document.visibilityState !== "visible" || (botOptions.isOpen && isScrolling); + + return !botOptions.theme?.embedded && messages.length > 0 && !isUserMessage && !isBotTypingOrInvisible && !botOptions.notification?.disabled && notificationToggledOn && hasInteracted; + }; + /** * Retrieves current path for user. */