Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support to save chat panel's scroll position #88

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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(() => {
anguyen-yext2 marked this conversation as resolved.
Show resolved Hide resolved
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];
};
Loading