feat(steps): ✨ Add Embed bubble
This commit is contained in:
@ -13,6 +13,7 @@ import {
|
|||||||
FlagIcon,
|
FlagIcon,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
|
LayoutIcon,
|
||||||
NumberIcon,
|
NumberIcon,
|
||||||
PhoneIcon,
|
PhoneIcon,
|
||||||
SendEmailIcon,
|
SendEmailIcon,
|
||||||
@ -39,6 +40,8 @@ export const StepIcon = ({ type, ...props }: StepIconProps) => {
|
|||||||
return <ImageIcon color="blue.500" {...props} />
|
return <ImageIcon color="blue.500" {...props} />
|
||||||
case BubbleStepType.VIDEO:
|
case BubbleStepType.VIDEO:
|
||||||
return <FilmIcon color="blue.500" {...props} />
|
return <FilmIcon color="blue.500" {...props} />
|
||||||
|
case BubbleStepType.EMBED:
|
||||||
|
return <LayoutIcon color="blue.500" {...props} />
|
||||||
case InputStepType.TEXT:
|
case InputStepType.TEXT:
|
||||||
return <TextIcon color="orange.500" {...props} />
|
return <TextIcon color="orange.500" {...props} />
|
||||||
case InputStepType.NUMBER:
|
case InputStepType.NUMBER:
|
||||||
|
@ -19,6 +19,12 @@ export const StepTypeLabel = ({ type }: Props) => {
|
|||||||
return <Text>Image</Text>
|
return <Text>Image</Text>
|
||||||
case BubbleStepType.VIDEO:
|
case BubbleStepType.VIDEO:
|
||||||
return <Text>Video</Text>
|
return <Text>Video</Text>
|
||||||
|
case BubbleStepType.EMBED:
|
||||||
|
return (
|
||||||
|
<Tooltip label="Embed a pdf, an iframe, a website...">
|
||||||
|
<Text>Embed</Text>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
case InputStepType.NUMBER:
|
case InputStepType.NUMBER:
|
||||||
return <Text>Number</Text>
|
return <Text>Number</Text>
|
||||||
case InputStepType.EMAIL:
|
case InputStepType.EMAIL:
|
||||||
|
@ -0,0 +1,51 @@
|
|||||||
|
import { HStack, Stack, Text } from '@chakra-ui/react'
|
||||||
|
import { SmartNumberInput } from 'components/shared/SmartNumberInput'
|
||||||
|
import { Input } from 'components/shared/Textbox/Input'
|
||||||
|
import { EmbedBubbleContent } from 'models'
|
||||||
|
import { sanitizeUrl } from 'utils'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
content: EmbedBubbleContent
|
||||||
|
onSubmit: (content: EmbedBubbleContent) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EmbedUploadContent = ({ content, onSubmit }: Props) => {
|
||||||
|
const handleUrlChange = (url: string) => {
|
||||||
|
let iframeUrl = sanitizeUrl(
|
||||||
|
url.trim().startsWith('<iframe') ? extractUrlFromIframe(url) : url
|
||||||
|
)
|
||||||
|
if (iframeUrl.endsWith('.pdf')) {
|
||||||
|
iframeUrl = `https://docs.google.com/viewer?embedded=true&url=${iframeUrl}`
|
||||||
|
}
|
||||||
|
onSubmit({ ...content, url: iframeUrl })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHeightChange = (height?: number) =>
|
||||||
|
height && onSubmit({ ...content, height })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack p="2" spacing={6}>
|
||||||
|
<Stack>
|
||||||
|
<Input
|
||||||
|
placeholder="Paste the link or code..."
|
||||||
|
defaultValue={content?.url ?? ''}
|
||||||
|
onChange={handleUrlChange}
|
||||||
|
/>
|
||||||
|
<Text fontSize="sm" color="gray.400" textAlign="center">
|
||||||
|
Works with PDFs, iframes, websites...
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<HStack justify="space-between">
|
||||||
|
<Text>Height: </Text>
|
||||||
|
<SmartNumberInput
|
||||||
|
value={content?.height}
|
||||||
|
onValueChange={handleHeightChange}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractUrlFromIframe = (iframe: string) =>
|
||||||
|
[...iframe.matchAll(/src="([^"]+)"/g)][0][1]
|
@ -12,6 +12,7 @@ import {
|
|||||||
TextBubbleStep,
|
TextBubbleStep,
|
||||||
} from 'models'
|
} from 'models'
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
|
import { EmbedUploadContent } from './EmbedUploadContent'
|
||||||
import { VideoUploadContent } from './VideoUploadContent'
|
import { VideoUploadContent } from './VideoUploadContent'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@ -25,7 +26,10 @@ export const MediaBubblePopoverContent = (props: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Portal>
|
<Portal>
|
||||||
<PopoverContent onMouseDown={handleMouseDown} w="500px">
|
<PopoverContent
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
w={props.step.type === BubbleStepType.IMAGE ? '500px' : '400px'}
|
||||||
|
>
|
||||||
<PopoverArrow />
|
<PopoverArrow />
|
||||||
<PopoverBody ref={ref} shadow="lg">
|
<PopoverBody ref={ref} shadow="lg">
|
||||||
<MediaBubbleContent {...props} />
|
<MediaBubbleContent {...props} />
|
||||||
@ -52,5 +56,10 @@ export const MediaBubbleContent = ({ step, onContentChange }: Props) => {
|
|||||||
<VideoUploadContent content={step.content} onSubmit={onContentChange} />
|
<VideoUploadContent content={step.content} onSubmit={onContentChange} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
case BubbleStepType.EMBED: {
|
||||||
|
return (
|
||||||
|
<EmbedUploadContent content={step.content} onSubmit={onContentChange} />
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -11,6 +11,7 @@ import {
|
|||||||
import { isChoiceInput, isInputStep } from 'utils'
|
import { isChoiceInput, isInputStep } from 'utils'
|
||||||
import { ItemNodesList } from '../../ItemNode'
|
import { ItemNodesList } from '../../ItemNode'
|
||||||
import {
|
import {
|
||||||
|
EmbedBubbleContent,
|
||||||
SetVariableContent,
|
SetVariableContent,
|
||||||
TextBubbleContent,
|
TextBubbleContent,
|
||||||
VideoBubbleContent,
|
VideoBubbleContent,
|
||||||
@ -42,6 +43,9 @@ export const StepNodeContent = ({ step, indices }: Props) => {
|
|||||||
case BubbleStepType.VIDEO: {
|
case BubbleStepType.VIDEO: {
|
||||||
return <VideoBubbleContent step={step} />
|
return <VideoBubbleContent step={step} />
|
||||||
}
|
}
|
||||||
|
case BubbleStepType.EMBED: {
|
||||||
|
return <EmbedBubbleContent step={step} />
|
||||||
|
}
|
||||||
case InputStepType.TEXT: {
|
case InputStepType.TEXT: {
|
||||||
return (
|
return (
|
||||||
<PlaceholderContent
|
<PlaceholderContent
|
||||||
|
@ -0,0 +1,20 @@
|
|||||||
|
import { Box, Text } from '@chakra-ui/react'
|
||||||
|
import { EmbedBubbleStep } from 'models'
|
||||||
|
|
||||||
|
export const EmbedBubbleContent = ({ step }: { step: EmbedBubbleStep }) => {
|
||||||
|
if (!step.content?.url) return <Text color="gray.500">Click to edit...</Text>
|
||||||
|
return (
|
||||||
|
<Box w="full" h="120px" pos="relative">
|
||||||
|
<iframe
|
||||||
|
id="embed-bubble-content"
|
||||||
|
src={step.content.url}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
borderRadius: '5px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
@ -3,3 +3,4 @@ export * from './WithVariableContent'
|
|||||||
export * from './VideoBubbleContent'
|
export * from './VideoBubbleContent'
|
||||||
export * from './WebhookContent'
|
export * from './WebhookContent'
|
||||||
export * from './TextBubbleContent'
|
export * from './TextBubbleContent'
|
||||||
|
export * from './EmbedBubbleContent'
|
||||||
|
74
apps/builder/playwright/tests/bubbles/embed.spec.ts
Normal file
74
apps/builder/playwright/tests/bubbles/embed.spec.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import test, { expect } from '@playwright/test'
|
||||||
|
import {
|
||||||
|
createTypebots,
|
||||||
|
parseDefaultBlockWithStep,
|
||||||
|
} from '../../services/database'
|
||||||
|
import { BubbleStepType, defaultEmbedBubbleContent } from 'models'
|
||||||
|
import { typebotViewer } from '../../services/selectorUtils'
|
||||||
|
import cuid from 'cuid'
|
||||||
|
|
||||||
|
const pdfSrc = 'https://www.orimi.com/pdf-test.pdf'
|
||||||
|
const iframeCode = '<iframe src="https://typebot.io"></iframe>'
|
||||||
|
const siteSrc = 'https://app.cal.com/baptistearno/15min'
|
||||||
|
|
||||||
|
test.describe.parallel('Embed bubble step', () => {
|
||||||
|
test.describe('Content settings', () => {
|
||||||
|
test('should import and parse embed correctly', async ({ page }) => {
|
||||||
|
const typebotId = cuid()
|
||||||
|
await createTypebots([
|
||||||
|
{
|
||||||
|
id: typebotId,
|
||||||
|
...parseDefaultBlockWithStep({
|
||||||
|
type: BubbleStepType.EMBED,
|
||||||
|
content: defaultEmbedBubbleContent,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
await page.goto(`/typebots/${typebotId}/edit`)
|
||||||
|
await page.click('text=Click to edit...')
|
||||||
|
await page.fill('input[placeholder="Paste the link or code..."]', pdfSrc)
|
||||||
|
await expect(page.locator('iframe#embed-bubble-content')).toHaveAttribute(
|
||||||
|
'src',
|
||||||
|
`https://docs.google.com/viewer?embedded=true&url=${pdfSrc}`
|
||||||
|
)
|
||||||
|
await page.fill(
|
||||||
|
'input[placeholder="Paste the link or code..."]',
|
||||||
|
iframeCode
|
||||||
|
)
|
||||||
|
await expect(page.locator('iframe#embed-bubble-content')).toHaveAttribute(
|
||||||
|
'src',
|
||||||
|
'https://typebot.io'
|
||||||
|
)
|
||||||
|
await page.fill('input[placeholder="Paste the link or code..."]', siteSrc)
|
||||||
|
await expect(page.locator('iframe#embed-bubble-content')).toHaveAttribute(
|
||||||
|
'src',
|
||||||
|
siteSrc
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Preview', () => {
|
||||||
|
test('should display embed correctly', async ({ page }) => {
|
||||||
|
const typebotId = cuid()
|
||||||
|
await createTypebots([
|
||||||
|
{
|
||||||
|
id: typebotId,
|
||||||
|
...parseDefaultBlockWithStep({
|
||||||
|
type: BubbleStepType.EMBED,
|
||||||
|
content: {
|
||||||
|
url: siteSrc,
|
||||||
|
height: 700,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
await page.goto(`/typebots/${typebotId}/edit`)
|
||||||
|
await page.click('text=Preview')
|
||||||
|
await expect(
|
||||||
|
typebotViewer(page).locator('iframe#embed-bubble-content')
|
||||||
|
).toHaveAttribute('src', siteSrc)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
@ -34,6 +34,7 @@ import {
|
|||||||
ItemType,
|
ItemType,
|
||||||
defaultConditionContent,
|
defaultConditionContent,
|
||||||
defaultSendEmailOptions,
|
defaultSendEmailOptions,
|
||||||
|
defaultEmbedBubbleContent,
|
||||||
} from 'models'
|
} from 'models'
|
||||||
import { Typebot } from 'models'
|
import { Typebot } from 'models'
|
||||||
import useSWR from 'swr'
|
import useSWR from 'swr'
|
||||||
@ -250,6 +251,8 @@ const parseDefaultContent = (type: BubbleStepType): BubbleStepContent => {
|
|||||||
return defaultImageBubbleContent
|
return defaultImageBubbleContent
|
||||||
case BubbleStepType.VIDEO:
|
case BubbleStepType.VIDEO:
|
||||||
return defaultVideoBubbleContent
|
return defaultVideoBubbleContent
|
||||||
|
case BubbleStepType.EMBED:
|
||||||
|
return defaultEmbedBubbleContent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -15,7 +15,8 @@
|
|||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"composite": true
|
"composite": true,
|
||||||
|
"downlevelIteration": true
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
|
@ -197,9 +197,12 @@ const ChatChunks = ({
|
|||||||
const avatarSideContainerRef = useRef<any>()
|
const avatarSideContainerRef = useRef<any>()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
avatarSideContainerRef.current?.refreshTopOffset()
|
refreshTopOffset()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const refreshTopOffset = () =>
|
||||||
|
avatarSideContainerRef.current?.refreshTopOffset()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
@ -209,18 +212,26 @@ const ChatChunks = ({
|
|||||||
hostAvatarSrc={hostAvatar.src}
|
hostAvatarSrc={hostAvatar.src}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<TransitionGroup>
|
<div className="flex-1">
|
||||||
{bubbles.map((step) => (
|
<TransitionGroup>
|
||||||
<CSSTransition
|
{bubbles.map((step) => (
|
||||||
key={step.id}
|
<CSSTransition
|
||||||
classNames="bubble"
|
key={step.id}
|
||||||
timeout={500}
|
classNames="bubble"
|
||||||
unmountOnExit
|
timeout={500}
|
||||||
>
|
unmountOnExit
|
||||||
<HostBubble step={step} onTransitionEnd={onDisplayNextStep} />
|
>
|
||||||
</CSSTransition>
|
<HostBubble
|
||||||
))}
|
step={step}
|
||||||
</TransitionGroup>
|
onTransitionEnd={() => {
|
||||||
|
onDisplayNextStep()
|
||||||
|
refreshTopOffset()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CSSTransition>
|
||||||
|
))}
|
||||||
|
</TransitionGroup>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CSSTransition
|
<CSSTransition
|
||||||
classNames="bubble"
|
classNames="bubble"
|
||||||
|
@ -0,0 +1,68 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
|
import { EmbedBubbleStep } from 'models'
|
||||||
|
import { TypingContent } from './TypingContent'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
step: EmbedBubbleStep
|
||||||
|
onTransitionEnd: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const showAnimationDuration = 400
|
||||||
|
|
||||||
|
export const EmbedBubble = ({ step, onTransitionEnd }: Props) => {
|
||||||
|
const messageContainer = useRef<HTMLDivElement | null>(null)
|
||||||
|
const [isTyping, setIsTyping] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
showContentAfterMediaLoad()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const showContentAfterMediaLoad = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsTyping(false)
|
||||||
|
onTypingEnd()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTypingEnd = () => {
|
||||||
|
setIsTyping(false)
|
||||||
|
setTimeout(() => {
|
||||||
|
onTransitionEnd()
|
||||||
|
}, showAnimationDuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col w-full" 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 w-full'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<iframe
|
||||||
|
id="embed-bubble-content"
|
||||||
|
src={step.content.url}
|
||||||
|
className={
|
||||||
|
'w-full z-20 p-4 content-opacity ' +
|
||||||
|
(isTyping ? 'opacity-0' : 'opacity-100')
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
height: isTyping ? '2rem' : step.content.height,
|
||||||
|
borderRadius: '15px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -1,5 +1,6 @@
|
|||||||
import { BubbleStep, BubbleStepType } from 'models'
|
import { BubbleStep, BubbleStepType } from 'models'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { EmbedBubble } from './EmbedBubble'
|
||||||
import { ImageBubble } from './ImageBubble'
|
import { ImageBubble } from './ImageBubble'
|
||||||
import { TextBubble } from './TextBubble'
|
import { TextBubble } from './TextBubble'
|
||||||
import { VideoBubble } from './VideoBubble'
|
import { VideoBubble } from './VideoBubble'
|
||||||
@ -17,5 +18,7 @@ export const HostBubble = ({ step, onTransitionEnd }: Props) => {
|
|||||||
return <ImageBubble step={step} onTransitionEnd={onTransitionEnd} />
|
return <ImageBubble step={step} onTransitionEnd={onTransitionEnd} />
|
||||||
case BubbleStepType.VIDEO:
|
case BubbleStepType.VIDEO:
|
||||||
return <VideoBubble step={step} onTransitionEnd={onTransitionEnd} />
|
return <VideoBubble step={step} onTransitionEnd={onTransitionEnd} />
|
||||||
|
case BubbleStepType.EMBED:
|
||||||
|
return <EmbedBubble step={step} onTransitionEnd={onTransitionEnd} />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,17 +1,23 @@
|
|||||||
import { StepBase } from '.'
|
import { StepBase } from '.'
|
||||||
|
|
||||||
export type BubbleStep = TextBubbleStep | ImageBubbleStep | VideoBubbleStep
|
export type BubbleStep =
|
||||||
|
| TextBubbleStep
|
||||||
|
| ImageBubbleStep
|
||||||
|
| VideoBubbleStep
|
||||||
|
| EmbedBubbleStep
|
||||||
|
|
||||||
export enum BubbleStepType {
|
export enum BubbleStepType {
|
||||||
TEXT = 'text',
|
TEXT = 'text',
|
||||||
IMAGE = 'image',
|
IMAGE = 'image',
|
||||||
VIDEO = 'video',
|
VIDEO = 'video',
|
||||||
|
EMBED = 'embed',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BubbleStepContent =
|
export type BubbleStepContent =
|
||||||
| TextBubbleContent
|
| TextBubbleContent
|
||||||
| ImageBubbleContent
|
| ImageBubbleContent
|
||||||
| VideoBubbleContent
|
| VideoBubbleContent
|
||||||
|
| EmbedBubbleContent
|
||||||
|
|
||||||
export type TextBubbleStep = StepBase & {
|
export type TextBubbleStep = StepBase & {
|
||||||
type: BubbleStepType.TEXT
|
type: BubbleStepType.TEXT
|
||||||
@ -28,6 +34,11 @@ export type VideoBubbleStep = StepBase & {
|
|||||||
content: VideoBubbleContent
|
content: VideoBubbleContent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EmbedBubbleStep = StepBase & {
|
||||||
|
type: BubbleStepType.EMBED
|
||||||
|
content: EmbedBubbleContent
|
||||||
|
}
|
||||||
|
|
||||||
export type TextBubbleContent = {
|
export type TextBubbleContent = {
|
||||||
html: string
|
html: string
|
||||||
richText: unknown[]
|
richText: unknown[]
|
||||||
@ -38,6 +49,11 @@ export type ImageBubbleContent = {
|
|||||||
url?: string
|
url?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EmbedBubbleContent = {
|
||||||
|
url?: string
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
export enum VideoBubbleContentType {
|
export enum VideoBubbleContentType {
|
||||||
URL = 'url',
|
URL = 'url',
|
||||||
YOUTUBE = 'youtube',
|
YOUTUBE = 'youtube',
|
||||||
@ -59,3 +75,5 @@ export const defaultTextBubbleContent: TextBubbleContent = {
|
|||||||
export const defaultImageBubbleContent: ImageBubbleContent = {}
|
export const defaultImageBubbleContent: ImageBubbleContent = {}
|
||||||
|
|
||||||
export const defaultVideoBubbleContent: VideoBubbleContent = {}
|
export const defaultVideoBubbleContent: VideoBubbleContent = {}
|
||||||
|
|
||||||
|
export const defaultEmbedBubbleContent: EmbedBubbleContent = { height: 400 }
|
||||||
|
@ -149,3 +149,11 @@ export const omit: Omit = (obj, ...keys) => {
|
|||||||
}
|
}
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const sanitizeUrl = (url: string): string =>
|
||||||
|
url.startsWith('http') ||
|
||||||
|
url.startsWith('mailto:') ||
|
||||||
|
url.startsWith('tel:') ||
|
||||||
|
url.startsWith('sms:')
|
||||||
|
? url
|
||||||
|
: `https://${url}`
|
||||||
|
Reference in New Issue
Block a user