Skip to content

Commit

Permalink
Add support to save chat panel's scroll position (#88)
Browse files Browse the repository at this point in the history
J=CLIP-1675
TEST=manual,auto

verified on test-site that the panel remains at the correct scroll position after being reloaded/opened in another tab. Also verified that multiple bot panels do not override each other scroll position.

added and ran unit test
  • Loading branch information
anguyen-yext2 authored Jan 8, 2025
1 parent 1a85e51 commit 3a33648
Show file tree
Hide file tree
Showing 4 changed files with 187 additions and 17 deletions.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": "[email protected]",
"main": "./lib/commonjs/src/index.js",
Expand Down
120 changes: 107 additions & 13 deletions src/components/ChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import React, {
useMemo,
useRef,
useState,
useLayoutEffect,
} from "react";
import { useChatState } from "@yext/chat-headless-react";
import {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -157,25 +161,40 @@ export function ChatPanel(props: ChatPanelProps) {
const messagesRef = useRef<Array<HTMLDivElement | null>>([]);
const messagesContainer = useRef<HTMLDivElement>(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;
Expand All @@ -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 (
<div className="yext-chat w-full h-full">
<div className={cssClasses.container}>
{header}
<div className={cssClasses.messagesScrollContainer}>
<div ref={messagesContainer} className={cssClasses.messagesContainer}>
<div
ref={messagesContainer}
className={cssClasses.messagesContainer}
aria-label="Chat Panel Messages Container"
>
{messages.map((message, index) => (
<div key={index} ref={setMessagesRef(index)}>
<MessageBubble
Expand Down Expand Up @@ -250,3 +289,58 @@ export function ChatPanel(props: ChatPanelProps) {
</div>
);
}

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)
);
};
78 changes: 77 additions & 1 deletion tests/components/ChatPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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",
Expand Down Expand Up @@ -188,3 +198,69 @@ it("applies link target setting (default blank)", async () => {
render(<ChatPanel />);
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(<ChatPanel />);
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(<ChatPanel />);
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(<ChatPanel />);

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."
);
});
});

0 comments on commit 3a33648

Please sign in to comment.