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:
Baptiste Arnaud
2024-03-07 15:39:09 +01:00
committed by GitHub
parent 583294f90c
commit 0dc276c18f
31 changed files with 427 additions and 154 deletions

View File

@@ -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

View File

@@ -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} />

View File

@@ -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

View File

@@ -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}

View File

@@ -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}

View File

@@ -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>
)