⚡ Restore chat state when user is remembered (#1333)
Closes #993 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -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<number | undefined>(
|
||||
props.initialChatReply.progress
|
||||
const [progressValue, setProgressValue] = persist(
|
||||
createSignal<number | undefined>(props.initialChatReply.progress),
|
||||
{
|
||||
storage: props.context.storage,
|
||||
key: `typebot-${props.context.typebot.id}-progressValue`,
|
||||
}
|
||||
)
|
||||
let botContainer: HTMLDivElement | undefined
|
||||
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
>
|
||||
<Avatar initialAvatarSrc={props.hostAvatarSrc} />
|
||||
|
||||
@@ -12,11 +12,12 @@ import { defaultSettings } from '@typebot.io/schemas/features/typebot/settings/c
|
||||
type Props = Pick<ContinueChatResponse, 'messages' | 'input'> & {
|
||||
theme: Theme
|
||||
settings: Settings
|
||||
inputIndex: number
|
||||
index: number
|
||||
context: BotContext
|
||||
hasError: boolean
|
||||
hideAvatar: boolean
|
||||
streamingMessageId: ChatChunkType['streamingMessageId']
|
||||
isTransitionDisabled?: boolean
|
||||
onNewBubbleDisplayed: (blockId: string) => Promise<void>
|
||||
onScrollToBottom: (top?: number) => void
|
||||
onSubmit: (input?: string) => void
|
||||
@@ -26,7 +27,9 @@ type Props = Pick<ContinueChatResponse, 'messages' | 'input'> & {
|
||||
|
||||
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<number>()
|
||||
|
||||
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) => {
|
||||
<AvatarSideContainer
|
||||
hostAvatarSrc={props.theme.chat?.hostAvatar?.url}
|
||||
hideAvatar={props.hideAvatar}
|
||||
isTransitionDisabled={props.isTransitionDisabled}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
@@ -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) => {
|
||||
<InputChatBlock
|
||||
ref={inputRef}
|
||||
block={props.input}
|
||||
inputIndex={props.inputIndex}
|
||||
chunkIndex={props.index}
|
||||
hasHostAvatar={
|
||||
props.theme.chat?.hostAvatar?.isEnabled ??
|
||||
defaultTheme.chat.hostAvatar.isEnabled
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/constants'
|
||||
import { saveClientLogsQuery } from '@/queries/saveClientLogsQuery'
|
||||
import { HTTPError } from 'ky'
|
||||
import { persist } from '@/utils/persist'
|
||||
|
||||
const parseDynamicTheme = (
|
||||
initialTheme: Theme,
|
||||
@@ -69,13 +70,19 @@ type Props = {
|
||||
|
||||
export const ConversationContainer = (props: Props) => {
|
||||
let chatContainer: HTMLDivElement | undefined
|
||||
const [chatChunks, setChatChunks] = createSignal<ChatChunkType[]>([
|
||||
const [chatChunks, setChatChunks] = persist(
|
||||
createSignal<ChatChunkType[]>([
|
||||
{
|
||||
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) => {
|
||||
<For each={chatChunks()}>
|
||||
{(chatChunk, index) => (
|
||||
<ChatChunk
|
||||
inputIndex={index()}
|
||||
index={index()}
|
||||
messages={chatChunk.messages}
|
||||
input={chatChunk.input}
|
||||
theme={theme()}
|
||||
@@ -290,6 +297,7 @@ export const ConversationContainer = (props: Props) => {
|
||||
(chatChunk.messages.length > 0 && isSending()))
|
||||
}
|
||||
hasError={hasError() && index() === chatChunks().length - 1}
|
||||
isTransitionDisabled={index() !== chatChunks().length - 1}
|
||||
onNewBubbleDisplayed={handleNewBubbleDisplayed}
|
||||
onAllBubblesDisplayed={handleAllBubblesDisplayed}
|
||||
onSubmit={sendMessage}
|
||||
|
||||
@@ -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<ContinueChatResponse['input']>
|
||||
hasHostAvatar: boolean
|
||||
guestAvatar?: NonNullable<Theme['chat']>['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<string>()
|
||||
const [answer, setAnswer] = persist(createSignal<string>(), {
|
||||
key: `typebot-${props.context.typebot.id}-input-${props.chunkIndex}`,
|
||||
storage: props.context.storage,
|
||||
})
|
||||
const [formattedMessage, setFormattedMessage] = createSignal<string>()
|
||||
|
||||
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) => {
|
||||
<Input
|
||||
context={props.context}
|
||||
block={props.block}
|
||||
inputIndex={props.inputIndex}
|
||||
chunkIndex={props.chunkIndex}
|
||||
isInputPrefillEnabled={props.isInputPrefillEnabled}
|
||||
existingAnswer={props.hasError ? answer() : undefined}
|
||||
onTransitionEnd={props.onTransitionEnd}
|
||||
@@ -117,7 +121,7 @@ export const InputChatBlock = (props: Props) => {
|
||||
const Input = (props: {
|
||||
context: BotContext
|
||||
block: NonNullable<ContinueChatResponse['input']>
|
||||
inputIndex: number
|
||||
chunkIndex: number
|
||||
isInputPrefillEnabled: boolean
|
||||
existingAnswer?: string
|
||||
onTransitionEnd: () => void
|
||||
@@ -189,7 +193,7 @@ const Input = (props: {
|
||||
<Switch>
|
||||
<Match when={!block.options?.isMultipleChoice}>
|
||||
<Buttons
|
||||
inputIndex={props.inputIndex}
|
||||
chunkIndex={props.chunkIndex}
|
||||
defaultItems={block.items}
|
||||
options={block.options}
|
||||
onSubmit={onSubmit}
|
||||
@@ -197,7 +201,6 @@ const Input = (props: {
|
||||
</Match>
|
||||
<Match when={block.options?.isMultipleChoice}>
|
||||
<MultipleChoicesForm
|
||||
inputIndex={props.inputIndex}
|
||||
defaultItems={block.items}
|
||||
options={block.options}
|
||||
onSubmit={onSubmit}
|
||||
|
||||
@@ -21,60 +21,50 @@ type Props = {
|
||||
message: ChatMessage
|
||||
typingEmulation: Settings['typingEmulation']
|
||||
isTypingSkipped: boolean
|
||||
onTransitionEnd: (offsetTop?: number) => 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 (
|
||||
<Switch>
|
||||
<Match when={props.message.type === BubbleBlockType.TEXT}>
|
||||
<TextBubble
|
||||
content={props.message.content as TextBubbleBlock['content']}
|
||||
isTypingSkipped={props.isTypingSkipped}
|
||||
typingEmulation={props.typingEmulation}
|
||||
onTransitionEnd={onTransitionEnd}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.message.type === BubbleBlockType.IMAGE}>
|
||||
<ImageBubble
|
||||
content={props.message.content as ImageBubbleBlock['content']}
|
||||
onTransitionEnd={onTransitionEnd}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.message.type === BubbleBlockType.VIDEO}>
|
||||
<VideoBubble
|
||||
content={props.message.content as VideoBubbleBlock['content']}
|
||||
onTransitionEnd={onTransitionEnd}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.message.type === BubbleBlockType.EMBED}>
|
||||
<EmbedBubble
|
||||
content={props.message.content as EmbedBubbleBlock['content']}
|
||||
onTransitionEnd={onTransitionEnd}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.message.type === 'custom-embed'}>
|
||||
<CustomEmbedBubble
|
||||
content={props.message.content as CustomEmbedBubbleProps['content']}
|
||||
onTransitionEnd={onTransitionEnd}
|
||||
onCompleted={onCompleted}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.message.type === BubbleBlockType.AUDIO}>
|
||||
<AudioBubble
|
||||
content={props.message.content as AudioBubbleBlock['content']}
|
||||
onTransitionEnd={onTransitionEnd}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
export const HostBubble = (props: Props) => (
|
||||
<Switch>
|
||||
<Match when={props.message.type === BubbleBlockType.TEXT}>
|
||||
<TextBubble
|
||||
content={props.message.content as TextBubbleBlock['content']}
|
||||
isTypingSkipped={props.isTypingSkipped}
|
||||
typingEmulation={props.typingEmulation}
|
||||
onTransitionEnd={props.onTransitionEnd}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.message.type === BubbleBlockType.IMAGE}>
|
||||
<ImageBubble
|
||||
content={props.message.content as ImageBubbleBlock['content']}
|
||||
onTransitionEnd={props.onTransitionEnd}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.message.type === BubbleBlockType.VIDEO}>
|
||||
<VideoBubble
|
||||
content={props.message.content as VideoBubbleBlock['content']}
|
||||
onTransitionEnd={props.onTransitionEnd}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.message.type === BubbleBlockType.EMBED}>
|
||||
<EmbedBubble
|
||||
content={props.message.content as EmbedBubbleBlock['content']}
|
||||
onTransitionEnd={props.onTransitionEnd}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.message.type === 'custom-embed'}>
|
||||
<CustomEmbedBubble
|
||||
content={props.message.content as CustomEmbedBubbleProps['content']}
|
||||
onTransitionEnd={props.onTransitionEnd}
|
||||
onCompleted={props.onCompleted}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.message.type === BubbleBlockType.AUDIO}>
|
||||
<AudioBubble
|
||||
content={props.message.content as AudioBubbleBlock['content']}
|
||||
onTransitionEnd={props.onTransitionEnd}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user