From 0dc276c18fc1070f52f931fa033ff43977e4cf63 Mon Sep 17 00:00:00 2001 From: Baptiste Arnaud Date: Thu, 7 Mar 2024 15:39:09 +0100 Subject: [PATCH] :zap: Restore chat state when user is remembered (#1333) Closes #993 ## Summary by CodeRabbit - **New Features** - Added a detailed explanation page for the "Remember user" setting in the app documentation. - Introduced persistence of chat state across sessions, with options for local or session storage. - Enhanced bot functionality to store and retrieve initial chat replies and manage bot open state with improved storage handling. - Added a new callback for chat state persistence to bot component props. - **Improvements** - Updated the general settings form to clarify the description of the "Remember user" feature. - Enhanced custom CSS handling and progress value persistence in bot components. - Added conditional transition disabling in various components for smoother user experiences. - Simplified the handling of `onTransitionEnd` across multiple bubble components. - **Refactor** - Renamed `inputIndex` to `chunkIndex` or `index` in various components for consistency. - Removed unused ESLint disable comments related to reactivity rules. - Adjusted import statements and cleaned up code across several files. - **Bug Fixes** - Fixed potential issues with undefined callbacks by introducing optional chaining in component props. --- .../components/GeneralSettingsForm.tsx | 4 +- apps/docs/mint.json | 2 +- apps/docs/settings/overview.mdx | 2 +- apps/docs/settings/remember-user.mdx | 13 +++ packages/bot-engine/queries/findResult.ts | 2 +- packages/embeds/js/.eslintrc.cjs | 1 + packages/embeds/js/package.json | 2 +- packages/embeds/js/src/components/Bot.tsx | 70 ++++++++++--- .../AvatarSideContainer.tsx | 10 +- .../ConversationContainer/ChatChunk.tsx | 17 ++-- .../ConversationContainer.tsx | 22 +++-- .../js/src/components/InputChatBlock.tsx | 17 ++-- .../js/src/components/bubbles/HostBubble.tsx | 98 +++++++++---------- .../bubbles/audio/components/AudioBubble.tsx | 23 +++-- .../embed/components/CustomEmbedBubble.tsx | 16 ++- .../bubbles/embed/components/EmbedBubble.tsx | 16 ++- .../bubbles/image/components/ImageBubble.tsx | 24 +++-- .../textBubble/components/TextBubble.tsx | 16 ++- .../bubbles/video/components/VideoBubble.tsx | 18 +++- .../inputs/buttons/components/Buttons.tsx | 4 +- .../components/MultipleChoicesForm.tsx | 1 - .../inputs/number/components/NumberInput.tsx | 1 - .../src/features/bubble/components/Bubble.tsx | 18 +++- .../src/features/popup/components/Popup.tsx | 31 ++++-- packages/embeds/js/src/types.ts | 1 + .../embeds/js/src/utils/injectStartProps.ts | 1 - packages/embeds/js/src/utils/persist.ts | 54 ++++++++++ packages/embeds/js/src/utils/storage.ts | 92 +++++++++++++++-- packages/embeds/js/src/window.ts | 1 - packages/embeds/nextjs/package.json | 2 +- packages/embeds/react/package.json | 2 +- 31 files changed, 427 insertions(+), 154 deletions(-) create mode 100644 apps/docs/settings/remember-user.mdx create mode 100644 packages/embeds/js/src/utils/persist.ts diff --git a/apps/builder/src/features/settings/components/GeneralSettingsForm.tsx b/apps/builder/src/features/settings/components/GeneralSettingsForm.tsx index 153fe2156..9201edc48 100644 --- a/apps/builder/src/features/settings/components/GeneralSettingsForm.tsx +++ b/apps/builder/src/features/settings/components/GeneralSettingsForm.tsx @@ -85,7 +85,7 @@ export const GeneralSettingsForm = ({ /> local {' '} - to remember the user forever. + to remember the user forever on the same device. diff --git a/apps/docs/mint.json b/apps/docs/mint.json index ba370adfa..91881dd3b 100644 --- a/apps/docs/mint.json +++ b/apps/docs/mint.json @@ -142,7 +142,7 @@ }, { "group": "Settings", - "pages": ["settings/overview"] + "pages": ["settings/overview", "settings/remember-user"] }, { "group": "Deploy", diff --git a/apps/docs/settings/overview.mdx b/apps/docs/settings/overview.mdx index 1d0185bac..69d983ac8 100644 --- a/apps/docs/settings/overview.mdx +++ b/apps/docs/settings/overview.mdx @@ -14,7 +14,7 @@ The general settings represent the general behaviors of your typebot. - **Prefill input**: If enabled, the inputs will be automatically pre-filled whenever their associated variable has a value. - **Hide query params on bot start**: If enabled, the query params will be hidden when the bot starts. -- **Remember user**: If enabled, user previous variables will be prefilled and his new answers will override the previous ones. +- [**Remember user**](./remember-user) ## Typing emulation diff --git a/apps/docs/settings/remember-user.mdx b/apps/docs/settings/remember-user.mdx new file mode 100644 index 000000000..a995efcc7 --- /dev/null +++ b/apps/docs/settings/remember-user.mdx @@ -0,0 +1,13 @@ +--- +title: Remember user +icon: bookmark +--- + +Head over to the `Settings` tab of your typebot, under the `General` section you can find the `Remember user` setting. + +This setting allows you to save the chat session state into the user's web browser storage. It means that if he answers a question and then closes the chat, the next time he opens it, the chat will be in the same state as it was before. + +There are 2 storage options: + +- **Local storage**: The chat state will be saved in the user's web browser. It will be available only on the same device and web browser. +- **Session storage**: The chat state will be saved in the user's web browser. It will be available only on the same device and web browser, but it will be deleted when the user closes the current tab or the web browser. diff --git a/packages/bot-engine/queries/findResult.ts b/packages/bot-engine/queries/findResult.ts index 37487ab88..7d7315e69 100644 --- a/packages/bot-engine/queries/findResult.ts +++ b/packages/bot-engine/queries/findResult.ts @@ -6,7 +6,7 @@ type Props = { } export const findResult = ({ id }: Props) => prisma.result.findFirst({ - where: { id }, + where: { id, isArchived: { not: true } }, select: { id: true, variables: true, diff --git a/packages/embeds/js/.eslintrc.cjs b/packages/embeds/js/.eslintrc.cjs index 19e7bb339..209e54491 100644 --- a/packages/embeds/js/.eslintrc.cjs +++ b/packages/embeds/js/.eslintrc.cjs @@ -6,5 +6,6 @@ module.exports = { '@next/next/no-img-element': 'off', '@next/next/no-html-link-for-pages': 'off', 'solid/no-innerhtml': 'off', + 'solid/reactivity': 'off', }, } diff --git a/packages/embeds/js/package.json b/packages/embeds/js/package.json index ff649f049..6a9d04ab6 100644 --- a/packages/embeds/js/package.json +++ b/packages/embeds/js/package.json @@ -1,6 +1,6 @@ { "name": "@typebot.io/js", - "version": "0.2.48", + "version": "0.2.49", "description": "Javascript library to display typebots on your website", "type": "module", "main": "dist/index.js", diff --git a/packages/embeds/js/src/components/Bot.tsx b/packages/embeds/js/src/components/Bot.tsx index 2db1f91c7..8ba4ca878 100644 --- a/packages/embeds/js/src/components/Bot.tsx +++ b/packages/embeds/js/src/components/Bot.tsx @@ -8,7 +8,10 @@ import { BotContext, InitialChatReply, OutgoingLog } from '@/types' import { ErrorMessage } from './ErrorMessage' import { getExistingResultIdFromStorage, + getInitialChatReplyFromStorage, + setInitialChatReplyInStorage, setResultInStorage, + wipeExistingChatStateInStorage, } from '@/utils/storage' import { setCssVariablesValue } from '@/utils/setCssVariablesValue' import immutableCss from '../assets/immutable.css' @@ -20,6 +23,8 @@ import { HTTPError } from 'ky' import { injectFont } from '@/utils/injectFont' import { ProgressBar } from './ProgressBar' import { Portal } from 'solid-js/web' +import { defaultSettings } from '@typebot.io/schemas/features/typebot/settings/constants' +import { persist } from '@/utils/persist' export type BotProps = { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -35,6 +40,7 @@ export type BotProps = { onInit?: () => void onEnd?: () => void onNewLogs?: (logs: OutgoingLog[]) => void + onChatStatePersisted?: (isEnabled: boolean) => void startFrom?: StartFrom } @@ -59,14 +65,13 @@ export const Bot = (props: BotProps & { class?: string }) => { typeof props.typebot === 'string' ? props.typebot : undefined const isPreview = typeof props.typebot !== 'string' || (props.isPreview ?? false) + const resultIdInStorage = getExistingResultIdFromStorage(typebotIdFromProps) const { data, error } = await startChatQuery({ stripeRedirectStatus: urlParams.get('redirect_status') ?? undefined, typebot: props.typebot, apiHost: props.apiHost, isPreview, - resultId: isNotEmpty(props.resultId) - ? props.resultId - : getExistingResultIdFromStorage(typebotIdFromProps), + resultId: isNotEmpty(props.resultId) ? props.resultId : resultIdInStorage, prefilledVariables: { ...prefilledVariables, ...props.prefilledVariables, @@ -111,17 +116,40 @@ export const Bot = (props: BotProps & { class?: string }) => { ) } - if (data.resultId && typebotIdFromProps) - setResultInStorage(data.typebot.settings.general?.rememberUser?.storage)( - typebotIdFromProps, - data.resultId + if ( + data.resultId && + typebotIdFromProps && + (data.typebot.settings.general?.rememberUser?.isEnabled ?? + defaultSettings.general.rememberUser.isEnabled) + ) { + if (resultIdInStorage && resultIdInStorage !== data.resultId) + wipeExistingChatStateInStorage(data.typebot.id) + const storage = + data.typebot.settings.general?.rememberUser?.storage ?? + defaultSettings.general.rememberUser.storage + setResultInStorage(storage)(typebotIdFromProps, data.resultId) + const initialChatInStorage = getInitialChatReplyFromStorage( + data.typebot.id ) - setInitialChatReply(data) - setCustomCss(data.typebot.theme.customCss ?? '') + if (initialChatInStorage) { + setInitialChatReply(initialChatInStorage) + } else { + setInitialChatReply(data) + setInitialChatReplyInStorage(data, { + typebotId: data.typebot.id, + storage, + }) + } + props.onChatStatePersisted?.(true) + } else { + setInitialChatReply(data) + if (data.input?.id && props.onNewInputBlock) + props.onNewInputBlock(data.input) + if (data.logs) props.onNewLogs?.(data.logs) + props.onChatStatePersisted?.(false) + } - if (data.input?.id && props.onNewInputBlock) - props.onNewInputBlock(data.input) - if (data.logs) props.onNewLogs?.(data.logs) + setCustomCss(data.typebot.theme.customCss ?? '') } createEffect(() => { @@ -178,6 +206,16 @@ export const Bot = (props: BotProps & { class?: string }) => { resultId: initialChatReply.resultId, sessionId: initialChatReply.sessionId, typebot: initialChatReply.typebot, + storage: + initialChatReply.typebot.settings.general?.rememberUser + ?.isEnabled && + !( + typeof props.typebot !== 'string' || + (props.isPreview ?? false) + ) + ? initialChatReply.typebot.settings.general?.rememberUser + ?.storage ?? defaultSettings.general.rememberUser.storage + : undefined, }} progressBarRef={props.progressBarRef} onNewInputBlock={props.onNewInputBlock} @@ -203,8 +241,12 @@ type BotContentProps = { } const BotContent = (props: BotContentProps) => { - const [progressValue, setProgressValue] = createSignal( - props.initialChatReply.progress + const [progressValue, setProgressValue] = persist( + createSignal(props.initialChatReply.progress), + { + storage: props.context.storage, + key: `typebot-${props.context.typebot.id}-progressValue`, + } ) let botContainer: HTMLDivElement | undefined diff --git a/packages/embeds/js/src/components/ConversationContainer/AvatarSideContainer.tsx b/packages/embeds/js/src/components/ConversationContainer/AvatarSideContainer.tsx index b9e3056c3..f50cd0900 100644 --- a/packages/embeds/js/src/components/ConversationContainer/AvatarSideContainer.tsx +++ b/packages/embeds/js/src/components/ConversationContainer/AvatarSideContainer.tsx @@ -2,7 +2,11 @@ import { createSignal, onCleanup, onMount } from 'solid-js' import { isMobile } from '@/utils/isMobileSignal' import { Avatar } from '../avatars/Avatar' -type Props = { hostAvatarSrc?: string; hideAvatar?: boolean } +type Props = { + hostAvatarSrc?: string + hideAvatar?: boolean + isTransitionDisabled?: boolean +} export const AvatarSideContainer = (props: Props) => { let avatarContainer: HTMLDivElement | undefined @@ -40,7 +44,9 @@ export const AvatarSideContainer = (props: Props) => { } style={{ top: `${top()}px`, - transition: 'top 350ms ease-out, opacity 250ms ease-out', + transition: props.isTransitionDisabled + ? undefined + : 'top 350ms ease-out, opacity 250ms ease-out', }} > diff --git a/packages/embeds/js/src/components/ConversationContainer/ChatChunk.tsx b/packages/embeds/js/src/components/ConversationContainer/ChatChunk.tsx index 55b2f72c0..a0ec18a9f 100644 --- a/packages/embeds/js/src/components/ConversationContainer/ChatChunk.tsx +++ b/packages/embeds/js/src/components/ConversationContainer/ChatChunk.tsx @@ -12,11 +12,12 @@ import { defaultSettings } from '@typebot.io/schemas/features/typebot/settings/c type Props = Pick & { theme: Theme settings: Settings - inputIndex: number + index: number context: BotContext hasError: boolean hideAvatar: boolean streamingMessageId: ChatChunkType['streamingMessageId'] + isTransitionDisabled?: boolean onNewBubbleDisplayed: (blockId: string) => Promise onScrollToBottom: (top?: number) => void onSubmit: (input?: string) => void @@ -26,7 +27,9 @@ type Props = Pick & { export const ChatChunk = (props: Props) => { let inputRef: HTMLDivElement | undefined - const [displayedMessageIndex, setDisplayedMessageIndex] = createSignal(0) + const [displayedMessageIndex, setDisplayedMessageIndex] = createSignal( + props.isTransitionDisabled ? props.messages.length : 0 + ) const [lastBubbleOffsetTop, setLastBubbleOffsetTop] = createSignal() onMount(() => { @@ -45,7 +48,6 @@ export const ChatChunk = (props: Props) => { defaultSettings.typingEmulation.delayBetweenBubbles) > 0 && displayedMessageIndex() < props.messages.length - 1 ) { - // eslint-disable-next-line solid/reactivity await new Promise((resolve) => setTimeout( resolve, @@ -82,6 +84,7 @@ export const ChatChunk = (props: Props) => { @@ -108,10 +111,12 @@ export const ChatChunk = (props: Props) => { (props.settings.typingEmulation?.isDisabledOnFirstMessage ?? defaultSettings.typingEmulation .isDisabledOnFirstMessage) && - props.inputIndex === 0 && + props.index === 0 && idx() === 0 } - onTransitionEnd={displayNextMessage} + onTransitionEnd={ + props.isTransitionDisabled ? undefined : displayNextMessage + } onCompleted={props.onSubmit} /> )} @@ -123,7 +128,7 @@ export const ChatChunk = (props: Props) => { { let chatContainer: HTMLDivElement | undefined - const [chatChunks, setChatChunks] = createSignal([ + const [chatChunks, setChatChunks] = persist( + createSignal([ + { + input: props.initialChatReply.input, + messages: props.initialChatReply.messages, + clientSideActions: props.initialChatReply.clientSideActions, + }, + ]), { - input: props.initialChatReply.input, - messages: props.initialChatReply.messages, - clientSideActions: props.initialChatReply.clientSideActions, - }, - ]) + key: `typebot-${props.context.typebot.id}-chatChunks`, + storage: props.context.storage, + } + ) const [dynamicTheme, setDynamicTheme] = createSignal< ContinueChatResponse['dynamicTheme'] >(props.initialChatReply.dynamicTheme) @@ -276,7 +283,7 @@ export const ConversationContainer = (props: Props) => { {(chatChunk, index) => ( { (chatChunk.messages.length > 0 && isSending())) } hasError={hasError() && index() === chatChunks().length - 1} + isTransitionDisabled={index() !== chatChunks().length - 1} onNewBubbleDisplayed={handleNewBubbleDisplayed} onAllBubblesDisplayed={handleAllBubblesDisplayed} onSubmit={sendMessage} diff --git a/packages/embeds/js/src/components/InputChatBlock.tsx b/packages/embeds/js/src/components/InputChatBlock.tsx index a5b2154bb..db9a2065f 100644 --- a/packages/embeds/js/src/components/InputChatBlock.tsx +++ b/packages/embeds/js/src/components/InputChatBlock.tsx @@ -36,13 +36,14 @@ import { formattedMessages } from '@/utils/formattedMessagesSignal' import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/constants' import { defaultPaymentInputOptions } from '@typebot.io/schemas/features/blocks/inputs/payment/constants' import { defaultTheme } from '@typebot.io/schemas/features/typebot/theme/constants' +import { persist } from '@/utils/persist' type Props = { ref: HTMLDivElement | undefined block: NonNullable hasHostAvatar: boolean guestAvatar?: NonNullable['guestAvatar'] - inputIndex: number + chunkIndex: number context: BotContext isInputPrefillEnabled: boolean hasError: boolean @@ -52,7 +53,10 @@ type Props = { } export const InputChatBlock = (props: Props) => { - const [answer, setAnswer] = createSignal() + const [answer, setAnswer] = persist(createSignal(), { + key: `typebot-${props.context.typebot.id}-input-${props.chunkIndex}`, + storage: props.context.storage, + }) const [formattedMessage, setFormattedMessage] = createSignal() const handleSubmit = async ({ label, value }: InputSubmitContent) => { @@ -67,7 +71,7 @@ export const InputChatBlock = (props: Props) => { createEffect(() => { const formattedMessage = formattedMessages().findLast( - (message) => props.inputIndex === message.inputIndex + (message) => props.chunkIndex === message.inputIndex )?.formattedMessage if (formattedMessage) setFormattedMessage(formattedMessage) }) @@ -101,7 +105,7 @@ export const InputChatBlock = (props: Props) => { { const Input = (props: { context: BotContext block: NonNullable - inputIndex: number + chunkIndex: number isInputPrefillEnabled: boolean existingAnswer?: string onTransitionEnd: () => void @@ -189,7 +193,7 @@ const Input = (props: { void + onTransitionEnd?: (offsetTop?: number) => void onCompleted: (reply?: string) => void } -export const HostBubble = (props: Props) => { - const onTransitionEnd = (offsetTop?: number) => { - props.onTransitionEnd(offsetTop) - } - - const onCompleted = (reply?: string) => { - props.onCompleted(reply) - } - - return ( - - - - - - - - - - - - - - - - - - - - - ) -} +export const HostBubble = (props: Props) => ( + + + + + + + + + + + + + + + + + + + + +) diff --git a/packages/embeds/js/src/features/blocks/bubbles/audio/components/AudioBubble.tsx b/packages/embeds/js/src/features/blocks/bubbles/audio/components/AudioBubble.tsx index 6c106a403..7324bcdd2 100644 --- a/packages/embeds/js/src/features/blocks/bubbles/audio/components/AudioBubble.tsx +++ b/packages/embeds/js/src/features/blocks/bubbles/audio/components/AudioBubble.tsx @@ -3,10 +3,11 @@ import { isMobile } from '@/utils/isMobileSignal' import { AudioBubbleBlock } from '@typebot.io/schemas' import { createSignal, onCleanup, onMount } from 'solid-js' import { defaultAudioBubbleContent } from '@typebot.io/schemas/features/blocks/bubbles/audio/constants' +import clsx from 'clsx' type Props = { content: AudioBubbleBlock['content'] - onTransitionEnd: (offsetTop?: number) => void + onTransitionEnd?: (offsetTop?: number) => void } const showAnimationDuration = 400 @@ -18,7 +19,9 @@ export const AudioBubble = (props: Props) => { let isPlayed = false let ref: HTMLDivElement | undefined let audioElement: HTMLAudioElement | undefined - const [isTyping, setIsTyping] = createSignal(true) + const [isTyping, setIsTyping] = createSignal( + props.onTransitionEnd ? true : false + ) onMount(() => { typingTimeout = setTimeout(() => { @@ -26,7 +29,7 @@ export const AudioBubble = (props: Props) => { isPlayed = true setIsTyping(false) setTimeout( - () => props.onTransitionEnd(ref?.offsetTop), + () => props.onTransitionEnd?.(ref?.offsetTop), showAnimationDuration ) }, typingDuration) @@ -37,7 +40,13 @@ export const AudioBubble = (props: Props) => { }) return ( -
+
{ ref={audioElement} src={props.content?.url} autoplay={ - props.content?.isAutoplayEnabled ?? - defaultAudioBubbleContent.isAutoplayEnabled + props.onTransitionEnd + ? props.content?.isAutoplayEnabled ?? + defaultAudioBubbleContent.isAutoplayEnabled + : false } class={ 'z-10 text-fade-in ' + diff --git a/packages/embeds/js/src/features/blocks/bubbles/embed/components/CustomEmbedBubble.tsx b/packages/embeds/js/src/features/blocks/bubbles/embed/components/CustomEmbedBubble.tsx index af5635f3d..b7f839fb9 100644 --- a/packages/embeds/js/src/features/blocks/bubbles/embed/components/CustomEmbedBubble.tsx +++ b/packages/embeds/js/src/features/blocks/bubbles/embed/components/CustomEmbedBubble.tsx @@ -7,7 +7,7 @@ import { executeCode } from '@/features/blocks/logic/script/executeScript' type Props = { content: CustomEmbedBubbleProps['content'] - onTransitionEnd: (offsetTop?: number) => void + onTransitionEnd?: (offsetTop?: number) => void onCompleted: (reply?: string) => void } @@ -17,7 +17,9 @@ export const showAnimationDuration = 400 export const CustomEmbedBubble = (props: Props) => { let ref: HTMLDivElement | undefined - const [isTyping, setIsTyping] = createSignal(true) + const [isTyping, setIsTyping] = createSignal( + props.onTransitionEnd ? true : false + ) let containerRef: HTMLDivElement | undefined onMount(() => { @@ -41,7 +43,7 @@ export const CustomEmbedBubble = (props: Props) => { typingTimeout = setTimeout(() => { setIsTyping(false) setTimeout( - () => props.onTransitionEnd(ref?.offsetTop), + () => props.onTransitionEnd?.(ref?.offsetTop), showAnimationDuration ) }, 2000) @@ -52,7 +54,13 @@ export const CustomEmbedBubble = (props: Props) => { }) return ( -
+
void + onTransitionEnd?: (offsetTop?: number) => void } let typingTimeout: NodeJS.Timeout @@ -16,13 +16,15 @@ export const showAnimationDuration = 400 export const EmbedBubble = (props: Props) => { let ref: HTMLDivElement | undefined - const [isTyping, setIsTyping] = createSignal(true) + const [isTyping, setIsTyping] = createSignal( + props.onTransitionEnd ? true : false + ) onMount(() => { typingTimeout = setTimeout(() => { setIsTyping(false) setTimeout(() => { - props.onTransitionEnd(ref?.offsetTop) + props.onTransitionEnd?.(ref?.offsetTop) }, showAnimationDuration) }, 2000) }) @@ -32,7 +34,13 @@ export const EmbedBubble = (props: Props) => { }) return ( -
+
void + onTransitionEnd?: (offsetTop?: number) => void } export const showAnimationDuration = 400 @@ -19,13 +19,15 @@ let typingTimeout: NodeJS.Timeout export const ImageBubble = (props: Props) => { let ref: HTMLDivElement | undefined let image: HTMLImageElement | undefined - const [isTyping, setIsTyping] = createSignal(true) + const [isTyping, setIsTyping] = createSignal( + props.onTransitionEnd ? true : false + ) const onTypingEnd = () => { if (!isTyping()) return setIsTyping(false) setTimeout(() => { - props.onTransitionEnd(ref?.offsetTop) + props.onTransitionEnd?.(ref?.offsetTop) }, showAnimationDuration) } @@ -49,9 +51,11 @@ export const ImageBubble = (props: Props) => { alt={ props.content?.clickLink?.alt ?? defaultImageBubbleContent.clickLink.alt } - class={ - 'text-fade-in w-full ' + (isTyping() ? 'opacity-0' : 'opacity-100') - } + class={clsx( + 'w-full', + isTyping() ? 'opacity-0' : 'opacity-100', + props.onTransitionEnd ? 'text-fade-in' : undefined + )} style={{ 'max-height': '512px', height: isTyping() ? '32px' : 'auto', @@ -62,7 +66,13 @@ export const ImageBubble = (props: Props) => { ) return ( -
+
void + onTransitionEnd?: (offsetTop?: number) => void } export const showAnimationDuration = 400 @@ -20,13 +20,15 @@ let typingTimeout: NodeJS.Timeout export const TextBubble = (props: Props) => { let ref: HTMLDivElement | undefined - const [isTyping, setIsTyping] = createSignal(true) + const [isTyping, setIsTyping] = createSignal( + props.onTransitionEnd ? true : false + ) const onTypingEnd = () => { if (!isTyping()) return setIsTyping(false) setTimeout(() => { - props.onTransitionEnd(ref?.offsetTop) + props.onTransitionEnd?.(ref?.offsetTop) }, showAnimationDuration) } @@ -50,7 +52,13 @@ export const TextBubble = (props: Props) => { }) return ( -
+
void + onTransitionEnd?: (offsetTop?: number) => void } export const showAnimationDuration = 400 @@ -23,7 +23,9 @@ let typingTimeout: NodeJS.Timeout export const VideoBubble = (props: Props) => { let ref: HTMLDivElement | undefined - const [isTyping, setIsTyping] = createSignal(true) + const [isTyping, setIsTyping] = createSignal( + props.onTransitionEnd ? true : false + ) onMount(() => { const typingDuration = @@ -37,7 +39,7 @@ export const VideoBubble = (props: Props) => { if (!isTyping()) return setIsTyping(false) setTimeout(() => { - props.onTransitionEnd(ref?.offsetTop) + props.onTransitionEnd?.(ref?.offsetTop) }, showAnimationDuration) }, typingDuration) }) @@ -47,7 +49,13 @@ export const VideoBubble = (props: Props) => { }) return ( -
+
{ } >
diff --git a/packages/embeds/js/src/types.ts b/packages/embeds/js/src/types.ts index dba617d03..f126df8fc 100644 --- a/packages/embeds/js/src/types.ts +++ b/packages/embeds/js/src/types.ts @@ -11,6 +11,7 @@ export type BotContext = { isPreview: boolean apiHost?: string sessionId: string + storage: 'local' | 'session' | undefined } export type InitialChatReply = StartChatResponse & { diff --git a/packages/embeds/js/src/utils/injectStartProps.ts b/packages/embeds/js/src/utils/injectStartProps.ts index 30083cc2d..79a2d6e06 100644 --- a/packages/embeds/js/src/utils/injectStartProps.ts +++ b/packages/embeds/js/src/utils/injectStartProps.ts @@ -1,4 +1,3 @@ -/* eslint-disable solid/reactivity */ import { initGoogleAnalytics } from '@/lib/gtag' import { gtmBodyElement } from '@/lib/gtm' import { initPixel } from '@/lib/pixel' diff --git a/packages/embeds/js/src/utils/persist.ts b/packages/embeds/js/src/utils/persist.ts new file mode 100644 index 000000000..f95b9fbeb --- /dev/null +++ b/packages/embeds/js/src/utils/persist.ts @@ -0,0 +1,54 @@ +// Copied from https://github.com/solidjs-community/solid-primitives/blob/main/packages/storage/src/types.ts +// Simplifying and adding a `isEnabled` prop + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { defaultSettings } from '@typebot.io/schemas/features/typebot/settings/constants' +import type { Setter, Signal } from 'solid-js' +import { untrack } from 'solid-js' +import { reconcile } from 'solid-js/store' + +type Params = { + key: string + storage: 'local' | 'session' | undefined +} + +export function persist(signal: Signal, params: Params): Signal { + if (!params.storage) return signal + + const storage = parseRememberUserStorage( + params.storage || defaultSettings.general.rememberUser.storage + ) + const serialize: (data: T) => string = JSON.stringify.bind(JSON) + const deserialize: (data: string) => T = JSON.parse.bind(JSON) + const init = storage.getItem(params.key) + const set = + typeof signal[0] === 'function' + ? (data: string) => (signal[1] as any)(() => deserialize(data)) + : (data: string) => (signal[1] as any)(reconcile(deserialize(data))) + + if (init) set(init) + + return [ + signal[0], + typeof signal[0] === 'function' + ? (value?: T | ((prev: T) => T)) => { + const output = (signal[1] as Setter)(value as any) + + if (value) storage.setItem(params.key, serialize(output)) + else storage.removeItem(params.key) + return output + } + : (...args: any[]) => { + ;(signal[1] as any)(...args) + const value = serialize(untrack(() => signal[0] as any)) + storage.setItem(params.key, value) + }, + ] as typeof signal +} + +const parseRememberUserStorage = ( + storage: 'local' | 'session' | undefined +): typeof localStorage | typeof sessionStorage => + (storage ?? defaultSettings.general.rememberUser.storage) === 'session' + ? sessionStorage + : localStorage diff --git a/packages/embeds/js/src/utils/storage.ts b/packages/embeds/js/src/utils/storage.ts index e276f18e2..3c18dd25b 100644 --- a/packages/embeds/js/src/utils/storage.ts +++ b/packages/embeds/js/src/utils/storage.ts @@ -1,11 +1,14 @@ -const sessionStorageKey = 'resultId' +import { InitialChatReply } from '@/types' +import { defaultSettings } from '@typebot.io/schemas/features/typebot/settings/constants' + +const storageResultIdKey = 'resultId' export const getExistingResultIdFromStorage = (typebotId?: string) => { if (!typebotId) return try { return ( - sessionStorage.getItem(`${sessionStorageKey}-${typebotId}`) ?? - localStorage.getItem(`${sessionStorageKey}-${typebotId}`) ?? + sessionStorage.getItem(`${storageResultIdKey}-${typebotId}`) ?? + localStorage.getItem(`${storageResultIdKey}-${typebotId}`) ?? undefined ) } catch { @@ -17,13 +20,86 @@ export const setResultInStorage = (storageType: 'local' | 'session' = 'session') => (typebotId: string, resultId: string) => { try { - ;(storageType === 'session' ? localStorage : sessionStorage).removeItem( - `${sessionStorageKey}-${typebotId}` + parseRememberUserStorage(storageType).setItem( + `${storageResultIdKey}-${typebotId}`, + resultId ) - return ( - storageType === 'session' ? sessionStorage : localStorage - ).setItem(`${sessionStorageKey}-${typebotId}`, resultId) } catch { /* empty */ } } + +export const getInitialChatReplyFromStorage = ( + typebotId: string | undefined +) => { + if (!typebotId) return + try { + const rawInitialChatReply = + sessionStorage.getItem(`typebot-${typebotId}-initialChatReply`) ?? + localStorage.getItem(`typebot-${typebotId}-initialChatReply`) + if (!rawInitialChatReply) return + return JSON.parse(rawInitialChatReply) as InitialChatReply + } catch { + /* empty */ + } +} +export const setInitialChatReplyInStorage = ( + initialChatReply: InitialChatReply, + { + typebotId, + storage, + }: { + typebotId: string + storage?: 'local' | 'session' + } +) => { + try { + const rawInitialChatReply = JSON.stringify(initialChatReply) + parseRememberUserStorage(storage).setItem( + `typebot-${typebotId}-initialChatReply`, + rawInitialChatReply + ) + } catch { + /* empty */ + } +} + +export const setBotOpenedStateInStorage = () => { + try { + sessionStorage.setItem(`typebot-botOpened`, 'true') + } catch { + /* empty */ + } +} + +export const removeBotOpenedStateInStorage = () => { + try { + sessionStorage.removeItem(`typebot-botOpened`) + } catch { + /* empty */ + } +} + +export const getBotOpenedStateFromStorage = () => { + try { + return sessionStorage.getItem(`typebot-botOpened`) === 'true' + } catch { + return false + } +} + +export const parseRememberUserStorage = ( + storage: 'local' | 'session' | undefined +): typeof localStorage | typeof sessionStorage => + (storage ?? defaultSettings.general.rememberUser.storage) === 'session' + ? sessionStorage + : localStorage + +export const wipeExistingChatStateInStorage = (typebotId: string) => { + Object.keys(localStorage).forEach((key) => { + if (key.startsWith(`typebot-${typebotId}`)) localStorage.removeItem(key) + }) + Object.keys(sessionStorage).forEach((key) => { + if (key.startsWith(`typebot-${typebotId}`)) sessionStorage.removeItem(key) + }) +} diff --git a/packages/embeds/js/src/window.ts b/packages/embeds/js/src/window.ts index 560855b04..62f3debb1 100644 --- a/packages/embeds/js/src/window.ts +++ b/packages/embeds/js/src/window.ts @@ -1,4 +1,3 @@ -/* eslint-disable solid/reactivity */ import { BubbleProps } from './features/bubble' import { PopupProps } from './features/popup' import { BotProps } from './components/Bot' diff --git a/packages/embeds/nextjs/package.json b/packages/embeds/nextjs/package.json index bc4871cac..d24a7ffb9 100644 --- a/packages/embeds/nextjs/package.json +++ b/packages/embeds/nextjs/package.json @@ -1,6 +1,6 @@ { "name": "@typebot.io/nextjs", - "version": "0.2.48", + "version": "0.2.49", "description": "Convenient library to display typebots on your Next.js website", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/embeds/react/package.json b/packages/embeds/react/package.json index ad4bb964f..49e0052c2 100644 --- a/packages/embeds/react/package.json +++ b/packages/embeds/react/package.json @@ -1,6 +1,6 @@ { "name": "@typebot.io/react", - "version": "0.2.48", + "version": "0.2.49", "description": "Convenient library to display typebots on your React app", "main": "dist/index.js", "types": "dist/index.d.ts",