diff --git a/apps/builder/assets/icons.tsx b/apps/builder/assets/icons.tsx
index 6cdf97e03..df5a15ce7 100644
--- a/apps/builder/assets/icons.tsx
+++ b/apps/builder/assets/icons.tsx
@@ -267,3 +267,16 @@ export const ExternalLinkIcon = (props: IconProps) => (
)
+
+export const FilmIcon = (props: IconProps) => (
+
+
+
+
+
+
+
+
+
+
+)
diff --git a/apps/builder/components/board/StepTypesList/StepIcon.tsx b/apps/builder/components/board/StepTypesList/StepIcon.tsx
index b24a8c195..e72d59b4f 100644
--- a/apps/builder/components/board/StepTypesList/StepIcon.tsx
+++ b/apps/builder/components/board/StepTypesList/StepIcon.tsx
@@ -6,6 +6,7 @@ import {
EditIcon,
EmailIcon,
ExternalLinkIcon,
+ FilmIcon,
FilterIcon,
FlagIcon,
GlobeIcon,
@@ -32,6 +33,8 @@ export const StepIcon = ({ type, ...props }: StepIconProps) => {
return
case BubbleStepType.IMAGE:
return
+ case BubbleStepType.VIDEO:
+ return
case InputStepType.TEXT:
return
case InputStepType.NUMBER:
diff --git a/apps/builder/components/board/StepTypesList/StepTypeLabel.tsx b/apps/builder/components/board/StepTypesList/StepTypeLabel.tsx
index b66953235..279e8467c 100644
--- a/apps/builder/components/board/StepTypesList/StepTypeLabel.tsx
+++ b/apps/builder/components/board/StepTypesList/StepTypeLabel.tsx
@@ -13,54 +13,43 @@ type Props = { type: StepType }
export const StepTypeLabel = ({ type }: Props) => {
switch (type) {
case BubbleStepType.TEXT:
- case InputStepType.TEXT: {
+ case InputStepType.TEXT:
return Text
- }
case BubbleStepType.IMAGE:
return Image
- case InputStepType.NUMBER: {
+ case BubbleStepType.VIDEO:
+ return Video
+ case InputStepType.NUMBER:
return Number
- }
- case InputStepType.EMAIL: {
+ case InputStepType.EMAIL:
return Email
- }
- case InputStepType.URL: {
+ case InputStepType.URL:
return Website
- }
- case InputStepType.DATE: {
+ case InputStepType.DATE:
return Date
- }
- case InputStepType.PHONE: {
+ case InputStepType.PHONE:
return Phone
- }
- case InputStepType.CHOICE: {
+ case InputStepType.CHOICE:
return Button
- }
- case LogicStepType.SET_VARIABLE: {
+ case LogicStepType.SET_VARIABLE:
return Set variable
- }
- case LogicStepType.CONDITION: {
+ case LogicStepType.CONDITION:
return Condition
- }
- case LogicStepType.REDIRECT: {
+ case LogicStepType.REDIRECT:
return Redirect
- }
- case IntegrationStepType.GOOGLE_SHEETS: {
+ case IntegrationStepType.GOOGLE_SHEETS:
return (
Sheets
)
- }
- case IntegrationStepType.GOOGLE_ANALYTICS: {
+ case IntegrationStepType.GOOGLE_ANALYTICS:
return (
Analytics
)
- }
- default: {
+ default:
return <>>
- }
}
}
diff --git a/apps/builder/components/board/graph/BlockNode/StepNode/ContentPopover/ContentPopover.tsx b/apps/builder/components/board/graph/BlockNode/StepNode/ContentPopover/ContentPopover.tsx
index 59cb42cc2..daf1d068c 100644
--- a/apps/builder/components/board/graph/BlockNode/StepNode/ContentPopover/ContentPopover.tsx
+++ b/apps/builder/components/board/graph/BlockNode/StepNode/ContentPopover/ContentPopover.tsx
@@ -4,16 +4,17 @@ import {
PopoverArrow,
PopoverBody,
} from '@chakra-ui/react'
-import { ImagePopoverContent } from 'components/shared/ImageUploadContent'
+import { ImageUploadContent } from 'components/shared/ImageUploadContent'
import { useTypebot } from 'contexts/TypebotContext'
import {
BubbleStep,
+ BubbleStepContent,
BubbleStepType,
- ImageBubbleContent,
ImageBubbleStep,
TextBubbleStep,
} from 'models'
import { useRef } from 'react'
+import { VideoUploadContent } from './VideoUploadContent'
type Props = {
step: Exclude
@@ -25,7 +26,7 @@ export const ContentPopover = ({ step }: Props) => {
return (
-
+
@@ -37,16 +38,24 @@ export const ContentPopover = ({ step }: Props) => {
export const StepContent = ({ step }: Props) => {
const { updateStep } = useTypebot()
- const handleContentChange = (content: ImageBubbleContent) =>
+
+ const handleContentChange = (content: BubbleStepContent) =>
updateStep(step.id, { content } as Partial)
- const handleNewImageSubmit = (url: string) => handleContentChange({ url })
switch (step.type) {
case BubbleStepType.IMAGE: {
return (
-
+ )
+ }
+ case BubbleStepType.VIDEO: {
+ return (
+
)
}
diff --git a/apps/builder/components/board/graph/BlockNode/StepNode/ContentPopover/VideoUploadContent.tsx b/apps/builder/components/board/graph/BlockNode/StepNode/ContentPopover/VideoUploadContent.tsx
new file mode 100644
index 000000000..814eef702
--- /dev/null
+++ b/apps/builder/components/board/graph/BlockNode/StepNode/ContentPopover/VideoUploadContent.tsx
@@ -0,0 +1,38 @@
+import { Stack, Text } from '@chakra-ui/react'
+import { InputWithVariableButton } from 'components/shared/InputWithVariableButton'
+import { VideoBubbleContent, VideoBubbleContentType } from 'models'
+import urlParser from 'js-video-url-parser/lib/base'
+import 'js-video-url-parser/lib/provider/vimeo'
+import 'js-video-url-parser/lib/provider/youtube'
+import { isDefined } from 'utils'
+
+type Props = {
+ content?: VideoBubbleContent
+ onSubmit: (content: VideoBubbleContent) => void
+}
+
+export const VideoUploadContent = ({ content, onSubmit }: Props) => {
+ const handleUrlChange = (url: string) => {
+ const info = urlParser.parse(url)
+ return isDefined(info) && info.provider && info.id
+ ? onSubmit({
+ type: info.provider as VideoBubbleContentType,
+ url,
+ id: info.id,
+ })
+ : onSubmit({ type: VideoBubbleContentType.URL, url })
+ }
+ return (
+
+
+
+ Works with Youtube, Vimeo and others
+
+
+ )
+}
diff --git a/apps/builder/components/board/graph/BlockNode/StepNode/StepNodeContent.tsx b/apps/builder/components/board/graph/BlockNode/StepNode/StepNodeContent.tsx
index fe49e3277..0739718f0 100644
--- a/apps/builder/components/board/graph/BlockNode/StepNode/StepNodeContent.tsx
+++ b/apps/builder/components/board/graph/BlockNode/StepNode/StepNodeContent.tsx
@@ -9,6 +9,8 @@ import {
SetVariableStep,
ConditionStep,
IntegrationStepType,
+ VideoBubbleStep,
+ VideoBubbleContentType,
} from 'models'
import { ChoiceItemsList } from './ChoiceInputStepNode/ChoiceItemsList'
import { SourceEndpoint } from './SourceEndpoint'
@@ -48,6 +50,9 @@ export const StepNodeContent = ({ step }: Props) => {
)
}
+ case BubbleStepType.VIDEO: {
+ return
+ }
case InputStepType.TEXT: {
return (
@@ -182,3 +187,52 @@ const ConditionNodeContent = ({ step }: { step: ConditionStep }) => {
)
}
+
+const VideoStepNodeContent = ({ step }: { step: VideoBubbleStep }) => {
+ if (!step.content?.url || !step.content.type)
+ return Click to edit...
+ switch (step.content.type) {
+ case VideoBubbleContentType.URL:
+ return (
+
+
+
+ )
+ case VideoBubbleContentType.VIMEO:
+ case VideoBubbleContentType.YOUTUBE: {
+ const baseUrl =
+ step.content.type === VideoBubbleContentType.VIMEO
+ ? 'https://player.vimeo.com/video'
+ : 'https://www.youtube.com/embed'
+ return (
+
+
+
+ )
+ }
+ }
+}
diff --git a/apps/builder/components/shared/ImageUploadContent/ImageUploadContent.tsx b/apps/builder/components/shared/ImageUploadContent/ImageUploadContent.tsx
index aa04f6434..5c6474501 100644
--- a/apps/builder/components/shared/ImageUploadContent/ImageUploadContent.tsx
+++ b/apps/builder/components/shared/ImageUploadContent/ImageUploadContent.tsx
@@ -4,17 +4,19 @@ import { SearchContextManager } from '@giphy/react-components'
import { UploadButton } from '../buttons/UploadButton'
import { GiphySearch } from './GiphySearch'
import { useTypebot } from 'contexts/TypebotContext'
+import { ImageBubbleContent } from 'models'
type Props = {
- url?: string
- onSubmit: (url: string) => void
+ content?: ImageBubbleContent
+ onSubmit: (content: ImageBubbleContent) => void
}
-export const ImageUploadContent = ({ url, onSubmit }: Props) => {
+export const ImageUploadContent = ({ content, onSubmit }: Props) => {
const [currentTab, setCurrentTab] = useState<'link' | 'upload' | 'giphy'>(
'upload'
)
+ const handleSubmit = (url: string) => onSubmit({ url })
return (
@@ -43,7 +45,11 @@ export const ImageUploadContent = ({ url, onSubmit }: Props) => {
)}
-
+
)
}
diff --git a/apps/builder/components/shared/ImageUploadContent/index.tsx b/apps/builder/components/shared/ImageUploadContent/index.tsx
index 42cc8d55f..7bbc1a574 100644
--- a/apps/builder/components/shared/ImageUploadContent/index.tsx
+++ b/apps/builder/components/shared/ImageUploadContent/index.tsx
@@ -1 +1 @@
-export { ImageUploadContent as ImagePopoverContent } from './ImageUploadContent'
+export { ImageUploadContent } from './ImageUploadContent'
diff --git a/apps/builder/cypress/tests/bubbles/video.ts b/apps/builder/cypress/tests/bubbles/video.ts
new file mode 100644
index 000000000..6bec574f6
--- /dev/null
+++ b/apps/builder/cypress/tests/bubbles/video.ts
@@ -0,0 +1,116 @@
+import { createTypebotWithStep } from 'cypress/plugins/data'
+import { preventUserFromRefreshing } from 'cypress/plugins/utils'
+import { getIframeBody } from 'cypress/support'
+import { BubbleStepType, Step, VideoBubbleContentType } from 'models'
+
+const videoSrc =
+ 'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4'
+const youtubeVideoSrc = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
+const vimeoVideoSrc = 'https://vimeo.com/649301125'
+
+describe('Video bubbles', () => {
+ afterEach(() => {
+ cy.window().then((win) => {
+ win.removeEventListener('beforeunload', preventUserFromRefreshing)
+ })
+ })
+ describe('Content settings', () => {
+ beforeEach(() => {
+ cy.task('seed')
+ createTypebotWithStep({
+ type: BubbleStepType.VIDEO,
+ } as Omit)
+ cy.signOut()
+ })
+
+ it('upload image file correctly', () => {
+ cy.signIn('test2@gmail.com')
+ cy.visit('/typebots/typebot3/edit')
+ cy.findByText('Click to edit...').click()
+ cy.findByPlaceholderText('Paste the video link...').type(videoSrc, {
+ waitForAnimations: false,
+ })
+ cy.get('video > source').should('have.attr', 'src').should('eq', videoSrc)
+
+ cy.findByPlaceholderText('Paste the video link...')
+ .clear()
+ .type(youtubeVideoSrc, {
+ waitForAnimations: false,
+ })
+ cy.get('iframe')
+ .should('have.attr', 'src')
+ .should('eq', 'https://www.youtube.com/embed/dQw4w9WgXcQ')
+
+ cy.findByPlaceholderText('Paste the video link...')
+ .clear()
+ .type(vimeoVideoSrc, {
+ waitForAnimations: false,
+ })
+ cy.get('iframe')
+ .should('have.attr', 'src')
+ .should('eq', 'https://player.vimeo.com/video/649301125')
+ })
+ })
+
+ describe('Preview', () => {
+ beforeEach(() => {
+ cy.task('seed')
+ cy.signOut()
+ })
+
+ it('should display video correctly', () => {
+ createTypebotWithStep({
+ type: BubbleStepType.VIDEO,
+ content: {
+ type: VideoBubbleContentType.URL,
+ url: videoSrc,
+ },
+ } as Omit)
+ cy.signIn('test2@gmail.com')
+ cy.visit('/typebots/typebot3/edit')
+ cy.findByRole('button', { name: 'Preview' }).click()
+ getIframeBody()
+ .get('video > source')
+ .should('have.attr', 'src')
+ .should('eq', videoSrc)
+ })
+
+ it('should display youtube iframe correctly', () => {
+ createTypebotWithStep({
+ type: BubbleStepType.VIDEO,
+ content: {
+ type: VideoBubbleContentType.YOUTUBE,
+ url: youtubeVideoSrc,
+ id: 'dQw4w9WgXcQ',
+ },
+ } as Omit)
+ cy.signIn('test2@gmail.com')
+ cy.visit('/typebots/typebot3/edit')
+ cy.findByRole('button', { name: 'Preview' }).click()
+ getIframeBody()
+ .get('iframe')
+ .first()
+ .should('have.attr', 'src')
+ .should('eq', 'https://www.youtube.com/embed/dQw4w9WgXcQ')
+ })
+
+ it('should display vimeo iframe correctly', () => {
+ createTypebotWithStep({
+ type: BubbleStepType.VIDEO,
+ content: {
+ type: VideoBubbleContentType.VIMEO,
+ url: vimeoVideoSrc,
+ id: '649301125',
+ },
+ } as Omit)
+ cy.signIn('test2@gmail.com')
+ cy.visit('/typebots/typebot3/edit')
+ cy.findByRole('button', { name: 'Preview' }).click()
+ getIframeBody()
+ .get('iframe')
+ .first()
+ .should('have.attr', 'src')
+ .should('eq', 'https://player.vimeo.com/video/649301125')
+ })
+ })
+})
diff --git a/apps/builder/package.json b/apps/builder/package.json
index f3d58a041..0aacc739a 100644
--- a/apps/builder/package.json
+++ b/apps/builder/package.json
@@ -38,6 +38,7 @@
"google-spreadsheet": "^3.2.0",
"htmlparser2": "^7.2.0",
"immer": "^9.0.7",
+ "js-video-url-parser": "^0.5.1",
"kbar": "^0.1.0-beta.24",
"micro": "^9.3.4",
"micro-cors": "^0.1.1",
diff --git a/packages/bot-engine/src/components/ChatBlock/ChatStep/bubbles/HostBubble.tsx b/packages/bot-engine/src/components/ChatBlock/ChatStep/bubbles/HostBubble.tsx
index 6352b69d3..6ac1001cd 100644
--- a/packages/bot-engine/src/components/ChatBlock/ChatStep/bubbles/HostBubble.tsx
+++ b/packages/bot-engine/src/components/ChatBlock/ChatStep/bubbles/HostBubble.tsx
@@ -2,6 +2,7 @@ import { BubbleStep, BubbleStepType } from 'models'
import React from 'react'
import { ImageBubble } from './ImageBubble'
import { TextBubble } from './TextBubble'
+import { VideoBubble } from './VideoBubble'
type Props = {
step: BubbleStep
@@ -14,5 +15,7 @@ export const HostBubble = ({ step, onTransitionEnd }: Props) => {
return
case BubbleStepType.IMAGE:
return
+ case BubbleStepType.VIDEO:
+ return
}
}
diff --git a/packages/bot-engine/src/components/ChatBlock/ChatStep/bubbles/VideoBubble.tsx b/packages/bot-engine/src/components/ChatBlock/ChatStep/bubbles/VideoBubble.tsx
new file mode 100644
index 000000000..03537df75
--- /dev/null
+++ b/packages/bot-engine/src/components/ChatBlock/ChatStep/bubbles/VideoBubble.tsx
@@ -0,0 +1,132 @@
+import React, { useEffect, useMemo, useRef, useState } from 'react'
+import { useHostAvatars } from 'contexts/HostAvatarsContext'
+import { useTypebot } from 'contexts/TypebotContext'
+import {
+ Table,
+ Variable,
+ VideoBubbleContent,
+ VideoBubbleContentType,
+ VideoBubbleStep,
+} from 'models'
+import { TypingContent } from './TypingContent'
+import { parseVariables } from 'services/variable'
+
+type Props = {
+ step: VideoBubbleStep
+ onTransitionEnd: () => void
+}
+
+export const showAnimationDuration = 400
+
+export const mediaLoadingFallbackTimeout = 5000
+
+export const VideoBubble = ({ step, onTransitionEnd }: Props) => {
+ const { typebot } = useTypebot()
+ const { updateLastAvatarOffset } = useHostAvatars()
+ const messageContainer = useRef(null)
+ const [isTyping, setIsTyping] = useState(true)
+
+ useEffect(() => {
+ showContentAfterMediaLoad()
+ }, [])
+
+ const showContentAfterMediaLoad = () => {
+ setTimeout(() => {
+ setIsTyping(false)
+ onTypingEnd()
+ }, 1000)
+ }
+
+ const onTypingEnd = () => {
+ setIsTyping(false)
+ setTimeout(() => {
+ sendAvatarOffset()
+ onTransitionEnd()
+ }, showAnimationDuration)
+ }
+
+ const sendAvatarOffset = () => {
+ if (!messageContainer.current) return
+ const containerDimensions = messageContainer.current.getBoundingClientRect()
+ updateLastAvatarOffset(containerDimensions.top + containerDimensions.height)
+ }
+
+ return (
+
+
+
+
+ {isTyping ? : <>>}
+
+
+
+
+
+ )
+}
+
+const VideoContent = ({
+ content,
+ isTyping,
+ variables,
+}: {
+ content?: VideoBubbleContent
+ isTyping: boolean
+ variables: Table
+}) => {
+ const url = useMemo(
+ () => parseVariables({ text: content?.url, variables: variables }),
+ [variables]
+ )
+ if (!content?.type) return <>>
+ switch (content.type) {
+ case VideoBubbleContentType.URL:
+ const isSafariBrowser = window.navigator.vendor.match(/apple/i)
+ return (
+
+ )
+ case VideoBubbleContentType.VIMEO:
+ case VideoBubbleContentType.YOUTUBE: {
+ const baseUrl =
+ content.type === VideoBubbleContentType.VIMEO
+ ? 'https://player.vimeo.com/video'
+ : 'https://www.youtube.com/embed'
+ return (
+
+ )
+ }
+ }
+}
diff --git a/packages/models/src/typebot/steps/bubble.ts b/packages/models/src/typebot/steps/bubble.ts
index 0ef864a22..39299191d 100644
--- a/packages/models/src/typebot/steps/bubble.ts
+++ b/packages/models/src/typebot/steps/bubble.ts
@@ -1,12 +1,18 @@
import { StepBase } from '.'
-export type BubbleStep = TextBubbleStep | ImageBubbleStep
+export type BubbleStep = TextBubbleStep | ImageBubbleStep | VideoBubbleStep
export enum BubbleStepType {
TEXT = 'text',
IMAGE = 'image',
+ VIDEO = 'video',
}
+export type BubbleStepContent =
+ | TextBubbleContent
+ | ImageBubbleContent
+ | VideoBubbleContent
+
export type TextBubbleStep = StepBase & {
type: BubbleStepType.TEXT
content: TextBubbleContent
@@ -17,6 +23,11 @@ export type ImageBubbleStep = StepBase & {
content?: ImageBubbleContent
}
+export type VideoBubbleStep = StepBase & {
+ type: BubbleStepType.VIDEO
+ content?: VideoBubbleContent
+}
+
export type TextBubbleContent = {
html: string
richText: unknown[]
@@ -26,3 +37,15 @@ export type TextBubbleContent = {
export type ImageBubbleContent = {
url?: string
}
+
+export enum VideoBubbleContentType {
+ URL = 'url',
+ YOUTUBE = 'youtube',
+ VIMEO = 'vimeo',
+}
+
+export type VideoBubbleContent = {
+ type?: VideoBubbleContentType
+ url?: string
+ id?: string
+}
diff --git a/yarn.lock b/yarn.lock
index c1841e2d4..0ea600deb 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4972,6 +4972,11 @@ js-cookie@^2.2.1:
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
+js-video-url-parser@^0.5.1:
+ version "0.5.1"
+ resolved "https://registry.yarnpkg.com/js-video-url-parser/-/js-video-url-parser-0.5.1.tgz#78fea9bf6944b538276af9658833e48a83054909"
+ integrity sha512-/vwqT67k0AyIGMHAvSOt+n4JfrZWF7cPKgKswDO35yr27GfW4HtjpQVlTx6JLF45QuPm8mkzFHkZgFVnFm4x/w==
+
js-yaml@^3.13.1:
version "3.14.1"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537"