diff --git a/apps/builder/playwright.config.ts b/apps/builder/playwright.config.ts index 72fe4e447..16b855556 100644 --- a/apps/builder/playwright.config.ts +++ b/apps/builder/playwright.config.ts @@ -31,6 +31,13 @@ export default defineConfig({ locale: 'en-US', baseURL: process.env.NEXTAUTH_URL, storageState: './src/test/storageState.json', + permissions: ['microphone'], + launchOptions: { + args: [ + '--use-fake-ui-for-media-stream', + '--use-fake-device-for-media-stream', + ], + }, }, projects: [ { diff --git a/apps/builder/src/features/blocks/inputs/textInput/components/TextInputNodeContent.tsx b/apps/builder/src/features/blocks/inputs/textInput/components/TextInputNodeContent.tsx index 9ba9d005a..e50a0b398 100644 --- a/apps/builder/src/features/blocks/inputs/textInput/components/TextInputNodeContent.tsx +++ b/apps/builder/src/features/blocks/inputs/textInput/components/TextInputNodeContent.tsx @@ -16,6 +16,10 @@ export const TextInputNodeContent = ({ options }: Props) => { typebot && options?.attachments?.isEnabled && options?.attachments.saveVariableId + const audioClipVariableId = + typebot && + options?.audioClip?.isEnabled && + options?.audioClip.saveVariableId if (options?.variableId) return ( @@ -29,6 +33,12 @@ export const TextInputNodeContent = ({ options }: Props) => { variableId={attachmentVariableId} /> )} + {audioClipVariableId && ( + + )} ) return ( @@ -43,6 +53,12 @@ export const TextInputNodeContent = ({ options }: Props) => { variableId={attachmentVariableId} /> )} + {audioClipVariableId && ( + + )} ) } diff --git a/apps/builder/src/features/blocks/inputs/textInput/components/TextInputSettings.tsx b/apps/builder/src/features/blocks/inputs/textInput/components/TextInputSettings.tsx index 121c50c7a..17a11eae0 100644 --- a/apps/builder/src/features/blocks/inputs/textInput/components/TextInputSettings.tsx +++ b/apps/builder/src/features/blocks/inputs/textInput/components/TextInputSettings.tsx @@ -49,6 +49,26 @@ export const TextInputSettings = ({ options, onOptionsChange }: Props) => { attachments: { ...options?.attachments, visibility }, }) + const updateAudioClipEnabled = (isEnabled: boolean) => + onOptionsChange({ + ...options, + audioClip: { ...options?.audioClip, isEnabled }, + }) + + const updateAudioClipSaveVariableId = (variable?: Pick) => + onOptionsChange({ + ...options, + audioClip: { ...options?.audioClip, saveVariableId: variable?.id }, + }) + + const updateAudioClipVisibility = ( + visibility: (typeof fileVisibilityOptions)[number] + ) => + onOptionsChange({ + ...options, + audioClip: { ...options?.audioClip, visibility }, + }) + return ( { } onChange={updateButtonLabel} /> + + + + Save the URL in a variable: + + + + + { await expect(page.getByRole('button', { name: 'Go' })).toBeVisible() }) - test('hey boy', async ({ page }) => { + test('attachments should work', async ({ page }) => { const typebotId = createId() await createTypebots([ { @@ -82,4 +82,31 @@ test.describe.parallel('Text input block', () => { ).toBeVisible() await expect(page.getByText('Help me with these')).toBeVisible() }) + + test('audio clips should work', async ({ page }) => { + const typebotId = createId() + await createTypebots([ + { + id: typebotId, + ...parseDefaultGroupWithBlock({ + type: InputBlockType.TEXT, + }), + }, + ]) + + await page.goto(`/typebots/${typebotId}/edit`) + + await page.click(`text=${defaultTextInputOptions.labels.placeholder}`) + await page.getByText('Allow audio clip').click() + await page.locator('[data-testid="variables-input"]').first().click() + await page.getByText('var1').click() + await page.getByRole('button', { name: 'Test' }).click() + await page.getByRole('button', { name: 'Record voice' }).click() + await page.waitForTimeout(1000) + await page.getByRole('button', { name: 'Send' }).click() + await expect(page.locator('audio')).toHaveAttribute( + 'src', + /http:\/\/localhost:9000/ + ) + }) }) diff --git a/apps/docs/openapi/builder.json b/apps/docs/openapi/builder.json index 0c616e256..39208aa77 100644 --- a/apps/docs/openapi/builder.json +++ b/apps/docs/openapi/builder.json @@ -12986,6 +12986,37 @@ "workspaceId", "name" ] + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "apiKey": { + "type": "string" + } + } + }, + "type": { + "type": "string", + "enum": [ + "segment" + ] + }, + "workspaceId": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "data", + "type", + "workspaceId", + "name" + ] } ] }, @@ -13106,7 +13137,8 @@ "anthropic", "together-ai", "open-router", - "nocodb" + "nocodb", + "segment" ] } } @@ -13142,7 +13174,8 @@ "anthropic", "together-ai", "open-router", - "nocodb" + "nocodb", + "segment" ] }, "name": { @@ -13838,6 +13871,37 @@ "type", "workspaceId" ] + }, + { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "apiKey": { + "type": "string" + } + } + }, + "type": { + "type": "string", + "enum": [ + "segment" + ] + }, + "workspaceId": { + "type": "string" + } + }, + "required": [ + "name", + "data", + "type", + "workspaceId" + ] } ] } @@ -16402,6 +16466,25 @@ "isLong": { "type": "boolean" }, + "audioClip": { + "type": "object", + "properties": { + "isEnabled": { + "type": "boolean" + }, + "saveVariableId": { + "type": "string" + }, + "visibility": { + "type": "string", + "enum": [ + "Auto", + "Public", + "Private" + ] + } + } + }, "attachments": { "type": "object", "properties": { @@ -22214,6 +22297,176 @@ "id", "type" ] + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "outgoingEdgeId": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "segment" + ] + }, + "options": { + "oneOf": [ + { + "type": "object", + "properties": { + "credentialsId": { + "type": "string" + } + } + }, + { + "type": "object", + "properties": { + "credentialsId": { + "type": "string" + }, + "action": { + "type": "string", + "enum": [ + "Alias" + ] + }, + "userId": { + "type": "string" + }, + "previousId": { + "type": "string" + } + }, + "required": [ + "action" + ] + }, + { + "type": "object", + "properties": { + "credentialsId": { + "type": "string" + }, + "action": { + "type": "string", + "enum": [ + "Identify User" + ] + }, + "userId": { + "type": "string" + }, + "email": { + "type": "string" + }, + "traits": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + } + } + } + }, + "required": [ + "action" + ] + }, + { + "type": "object", + "properties": { + "credentialsId": { + "type": "string" + }, + "action": { + "type": "string", + "enum": [ + "Page" + ] + }, + "userId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "category": { + "type": "string" + }, + "properties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + } + } + } + }, + "required": [ + "action" + ] + }, + { + "type": "object", + "properties": { + "credentialsId": { + "type": "string" + }, + "action": { + "type": "string", + "enum": [ + "Track" + ] + }, + "eventName": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "properties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + } + } + } + }, + "required": [ + "action" + ] + } + ] + } + }, + "required": [ + "id", + "type" + ] } ], "title": "Block" diff --git a/apps/docs/openapi/viewer.json b/apps/docs/openapi/viewer.json index c82d5734b..9e228b645 100644 --- a/apps/docs/openapi/viewer.json +++ b/apps/docs/openapi/viewer.json @@ -1082,6 +1082,25 @@ "type", "text" ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "audio" + ] + }, + "url": { + "type": "string" + } + }, + "required": [ + "type", + "url" + ], + "description": "Can only be provided if current input block is a text input that allows audio clips" } ], "description": "Only provide it if your flow starts with an input block and you'd like to directly provide an answer to it." @@ -1502,6 +1521,25 @@ "type", "text" ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "audio" + ] + }, + "url": { + "type": "string" + } + }, + "required": [ + "type", + "url" + ], + "description": "Can only be provided if current input block is a text input that allows audio clips" } ] }, @@ -1872,6 +1910,25 @@ "type", "text" ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "audio" + ] + }, + "url": { + "type": "string" + } + }, + "required": [ + "type", + "url" + ], + "description": "Can only be provided if current input block is a text input that allows audio clips" } ] }, @@ -5928,6 +5985,25 @@ "isLong": { "type": "boolean" }, + "audioClip": { + "type": "object", + "properties": { + "isEnabled": { + "type": "boolean" + }, + "saveVariableId": { + "type": "string" + }, + "visibility": { + "type": "string", + "enum": [ + "Auto", + "Public", + "Private" + ] + } + } + }, "attachments": { "type": "object", "properties": { @@ -12505,6 +12581,176 @@ "id", "type" ] + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "outgoingEdgeId": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "segment" + ] + }, + "options": { + "oneOf": [ + { + "type": "object", + "properties": { + "credentialsId": { + "type": "string" + } + } + }, + { + "type": "object", + "properties": { + "credentialsId": { + "type": "string" + }, + "action": { + "type": "string", + "enum": [ + "Alias" + ] + }, + "userId": { + "type": "string" + }, + "previousId": { + "type": "string" + } + }, + "required": [ + "action" + ] + }, + { + "type": "object", + "properties": { + "credentialsId": { + "type": "string" + }, + "action": { + "type": "string", + "enum": [ + "Identify User" + ] + }, + "userId": { + "type": "string" + }, + "email": { + "type": "string" + }, + "traits": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + } + } + } + }, + "required": [ + "action" + ] + }, + { + "type": "object", + "properties": { + "credentialsId": { + "type": "string" + }, + "action": { + "type": "string", + "enum": [ + "Page" + ] + }, + "userId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "category": { + "type": "string" + }, + "properties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + } + } + } + }, + "required": [ + "action" + ] + }, + { + "type": "object", + "properties": { + "credentialsId": { + "type": "string" + }, + "action": { + "type": "string", + "enum": [ + "Track" + ] + }, + "eventName": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "properties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + } + } + } + }, + "required": [ + "action" + ] + } + ] + } + }, + "required": [ + "id", + "type" + ] } ], "title": "Block" diff --git a/apps/viewer/src/features/fileUpload/api/generateUploadUrl.ts b/apps/viewer/src/features/fileUpload/api/generateUploadUrl.ts index 5934ab90e..3c6bf5504 100644 --- a/apps/viewer/src/features/fileUpload/api/generateUploadUrl.ts +++ b/apps/viewer/src/features/fileUpload/api/generateUploadUrl.ts @@ -85,7 +85,9 @@ export const generateUploadUrl = publicProcedure if ( block?.type !== InputBlockType.FILE && (block.type !== InputBlockType.TEXT || - !block.options?.attachments?.isEnabled) + !block.options?.attachments?.isEnabled) && + (block.type !== InputBlockType.TEXT || + !block.options?.audioClip?.isEnabled) ) throw new TRPCError({ code: 'BAD_REQUEST', diff --git a/apps/viewer/src/test/fileUpload.spec.ts b/apps/viewer/src/test/fileUpload.spec.ts index 229c96ae5..cf408f21e 100644 --- a/apps/viewer/src/test/fileUpload.spec.ts +++ b/apps/viewer/src/test/fileUpload.spec.ts @@ -27,7 +27,10 @@ test('should work as expected', async ({ page, browser }) => { await page.goto(`${env.NEXTAUTH_URL}/typebots/${typebotId}/results`) await expect(page.getByRole('link', { name: 'api.json' })).toHaveAttribute( 'href', - /.+\/api\.json/ + /.+\/api\.json/, + { + timeout: 10000, + } ) await expect( page.getByRole('link', { name: 'fileUpload.json' }) diff --git a/apps/viewer/src/test/results.spec.ts b/apps/viewer/src/test/results.spec.ts index addfc45df..380662c56 100644 --- a/apps/viewer/src/test/results.spec.ts +++ b/apps/viewer/src/test/results.spec.ts @@ -17,7 +17,9 @@ test('Big groups should work as expected', async ({ page }) => { await page.locator('input').press('Enter') await page.getByRole('button', { name: 'Yes' }).click() await page.goto(`${env.NEXTAUTH_URL}/typebots/${typebotId}/results`) - await expect(page.locator('text="Baptiste"')).toBeVisible() + await expect(page.locator('text="Baptiste"')).toBeVisible({ + timeout: 10000, + }) await expect(page.locator('text="26"')).toBeVisible() await expect(page.locator('text="Yes"')).toBeVisible() await page.hover('tbody > tr') diff --git a/packages/bot-engine/continueBotFlow.ts b/packages/bot-engine/continueBotFlow.ts index e12066507..89a2d1266 100644 --- a/packages/bot-engine/continueBotFlow.ts +++ b/packages/bot-engine/continueBotFlow.ts @@ -63,20 +63,15 @@ export const continueBotFlow = async ( setVariableHistory: SetVariableHistoryItem[] } > => { - let firstBubbleWasStreamed = false - let newSessionState = { ...state } - const visitedEdges: VisitedEdge[] = [] - const setVariableHistory: SetVariableHistoryItem[] = [] - - if (!newSessionState.currentBlockId) + if (!state.currentBlockId) return startBotFlow({ - state: resetSessionState(newSessionState), + state: resetSessionState(state), version, textBubbleContentFormat, }) const { block, group, blockIndex } = getBlockById( - newSessionState.currentBlockId, + state.currentBlockId, state.typebotsQueue[0].typebot.groups ) @@ -86,7 +81,138 @@ export const continueBotFlow = async ( message: 'Group / block not found', }) + const nonInputProcessResult = await processNonInputBlock({ + block, + state, + reply, + }) + + let newSessionState = nonInputProcessResult.newSessionState + const { setVariableHistory, firstBubbleWasStreamed } = nonInputProcessResult + + let formattedReply: string | undefined + + if (isInputBlock(block)) { + const parsedReplyResult = await parseReply(newSessionState)(reply, block) + + if (parsedReplyResult.status === 'fail') + return { + ...(await parseRetryMessage(newSessionState)( + block, + textBubbleContentFormat + )), + newSessionState, + visitedEdges: [], + setVariableHistory: [], + } + + formattedReply = + 'reply' in parsedReplyResult ? parsedReplyResult.reply : undefined + newSessionState = await processAndSaveAnswer( + state, + block + )( + isDefined(formattedReply) + ? { ...reply, type: 'text', text: formattedReply } + : undefined + ) + } + + const groupHasMoreBlocks = blockIndex < group.blocks.length - 1 + + const { edgeId: nextEdgeId, isOffDefaultPath } = getOutgoingEdgeId( + newSessionState + )(block, formattedReply) + + const lastMessageNewFormat = + reply?.type === 'text' && formattedReply !== reply?.text + ? formattedReply + : undefined + + if (groupHasMoreBlocks && !nextEdgeId) { + const chatReply = await executeGroup( + { + ...group, + blocks: group.blocks.slice(blockIndex + 1), + } as Group, + { + version, + state: newSessionState, + visitedEdges: [], + setVariableHistory, + firstBubbleWasStreamed, + startTime, + textBubbleContentFormat, + } + ) + return { + ...chatReply, + lastMessageNewFormat, + } + } + + if (!nextEdgeId && state.typebotsQueue.length === 1) + return { + messages: [], + newSessionState, + lastMessageNewFormat, + visitedEdges: [], + setVariableHistory, + } + + const nextGroup = await getNextGroup({ + state: newSessionState, + edgeId: nextEdgeId, + isOffDefaultPath, + }) + + newSessionState = nextGroup.newSessionState + + if (!nextGroup.group) + return { + messages: [], + newSessionState, + lastMessageNewFormat, + visitedEdges: nextGroup.visitedEdge ? [nextGroup.visitedEdge] : [], + setVariableHistory, + } + + const chatReply = await executeGroup(nextGroup.group, { + version, + state: newSessionState, + firstBubbleWasStreamed, + visitedEdges: nextGroup.visitedEdge ? [nextGroup.visitedEdge] : [], + setVariableHistory, + startTime, + textBubbleContentFormat, + }) + + return { + ...chatReply, + lastMessageNewFormat, + } +} + +const processNonInputBlock = async ({ + block, + state, + reply, +}: { + block: Block + state: SessionState + reply: Reply +}) => { + if (reply?.type !== 'text') + return { + newSessionState: state, + setVariableHistory: [], + firstBubbleWasStreamed: false, + } + + const setVariableHistory: SetVariableHistoryItem[] = [] let variableToUpdate: Variable | undefined + let newSessionState = state + let firstBubbleWasStreamed = false if (block.type === LogicBlockType.SET_VARIABLE) { const existingVariable = state.typebotsQueue[0].typebot.variables.find( @@ -169,107 +295,10 @@ export const continueBotFlow = async ( setVariableHistory.push(...newSetVariableHistory) } - let formattedReply: string | undefined - - if (isInputBlock(block)) { - const parsedReplyResult = await parseReply(newSessionState)(reply, block) - - if (parsedReplyResult.status === 'fail') - return { - ...(await parseRetryMessage(newSessionState)( - block, - textBubbleContentFormat - )), - newSessionState, - visitedEdges: [], - setVariableHistory: [], - } - - formattedReply = - 'reply' in parsedReplyResult ? parsedReplyResult.reply : undefined - newSessionState = await processAndSaveAnswer( - state, - block - )( - isDefined(formattedReply) - ? { ...reply, type: 'text', text: formattedReply } - : undefined - ) - } - - const groupHasMoreBlocks = blockIndex < group.blocks.length - 1 - - const { edgeId: nextEdgeId, isOffDefaultPath } = getOutgoingEdgeId( - newSessionState - )(block, formattedReply) - - if (groupHasMoreBlocks && !nextEdgeId) { - const chatReply = await executeGroup( - { - ...group, - blocks: group.blocks.slice(blockIndex + 1), - } as Group, - { - version, - state: newSessionState, - visitedEdges, - setVariableHistory, - firstBubbleWasStreamed, - startTime, - textBubbleContentFormat, - } - ) - return { - ...chatReply, - lastMessageNewFormat: - formattedReply !== reply?.text ? formattedReply : undefined, - } - } - - if (!nextEdgeId && state.typebotsQueue.length === 1) - return { - messages: [], - newSessionState, - lastMessageNewFormat: - formattedReply !== reply?.text ? formattedReply : undefined, - visitedEdges, - setVariableHistory, - } - - const nextGroup = await getNextGroup({ - state: newSessionState, - edgeId: nextEdgeId, - isOffDefaultPath, - }) - - if (nextGroup.visitedEdge) visitedEdges.push(nextGroup.visitedEdge) - - newSessionState = nextGroup.newSessionState - - if (!nextGroup.group) - return { - messages: [], - newSessionState, - lastMessageNewFormat: - formattedReply !== reply ? formattedReply : undefined, - visitedEdges, - setVariableHistory, - } - - const chatReply = await executeGroup(nextGroup.group, { - version, - state: newSessionState, - firstBubbleWasStreamed, - visitedEdges, - setVariableHistory, - startTime, - textBubbleContentFormat, - }) - return { - ...chatReply, - lastMessageNewFormat: - formattedReply !== reply?.text ? formattedReply : undefined, + newSessionState, + setVariableHistory, + firstBubbleWasStreamed, } } @@ -284,7 +313,8 @@ const saveVariablesValueIfAny = (state: SessionState, block: InputBlock) => (reply: Message): SessionState => { if (!block.options?.variableId) return state - const newSessionState = saveAttachmentsVarIfAny({ block, reply, state }) + let newSessionState = saveAttachmentsVarIfAny({ block, reply, state }) + newSessionState = saveAudioClipVarIfAny({ block, reply, state }) return saveInputVarIfAny({ block, reply, state: newSessionState }) } @@ -298,6 +328,7 @@ const saveAttachmentsVarIfAny = ({ state: SessionState }): SessionState => { if ( + reply.type !== 'text' || block.type !== InputBlockType.TEXT || !block.options?.attachments?.isEnabled || !block.options?.attachments?.saveVariableId || @@ -330,6 +361,44 @@ const saveAttachmentsVarIfAny = ({ return updatedState } +const saveAudioClipVarIfAny = ({ + block, + reply, + state, +}: { + block: InputBlock + reply: Message + state: SessionState +}): SessionState => { + if ( + reply.type !== 'audio' || + block.type !== InputBlockType.TEXT || + !block.options?.audioClip?.isEnabled || + !block.options?.audioClip?.saveVariableId + ) + return state + + const variable = state.typebotsQueue[0].typebot.variables.find( + (variable) => variable.id === block.options?.attachments?.saveVariableId + ) + + if (!variable) return state + + const { updatedState } = updateVariablesInSession({ + newVariables: [ + { + id: variable.id, + name: variable.name, + value: reply.url, + }, + ], + currentBlockId: undefined, + state, + }) + + return updatedState +} + const saveInputVarIfAny = ({ block, reply, @@ -339,6 +408,8 @@ const saveInputVarIfAny = ({ reply: Message state: SessionState }): SessionState => { + if (reply.type !== 'text') return state + const foundVariable = state.typebotsQueue[0].typebot.variables.find( (variable) => variable.id === block.options?.variableId ) @@ -411,11 +482,14 @@ const saveAnswerInDb = (state: SessionState, block: InputBlock) => async (reply: Message): Promise => { let newSessionState = state + const replyContent = reply.type === 'audio' ? reply.url : reply.text + const attachedFileUrls = + reply.type === 'text' ? reply.attachedFileUrls : undefined await saveAnswer({ answer: { blockId: block.id, - content: reply.text, - attachedFileUrls: reply.attachedFileUrls, + content: replyContent, + attachedFileUrls, }, state, }) @@ -428,8 +502,8 @@ const saveAnswerInDb = ...newSessionState.previewMetadata, answers: (newSessionState.previewMetadata?.answers ?? []).concat({ blockId: block.id, - content: reply.text, - attachedFileUrls: reply.attachedFileUrls, + content: replyContent, + attachedFileUrls, }), }, } @@ -443,9 +517,9 @@ const saveAnswerInDb = return setNewAnswerInState(newSessionState)({ key: key ?? block.id, value: - (reply.attachedFileUrls ?? []).length > 0 - ? `${reply.attachedFileUrls!.join(', ')}\n\n${reply.text}` - : reply.text, + (attachedFileUrls ?? []).length > 0 + ? `${attachedFileUrls!.join(', ')}\n\n${replyContent}` + : replyContent, }) } @@ -534,13 +608,13 @@ const parseReply = async (reply: Reply, block: InputBlock): Promise => { switch (block.type) { case InputBlockType.EMAIL: { - if (!reply) return { status: 'fail' } + if (!reply || reply.type !== 'text') return { status: 'fail' } const formattedEmail = formatEmail(reply.text) if (!formattedEmail) return { status: 'fail' } return { status: 'success', reply: formattedEmail } } case InputBlockType.PHONE: { - if (!reply) return { status: 'fail' } + if (!reply || reply.type !== 'text') return { status: 'fail' } const formattedPhone = formatPhoneNumber( reply.text, block.options?.defaultCountryCode @@ -549,17 +623,17 @@ const parseReply = return { status: 'success', reply: formattedPhone } } case InputBlockType.URL: { - if (!reply) return { status: 'fail' } + if (!reply || reply.type !== 'text') return { status: 'fail' } const isValid = isURL(reply.text, { require_protocol: false }) if (!isValid) return { status: 'fail' } return { status: 'success', reply: reply.text } } case InputBlockType.CHOICE: { - if (!reply) return { status: 'fail' } + if (!reply || reply.type !== 'text') return { status: 'fail' } return parseButtonsReply(state)(reply.text, block) } case InputBlockType.NUMBER: { - if (!reply) return { status: 'fail' } + if (!reply || reply.type !== 'text') return { status: 'fail' } const isValid = validateNumber(reply.text, { options: block.options, variables: state.typebotsQueue[0].typebot.variables, @@ -568,7 +642,7 @@ const parseReply = return { status: 'success', reply: parseNumber(reply.text) } } case InputBlockType.DATE: { - if (!reply) return { status: 'fail' } + if (!reply || reply.type !== 'text') return { status: 'fail' } return parseDateReply(reply.text, block) } case InputBlockType.FILE: { @@ -576,34 +650,38 @@ const parseReply = return block.options?.isRequired ?? defaultFileInputOptions.isRequired ? { status: 'fail' } : { status: 'skip' } - const urls = reply.text.split(', ') + const replyValue = reply.type === 'audio' ? reply.url : reply.text + const urls = replyValue.split(', ') const status = urls.some((url) => isURL(url, { require_tld: env.S3_ENDPOINT !== 'localhost' }) ) ? 'success' : 'fail' if (!block.options?.isMultipleAllowed && urls.length > 1) - return { status, reply: reply.text.split(',')[0] } - return { status, reply: reply.text } + return { status, reply: replyValue.split(',')[0] } + return { status, reply: replyValue } } case InputBlockType.PAYMENT: { - if (!reply) return { status: 'fail' } + if (!reply || reply.type !== 'text') return { status: 'fail' } if (reply.text === 'fail') return { status: 'fail' } return { status: 'success', reply: reply.text } } case InputBlockType.RATING: { - if (!reply) return { status: 'fail' } + if (!reply || reply.type !== 'text') return { status: 'fail' } const isValid = validateRatingReply(reply.text, block) if (!isValid) return { status: 'fail' } return { status: 'success', reply: reply.text } } case InputBlockType.PICTURE_CHOICE: { - if (!reply) return { status: 'fail' } + if (!reply || reply.type !== 'text') return { status: 'fail' } return parsePictureChoicesReply(state)(reply.text, block) } case InputBlockType.TEXT: { if (!reply) return { status: 'fail' } - return { status: 'success', reply: reply.text } + return { + status: 'success', + reply: reply.type === 'audio' ? reply.url : reply.text, + } } } } diff --git a/packages/bot-engine/whatsapp/resumeWhatsAppFlow.ts b/packages/bot-engine/whatsapp/resumeWhatsAppFlow.ts index 849e7a014..90e312701 100644 --- a/packages/bot-engine/whatsapp/resumeWhatsAppFlow.ts +++ b/packages/bot-engine/whatsapp/resumeWhatsAppFlow.ts @@ -232,7 +232,11 @@ const getIncomingMessageContent = async ({ if (message.type === 'document') mediaId = message.document.id if (!mediaId) return const fileVisibility = - block?.type === InputBlockType.FILE + block?.type === InputBlockType.TEXT && + block.options?.audioClip?.isEnabled && + message.type === 'audio' + ? block.options?.audioClip.visibility + : block?.type === InputBlockType.FILE ? block.options?.visibility : block?.type === InputBlockType.TEXT ? block.options?.attachments?.visibility @@ -259,6 +263,11 @@ const getIncomingMessageContent = async ({ }) fileUrl = url } + if (message.type === 'audio') + return { + type: 'audio', + url: fileUrl, + } if (block?.type === InputBlockType.FILE) { if (text !== '') text += `, ${fileUrl}` else text = fileUrl diff --git a/packages/bot-engine/whatsapp/startWhatsAppSession.ts b/packages/bot-engine/whatsapp/startWhatsAppSession.ts index 9c6999e71..7ff65215f 100644 --- a/packages/bot-engine/whatsapp/startWhatsAppSession.ts +++ b/packages/bot-engine/whatsapp/startWhatsAppSession.ts @@ -112,7 +112,7 @@ export const messageMatchStartCondition = ( startCondition: NonNullable['startCondition'] ) => { if (!startCondition) return true - if (!message?.text) return false + if (message?.type !== 'text' || !message.text) return false return startCondition.logicalOperator === LogicalOperator.AND ? startCondition.comparisons.every((comparison) => matchComparison( diff --git a/packages/embeds/js/package.json b/packages/embeds/js/package.json index d381ffb27..ec334c626 100644 --- a/packages/embeds/js/package.json +++ b/packages/embeds/js/package.json @@ -1,6 +1,6 @@ { "name": "@typebot.io/js", - "version": "0.3.8", + "version": "0.3.9", "description": "Javascript library to display typebots on your website", "type": "module", "main": "dist/index.js", diff --git a/packages/embeds/js/rollup.config.js b/packages/embeds/js/rollup.config.js index 1ae8900db..a7d857da6 100644 --- a/packages/embeds/js/rollup.config.js +++ b/packages/embeds/js/rollup.config.js @@ -21,6 +21,7 @@ const indexConfig = { output: { file: 'dist/index.js', format: 'es', + sourcemap: true, }, onwarn, watch: { @@ -63,6 +64,7 @@ const configs = [ output: { file: 'dist/web.js', format: 'es', + sourcemap: true, }, }, ] diff --git a/packages/embeds/js/src/assets/index.css b/packages/embeds/js/src/assets/index.css index 96cf55c2d..1fd6eab8c 100644 --- a/packages/embeds/js/src/assets/index.css +++ b/packages/embeds/js/src/assets/index.css @@ -456,6 +456,27 @@ select option { transition: width 0.25s ease; } +.typebot-recorder .left-gradient { + background-image: linear-gradient( + to right, + rgba(var(--typebot-input-bg-rgb), 1), + rgba(var(--typebot-input-bg-rgb), 0) + ); +} + +.typebot-recorder .right-gradient { + background-image: linear-gradient( + to left, + rgba(var(--typebot-input-bg-rgb), 1), + rgba(var(--typebot-input-bg-rgb), 0) + ); +} + +.typebot-recorder button { + color: rgba(var(--typebot-button-bg-rgb)); + background-color: rgba(var(--typebot-button-bg-rgb), 0.3); +} + @keyframes fadeInFromTop { 0% { opacity: 0; diff --git a/packages/embeds/js/src/components/Bot.tsx b/packages/embeds/js/src/components/Bot.tsx index ababab325..f13cd5dad 100644 --- a/packages/embeds/js/src/components/Bot.tsx +++ b/packages/embeds/js/src/components/Bot.tsx @@ -38,6 +38,7 @@ import { CorsError } from '@/utils/CorsError' import { Toaster, Toast } from '@ark-ui/solid' import { CloseIcon } from './icons/CloseIcon' import { toaster } from '@/utils/toaster' +import { setBotContainer } from '@/utils/botContainerSignal' export type BotProps = { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -285,16 +286,18 @@ const BotContent = (props: BotContentProps) => { key: `typebot-${props.context.typebot.id}-progressValue`, } ) - let botContainer: HTMLDivElement | undefined + let botContainerElement: HTMLDivElement | undefined const resizeObserver = new ResizeObserver((entries) => { return setIsMobile(entries[0].target.clientWidth < 400) }) onMount(() => { - if (!botContainer) return - resizeObserver.observe(botContainer) - setBotContainerHeight(`${botContainer.clientHeight}px`) + if (!botContainerElement) return + console.log('yes') + setBotContainer(botContainerElement) + resizeObserver.observe(botContainerElement) + setBotContainerHeight(`${botContainerElement.clientHeight}px`) }) createEffect(() => { @@ -304,22 +307,22 @@ const BotContent = (props: BotContentProps) => { family: defaultFontFamily, } ) - if (!botContainer) return + if (!botContainerElement) return setCssVariablesValue( props.initialChatReply.typebot.theme, - botContainer, + botContainerElement, props.context.isPreview ) }) onCleanup(() => { - if (!botContainer) return - resizeObserver.unobserve(botContainer) + if (!botContainerElement) return + resizeObserver.unobserve(botContainerElement) }) return (
{ props.initialChatReply.typebot.settings.general?.isBrandingEnabled } > - + {(toast) => ( diff --git a/packages/embeds/js/src/components/ConversationContainer/ChatChunk.tsx b/packages/embeds/js/src/components/ConversationContainer/ChatChunk.tsx index a23d0d85f..70c02424d 100644 --- a/packages/embeds/js/src/components/ConversationContainer/ChatChunk.tsx +++ b/packages/embeds/js/src/components/ConversationContainer/ChatChunk.tsx @@ -1,4 +1,8 @@ -import { Answer, BotContext, ChatChunk as ChatChunkType } from '@/types' +import { + InputSubmitContent, + BotContext, + ChatChunk as ChatChunkType, +} from '@/types' import { isMobile } from '@/utils/isMobileSignal' import { ContinueChatResponse, Settings, Theme } from '@typebot.io/schemas' import { createSignal, For, onMount, Show } from 'solid-js' @@ -23,7 +27,7 @@ type Props = Pick & { isTransitionDisabled?: boolean onNewBubbleDisplayed: (blockId: string) => Promise onScrollToBottom: (ref?: HTMLDivElement, offset?: number) => void - onSubmit: (answer?: string, attachments?: Answer['attachments']) => void + onSubmit: (answer?: InputSubmitContent) => void onSkip: () => void onAllBubblesDisplayed: () => void } diff --git a/packages/embeds/js/src/components/ConversationContainer/ConversationContainer.tsx b/packages/embeds/js/src/components/ConversationContainer/ConversationContainer.tsx index 6260d6e3e..0fd559de2 100644 --- a/packages/embeds/js/src/components/ConversationContainer/ConversationContainer.tsx +++ b/packages/embeds/js/src/components/ConversationContainer/ConversationContainer.tsx @@ -4,6 +4,7 @@ import { Theme, ChatLog, StartChatResponse, + Message, } from '@typebot.io/schemas' import { createEffect, @@ -16,9 +17,9 @@ import { import { continueChatQuery } from '@/queries/continueChatQuery' import { ChatChunk } from './ChatChunk' import { - Answer, BotContext, ChatChunk as ChatChunkType, + InputSubmitContent, OutgoingLog, } from '@/types' import { isNotDefined } from '@typebot.io/lib' @@ -33,6 +34,7 @@ import { import { saveClientLogsQuery } from '@/queries/saveClientLogsQuery' import { HTTPError } from 'ky' import { persist } from '@/utils/persist' +import { getAnswerContent } from '@/utils/getAnswerContent' const autoScrollBottomToleranceScreenPercent = 0.6 const bottomSpacerHeight = 128 @@ -142,15 +144,15 @@ export const ConversationContainer = (props: Props) => { }) } - const sendMessage = async ( - message?: string, - attachments?: Answer['attachments'] - ) => { + const sendMessage = async (answer?: InputSubmitContent) => { setIsRecovered(false) setHasError(false) const currentInputBlock = [...chatChunks()].pop()?.input - if (currentInputBlock?.id && props.onAnswer && message) - props.onAnswer({ message, blockId: currentInputBlock.id }) + if (currentInputBlock?.id && props.onAnswer && answer) + props.onAnswer({ + message: getAnswerContent(answer), + blockId: currentInputBlock.id, + }) const longRequest = setTimeout(() => { setIsSending(true) }, 1000) @@ -158,13 +160,7 @@ export const ConversationContainer = (props: Props) => { const { data, error } = await continueChatQuery({ apiHost: props.context.apiHost, sessionId: props.initialChatReply.sessionId, - message: message - ? { - type: 'text', - text: message, - attachedFileUrls: attachments?.map((attachment) => attachment.url), - } - : undefined, + message: convertSubmitContentToMessage(answer), }) clearTimeout(longRequest) setIsSending(false) @@ -294,7 +290,11 @@ export const ConversationContainer = (props: Props) => { if (response && 'logs' in response) saveLogs(response.logs) if (response && 'replyToSend' in response) { setIsSending(false) - sendMessage(response.replyToSend) + sendMessage( + response.replyToSend + ? { type: 'text', value: response.replyToSend } + : undefined + ) return } if (response && 'blockedPopupUrl' in response) @@ -364,3 +364,16 @@ const BottomSpacer = () => ( style={{ height: bottomSpacerHeight + 'px' }} /> ) + +const convertSubmitContentToMessage = ( + answer: InputSubmitContent | undefined +): Message | undefined => { + if (!answer) return + if (answer.type === 'text') + return { + type: 'text', + text: answer.value, + attachedFileUrls: answer.attachments?.map((attachment) => attachment.url), + } + if (answer.type === 'recording') return { type: 'audio', url: answer.url } +} diff --git a/packages/embeds/js/src/components/InputChatBlock.tsx b/packages/embeds/js/src/components/InputChatBlock.tsx index c7d5fa4d0..725de233d 100644 --- a/packages/embeds/js/src/components/InputChatBlock.tsx +++ b/packages/embeds/js/src/components/InputChatBlock.tsx @@ -15,7 +15,7 @@ import type { DateInputBlock, } from '@typebot.io/schemas' import { GuestBubble } from './bubbles/GuestBubble' -import { Answer, BotContext, InputSubmitContent } from '@/types' +import { 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,33 +48,24 @@ type Props = { isInputPrefillEnabled: boolean hasError: boolean onTransitionEnd: () => void - onSubmit: (answer: string, attachments?: Answer['attachments']) => void + onSubmit: (content: InputSubmitContent) => void onSkip: () => void } export const InputChatBlock = (props: Props) => { - const [answer, setAnswer] = persist(createSignal(), { + const [answer, setAnswer] = persist(createSignal(), { key: `typebot-${props.context.typebot.id}-input-${props.chunkIndex}`, storage: props.context.storage, }) - const handleSubmit = async ({ - label, - value, - attachments, - }: InputSubmitContent & Pick) => { - setAnswer({ - text: props.block.type !== InputBlockType.FILE ? label ?? value : '', - attachments, - }) - props.onSubmit( - value ?? label, - props.block.type === InputBlockType.FILE ? undefined : attachments - ) + const handleSubmit = async (content: InputSubmitContent) => { + console.log(content) + setAnswer(content) + props.onSubmit(content) } const handleSkip = (label: string) => { - setAnswer({ text: label }) + setAnswer({ type: 'text', value: label }) props.onSkip() } @@ -83,14 +74,18 @@ export const InputChatBlock = (props: Props) => { (message) => props.chunkIndex === message.inputIndex )?.formattedMessage if (formattedMessage && props.block.type !== InputBlockType.FILE) - setAnswer((answer) => ({ ...answer, text: formattedMessage })) + setAnswer((answer) => + answer?.type === 'text' + ? { ...answer, label: formattedMessage } + : answer + ) }) return ( { block={props.block} chunkIndex={props.chunkIndex} isInputPrefillEnabled={props.isInputPrefillEnabled} - existingAnswer={props.hasError ? answer()?.text : undefined} + existingAnswer={ + props.hasError ? getAnswerValue(answer()!) : undefined + } onTransitionEnd={props.onTransitionEnd} onSubmit={handleSubmit} onSkip={handleSkip} @@ -128,6 +125,11 @@ export const InputChatBlock = (props: Props) => { ) } +const getAnswerValue = (answer?: InputSubmitContent) => { + if (!answer) return + return answer.type === 'text' ? answer.value : answer.url +} + const Input = (props: { context: BotContext block: NonNullable @@ -146,6 +148,7 @@ const Input = (props: { const submitPaymentSuccess = () => props.onSubmit({ + type: 'text', value: (props.block.options as PaymentInputBlock['options'])?.labels ?.success ?? defaultPaymentInputOptions.labels.success, diff --git a/packages/embeds/js/src/components/bubbles/GuestBubble.tsx b/packages/embeds/js/src/components/bubbles/GuestBubble.tsx index 62bfa47eb..efb500305 100644 --- a/packages/embeds/js/src/components/bubbles/GuestBubble.tsx +++ b/packages/embeds/js/src/components/bubbles/GuestBubble.tsx @@ -1,22 +1,24 @@ -import { createSignal, For, Show } from 'solid-js' +import { createSignal, For, Show, Switch, Match } from 'solid-js' import { Avatar } from '../avatars/Avatar' import { isMobile } from '@/utils/isMobileSignal' -import { Answer } from '@/types' +import { + InputSubmitContent, + RecordingInputSubmitContent, + TextInputSubmitContent, +} 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: Answer + answer?: InputSubmitContent showAvatar: boolean avatarSrc?: string hasHostAvatar: boolean } export const GuestBubble = (props: Props) => { - const [clickedImageSrc, setClickedImageSrc] = createSignal() - return (
{ : undefined, }} > -
- 0}> -
- - attachment.type.startsWith('image') - )} - > - {(attachment, idx) => ( - {`Attached - attachment.type.startsWith('image') - ).length > 1 && 'max-w-[90%]' - )} - onClick={() => setClickedImageSrc(attachment.url)} - /> - )} - -
-
- !attachment.type.startsWith('image') - )} - > - {(attachment) => ( - - )} - -
-
-
- - {props.message.text} - -
-
+ + + + + + + + + + + +
+ ) +} + +const TextGuestBubble = (props: { answer: TextInputSubmitContent }) => { + const [clickedImageSrc, setClickedImageSrc] = createSignal() + + return ( +
+ 0}> +
+ + attachment.type.startsWith('image') + )} + > + {(attachment, idx) => ( + {`Attached + attachment.type.startsWith('image') + ).length > 1 && 'max-w-[90%]' + )} + onClick={() => setClickedImageSrc(attachment.url)} + /> + )} + +
+
+ !attachment.type.startsWith('image') + )} + > + {(attachment) => ( + + )} + +
+
+
+ + + {props.answer.label ?? props.answer.value} + + +
setClickedImageSrc(undefined)} @@ -97,9 +121,19 @@ export const GuestBubble = (props: Props) => { style={{ 'border-radius': '6px' }} /> - - - +
+ ) +} + +const AudioGuestBubble = (props: { answer: RecordingInputSubmitContent }) => { + return ( +
+
+
) } diff --git a/packages/embeds/js/src/components/bubbles/HostBubble.tsx b/packages/embeds/js/src/components/bubbles/HostBubble.tsx index 6c883843d..251f25d65 100644 --- a/packages/embeds/js/src/components/bubbles/HostBubble.tsx +++ b/packages/embeds/js/src/components/bubbles/HostBubble.tsx @@ -4,6 +4,7 @@ import { CustomEmbedBubble } from '@/features/blocks/bubbles/embed/components/Cu import { ImageBubble } from '@/features/blocks/bubbles/image' import { TextBubble } from '@/features/blocks/bubbles/textBubble' import { VideoBubble } from '@/features/blocks/bubbles/video' +import { InputSubmitContent } from '@/types' import type { AudioBubbleBlock, ChatMessage, @@ -22,7 +23,7 @@ type Props = { typingEmulation: Settings['typingEmulation'] isTypingSkipped: boolean onTransitionEnd?: (ref?: HTMLDivElement) => void - onCompleted: (reply?: string) => void + onCompleted: (reply?: InputSubmitContent) => void } export const HostBubble = (props: Props) => ( diff --git a/packages/embeds/js/src/components/icons/MicrophoneIcon.tsx b/packages/embeds/js/src/components/icons/MicrophoneIcon.tsx new file mode 100644 index 000000000..59e41427a --- /dev/null +++ b/packages/embeds/js/src/components/icons/MicrophoneIcon.tsx @@ -0,0 +1,12 @@ +import { JSX } from 'solid-js/jsx-runtime' + +export const MicrophoneIcon = (props: JSX.SvgSVGAttributes) => ( + + + +) 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 4e080c60f..9115590f5 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 @@ -5,11 +5,12 @@ import { clsx } from 'clsx' import { CustomEmbedBubble as CustomEmbedBubbleProps } from '@typebot.io/schemas' import { executeCode } from '@/features/blocks/logic/script/executeScript' import { botContainerHeight } from '@/utils/botContainerHeightSignal' +import { InputSubmitContent } from '@/types' type Props = { content: CustomEmbedBubbleProps['content'] onTransitionEnd?: (ref?: HTMLDivElement) => void - onCompleted: (reply?: string) => void + onCompleted: (reply?: InputSubmitContent) => void } let typingTimeout: NodeJS.Timeout @@ -36,7 +37,8 @@ export const CustomEmbedBubble = (props: Props) => { executeCode({ args: { ...props.content.waitForEventFunction.args, - continueFlow: props.onCompleted, + continueFlow: (text: string) => + props.onCompleted(text ? { type: 'text', value: text } : undefined), }, content: props.content.waitForEventFunction.content, }) diff --git a/packages/embeds/js/src/features/blocks/bubbles/embed/components/EmbedBubble.tsx b/packages/embeds/js/src/features/blocks/bubbles/embed/components/EmbedBubble.tsx index 84a241bae..85ea6d189 100644 --- a/packages/embeds/js/src/features/blocks/bubbles/embed/components/EmbedBubble.tsx +++ b/packages/embeds/js/src/features/blocks/bubbles/embed/components/EmbedBubble.tsx @@ -5,11 +5,12 @@ import { clsx } from 'clsx' import { EmbedBubbleBlock } from '@typebot.io/schemas' import { defaultEmbedBubbleContent } from '@typebot.io/schemas/features/blocks/bubbles/embed/constants' import { isNotEmpty } from '@typebot.io/lib/utils' +import { InputSubmitContent } from '@/types' type Props = { content: EmbedBubbleBlock['content'] onTransitionEnd?: (ref?: HTMLDivElement) => void - onCompleted?: (data?: string) => void + onCompleted?: (data?: InputSubmitContent) => void } let typingTimeout: NodeJS.Timeout @@ -32,7 +33,10 @@ export const EmbedBubble = (props: Props) => { ) { props.onCompleted?.( props.content.waitForEvent.saveDataInVariableId && event.data.data - ? event.data.data + ? { + type: 'text', + value: event.data.data, + } : undefined ) window.removeEventListener('message', handleMessage) diff --git a/packages/embeds/js/src/features/blocks/inputs/buttons/components/Buttons.tsx b/packages/embeds/js/src/features/blocks/inputs/buttons/components/Buttons.tsx index 07baffa67..10a7583a6 100644 --- a/packages/embeds/js/src/features/blocks/inputs/buttons/components/Buttons.tsx +++ b/packages/embeds/js/src/features/blocks/inputs/buttons/components/Buttons.tsx @@ -22,7 +22,10 @@ export const Buttons = (props: Props) => { }) const handleClick = (itemIndex: number) => - props.onSubmit({ value: filteredItems()[itemIndex].content ?? '' }) + props.onSubmit({ + type: 'text', + value: filteredItems()[itemIndex].content ?? '', + }) const filterItems = (inputValue: string) => { setFilteredItems( diff --git a/packages/embeds/js/src/features/blocks/inputs/buttons/components/MultipleChoicesForm.tsx b/packages/embeds/js/src/features/blocks/inputs/buttons/components/MultipleChoicesForm.tsx index c2f7307a6..61a294fee 100644 --- a/packages/embeds/js/src/features/blocks/inputs/buttons/components/MultipleChoicesForm.tsx +++ b/packages/embeds/js/src/features/blocks/inputs/buttons/components/MultipleChoicesForm.tsx @@ -39,6 +39,7 @@ export const MultipleChoicesForm = (props: Props) => { const handleSubmit = () => props.onSubmit({ + type: 'text', value: selectedItemIds() .map( (selectedItemId) => diff --git a/packages/embeds/js/src/features/blocks/inputs/date/components/DateForm.tsx b/packages/embeds/js/src/features/blocks/inputs/date/components/DateForm.tsx index 1d11cfc60..e323f8a5f 100644 --- a/packages/embeds/js/src/features/blocks/inputs/date/components/DateForm.tsx +++ b/packages/embeds/js/src/features/blocks/inputs/date/components/DateForm.tsx @@ -19,6 +19,7 @@ export const DateForm = (props: Props) => { const submit = () => { if (inputValues().from === '' && inputValues().to === '') return props.onSubmit({ + type: 'text', value: `${inputValues().from}${ props.options?.isRange ? ` to ${inputValues().to}` : '' }`, diff --git a/packages/embeds/js/src/features/blocks/inputs/email/components/EmailInput.tsx b/packages/embeds/js/src/features/blocks/inputs/email/components/EmailInput.tsx index 92c453d14..3ae26a706 100644 --- a/packages/embeds/js/src/features/blocks/inputs/email/components/EmailInput.tsx +++ b/packages/embeds/js/src/features/blocks/inputs/email/components/EmailInput.tsx @@ -24,7 +24,7 @@ export const EmailInput = (props: Props) => { const submit = () => { if (checkIfInputIsValid()) - props.onSubmit({ value: inputRef?.value ?? inputValue() }) + props.onSubmit({ type: 'text', value: inputRef?.value ?? inputValue() }) else inputRef?.focus() } diff --git a/packages/embeds/js/src/features/blocks/inputs/fileUpload/components/FileUploadForm.tsx b/packages/embeds/js/src/features/blocks/inputs/fileUpload/components/FileUploadForm.tsx index 9fcfce755..3603bbd06 100644 --- a/packages/embeds/js/src/features/blocks/inputs/fileUpload/components/FileUploadForm.tsx +++ b/packages/embeds/js/src/features/blocks/inputs/fileUpload/components/FileUploadForm.tsx @@ -78,6 +78,7 @@ export const FileUploadForm = (props: Props) => { setIsUploading(false) if (urls.length && urls[0]) return props.onSubmit({ + type: 'text', label: props.block.options?.labels?.success?.single ?? defaultFileInputOptions.labels.success.single, @@ -107,6 +108,7 @@ export const FileUploadForm = (props: Props) => { description: 'An error occured while uploading the files', }) props.onSubmit({ + type: 'text', label: urls.length > 1 ? ( diff --git a/packages/embeds/js/src/features/blocks/inputs/number/components/NumberInput.tsx b/packages/embeds/js/src/features/blocks/inputs/number/components/NumberInput.tsx index 0cbd69e0c..8765a7b33 100644 --- a/packages/embeds/js/src/features/blocks/inputs/number/components/NumberInput.tsx +++ b/packages/embeds/js/src/features/blocks/inputs/number/components/NumberInput.tsx @@ -27,7 +27,10 @@ export const NumberInput = (props: NumberInputProps) => { const submit = () => { if (checkIfInputIsValid()) - props.onSubmit({ value: inputRef?.value ?? inputValue().toString() }) + props.onSubmit({ + type: 'text', + value: inputRef?.value ?? inputValue().toString(), + }) else inputRef?.focus() } diff --git a/packages/embeds/js/src/features/blocks/inputs/phone/components/PhoneInput.tsx b/packages/embeds/js/src/features/blocks/inputs/phone/components/PhoneInput.tsx index e2995ae9f..d26cdea62 100644 --- a/packages/embeds/js/src/features/blocks/inputs/phone/components/PhoneInput.tsx +++ b/packages/embeds/js/src/features/blocks/inputs/phone/components/PhoneInput.tsx @@ -66,6 +66,7 @@ export const PhoneInput = (props: PhoneInputProps) => { if (checkIfInputIsValid()) { const val = inputRef?.value ?? inputValue() props.onSubmit({ + type: 'text', value: val.startsWith('+') ? val : `${selectedCountryDialCode ?? ''}${val}`, diff --git a/packages/embeds/js/src/features/blocks/inputs/pictureChoice/MultiplePictureChoice.tsx b/packages/embeds/js/src/features/blocks/inputs/pictureChoice/MultiplePictureChoice.tsx index d8da50630..12382fe4b 100644 --- a/packages/embeds/js/src/features/blocks/inputs/pictureChoice/MultiplePictureChoice.tsx +++ b/packages/embeds/js/src/features/blocks/inputs/pictureChoice/MultiplePictureChoice.tsx @@ -42,6 +42,7 @@ export const MultiplePictureChoice = (props: Props) => { const handleSubmit = () => props.onSubmit({ + type: 'text', value: selectedItemIds() .map((selectedItemId) => { const item = props.defaultItems.find( diff --git a/packages/embeds/js/src/features/blocks/inputs/pictureChoice/SinglePictureChoice.tsx b/packages/embeds/js/src/features/blocks/inputs/pictureChoice/SinglePictureChoice.tsx index dc8d306bf..5ce1680b7 100644 --- a/packages/embeds/js/src/features/blocks/inputs/pictureChoice/SinglePictureChoice.tsx +++ b/packages/embeds/js/src/features/blocks/inputs/pictureChoice/SinglePictureChoice.tsx @@ -24,6 +24,7 @@ export const SinglePictureChoice = (props: Props) => { const handleClick = (itemIndex: number) => { const item = filteredItems()[itemIndex] return props.onSubmit({ + type: 'text', label: isNotEmpty(item.title) ? item.title : item.pictureSrc ?? item.id, value: item.id, }) diff --git a/packages/embeds/js/src/features/blocks/inputs/rating/components/RatingForm.tsx b/packages/embeds/js/src/features/blocks/inputs/rating/components/RatingForm.tsx index 0eff01ea4..ce0f28888 100644 --- a/packages/embeds/js/src/features/blocks/inputs/rating/components/RatingForm.tsx +++ b/packages/embeds/js/src/features/blocks/inputs/rating/components/RatingForm.tsx @@ -21,12 +21,13 @@ export const RatingForm = (props: Props) => { e.preventDefault() const selectedRating = rating() if (isNotDefined(selectedRating)) return - props.onSubmit({ value: selectedRating.toString() }) + props.onSubmit({ type: 'text', value: selectedRating.toString() }) } const handleClick = (rating: number) => { if (props.block.options?.isOneClickSubmitEnabled) - props.onSubmit({ value: rating.toString() }) + props.onSubmit({ type: 'text', value: rating.toString() }) + setRating(rating) } diff --git a/packages/embeds/js/src/features/blocks/inputs/textInput/components/TextInput.tsx b/packages/embeds/js/src/features/blocks/inputs/textInput/components/TextInput.tsx index 0b17486ca..c392f7ac9 100644 --- a/packages/embeds/js/src/features/blocks/inputs/textInput/components/TextInput.tsx +++ b/packages/embeds/js/src/features/blocks/inputs/textInput/components/TextInput.tsx @@ -1,10 +1,18 @@ import { Textarea, ShortTextInput } from '@/components' import { SendButton } from '@/components/SendButton' import { CommandData } from '@/features/commands' -import { Answer, BotContext, InputSubmitContent } from '@/types' +import { Attachment, BotContext, InputSubmitContent } from '@/types' import { isMobile } from '@/utils/isMobileSignal' import type { TextInputBlock } from '@typebot.io/schemas' -import { For, Show, createSignal, onCleanup, onMount } from 'solid-js' +import { + For, + Match, + Show, + Switch, + createSignal, + onCleanup, + onMount, +} from 'solid-js' import { defaultTextInputOptions } from '@typebot.io/schemas/features/blocks/inputs/text/constants' import clsx from 'clsx' import { TextInputAddFileButton } from '@/components/TextInputAddFileButton' @@ -15,6 +23,9 @@ import { toaster } from '@/utils/toaster' import { isDefined } from '@typebot.io/lib' import { uploadFiles } from '../../fileUpload/helpers/uploadFiles' import { guessApiHost } from '@/utils/guessApiHost' +import { VoiceRecorder } from './VoiceRecorder' +import { Button } from '@/components/Button' +import { MicrophoneIcon } from '@/components/icons/MicrophoneIcon' type Props = { block: TextInputBlock @@ -30,7 +41,10 @@ export const TextInput = (props: Props) => { { fileIndex: number; progress: number } | undefined >(undefined) const [isDraggingOver, setIsDraggingOver] = createSignal(false) + const [isRecording, setIsRecording] = createSignal(false) let inputRef: HTMLInputElement | HTMLTextAreaElement | undefined + let mediaRecorder: MediaRecorder | undefined + let recordedChunks: Blob[] = [] const handleInput = (inputValue: string) => setInputValue(inputValue) @@ -38,8 +52,12 @@ export const TextInput = (props: Props) => { inputRef?.value !== '' && inputRef?.reportValidity() const submit = async () => { + if (isRecording() && mediaRecorder) { + mediaRecorder.stop() + return + } if (checkIfInputIsValid()) { - let attachments: Answer['attachments'] + let attachments: Attachment[] | undefined if (selectedFiles().length > 0) { setUploadProgress(undefined) const urls = await uploadFiles({ @@ -57,6 +75,7 @@ export const TextInput = (props: Props) => { attachments = urls?.filter(isDefined) } props.onSubmit({ + type: 'text', value: inputRef?.value ?? inputValue(), attachments, }) @@ -137,6 +156,59 @@ export const TextInput = (props: Props) => { ) } + const recordVoice = () => { + setIsRecording(true) + } + + const handleRecordingStart = (stream: MediaStream) => { + mediaRecorder = new MediaRecorder(stream) + mediaRecorder.ondataavailable = (event) => { + if (event.data.size === 0) return + recordedChunks.push(event.data) + } + mediaRecorder.onstop = async () => { + if (!isRecording() || recordedChunks.length === 0) return + const audioFile = new File( + recordedChunks, + `rec-${props.block.id}-${Date.now()}.mp3`, + { + type: 'audio/mp3', + } + ) + setUploadProgress(undefined) + const urls = ( + await uploadFiles({ + apiHost: + props.context.apiHost ?? guessApiHost({ ignoreChatApiUrl: true }), + files: [ + { + file: audioFile, + input: { + sessionId: props.context.sessionId, + fileName: audioFile.name, + }, + }, + ], + onUploadProgress: setUploadProgress, + }) + ) + .filter(isDefined) + .map((url) => url.url) + props.onSubmit({ + type: 'recording', + url: urls[0], + }) + } + mediaRecorder.start() + } + + const handleRecordingAbort = () => { + setIsRecording(false) + mediaRecorder?.stop() + mediaRecorder = undefined + recordedChunks = [] + } + return (
{ >
- + + + +
+ + {(file, index) => ( + removeSelectedFile(index())} + /> + )} + +
+
- - {(file, index) => ( - removeSelectedFile(index())} - /> - )} - + {props.block.options?.isLong ? ( +