2
0

feat(bubbles): Add video bubble

This commit is contained in:
Baptiste Arnaud
2022-01-20 17:45:25 +01:00
parent 2d178978ef
commit df2474ef43
14 changed files with 432 additions and 40 deletions

View File

@ -267,3 +267,16 @@ export const ExternalLinkIcon = (props: IconProps) => (
<line x1="10" y1="14" x2="21" y2="3"></line>
</Icon>
)
export const FilmIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"></rect>
<line x1="7" y1="2" x2="7" y2="22"></line>
<line x1="17" y1="2" x2="17" y2="22"></line>
<line x1="2" y1="12" x2="22" y2="12"></line>
<line x1="2" y1="7" x2="7" y2="7"></line>
<line x1="2" y1="17" x2="7" y2="17"></line>
<line x1="17" y1="17" x2="22" y2="17"></line>
<line x1="17" y1="7" x2="22" y2="7"></line>
</Icon>
)

View File

@ -6,6 +6,7 @@ import {
EditIcon,
EmailIcon,
ExternalLinkIcon,
FilmIcon,
FilterIcon,
FlagIcon,
GlobeIcon,
@ -32,6 +33,8 @@ export const StepIcon = ({ type, ...props }: StepIconProps) => {
return <ChatIcon {...props} />
case BubbleStepType.IMAGE:
return <ImageIcon {...props} />
case BubbleStepType.VIDEO:
return <FilmIcon {...props} />
case InputStepType.TEXT:
return <TextIcon {...props} />
case InputStepType.NUMBER:

View File

@ -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>Text</Text>
}
case BubbleStepType.IMAGE:
return <Text>Image</Text>
case InputStepType.NUMBER: {
case BubbleStepType.VIDEO:
return <Text>Video</Text>
case InputStepType.NUMBER:
return <Text>Number</Text>
}
case InputStepType.EMAIL: {
case InputStepType.EMAIL:
return <Text>Email</Text>
}
case InputStepType.URL: {
case InputStepType.URL:
return <Text>Website</Text>
}
case InputStepType.DATE: {
case InputStepType.DATE:
return <Text>Date</Text>
}
case InputStepType.PHONE: {
case InputStepType.PHONE:
return <Text>Phone</Text>
}
case InputStepType.CHOICE: {
case InputStepType.CHOICE:
return <Text>Button</Text>
}
case LogicStepType.SET_VARIABLE: {
case LogicStepType.SET_VARIABLE:
return <Text>Set variable</Text>
}
case LogicStepType.CONDITION: {
case LogicStepType.CONDITION:
return <Text>Condition</Text>
}
case LogicStepType.REDIRECT: {
case LogicStepType.REDIRECT:
return <Text>Redirect</Text>
}
case IntegrationStepType.GOOGLE_SHEETS: {
case IntegrationStepType.GOOGLE_SHEETS:
return (
<Tooltip label="Google Sheets">
<Text>Sheets</Text>
</Tooltip>
)
}
case IntegrationStepType.GOOGLE_ANALYTICS: {
case IntegrationStepType.GOOGLE_ANALYTICS:
return (
<Tooltip label="Google Analytics">
<Text>Analytics</Text>
</Tooltip>
)
}
default: {
default:
return <></>
}
}
}

View File

@ -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<BubbleStep, TextBubbleStep>
@ -25,7 +26,7 @@ export const ContentPopover = ({ step }: Props) => {
return (
<Portal>
<PopoverContent onMouseDown={handleMouseDown} w="500px">
<PopoverContent onMouseDown={handleMouseDown}>
<PopoverArrow />
<PopoverBody ref={ref} shadow="lg">
<StepContent step={step} />
@ -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<ImageBubbleStep>)
const handleNewImageSubmit = (url: string) => handleContentChange({ url })
switch (step.type) {
case BubbleStepType.IMAGE: {
return (
<ImagePopoverContent
url={step.content?.url}
onSubmit={handleNewImageSubmit}
<ImageUploadContent
content={step.content}
onSubmit={handleContentChange}
/>
)
}
case BubbleStepType.VIDEO: {
return (
<VideoUploadContent
content={step.content}
onSubmit={handleContentChange}
/>
)
}

View File

@ -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 (
<Stack p="2">
<InputWithVariableButton
placeholder="Paste the video link..."
initialValue={content?.url ?? ''}
onChange={handleUrlChange}
delay={100}
/>
<Text fontSize="sm" color="gray.400" textAlign="center">
Works with Youtube, Vimeo and others
</Text>
</Stack>
)
}

View File

@ -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) => {
</Box>
)
}
case BubbleStepType.VIDEO: {
return <VideoStepNodeContent step={step} />
}
case InputStepType.TEXT: {
return (
<Text color={'gray.500'}>
@ -182,3 +187,52 @@ const ConditionNodeContent = ({ step }: { step: ConditionStep }) => {
</Flex>
)
}
const VideoStepNodeContent = ({ step }: { step: VideoBubbleStep }) => {
if (!step.content?.url || !step.content.type)
return <Text color="gray.500">Click to edit...</Text>
switch (step.content.type) {
case VideoBubbleContentType.URL:
return (
<Box w="full" h="120px" pos="relative">
<video
key={step.content.url}
controls
style={{
width: '100%',
height: '100%',
position: 'absolute',
left: '0',
top: '0',
borderRadius: '10px',
}}
>
<source src={step.content.url} />
</video>
</Box>
)
case VideoBubbleContentType.VIMEO:
case VideoBubbleContentType.YOUTUBE: {
const baseUrl =
step.content.type === VideoBubbleContentType.VIMEO
? 'https://player.vimeo.com/video'
: 'https://www.youtube.com/embed'
return (
<Box w="full" h="120px" pos="relative">
<iframe
src={`${baseUrl}/${step.content.id}`}
allowFullScreen
style={{
width: '100%',
height: '100%',
position: 'absolute',
left: '0',
top: '0',
borderRadius: '10px',
}}
/>
</Box>
)
}
}
}

View File

@ -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 (
<Stack>
<HStack>
@ -43,7 +45,11 @@ export const ImageUploadContent = ({ url, onSubmit }: Props) => {
)}
</HStack>
<BodyContent tab={currentTab} onSubmit={onSubmit} url={url} />
<BodyContent
tab={currentTab}
onSubmit={handleSubmit}
url={content?.url}
/>
</Stack>
)
}

View File

@ -1 +1 @@
export { ImageUploadContent as ImagePopoverContent } from './ImageUploadContent'
export { ImageUploadContent } from './ImageUploadContent'

View File

@ -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<Step, 'id' | 'blockId'>)
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<Step, 'id' | 'blockId'>)
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<Step, 'id' | 'blockId'>)
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<Step, 'id' | 'blockId'>)
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')
})
})
})

View File

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

View File

@ -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 <TextBubble step={step} onTransitionEnd={onTransitionEnd} />
case BubbleStepType.IMAGE:
return <ImageBubble step={step} onTransitionEnd={onTransitionEnd} />
case BubbleStepType.VIDEO:
return <VideoBubble step={step} onTransitionEnd={onTransitionEnd} />
}
}

View File

@ -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<HTMLDivElement | null>(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 (
<div className="flex flex-col" ref={messageContainer}>
<div className="flex mb-2 w-full lg:w-11/12 items-center">
<div className={'flex relative z-10 items-start typebot-host-bubble'}>
<div
className="flex items-center absolute px-4 py-2 rounded-lg bubble-typing z-10 "
style={{
width: isTyping ? '4rem' : '100%',
height: isTyping ? '2rem' : '100%',
}}
>
{isTyping ? <TypingContent /> : <></>}
</div>
<VideoContent
content={step.content}
isTyping={isTyping}
variables={typebot.variables}
/>
</div>
</div>
</div>
)
}
const VideoContent = ({
content,
isTyping,
variables,
}: {
content?: VideoBubbleContent
isTyping: boolean
variables: Table<Variable>
}) => {
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 (
<video
controls
className={
'p-4 focus:outline-none w-full z-10 content-opacity rounded-md ' +
(isTyping ? 'opacity-0' : 'opacity-100')
}
style={{
height: isTyping ? '2rem' : 'auto',
maxHeight: isSafariBrowser ? '40vh' : '',
}}
autoPlay
>
<source src={url} type="video/mp4" />
Sorry, your browser doesn&apos;t support embedded videos.
</video>
)
case VideoBubbleContentType.VIMEO:
case VideoBubbleContentType.YOUTUBE: {
const baseUrl =
content.type === VideoBubbleContentType.VIMEO
? 'https://player.vimeo.com/video'
: 'https://www.youtube.com/embed'
return (
<iframe
src={`${baseUrl}/${content.id}`}
className={
'w-full p-4 content-opacity z-10 rounded-md ' +
(isTyping ? 'opacity-0' : 'opacity-100')
}
height={isTyping ? '2rem' : '200px'}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
)
}
}
}

View File

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

View File

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