♻️ Re-organize workspace folders
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import { createSignal, onCleanup, onMount } from 'solid-js'
|
||||
import { isMobile } from '@/utils/isMobileSignal'
|
||||
import { Avatar } from '../avatars/Avatar'
|
||||
|
||||
type Props = { hostAvatarSrc?: string }
|
||||
|
||||
export const AvatarSideContainer = (props: Props) => {
|
||||
let avatarContainer: HTMLDivElement | undefined
|
||||
const [top, setTop] = createSignal<number>(0)
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) =>
|
||||
setTop(entries[0].target.clientHeight - (isMobile() ? 24 : 40))
|
||||
)
|
||||
|
||||
onMount(() => {
|
||||
if (avatarContainer) {
|
||||
resizeObserver.observe(avatarContainer)
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (avatarContainer) {
|
||||
resizeObserver.unobserve(avatarContainer)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={avatarContainer}
|
||||
class={
|
||||
'flex mr-2 mb-2 flex-shrink-0 items-center relative typebot-avatar-container ' +
|
||||
(isMobile() ? 'w-6' : 'w-10')
|
||||
}
|
||||
>
|
||||
<div
|
||||
class={
|
||||
'absolute mb-2 flex items-center top-0 ' +
|
||||
(isMobile() ? 'w-6 h-6' : 'w-10 h-10')
|
||||
}
|
||||
style={{
|
||||
top: `${top()}px`,
|
||||
transition: 'top 350ms ease-out',
|
||||
}}
|
||||
>
|
||||
<Avatar initialAvatarSrc={props.hostAvatarSrc} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { BotContext } from '@/types'
|
||||
import { isMobile } from '@/utils/isMobileSignal'
|
||||
import type { ChatReply, Settings, Theme } from '@typebot.io/schemas'
|
||||
import { createSignal, For, onMount, Show } from 'solid-js'
|
||||
import { HostBubble } from '../bubbles/HostBubble'
|
||||
import { InputChatBlock } from '../InputChatBlock'
|
||||
import { AvatarSideContainer } from './AvatarSideContainer'
|
||||
|
||||
type Props = Pick<ChatReply, 'messages' | 'input'> & {
|
||||
theme: Theme
|
||||
settings: Settings
|
||||
inputIndex: number
|
||||
context: BotContext
|
||||
isLoadingBubbleDisplayed: boolean
|
||||
onNewBubbleDisplayed: (blockId: string) => Promise<void>
|
||||
onScrollToBottom: () => void
|
||||
onSubmit: (input: string) => void
|
||||
onSkip: () => void
|
||||
onAllBubblesDisplayed: () => void
|
||||
}
|
||||
|
||||
export const ChatChunk = (props: Props) => {
|
||||
const [displayedMessageIndex, setDisplayedMessageIndex] = createSignal(0)
|
||||
|
||||
onMount(() => {
|
||||
if (props.messages.length === 0) {
|
||||
props.onAllBubblesDisplayed()
|
||||
}
|
||||
props.onScrollToBottom()
|
||||
})
|
||||
|
||||
const displayNextMessage = async () => {
|
||||
const lastBubbleBlockId = props.messages[displayedMessageIndex()].id
|
||||
await props.onNewBubbleDisplayed(lastBubbleBlockId)
|
||||
setDisplayedMessageIndex(
|
||||
displayedMessageIndex() === props.messages.length
|
||||
? displayedMessageIndex()
|
||||
: displayedMessageIndex() + 1
|
||||
)
|
||||
props.onScrollToBottom()
|
||||
if (displayedMessageIndex() === props.messages.length) {
|
||||
props.onAllBubblesDisplayed()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex w-full">
|
||||
<div class="flex flex-col w-full min-w-0">
|
||||
<div class="flex">
|
||||
<Show
|
||||
when={
|
||||
props.theme.chat.hostAvatar?.isEnabled &&
|
||||
props.messages.length > 0
|
||||
}
|
||||
>
|
||||
<AvatarSideContainer
|
||||
hostAvatarSrc={props.theme.chat.hostAvatar?.url}
|
||||
/>
|
||||
</Show>
|
||||
<div
|
||||
class="flex-1"
|
||||
style={{
|
||||
'margin-right': props.theme.chat.guestAvatar?.isEnabled
|
||||
? isMobile()
|
||||
? '32px'
|
||||
: '48px'
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<For each={props.messages.slice(0, displayedMessageIndex() + 1)}>
|
||||
{(message) => (
|
||||
<HostBubble
|
||||
message={message}
|
||||
typingEmulation={props.settings.typingEmulation}
|
||||
onTransitionEnd={displayNextMessage}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
{props.input && displayedMessageIndex() === props.messages.length && (
|
||||
<InputChatBlock
|
||||
block={props.input}
|
||||
inputIndex={props.inputIndex}
|
||||
onSubmit={props.onSubmit}
|
||||
onSkip={props.onSkip}
|
||||
hasHostAvatar={props.theme.chat.hostAvatar?.isEnabled ?? false}
|
||||
guestAvatar={props.theme.chat.guestAvatar}
|
||||
context={props.context}
|
||||
isInputPrefillEnabled={
|
||||
props.settings.general.isInputPrefillEnabled ?? true
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { ChatReply, Theme } from '@typebot.io/schemas'
|
||||
import { createEffect, createSignal, For, onMount, Show } from 'solid-js'
|
||||
import { sendMessageQuery } from '@/queries/sendMessageQuery'
|
||||
import { ChatChunk } from './ChatChunk'
|
||||
import { BotContext, InitialChatReply, OutgoingLog } from '@/types'
|
||||
import { isNotDefined } from '@typebot.io/lib'
|
||||
import { executeClientSideAction } from '@/utils/executeClientSideActions'
|
||||
import { LoadingChunk } from './LoadingChunk'
|
||||
import { PopupBlockedToast } from './PopupBlockedToast'
|
||||
|
||||
const parseDynamicTheme = (
|
||||
initialTheme: Theme,
|
||||
dynamicTheme: ChatReply['dynamicTheme']
|
||||
): Theme => ({
|
||||
...initialTheme,
|
||||
chat: {
|
||||
...initialTheme.chat,
|
||||
hostAvatar:
|
||||
initialTheme.chat.hostAvatar && dynamicTheme?.hostAvatarUrl
|
||||
? {
|
||||
...initialTheme.chat.hostAvatar,
|
||||
url: dynamicTheme.hostAvatarUrl,
|
||||
}
|
||||
: initialTheme.chat.hostAvatar,
|
||||
guestAvatar:
|
||||
initialTheme.chat.guestAvatar && dynamicTheme?.guestAvatarUrl
|
||||
? {
|
||||
...initialTheme.chat.guestAvatar,
|
||||
url: dynamicTheme?.guestAvatarUrl,
|
||||
}
|
||||
: initialTheme.chat.guestAvatar,
|
||||
},
|
||||
})
|
||||
|
||||
type Props = {
|
||||
initialChatReply: InitialChatReply
|
||||
context: BotContext
|
||||
onNewInputBlock?: (ids: { id: string; groupId: string }) => void
|
||||
onAnswer?: (answer: { message: string; blockId: string }) => void
|
||||
onEnd?: () => void
|
||||
onNewLogs?: (logs: OutgoingLog[]) => void
|
||||
}
|
||||
|
||||
export const ConversationContainer = (props: Props) => {
|
||||
let chatContainer: HTMLDivElement | undefined
|
||||
let bottomSpacer: HTMLDivElement | undefined
|
||||
const [chatChunks, setChatChunks] = createSignal<
|
||||
Pick<ChatReply, 'messages' | 'input' | 'clientSideActions'>[]
|
||||
>([
|
||||
{
|
||||
input: props.initialChatReply.input,
|
||||
messages: props.initialChatReply.messages,
|
||||
clientSideActions: props.initialChatReply.clientSideActions,
|
||||
},
|
||||
])
|
||||
const [dynamicTheme, setDynamicTheme] = createSignal<
|
||||
ChatReply['dynamicTheme']
|
||||
>(props.initialChatReply.dynamicTheme)
|
||||
const [theme, setTheme] = createSignal(props.initialChatReply.typebot.theme)
|
||||
const [isSending, setIsSending] = createSignal(false)
|
||||
const [blockedPopupUrl, setBlockedPopupUrl] = createSignal<string>()
|
||||
|
||||
onMount(() => {
|
||||
;(async () => {
|
||||
const initialChunk = chatChunks()[0]
|
||||
if (initialChunk.clientSideActions) {
|
||||
const actionsBeforeFirstBubble = initialChunk.clientSideActions.filter(
|
||||
(action) => isNotDefined(action.lastBubbleBlockId)
|
||||
)
|
||||
for (const action of actionsBeforeFirstBubble) {
|
||||
const response = await executeClientSideAction(action)
|
||||
if (response) setBlockedPopupUrl(response.blockedPopupUrl)
|
||||
}
|
||||
}
|
||||
})()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
setTheme(
|
||||
parseDynamicTheme(props.initialChatReply.typebot.theme, dynamicTheme())
|
||||
)
|
||||
})
|
||||
|
||||
const sendMessage = async (message: string | undefined) => {
|
||||
const currentBlockId = [...chatChunks()].pop()?.input?.id
|
||||
if (currentBlockId && props.onAnswer && message)
|
||||
props.onAnswer({ message, blockId: currentBlockId })
|
||||
const longRequest = setTimeout(() => {
|
||||
setIsSending(true)
|
||||
}, 1000)
|
||||
const data = await sendMessageQuery({
|
||||
apiHost: props.context.apiHost,
|
||||
sessionId: props.initialChatReply.sessionId,
|
||||
message,
|
||||
})
|
||||
clearTimeout(longRequest)
|
||||
setIsSending(false)
|
||||
if (!data) return
|
||||
if (data.logs) props.onNewLogs?.(data.logs)
|
||||
if (data.dynamicTheme) setDynamicTheme(data.dynamicTheme)
|
||||
if (data.input?.id && props.onNewInputBlock) {
|
||||
props.onNewInputBlock({
|
||||
id: data.input.id,
|
||||
groupId: data.input.groupId,
|
||||
})
|
||||
}
|
||||
if (data.clientSideActions) {
|
||||
const actionsBeforeFirstBubble = data.clientSideActions.filter((action) =>
|
||||
isNotDefined(action.lastBubbleBlockId)
|
||||
)
|
||||
for (const action of actionsBeforeFirstBubble) {
|
||||
const response = await executeClientSideAction(action)
|
||||
if (response) setBlockedPopupUrl(response.blockedPopupUrl)
|
||||
}
|
||||
}
|
||||
setChatChunks((displayedChunks) => [
|
||||
...displayedChunks,
|
||||
{
|
||||
input: data.input,
|
||||
messages: data.messages,
|
||||
clientSideActions: data.clientSideActions,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
const autoScrollToBottom = () => {
|
||||
if (!bottomSpacer) return
|
||||
setTimeout(() => {
|
||||
chatContainer?.scrollTo(0, chatContainer.scrollHeight)
|
||||
}, 50)
|
||||
}
|
||||
|
||||
const handleAllBubblesDisplayed = async () => {
|
||||
const lastChunk = [...chatChunks()].pop()
|
||||
if (!lastChunk) return
|
||||
if (isNotDefined(lastChunk.input)) {
|
||||
props.onEnd?.()
|
||||
}
|
||||
}
|
||||
|
||||
const handleNewBubbleDisplayed = async (blockId: string) => {
|
||||
const lastChunk = [...chatChunks()].pop()
|
||||
if (!lastChunk) return
|
||||
if (lastChunk.clientSideActions) {
|
||||
const actionsToExecute = lastChunk.clientSideActions.filter(
|
||||
(action) => action.lastBubbleBlockId === blockId
|
||||
)
|
||||
for (const action of actionsToExecute) {
|
||||
const response = await executeClientSideAction(action)
|
||||
if (response) setBlockedPopupUrl(response.blockedPopupUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSkip = () => sendMessage(undefined)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={chatContainer}
|
||||
class="overflow-y-scroll w-full min-h-full rounded px-3 pt-10 relative scrollable-container typebot-chat-view scroll-smooth"
|
||||
>
|
||||
<For each={chatChunks()}>
|
||||
{(chatChunk, index) => (
|
||||
<ChatChunk
|
||||
inputIndex={index()}
|
||||
messages={chatChunk.messages}
|
||||
input={chatChunk.input}
|
||||
theme={theme()}
|
||||
settings={props.initialChatReply.typebot.settings}
|
||||
isLoadingBubbleDisplayed={isSending()}
|
||||
onNewBubbleDisplayed={handleNewBubbleDisplayed}
|
||||
onAllBubblesDisplayed={handleAllBubblesDisplayed}
|
||||
onSubmit={sendMessage}
|
||||
onScrollToBottom={autoScrollToBottom}
|
||||
onSkip={handleSkip}
|
||||
context={props.context}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<Show when={isSending()}>
|
||||
<LoadingChunk theme={theme()} />
|
||||
</Show>
|
||||
<Show when={blockedPopupUrl()} keyed>
|
||||
{(blockedPopupUrl) => (
|
||||
<div class="flex justify-end">
|
||||
<PopupBlockedToast
|
||||
url={blockedPopupUrl}
|
||||
onLinkClick={() => setBlockedPopupUrl(undefined)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<BottomSpacer ref={bottomSpacer} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type BottomSpacerProps = {
|
||||
ref: HTMLDivElement | undefined
|
||||
}
|
||||
const BottomSpacer = (props: BottomSpacerProps) => {
|
||||
return <div ref={props.ref} class="w-full h-32" />
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Theme } from '@typebot.io/schemas'
|
||||
import { Show } from 'solid-js'
|
||||
import { LoadingBubble } from '../bubbles/LoadingBubble'
|
||||
import { AvatarSideContainer } from './AvatarSideContainer'
|
||||
|
||||
type Props = {
|
||||
theme: Theme
|
||||
}
|
||||
|
||||
export const LoadingChunk = (props: Props) => (
|
||||
<div class="flex w-full">
|
||||
<div class="flex flex-col w-full min-w-0">
|
||||
<div class="flex">
|
||||
<Show when={props.theme.chat.hostAvatar?.isEnabled}>
|
||||
<AvatarSideContainer
|
||||
hostAvatarSrc={props.theme.chat.hostAvatar?.url}
|
||||
/>
|
||||
</Show>
|
||||
<LoadingBubble />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
type Props = {
|
||||
url: string
|
||||
onLinkClick: () => void
|
||||
}
|
||||
|
||||
export const PopupBlockedToast = (props: Props) => {
|
||||
return (
|
||||
<div
|
||||
class="w-full max-w-xs p-4 text-gray-500 bg-white rounded-lg shadow flex flex-col gap-2"
|
||||
role="alert"
|
||||
>
|
||||
<span class="mb-1 text-sm font-semibold text-gray-900">
|
||||
Popup blocked
|
||||
</span>
|
||||
<div class="mb-2 text-sm font-normal">
|
||||
The bot wants to open a new tab but it was blocked by your broswer. It
|
||||
needs a manual approval.
|
||||
</div>
|
||||
<a
|
||||
href={props.url}
|
||||
target="_blank"
|
||||
class="py-1 px-4 justify-center text-sm font-semibold rounded-md text-white focus:outline-none flex items-center disabled:opacity-50 disabled:cursor-not-allowed disabled:brightness-100 transition-all filter hover:brightness-90 active:brightness-75 typebot-button"
|
||||
rel="noreferrer"
|
||||
onClick={() => props.onLinkClick()}
|
||||
>
|
||||
Continue in new tab
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './ConversationContainer'
|
||||
Reference in New Issue
Block a user