diff --git a/package-lock.json b/package-lock.json index 1a5aa30..ff5d157 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@yext/chat-ui-react", - "version": "0.11.4", + "version": "0.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@yext/chat-ui-react", - "version": "0.11.4", + "version": "0.12.0", "license": "BSD-3-Clause", "dependencies": { "react-markdown": "^6.0.3", diff --git a/package.json b/package.json index dda5fed..0ccd65c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@yext/chat-ui-react", - "version": "0.11.4", + "version": "0.12.0", "description": "A library of React Components for powering Yext Chat integrations.", "author": "clippy@yext.com", "main": "./lib/commonjs/src/index.js", diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index a2165e2..f9a934c 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -5,6 +5,7 @@ import React, { useMemo, useRef, useState, + useLayoutEffect, } from "react"; import { useChatState } from "@yext/chat-headless-react"; import { @@ -117,6 +118,9 @@ export function ChatPanel(props: ChatPanelProps) { const suggestedReplies = useChatState( (state) => state.conversation.notes?.suggestedReplies ); + const conversationId = useChatState( + (state) => state.conversation.conversationId + ); const cssClasses = useComposedCssClasses(builtInCssClasses, customCssClasses); const reportAnalyticsEvent = useReportAnalyticsEvent(); useFetchInitialMessage(handleError, stream); @@ -157,25 +161,40 @@ export function ChatPanel(props: ChatPanelProps) { const messagesRef = useRef>([]); const messagesContainer = useRef(null); + // State to help detect initial messages rendering + const [initialMessagesLength] = useState(messages.length); + + const savedPanelState = useMemo(() => { + if (!conversationId) { + return {}; + } + return loadSessionState(conversationId); + }, [conversationId]); + // Handle scrolling when messages change useEffect(() => { - let scrollTop = 0; - messagesRef.current = messagesRef.current.slice(0, messages.length); - - // Sums up scroll heights of all messages except the last one - if (messagesRef?.current.length > 1) { - scrollTop = messagesRef.current - .slice(0, -1) - .map((elem, _) => elem?.scrollHeight ?? 0) - .reduce((total, height) => total + height); + const isInitialRender = messages.length === initialMessagesLength; + let scrollPos = 0; + if (isInitialRender && savedPanelState.scrollPosition !== undefined) { + // memorized position + scrollPos = savedPanelState?.scrollPosition; + } else { + messagesRef.current = messagesRef.current.slice(0, messages.length); + // Sums up scroll heights of all messages except the last one + if (messagesRef?.current.length > 1) { + // position of the top of the last message + scrollPos = messagesRef.current + .slice(0, -1) + .map((elem, _) => elem?.scrollHeight ?? 0) + .reduce((total, height) => total + height); + } } - // Scroll to the top of the last message messagesContainer.current?.scroll({ - top: scrollTop, + top: scrollPos, behavior: "smooth", }); - }, [messages]); + }, [messages, initialMessagesLength, savedPanelState.scrollPosition]); const setMessagesRef = useCallback((index) => { if (!messagesRef?.current) return null; @@ -190,12 +209,32 @@ export function ChatPanel(props: ChatPanelProps) { [cssClasses] ); + useLayoutEffect(() => { + const curr = messagesContainer.current; + const onScroll = () => { + if (!conversationId) { + return; + } + saveSessionState(conversationId, { + scrollPosition: curr?.scrollTop, + }); + }; + curr?.addEventListener("scroll", onScroll); + return () => { + curr?.removeEventListener("scroll", onScroll); + }; + }, [messagesContainer, conversationId]); + return (
{header}
-
+
{messages.map((message, index) => (
); } + +const BASE_STATE_LOCAL_STORAGE_KEY = "yext_chat_panel_state"; + +export function getStateLocalStorageKey( + hostname: string, + conversationId: string +): string { + return `${BASE_STATE_LOCAL_STORAGE_KEY}__${hostname}__${conversationId}`; +} + +/** + * Maintains the panel state of the session. + */ +export interface PanelState { + /** The scroll position of the panel. */ + scrollPosition?: number; +} + +/** + * Loads the {@link PanelState} from local storage. + */ +export const loadSessionState = (conversationId: string): PanelState => { + const hostname = window?.location?.hostname; + if (!localStorage || !hostname) { + return {}; + } + const savedState = localStorage.getItem( + getStateLocalStorageKey(hostname, conversationId) + ); + + if (savedState) { + try { + const parsedState: PanelState = JSON.parse(savedState); + return parsedState; + } catch (e) { + console.warn("Unabled to load saved panel state: error parsing state."); + localStorage.removeItem( + getStateLocalStorageKey(hostname, conversationId) + ); + } + } + + return {}; +}; + +export const saveSessionState = (conversationId: string, state: PanelState) => { + const hostname = window?.location?.hostname; + if (!localStorage || !hostname) { + return; + } + localStorage.setItem( + getStateLocalStorageKey(hostname, conversationId), + JSON.stringify(state) + ); +}; diff --git a/tests/components/ChatPanel.test.tsx b/tests/components/ChatPanel.test.tsx index 97f3c0e..c8829f9 100644 --- a/tests/components/ChatPanel.test.tsx +++ b/tests/components/ChatPanel.test.tsx @@ -1,5 +1,11 @@ /* eslint-disable testing-library/no-unnecessary-act */ -import { act, render, screen, waitFor } from "@testing-library/react"; +import { + act, + render, + screen, + waitFor, + fireEvent, +} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { ChatPanel } from "../../src"; import { @@ -9,6 +15,10 @@ import { spyOnActions, } from "../__utils__/mocks"; import { Message, MessageSource } from "@yext/chat-headless-react"; +import { + PanelState, + getStateLocalStorageKey, +} from "../../src/components/ChatPanel"; const dummyMessage: Message = { timestamp: "2023-05-31T14:22:19.376Z", @@ -188,3 +198,69 @@ it("applies link target setting (default blank)", async () => { render(); expect(screen.getByText("msg link")).toHaveAttribute("target", "_blank"); }); + +describe("loadSessionState works as expected", () => { + const jestHostname = "localhost"; + window.location.hostname = jestHostname; + const mockConvoId = "dummy-id"; + const mockKey = getStateLocalStorageKey(jestHostname, mockConvoId); + const mockPanelState: PanelState = { + scrollPosition: 23, + }; + const mockConvoState = { + conversation: { + conversationId: mockConvoId, + messages: [{ ...dummyMessage, timestamp: new Date().toISOString() }], + }, + }; + + it("saves panel state to local storage", () => { + mockChatState(mockConvoState); + const storageSetSpy = jest.spyOn(Storage.prototype, "setItem"); + + render(); + const scrollDiv = screen.getByLabelText("Chat Panel Messages Container"); + + fireEvent.scroll(scrollDiv, { + target: { scrollTop: mockPanelState.scrollPosition }, + }); + + expect(storageSetSpy).toHaveBeenCalledWith( + mockKey, + JSON.stringify(mockPanelState) + ); + expect(localStorage.getItem(mockKey)).toEqual( + JSON.stringify(mockPanelState) + ); + }); + + it("loads panel from local storage", () => { + mockChatState(mockConvoState); + localStorage.setItem(mockKey, JSON.stringify(mockPanelState)); + const storageGetSpy = jest.spyOn(Storage.prototype, "getItem"); + + render(); + expect(storageGetSpy).toHaveBeenCalledWith(mockKey); + expect(localStorage.getItem(mockKey)).toEqual( + JSON.stringify(mockPanelState) + ); + }); + + it("handles invalid state in local storage when loading saved state", () => { + mockChatState(mockConvoState); + localStorage.setItem(mockKey, "hello world"); + const storageGetSpy = jest.spyOn(Storage.prototype, "getItem"); + const storageRemoveSpy = jest.spyOn(Storage.prototype, "removeItem"); + const consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(); + render(); + + expect(storageGetSpy).toHaveBeenCalledWith(mockKey); + expect(storageRemoveSpy).toHaveBeenCalledWith(mockKey); + expect(localStorage.getItem(mockKey)).toBeNull(); + + expect(consoleWarnSpy).toBeCalledTimes(1); + expect(consoleWarnSpy).toBeCalledWith( + "Unabled to load saved panel state: error parsing state." + ); + }); +});