Add attachments option to text input (#1608)

Closes #854
This commit is contained in:
Baptiste Arnaud
2024-06-26 10:13:38 +02:00
committed by GitHub
parent 80da7af4f1
commit 6db0464fd7
88 changed files with 2959 additions and 735 deletions

View File

@@ -30,6 +30,9 @@ import {
defaultProgressBarPosition,
} from '@typebot.io/schemas/features/typebot/theme/constants'
import { CorsError } from '@/utils/CorsError'
import { Toaster, Toast } from '@ark-ui/solid'
import { CloseIcon } from './icons/CloseIcon'
import { toaster } from '@/utils/toaster'
export type BotProps = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -335,6 +338,17 @@ const BotContent = (props: BotContentProps) => {
>
<LiteBadge botContainer={botContainer} />
</Show>
<Toaster toaster={toaster}>
{(toast) => (
<Toast.Root>
<Toast.Title>{toast().title}</Toast.Title>
<Toast.Description>{toast().description}</Toast.Description>
<Toast.CloseTrigger class="absolute right-2 top-2">
<CloseIcon class="w-4 h-4" />
</Toast.CloseTrigger>
</Toast.Root>
)}
</Toaster>
</div>
)
}

View File

@@ -29,7 +29,6 @@ import {
formattedMessages,
setFormattedMessages,
} from '@/utils/formattedMessagesSignal'
import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/constants'
import { saveClientLogsQuery } from '@/queries/saveClientLogsQuery'
import { HTTPError } from 'ky'
import { persist } from '@/utils/persist'
@@ -147,13 +146,6 @@ export const ConversationContainer = (props: Props) => {
const currentInputBlock = [...chatChunks()].pop()?.input
if (currentInputBlock?.id && props.onAnswer && message)
props.onAnswer({ message, blockId: currentInputBlock.id })
if (currentInputBlock?.type === InputBlockType.FILE)
props.onNewLogs?.([
{
description: 'Files are not uploaded in preview mode',
status: 'info',
},
])
const longRequest = setTimeout(() => {
setIsSending(true)
}, 1000)

View File

@@ -15,7 +15,7 @@ import type {
DateInputBlock,
} from '@typebot.io/schemas'
import { GuestBubble } from './bubbles/GuestBubble'
import { BotContext, InputSubmitContent } from '@/types'
import { Answer, BotContext, InputSubmitContent } from '@/types'
import { TextInput } from '@/features/blocks/inputs/textInput'
import { NumberInput } from '@/features/blocks/inputs/number'
import { EmailInput } from '@/features/blocks/inputs/email'
@@ -48,24 +48,33 @@ type Props = {
isInputPrefillEnabled: boolean
hasError: boolean
onTransitionEnd: () => void
onSubmit: (answer: string) => void
onSubmit: (answer: string, attachments?: Answer['attachments']) => void
onSkip: () => void
}
export const InputChatBlock = (props: Props) => {
const [answer, setAnswer] = persist(createSignal<string>(), {
const [answer, setAnswer] = persist(createSignal<Answer>(), {
key: `typebot-${props.context.typebot.id}-input-${props.chunkIndex}`,
storage: props.context.storage,
})
const [formattedMessage, setFormattedMessage] = createSignal<string>()
const handleSubmit = async ({ label, value }: InputSubmitContent) => {
setAnswer(label ?? value)
props.onSubmit(value ?? label)
const handleSubmit = async ({
label,
value,
attachments,
}: InputSubmitContent & Pick<Answer, 'attachments'>) => {
setAnswer({
text: props.block.type !== InputBlockType.FILE ? label ?? value : '',
attachments,
})
props.onSubmit(
value ?? label,
props.block.type === InputBlockType.FILE ? undefined : attachments
)
}
const handleSkip = (label: string) => {
setAnswer(label)
setAnswer({ text: label })
props.onSkip()
}
@@ -73,14 +82,15 @@ export const InputChatBlock = (props: Props) => {
const formattedMessage = formattedMessages().findLast(
(message) => props.chunkIndex === message.inputIndex
)?.formattedMessage
if (formattedMessage) setFormattedMessage(formattedMessage)
if (formattedMessage && props.block.type !== InputBlockType.FILE)
setAnswer((answer) => ({ ...answer, text: formattedMessage }))
})
return (
<Switch>
<Match when={answer() && !props.hasError}>
<GuestBubble
message={formattedMessage() ?? (answer() as string)}
message={answer() as Answer}
showAvatar={
props.guestAvatar?.isEnabled ?? defaultGuestAvatarIsEnabled
}
@@ -107,7 +117,7 @@ export const InputChatBlock = (props: Props) => {
block={props.block}
chunkIndex={props.chunkIndex}
isInputPrefillEnabled={props.isInputPrefillEnabled}
existingAnswer={props.hasError ? answer() : undefined}
existingAnswer={props.hasError ? answer()?.text : undefined}
onTransitionEnd={props.onTransitionEnd}
onSubmit={handleSubmit}
onSkip={handleSkip}
@@ -147,6 +157,7 @@ const Input = (props: {
<TextInput
block={props.block as TextInputBlock}
defaultValue={getPrefilledValue()}
context={props.context}
onSubmit={onSubmit}
/>
</Match>

View File

@@ -0,0 +1,27 @@
import { Dialog } from '@ark-ui/solid'
import { JSX } from 'solid-js'
import { CloseIcon } from './icons/CloseIcon'
type Props = {
isOpen?: boolean
onClose?: () => void
children: JSX.Element
}
export const Modal = (props: Props) => {
return (
<Dialog.Root
open={props.isOpen}
lazyMount
unmountOnExit
onOpenChange={(e) => (!e.open ? props.onClose?.() : undefined)}
>
<Dialog.Backdrop class="fixed inset-0 bg-[rgba(0,0,0,0.5)] h-screen z-50" />
<Dialog.Positioner class="fixed inset-0 z-50 flex items-center justify-center px-2">
<Dialog.Content>{props.children}</Dialog.Content>
<Dialog.CloseTrigger class="fixed top-2 right-2 z-50 rounded-md bg-white p-2 text-black">
<CloseIcon class="w-6 h-6" />
</Dialog.CloseTrigger>
</Dialog.Positioner>
</Dialog.Root>
)
}

View File

@@ -1,29 +1,41 @@
import { isMobile } from '@/utils/isMobileSignal'
import { splitProps } from 'solid-js'
import { splitProps, Switch, Match } from 'solid-js'
import { JSX } from 'solid-js/jsx-runtime'
import { SendIcon } from './icons'
import { Button } from './Button'
import { isEmpty } from '@typebot.io/lib'
import clsx from 'clsx'
type SendButtonProps = {
isDisabled?: boolean
isLoading?: boolean
disableIcon?: boolean
class?: string
} & JSX.ButtonHTMLAttributes<HTMLButtonElement>
export const SendButton = (props: SendButtonProps) => {
const [local, others] = splitProps(props, ['disableIcon'])
const showIcon =
(isMobile() && !local.disableIcon) ||
!props.children ||
(typeof props.children === 'string' && isEmpty(props.children))
return (
<Button type="submit" {...others}>
{(isMobile() && !local.disableIcon) ||
!props.children ||
(typeof props.children === 'string' && isEmpty(props.children)) ? (
<SendIcon
class={'send-icon flex ' + (local.disableIcon ? 'hidden' : '')}
/>
) : (
props.children
)}
<Button
type="submit"
{...others}
class={clsx('flex items-center', props.class)}
aria-label={showIcon ? 'Send' : undefined}
>
<Switch>
<Match when={showIcon}>
<SendIcon
class={
'send-icon flex w-6 h-6 ' + (local.disableIcon ? 'hidden' : '')
}
/>
</Match>
<Match when={!showIcon}>{props.children}</Match>
</Switch>
</Button>
)
}

View File

@@ -0,0 +1,103 @@
import { Show } from 'solid-js'
import { Menu } from '@ark-ui/solid'
import { CameraIcon } from './icons/CameraIcon'
import { FileIcon } from './icons/FileIcon'
import { PictureIcon } from './icons/PictureIcon'
import { isMobile } from '@/utils/isMobileSignal'
import { PaperClipIcon } from './icons/PaperClipIcon'
import clsx from 'clsx'
type Props = {
onNewFiles: (files: FileList) => void
class?: string
}
export const TextInputAddFileButton = (props: Props) => {
return (
<>
<input
type="file"
id="document-upload"
multiple
class="hidden"
onChange={(e) => {
if (!e.currentTarget.files) return
props.onNewFiles(e.currentTarget.files)
}}
/>
<input
type="file"
id="photos-upload"
accept="image/*"
multiple
class="hidden"
onChange={(e) => {
if (!e.currentTarget.files) return
props.onNewFiles(e.currentTarget.files)
}}
/>
<Show when={isMobile()}>
<input
type="file"
id="camera-upload"
class="hidden"
multiple
accept="image/*"
capture="environment"
onChange={(e) => {
if (!e.currentTarget.files) return
props.onNewFiles(e.currentTarget.files)
}}
/>
</Show>
<Menu.Root>
<Menu.Trigger
class={clsx(
'filter data-[state=open]:backdrop-brightness-90 hover:backdrop-brightness-95 transition rounded-md p-2 focus:outline-none',
props.class
)}
aria-label="Add attachments"
>
<PaperClipIcon class="w-5" />
</Menu.Trigger>
<Menu.Positioner>
<Menu.Content class="p-3 gap-2 focus:outline-none">
<Menu.Item
value="document"
asChild={(props) => (
<label
{...props()}
for="document-upload"
class="p-2 filter hover:brightness-95 flex gap-3 items-center"
>
<FileIcon class="w-4" /> Document
</label>
)}
/>
<Menu.Item
value="photos"
asChild={(props) => (
<label
{...props()}
for="photos-upload"
class="p-2 filter hover:brightness-95 flex gap-3 items-center"
>
<PictureIcon class="w-4" /> Photos & videos
</label>
)}
/>
<Show when={isMobile()}>
<Menu.Item
value="camera"
class="p-2 filter hover:brightness-95 flex gap-3 items-center"
>
<CameraIcon class="w-4" />
Camera
</Menu.Item>
</Show>
</Menu.Content>
</Menu.Positioner>
</Menu.Root>
</>
)
}

View File

@@ -1,33 +1,105 @@
import { Show } from 'solid-js'
import { createSignal, For, Show } from 'solid-js'
import { Avatar } from '../avatars/Avatar'
import { isMobile } from '@/utils/isMobileSignal'
import { Answer } from '@/types'
import { Modal } from '../Modal'
import { isNotEmpty } from '@typebot.io/lib'
import { FilePreview } from '@/features/blocks/inputs/fileUpload/components/FilePreview'
import clsx from 'clsx'
type Props = {
message: string
message: Answer
showAvatar: boolean
avatarSrc?: string
hasHostAvatar: boolean
}
export const GuestBubble = (props: Props) => (
<div
class="flex justify-end items-end animate-fade-in gap-2 guest-container"
style={{
'margin-left': props.hasHostAvatar
? isMobile()
? '28px'
: '50px'
: undefined,
}}
>
<span
class="px-4 py-2 whitespace-pre-wrap max-w-full typebot-guest-bubble"
data-testid="guest-bubble"
export const GuestBubble = (props: Props) => {
const [clickedImageSrc, setClickedImageSrc] = createSignal<string>()
return (
<div
class="flex justify-end items-end animate-fade-in gap-2 guest-container"
style={{
'margin-left': props.hasHostAvatar
? isMobile()
? '28px'
: '50px'
: undefined,
}}
>
{props.message}
</span>
<Show when={props.showAvatar}>
<Avatar initialAvatarSrc={props.avatarSrc} />
</Show>
</div>
)
<div class="flex flex-col gap-1 items-end">
<Show when={(props.message.attachments ?? []).length > 0}>
<div
class={clsx(
'flex gap-1 overflow-auto max-w-[350px]',
isMobile() ? 'flex-wrap justify-end' : 'items-center'
)}
>
<For
each={props.message.attachments?.filter((attachment) =>
attachment.type.startsWith('image')
)}
>
{(attachment, idx) => (
<img
src={attachment.url}
alt={`Attached image ${idx() + 1}`}
class={clsx(
'typebot-guest-bubble-image-attachment cursor-pointer',
props.message.attachments!.filter((attachment) =>
attachment.type.startsWith('image')
).length > 1 && 'max-w-[90%]'
)}
onClick={() => setClickedImageSrc(attachment.url)}
/>
)}
</For>
</div>
<div
class={clsx(
'flex gap-1 overflow-auto max-w-[350px]',
isMobile() ? 'flex-wrap justify-end' : 'items-center'
)}
>
<For
each={props.message.attachments?.filter(
(attachment) => !attachment.type.startsWith('image')
)}
>
{(attachment) => (
<FilePreview
file={{
name: attachment.url.split('/').at(-1)!,
}}
/>
)}
</For>
</div>
</Show>
<div
class="p-[1px] whitespace-pre-wrap max-w-full typebot-guest-bubble flex flex-col"
data-testid="guest-bubble"
>
<Show when={isNotEmpty(props.message.text)}>
<span class="px-[15px] py-[7px]">{props.message.text}</span>
</Show>
</div>
</div>
<Modal
isOpen={clickedImageSrc() !== undefined}
onClose={() => setClickedImageSrc(undefined)}
>
<img
src={clickedImageSrc()}
alt="Attachment"
style={{ 'border-radius': '6px' }}
/>
</Modal>
<Show when={props.showAvatar}>
<Avatar initialAvatarSrc={props.avatarSrc} />
</Show>
</div>
)
}

View File

@@ -0,0 +1,17 @@
import { JSX } from 'solid-js/jsx-runtime'
export const CameraIcon = (props: JSX.SvgSVGAttributes<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2px"
stroke-linecap="round"
stroke-linejoin="round"
{...props}
>
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z" />
<circle cx="12" cy="13" r="3" />
</svg>
)

View File

@@ -0,0 +1,17 @@
import { JSX } from 'solid-js/jsx-runtime'
export const FileIcon = (props: JSX.SvgSVGAttributes<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2px"
stroke-linecap="round"
stroke-linejoin="round"
{...props}
>
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
</svg>
)

View File

@@ -0,0 +1,18 @@
import { JSX } from 'solid-js/jsx-runtime'
export const PaperClipIcon = (props: JSX.SvgSVGAttributes<SVGSVGElement>) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
{...props}
>
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
</svg>
)
}

View File

@@ -0,0 +1,19 @@
import { JSX } from 'solid-js/jsx-runtime'
export const PictureIcon = (props: JSX.SvgSVGAttributes<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2px"
stroke-linecap="round"
stroke-linejoin="round"
{...props}
>
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
<circle cx="10" cy="12" r="2" />
<path d="m20 17-1.296-1.296a2.41 2.41 0 0 0-3.408 0L9 22" />
</svg>
)

View File

@@ -0,0 +1,17 @@
import { JSX } from 'solid-js/jsx-runtime'
export const PlusIcon = (props: JSX.SvgSVGAttributes<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2px"
stroke-linecap="round"
stroke-linejoin="round"
{...props}
>
<path d="M5 12h14" />
<path d="M12 5v14" />
</svg>
)