@yext/chat-ui-react 0.11.3 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,7 @@ import React, {
5
5
  useMemo,
6
6
  useRef,
7
7
  useState,
8
+ useLayoutEffect,
8
9
  } from "react";
9
10
  import { useChatState } from "@yext/chat-headless-react";
10
11
  import {
@@ -45,7 +46,8 @@ const builtInCssClasses: ChatPanelCssClasses = withStylelessCssClasses(
45
46
  {
46
47
  container: "h-full w-full flex flex-col relative shadow-2xl bg-white",
47
48
  messagesScrollContainer: "flex flex-col mt-auto overflow-hidden",
48
- messagesContainer: "flex flex-col gap-y-1 px-4 overflow-auto [&>*:first-child]:mt-3",
49
+ messagesContainer:
50
+ "flex flex-col gap-y-1 px-4 overflow-auto [&>*:first-child]:mt-3",
49
51
  inputContainer: "w-full p-4",
50
52
  messageBubbleCssClasses: {
51
53
  topContainer: "mt-1",
@@ -116,6 +118,9 @@ export function ChatPanel(props: ChatPanelProps) {
116
118
  const suggestedReplies = useChatState(
117
119
  (state) => state.conversation.notes?.suggestedReplies
118
120
  );
121
+ const conversationId = useChatState(
122
+ (state) => state.conversation.conversationId
123
+ );
119
124
  const cssClasses = useComposedCssClasses(builtInCssClasses, customCssClasses);
120
125
  const reportAnalyticsEvent = useReportAnalyticsEvent();
121
126
  useFetchInitialMessage(handleError, stream);
@@ -156,25 +161,40 @@ export function ChatPanel(props: ChatPanelProps) {
156
161
  const messagesRef = useRef<Array<HTMLDivElement | null>>([]);
157
162
  const messagesContainer = useRef<HTMLDivElement>(null);
158
163
 
164
+ // State to help detect initial messages rendering
165
+ const [initialMessagesLength] = useState(messages.length);
166
+
167
+ const savedPanelState = useMemo(() => {
168
+ if (!conversationId) {
169
+ return {};
170
+ }
171
+ return loadSessionState(conversationId);
172
+ }, [conversationId]);
173
+
159
174
  // Handle scrolling when messages change
160
175
  useEffect(() => {
161
- let scrollTop = 0;
162
- messagesRef.current = messagesRef.current.slice(0, messages.length);
163
-
164
- // Sums up scroll heights of all messages except the last one
165
- if (messagesRef?.current.length > 1) {
166
- scrollTop = messagesRef.current
167
- .slice(0, -1)
168
- .map((elem, _) => elem?.scrollHeight ?? 0)
169
- .reduce((total, height) => total + height);
176
+ const isInitialRender = messages.length === initialMessagesLength;
177
+ let scrollPos = 0;
178
+ if (isInitialRender && savedPanelState.scrollPosition !== undefined) {
179
+ // memorized position
180
+ scrollPos = savedPanelState?.scrollPosition;
181
+ } else {
182
+ messagesRef.current = messagesRef.current.slice(0, messages.length);
183
+ // Sums up scroll heights of all messages except the last one
184
+ if (messagesRef?.current.length > 1) {
185
+ // position of the top of the last message
186
+ scrollPos = messagesRef.current
187
+ .slice(0, -1)
188
+ .map((elem, _) => elem?.scrollHeight ?? 0)
189
+ .reduce((total, height) => total + height);
190
+ }
170
191
  }
171
192
 
172
- // Scroll to the top of the last message
173
193
  messagesContainer.current?.scroll({
174
- top: scrollTop,
194
+ top: scrollPos,
175
195
  behavior: "smooth",
176
196
  });
177
- }, [messages]);
197
+ }, [messages, initialMessagesLength, savedPanelState.scrollPosition]);
178
198
 
179
199
  const setMessagesRef = useCallback((index) => {
180
200
  if (!messagesRef?.current) return null;
@@ -189,12 +209,32 @@ export function ChatPanel(props: ChatPanelProps) {
189
209
  [cssClasses]
190
210
  );
191
211
 
212
+ useLayoutEffect(() => {
213
+ const curr = messagesContainer.current;
214
+ const onScroll = () => {
215
+ if (!conversationId) {
216
+ return;
217
+ }
218
+ saveSessionState(conversationId, {
219
+ scrollPosition: curr?.scrollTop,
220
+ });
221
+ };
222
+ curr?.addEventListener("scroll", onScroll);
223
+ return () => {
224
+ curr?.removeEventListener("scroll", onScroll);
225
+ };
226
+ }, [messagesContainer, conversationId]);
227
+
192
228
  return (
193
229
  <div className="yext-chat w-full h-full">
194
230
  <div className={cssClasses.container}>
195
231
  {header}
196
232
  <div className={cssClasses.messagesScrollContainer}>
197
- <div ref={messagesContainer} className={cssClasses.messagesContainer}>
233
+ <div
234
+ ref={messagesContainer}
235
+ className={cssClasses.messagesContainer}
236
+ aria-label="Chat Panel Messages Container"
237
+ >
198
238
  {messages.map((message, index) => (
199
239
  <div key={index} ref={setMessagesRef(index)}>
200
240
  <MessageBubble
@@ -249,3 +289,58 @@ export function ChatPanel(props: ChatPanelProps) {
249
289
  </div>
250
290
  );
251
291
  }
292
+
293
+ const BASE_STATE_LOCAL_STORAGE_KEY = "yext_chat_panel_state";
294
+
295
+ export function getStateLocalStorageKey(
296
+ hostname: string,
297
+ conversationId: string
298
+ ): string {
299
+ return `${BASE_STATE_LOCAL_STORAGE_KEY}__${hostname}__${conversationId}`;
300
+ }
301
+
302
+ /**
303
+ * Maintains the panel state of the session.
304
+ */
305
+ export interface PanelState {
306
+ /** The scroll position of the panel. */
307
+ scrollPosition?: number;
308
+ }
309
+
310
+ /**
311
+ * Loads the {@link PanelState} from local storage.
312
+ */
313
+ export const loadSessionState = (conversationId: string): PanelState => {
314
+ const hostname = window?.location?.hostname;
315
+ if (!localStorage || !hostname) {
316
+ return {};
317
+ }
318
+ const savedState = localStorage.getItem(
319
+ getStateLocalStorageKey(hostname, conversationId)
320
+ );
321
+
322
+ if (savedState) {
323
+ try {
324
+ const parsedState: PanelState = JSON.parse(savedState);
325
+ return parsedState;
326
+ } catch (e) {
327
+ console.warn("Unabled to load saved panel state: error parsing state.");
328
+ localStorage.removeItem(
329
+ getStateLocalStorageKey(hostname, conversationId)
330
+ );
331
+ }
332
+ }
333
+
334
+ return {};
335
+ };
336
+
337
+ export const saveSessionState = (conversationId: string, state: PanelState) => {
338
+ const hostname = window?.location?.hostname;
339
+ if (!localStorage || !hostname) {
340
+ return;
341
+ }
342
+ localStorage.setItem(
343
+ getStateLocalStorageKey(hostname, conversationId),
344
+ JSON.stringify(state)
345
+ );
346
+ };
@@ -110,6 +110,12 @@ export interface ChatPopUpProps
110
110
  * This prop will override the "showInitialMessagePopUp" prop, if specified.
111
111
  */
112
112
  ctaLabel?: string;
113
+ /**
114
+ * A controlled prop to open or close the panel. If provided, the prop
115
+ * will override the openOnLoad prop and the panel will be controlled
116
+ * by the parent component.
117
+ */
118
+ isOpen?: boolean;
113
119
  }
114
120
 
115
121
  /**
@@ -134,6 +140,7 @@ export function ChatPopUp(props: ChatPopUpProps) {
134
140
  ctaLabel,
135
141
  title,
136
142
  footer,
143
+ isOpen,
137
144
  } = props;
138
145
 
139
146
  const reportAnalyticsEvent = useReportAnalyticsEvent();
@@ -147,24 +154,7 @@ export function ChatPopUp(props: ChatPopUpProps) {
147
154
  const [numReadMessages, setNumReadMessagesLength] = useState<number>(0);
148
155
  const [numUnreadMessages, setNumUnreadMessagesLength] = useState<number>(0);
149
156
 
150
- const [showInitialMessage, setshowInitialMessage] = useState(
151
- //only show initial message popup (if specified) when CTA label is not provided
152
- !ctaLabel && showInitialMessagePopUp
153
- );
154
-
155
- const onCloseInitialMessage = useCallback(() => {
156
- setshowInitialMessage(false);
157
- }, []);
158
-
159
- // control CSS behavior (fade-in/out animation) on open/close state of the panel.
160
- const [showChat, setShowChat] = useState(false);
161
-
162
- // control the actual DOM rendering of the panel. Start rendering on first open state
163
- // to avoid message requests immediately on load while the popup is still "hidden"
164
- const [renderChat, setRenderChat] = useState(false);
165
-
166
157
  // Set the initial value of the local storage flag for opening on load only if it doesn't already exist
167
-
168
158
  if (window.localStorage.getItem(popupLocalStorageKey) === null) {
169
159
  window.localStorage.setItem(
170
160
  popupLocalStorageKey,
@@ -179,6 +169,15 @@ export function ChatPopUp(props: ChatPopUpProps) {
179
169
  const isOpenOnLoad =
180
170
  (messages.length > 1 && openOnLoadLocalStorage === "true") || openOnLoad;
181
171
 
172
+ const {
173
+ renderChat,
174
+ showChat,
175
+ showInitialMessage,
176
+ toggleChat,
177
+ closeChat,
178
+ closeInitialMessage,
179
+ } = usePanelState(isOpen, isOpenOnLoad, !ctaLabel && showInitialMessagePopUp);
180
+
182
181
  // only fetch initial message when ChatPanel is closed on load (otherwise, it will be fetched in ChatPanel)
183
182
  useFetchInitialMessage(
184
183
  showInitialMessagePopUp ? console.error : handleError,
@@ -188,28 +187,18 @@ export function ChatPopUp(props: ChatPopUpProps) {
188
187
  !isOpenOnLoad
189
188
  );
190
189
 
191
- useEffect(() => {
192
- if (!renderChat && isOpenOnLoad) {
193
- setShowChat(true);
194
- setRenderChat(true);
195
- setshowInitialMessage(false);
196
- }
197
- }, [renderChat, messages.length, isOpenOnLoad]);
198
-
199
190
  const onClick = useCallback(() => {
200
- setShowChat((prev) => !prev);
201
- setRenderChat(true);
202
- setshowInitialMessage(false);
191
+ toggleChat();
203
192
  window.localStorage.setItem(popupLocalStorageKey, "true");
204
- }, []);
193
+ }, [toggleChat]);
205
194
 
206
195
  const onClose = useCallback(() => {
207
- setShowChat(false);
196
+ closeChat();
208
197
  customOnClose?.();
209
198
  // consider all the messages are read while the panel was open
210
199
  setNumReadMessagesLength(messages.length);
211
200
  window.localStorage.setItem(popupLocalStorageKey, "false");
212
- }, [customOnClose, messages.length]);
201
+ }, [closeChat, customOnClose, messages.length]);
213
202
 
214
203
  useEffect(() => {
215
204
  // update number of unread messages if there are new messages added while the panel is closed
@@ -255,7 +244,7 @@ export function ChatPopUp(props: ChatPopUpProps) {
255
244
  >
256
245
  {showInitialMessage && (
257
246
  <InitialMessagePopUp
258
- onClose={onCloseInitialMessage}
247
+ onClose={closeInitialMessage}
259
248
  customCssClasses={cssClasses.initialMessagePopUpCssClasses}
260
249
  />
261
250
  )}
@@ -298,3 +287,62 @@ export function ChatPopUp(props: ChatPopUpProps) {
298
287
  </div>
299
288
  );
300
289
  }
290
+
291
+ function usePanelState(
292
+ isOpen: boolean | undefined,
293
+ isOpenOnLoad: boolean | undefined,
294
+ initialMessageVisible: boolean | undefined
295
+ ) {
296
+ // control CSS behavior (fade-in/out animation) on open/close state of the panel.
297
+ const [showChat, setShowChat] = useState(false);
298
+ // control the actual DOM rendering of the panel. Start rendering on first open state
299
+ // to avoid message requests immediately on load while the popup is still "hidden"
300
+ const [renderChat, setRenderChat] = useState(false);
301
+ const [showInitialMessage, setshowInitialMessage] = useState(
302
+ initialMessageVisible
303
+ );
304
+
305
+ useEffect(() => {
306
+ if (isOpen !== undefined) {
307
+ setShowChat(isOpen);
308
+ setRenderChat(isOpen);
309
+ }
310
+ }, [isOpen]);
311
+
312
+ useEffect(() => {
313
+ if (!renderChat && isOpenOnLoad && isOpen === undefined) {
314
+ setShowChat(true);
315
+ setRenderChat(true);
316
+ setshowInitialMessage(false);
317
+ }
318
+ }, [renderChat, isOpen, isOpenOnLoad]);
319
+
320
+ const toggleChat = useCallback(() => {
321
+ if (isOpen !== undefined) {
322
+ return;
323
+ }
324
+ setShowChat((prev) => !prev);
325
+ setRenderChat(true);
326
+ setshowInitialMessage(false);
327
+ }, [isOpen]);
328
+
329
+ const closeChat = useCallback(() => {
330
+ if (isOpen !== undefined) {
331
+ return;
332
+ }
333
+ setShowChat(false);
334
+ }, [isOpen]);
335
+
336
+ const closeInitialMessage = useCallback(() => {
337
+ setshowInitialMessage(false);
338
+ }, []);
339
+
340
+ return {
341
+ showChat,
342
+ renderChat,
343
+ showInitialMessage,
344
+ toggleChat,
345
+ closeChat,
346
+ closeInitialMessage,
347
+ };
348
+ }
@@ -1,4 +1,7 @@
1
- import ReactMarkdown, { Options } from "react-markdown";
1
+ import ReactMarkdown, {
2
+ PluggableList,
3
+ ReactMarkdownOptions,
4
+ } from "react-markdown";
2
5
  import remarkGfm from "remark-gfm";
3
6
  import rehypeRaw from "rehype-raw";
4
7
  import rehypeSanitize from "rehype-sanitize";
@@ -7,7 +10,7 @@ import { useReportAnalyticsEvent } from "../hooks/useReportAnalyticsEvent";
7
10
  import { useComposedCssClasses } from "../hooks/useComposedCssClasses";
8
11
 
9
12
  // The Remark and Rehype plugins to use in conjunction with ReactMarkdown.
10
- const unifiedPlugins: { remark?: Options['remarkPlugins']; rehype: Options['rehypePlugins'] } = {
13
+ const unifiedPlugins: { remark?: PluggableList; rehype: PluggableList } = {
11
14
  remark: [
12
15
  remarkGfm, //renders Github-Flavored Markdown
13
16
  ],
@@ -69,7 +72,7 @@ export function Markdown({
69
72
  const reportAnalyticsEvent = useReportAnalyticsEvent();
70
73
  const cssClasses = useComposedCssClasses(builtInCssClasses, customCssClasses);
71
74
 
72
- const components: Options["components"] = useMemo(() => {
75
+ const components: ReactMarkdownOptions["components"] = useMemo(() => {
73
76
  const createClickHandlerFn = (href?: string) => () => {
74
77
  reportAnalyticsEvent({
75
78
  action: linkClickEvent,