Skip to content

Commit

Permalink
Add support to save chat panel's scroll position
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 committed Dec 16, 2024
1 parent 0378e6b commit cae12d0
Show file tree
Hide file tree
Showing 4 changed files with 247 additions and 5 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.11.5",
"description": "A library of React Components for powering Yext Chat integrations.",
"author": "[email protected]",
"main": "./lib/commonjs/src/index.js",
Expand Down
117 changes: 116 additions & 1 deletion src/components/ChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,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,8 +160,30 @@ 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 || !messages.length) {
return {};
}
return loadSessionState(
conversationId,
messages[messages.length - 1].timestamp
);
}, [conversationId, messages]);

// Handle scrolling when messages change
useEffect(() => {
const isInitialRender = messages.length === initialMessagesLength;
if (isInitialRender && savedPanelState.scrollPosition !== undefined) {
messagesContainer.current?.scroll({
top: savedPanelState?.scrollPosition,
behavior: "auto",
});
return;
}

let scrollTop = 0;
messagesRef.current = messagesRef.current.slice(0, messages.length);

Expand All @@ -175,7 +200,7 @@ export function ChatPanel(props: ChatPanelProps) {
top: scrollTop,
behavior: "smooth",
});
}, [messages]);
}, [messages, initialMessagesLength, savedPanelState.scrollPosition]);

const setMessagesRef = useCallback((index) => {
if (!messagesRef?.current) return null;
Expand All @@ -190,6 +215,18 @@ export function ChatPanel(props: ChatPanelProps) {
[cssClasses]
);

useEffect(() => {
const curr = messagesContainer.current;
curr?.addEventListener("scroll", () => {
if (!conversationId) {
return;
}
saveSessionState(conversationId, {
scrollPosition: curr.scrollTop,
});
});
}, [messagesContainer, conversationId]);

return (
<div className="yext-chat w-full h-full">
<div className={cssClasses.container}>
Expand Down Expand Up @@ -250,3 +287,81 @@ 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,
lastTimestamp?: 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);
const currentDate = new Date();
const lastDate = new Date(lastTimestamp || 0);
const diff = currentDate.getTime() - lastDate.getTime();
// If the last message was sent within the last day, we consider the session to be active
if (diff < 24 * 60 * 60 * 1000) {
return parsedState;
}
localStorage.removeItem(
getStateLocalStorageKey(hostname, conversationId)
);
} 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;
}
const previousState = localStorage.getItem(
getStateLocalStorageKey(hostname, conversationId)
);

if (previousState) {
try {
state = { ...JSON.parse(previousState), ...state };
} catch (e) {
console.warn("Unabled to load saved panel state: error parsing state.");
}
}
localStorage.setItem(
getStateLocalStorageKey(hostname, conversationId),
JSON.stringify(state)
);
};
129 changes: 128 additions & 1 deletion tests/components/ChatPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
/* eslint-disable testing-library/no-unnecessary-act */
import { act, render, screen, waitFor } from "@testing-library/react";
/* eslint-disable testing-library/no-node-access */
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 +16,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 +199,119 @@ 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() }],
},
};
const mockOldConvoState = {
conversation: {
conversationId: mockConvoId,
messages: [dummyMessage],
},
};

it("saves panel state to local storage", () => {
mockChatState(mockConvoState);
const storageSetSpy = jest.spyOn(Storage.prototype, "setItem");

const { container } = render(<ChatPanel />);
const scrollDiv = getChatPanelScrollDiv(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 saving new state", () => {
mockChatState(mockConvoState);
localStorage.setItem(mockKey, "hello world");
const storageSetSpy = jest.spyOn(Storage.prototype, "setItem");
const consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation();
const { container } = render(<ChatPanel />);
const scrollDiv = getChatPanelScrollDiv(container);

fireEvent.scroll(scrollDiv, {
target: { scrollTop: mockPanelState.scrollPosition },
});

expect(storageSetSpy).toHaveBeenCalledWith(
mockKey,
JSON.stringify(mockPanelState)
);
expect(localStorage.getItem(mockKey)).toEqual(
JSON.stringify(mockPanelState)
);

expect(consoleWarnSpy).toBeCalledTimes(1);
expect(consoleWarnSpy).toBeCalledWith(
"Unabled to load saved panel state: error parsing state."
);
});

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

it("ignores and removes state when loading saved state older than 24 hours", () => {
mockChatState(mockOldConvoState);
localStorage.setItem(mockKey, JSON.stringify(mockPanelState));
const storageGetSpy = jest.spyOn(Storage.prototype, "getItem");
const storageRemoveSpy = jest.spyOn(Storage.prototype, "removeItem");

render(<ChatPanel />);
expect(storageGetSpy).toHaveBeenCalledWith(mockKey);
expect(storageRemoveSpy).toHaveBeenCalledWith(mockKey);
expect(localStorage.getItem(mockKey)).toBeNull();
});
});

const getChatPanelScrollDiv = (chatPanelContainer: HTMLElement) => {
return chatPanelContainer.getElementsByClassName(
"yext-chat-panel__messages-container"
)[0];
};

0 comments on commit cae12d0

Please sign in to comment.