UNPKG

@botonic/react

Version:

Build Chatbots using React

937 lines (848 loc) 27.3 kB
import { INPUT, isMobile, params2queryString } from '@botonic/core' import merge from 'lodash.merge' import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState, } from 'react' import Textarea from 'react-textarea-autosize' import styled, { StyleSheetManager } from 'styled-components' import { useAsyncEffect } from 'use-async-effect' import { v4 as uuidv4 } from 'uuid' import { Audio, Document, Image, Text, Video } from '../components' import { Handoff } from '../components/handoff' import { normalizeWebchatSettings } from '../components/webchat-settings' import { COLORS, MAX_ALLOWED_SIZE_MB, ROLES, WEBCHAT } from '../constants' import { WebchatContext, WebviewRequestContext } from '../contexts' import { SENDERS } from '../index-types' import { getFullMimeWhitelist, getMediaType, isAllowedSize, isAudio, isDocument, isImage, isMedia, isText, isVideo, readDataURL, } from '../message-utils' import { msgToBotonic } from '../msg-to-botonic' import { scrollToBottom } from '../util/dom' import { isDev } from '../util/environment' import { deserializeRegex, stringifyWithRegexs } from '../util/regexs' import { _getThemeProperty, getServerErrorMessage, initSession, shouldKeepSessionOnReload, } from '../util/webchat' import { Attachment } from './components/attachment' import { EmojiPicker, OpenedEmojiPicker } from './components/emoji-picker' import { OpenedPersistentMenu, PersistentMenu, } from './components/persistent-menu' import { SendButton } from './components/send-button' import { DeviceAdapter } from './devices/device-adapter' import { StyledWebchatHeader } from './header' import { useComponentWillMount, usePrevious, useTyping, useWebchat, } from './hooks' import { WebchatMessageList } from './message-list' import { WebchatReplies } from './replies' import { TriggerButton } from './trigger-button' import { useStorageState } from './use-storage-state-hook' import { WebviewContainer } from './webview' export const getParsedAction = botonicAction => { const splittedAction = botonicAction.split('create_case:') if (splittedAction.length <= 1) return undefined return JSON.parse(splittedAction[1]) } const StyledWebchat = styled.div` position: fixed; right: 20px; bottom: 20px; width: ${props => props.width}px; height: ${props => props.height}px; margin: auto; background-color: ${COLORS.SOLID_WHITE}; border-radius: 10px; box-shadow: ${COLORS.SOLID_BLACK_ALPHA_0_2} 0px 0px 12px; display: flex; flex-direction: column; ` const UserInputContainer = styled.div` min-height: 52px; position: relative; display: flex; align-items: center; justify-content: flex-start; gap: 16px; padding: 0px 16px; z-index: 1; border-top: 1px solid ${COLORS.SOLID_BLACK_ALPHA_0_5}; ` const TextAreaContainer = styled.div` display: flex; flex: 1 1 auto; align-items: center; ` const ErrorMessageContainer = styled.div` position: relative; display: flex; z-index: 1; justify-content: center; width: 100%; ` const ErrorMessage = styled.div` position: absolute; top: 10px; font-size: 14px; line-height: 20px; padding: 4px 11px; display: flex; background-color: ${COLORS.ERROR_RED}; color: ${COLORS.CONCRETE_WHITE}; border-radius: 5px; align-items: center; justify-content: center; font-family: ${WEBCHAT.DEFAULTS.FONT_FAMILY}; ` const DarkBackgroundMenu = styled.div` background: ${COLORS.SOLID_BLACK}; opacity: 0.3; z-index: 1; right: 0; bottom: 0; border-radius: 10px; position: absolute; width: 100%; height: 100%; ` // eslint-disable-next-line complexity, react/display-name export const Webchat = forwardRef((props, ref) => { const { addMessage, addMessageComponent, clearMessages, doRenderCustomComponent, openWebviewT, resetUnreadMessages, setCurrentAttachment, setError, setLastMessageVisible, setOnline, toggleCoverComponent, toggleEmojiPicker, togglePersistentMenu, toggleWebchat, updateDevSettings, updateHandoff, updateLastMessageDate, updateLastRoutePath, updateLatestInput, updateMessage, updateReplies, updateSession, updateTheme, updateTyping, updateWebview, webchatState, // eslint-disable-next-line react-hooks/rules-of-hooks } = props.webchatHooks || useWebchat() const firstUpdate = useRef(true) const isOnline = () => webchatState.online const currentDateString = () => new Date().toISOString() const theme = merge(webchatState.theme, props.theme) const { initialSession, initialDevSettings, onStateChange } = props const getThemeProperty = _getThemeProperty(theme) const [customComponent, setCustomComponent] = useState(null) const storage = props.storage const storageKey = typeof props.storageKey === 'function' ? props.storageKey() : props.storageKey const [botonicState, saveState] = useStorageState(storage, storageKey) const host = props.host || document.body const deviceAdapter = new DeviceAdapter() const saveWebchatState = webchatState => { storage && saveState( JSON.parse( stringifyWithRegexs({ messages: webchatState.messagesJSON, session: webchatState.session, lastRoutePath: webchatState.lastRoutePath, devSettings: webchatState.devSettings, lastMessageUpdate: webchatState.lastMessageUpdate, themeUpdates: webchatState.themeUpdates, }) ) ) } const handleAttachment = event => { if (!isAllowedSize(event.target.files[0].size)) { throw new Error( `The file is too large. A maximum of ${MAX_ALLOWED_SIZE_MB}MB is allowed.` ) } setCurrentAttachment({ fileName: event.target.files[0].name, file: event.target.files[0], // TODO: Attach more files? attachmentType: getMediaType(event.target.files[0].type), }) } useEffect(() => { if (webchatState.currentAttachment) sendAttachment(webchatState.currentAttachment) }, [webchatState.currentAttachment]) const sendUserInput = async input => { if (props.onUserInput) { resetUnreadMessages() scrollToBottom({ host }) props.onUserInput({ user: webchatState.session.user, input: input, session: webchatState.session, lastRoutePath: webchatState.lastRoutePath, }) } } const sendChatEvent = async chatEvent => { const chatEventInput = { id: uuidv4(), type: INPUT.CHAT_EVENT, data: chatEvent, } props.onUserInput && props.onUserInput({ user: webchatState.session.user, input: chatEventInput, session: webchatState.session, lastRoutePath: webchatState.lastRoutePath, }) } // Load styles stored in window._botonicInsertStyles by Webpack useComponentWillMount(() => { if (window._botonicInsertStyles && window._botonicInsertStyles.length) { for (const botonicStyle of window._botonicInsertStyles) { // Injecting styles at head is needed even if we use shadowDOM // as some dependencies like simplebar rely on creating ephemeral elements // on document.body and assume styles will be available globally document.head.appendChild(botonicStyle) // injecting styles in host node too so that shadowDOM works if (props.shadowDOM) host.appendChild(botonicStyle.cloneNode(true)) } delete window._botonicInsertStyles } if (props.shadowDOM) { // emoji-picker-react injects styles in head, so we need to // re-inject them in our host node to make it work with shadowDOM for (const style of document.querySelectorAll('style')) { if ( style.textContent && style.textContent.includes('emoji-picker-react') ) host.appendChild(style.cloneNode(true)) } } }) // Load initial state from storage useEffect(() => { let { messages, session, lastRoutePath, devSettings, lastMessageUpdate, themeUpdates, } = botonicState || {} session = initSession(session) updateSession(session) if (shouldKeepSessionOnReload({ initialDevSettings, devSettings })) { if (messages) { messages.forEach(message => { addMessage(message) const newMessageComponent = msgToBotonic( { ...message, delay: 0, typing: 0 }, (props.theme.message && props.theme.message.customTypes) || props.theme.customMessageTypes ) if (newMessageComponent) addMessageComponent(newMessageComponent) }) } if (initialSession) updateSession(merge(initialSession, session)) if (lastRoutePath) updateLastRoutePath(lastRoutePath) } else updateSession(merge(initialSession, session)) if (devSettings) updateDevSettings(devSettings) else if (initialDevSettings) updateDevSettings(initialDevSettings) if (lastMessageUpdate) updateLastMessageDate(lastMessageUpdate) if (themeUpdates !== undefined) updateTheme(merge(props.theme, themeUpdates), themeUpdates) if (props.onInit) setTimeout(() => props.onInit(), 100) }, []) useEffect(() => { if (!webchatState.isWebchatOpen) { if (webchatState.isLastMessageVisible) { resetUnreadMessages() } return } deviceAdapter.init(host) }, [webchatState.isWebchatOpen]) useEffect(() => { if (onStateChange && typeof onStateChange === 'function') { onStateChange(webchatState) } saveWebchatState(webchatState) }, [ webchatState.messagesJSON, webchatState.session, webchatState.lastRoutePath, webchatState.devSettings, webchatState.lastMessageUpdate, ]) useAsyncEffect(async () => { if (!webchatState.online) { setError({ message: getServerErrorMessage(props.server), }) } else { if (!firstUpdate.current) { setError(undefined) } } }, [webchatState.online]) useTyping({ webchatState, updateTyping, updateMessage, host }) useEffect(() => { updateTheme(merge(props.theme, theme, webchatState.themeUpdates)) }, [props.theme, webchatState.themeUpdates]) const openWebview = (webviewComponent, params) => updateWebview(webviewComponent, params) const handleSelectedEmoji = event => { textArea.current.value += event.emoji textArea.current.focus() } const closeWebview = options => { updateWebview() if (userInputEnabled) { textArea.current.focus() } if (options?.payload) { sendPayload(options.payload) } else if (options?.path) { const params = options.params ? params2queryString(options.params) : '' sendPayload(`__PATH_PAYLOAD__${options.path}?${params}`) } } const handleMenu = () => { togglePersistentMenu(!webchatState.isPersistentMenuOpen) } const handleEmojiClick = () => { toggleEmojiPicker(!webchatState.isEmojiPickerOpen) } const persistentMenuOptions = getThemeProperty( WEBCHAT.CUSTOM_PROPERTIES.persistentMenu, props.persistentMenu ) const darkBackgroundMenu = getThemeProperty( WEBCHAT.CUSTOM_PROPERTIES.darkBackgroundMenu, false ) const getBlockInputs = (rule, inputData) => { const processedInput = rule.preprocess ? rule.preprocess(inputData) : inputData return rule.match.some(regex => { if (typeof regex === 'string') regex = deserializeRegex(regex) return regex.test(processedInput) }) } const checkBlockInput = input => { // if is a text we check if it is a serialized RE const blockInputs = getThemeProperty( WEBCHAT.CUSTOM_PROPERTIES.blockInputs, props.blockInputs ) if (!Array.isArray(blockInputs)) return false for (const rule of blockInputs) { if (getBlockInputs(rule, input.data)) { addMessageComponent( <Text id={input.id} sentBy={SENDERS.user} blob={false} style={{ backgroundColor: COLORS.SCORPION_GRAY, borderColor: COLORS.SCORPION_GRAY, padding: '8px 12px', }} > {rule.message} </Text> ) updateReplies(false) return true } } return false } const closeMenu = () => { togglePersistentMenu(false) } const persistentMenu = () => { return ( <OpenedPersistentMenu onClick={closeMenu} options={persistentMenuOptions} borderRadius={webchatState.theme.style.borderRadius || '10px'} /> ) } const getCoverComponent = () => { return getThemeProperty( WEBCHAT.CUSTOM_PROPERTIES.coverComponent, props.coverComponent && (props.coverComponent.component || props.coverComponent) ) } const CoverComponent = getCoverComponent() const closeCoverComponent = () => { toggleCoverComponent(false) } useEffect(() => { if (!CoverComponent) return if ( !botonicState || (botonicState.messages && botonicState.messages.length == 0) ) toggleCoverComponent(true) }, []) const coverComponent = () => { const coverComponentProps = getThemeProperty( WEBCHAT.CUSTOM_PROPERTIES.coverComponentProps, props.coverComponent && props.coverComponent.props ) if (CoverComponent && webchatState.isCoverComponentOpen) return ( <CoverComponent closeComponent={closeCoverComponent} {...coverComponentProps} /> ) return null } const messageComponentFromInput = input => { let messageComponent = null if (isText(input)) { messageComponent = ( <Text id={input.id} payload={input.payload} sentBy={SENDERS.user}> {input.data} </Text> ) } else if (isMedia(input)) { const temporaryDisplayUrl = URL.createObjectURL(input.data) const mediaProps = { id: input.id, sentBy: SENDERS.user, src: temporaryDisplayUrl, } if (isImage(input)) { mediaProps.input = input messageComponent = <Image {...mediaProps} /> } else if (isAudio(input)) messageComponent = <Audio {...mediaProps} /> else if (isVideo(input)) messageComponent = <Video {...mediaProps} /> else if (isDocument(input)) messageComponent = <Document {...mediaProps} /> } return messageComponent } const sendInput = async input => { if (!input || Object.keys(input).length == 0) return if (isText(input) && (!input.data || !input.data.trim())) return // in case trim() doesn't work in a browser we can use !/\S/.test(input.data) if (isText(input) && checkBlockInput(input)) return if (!input.id) input.id = uuidv4() const messageComponent = messageComponentFromInput(input) if (messageComponent) addMessageComponent(messageComponent) if (isMedia(input)) input.data = await readDataURL(input.data) sendUserInput(input) updateLatestInput(input) isOnline() && updateLastMessageDate(currentDateString()) updateReplies(false) togglePersistentMenu(false) toggleEmojiPicker(false) } /* This is the public API this component exposes to its parents https://stackoverflow.com/questions/37949981/call-child-method-from-parent */ const updateSessionWithUser = userToUpdate => updateSession(merge(webchatState.session, { user: userToUpdate })) useImperativeHandle(ref, () => ({ addBotResponse: ({ response, session, lastRoutePath }) => { updateTyping(false) const isUnread = !webchatState.isLastMessageVisible || webchatState.numUnreadMessages > 0 if (Array.isArray(response)) { response.forEach(r => { addMessageComponent({ ...r, props: { ...r.props, isUnread } }) }) } else if (response) { addMessageComponent({ ...response, props: { ...response.props, isUnread }, }) } if (session) { updateSession(merge(session, { user: webchatState.session.user })) const action = session._botonic_action || '' const handoff = action.startsWith('create_case') if (handoff && isDev) addMessageComponent(<Handoff />) updateHandoff(handoff) } if (lastRoutePath) updateLastRoutePath(lastRoutePath) updateLastMessageDate(currentDateString()) }, setTyping: typing => updateTyping(typing), addUserMessage: message => sendInput(message), updateUser: updateSessionWithUser, openWebchat: () => toggleWebchat(true), closeWebchat: () => toggleWebchat(false), toggleWebchat: () => toggleWebchat(!webchatState.isWebchatOpen), openCoverComponent: () => toggleCoverComponent(true), closeCoverComponent: () => toggleCoverComponent(false), renderCustomComponent: _customComponent => { setCustomComponent(_customComponent) doRenderCustomComponent(true) }, unmountCustomComponent: () => doRenderCustomComponent(false), toggleCoverComponent: () => toggleCoverComponent(!webchatState.isCoverComponentOpen), openWebviewApi: component => openWebviewT(component), setError, setOnline, getMessages: () => webchatState.messagesJSON, isOnline, clearMessages: () => { clearMessages() updateReplies(false) }, getLastMessageUpdate: () => webchatState.lastMessageUpdate, updateMessageInfo: (msgId, messageInfo) => { const messageToUpdate = webchatState.messagesJSON.filter( m => m.id == msgId )[0] const updatedMsg = merge(messageToUpdate, messageInfo) if (updatedMsg.ack === 1) delete updatedMsg.unsentInput updateMessage(updatedMsg) }, updateWebchatSettings: settings => { if (settings.user) { updateSessionWithUser(settings.user) } const themeUpdates = normalizeWebchatSettings(settings) updateTheme(merge(webchatState.theme, themeUpdates), themeUpdates) updateTyping(false) }, closeWebview: closeWebview, })) const resolveCase = () => { updateHandoff(false) updateSession({ ...webchatState.session, _botonic_action: null }) } const prevSession = usePrevious(webchatState.session) useEffect(() => { // Resume conversation after handoff if ( prevSession && prevSession._botonic_action && !webchatState.session._botonic_action ) { const action = getParsedAction(prevSession._botonic_action) if (action && action.on_finish) sendPayload(action.on_finish) } }, [webchatState.session._botonic_action]) const sendText = async (text, payload) => { if (!text) return const input = { type: INPUT.TEXT, data: text, payload } await sendInput(input) } const sendPayload = async payload => { if (!payload) return const input = { type: INPUT.POSTBACK, payload } await sendInput(input) } const sendAttachment = async attachment => { if (attachment.file) { const attachmentType = getMediaType(attachment.file.type) if (!attachmentType) return const input = { type: attachmentType, data: attachment.file, } await sendInput(input) setCurrentAttachment(undefined) } } const sendTextAreaText = () => { sendText(textArea.current.value) textArea.current.value = '' } let isTyping = false let typingTimeout = null function clearTimeoutWithReset(reset) { const waitTime = 20 * 1000 if (typingTimeout) clearTimeout(typingTimeout) if (reset) typingTimeout = setTimeout(stopTyping, waitTime) } function startTyping() { isTyping = true sendChatEvent('typing_on') } function stopTyping() { clearTimeoutWithReset(false) isTyping = false sendChatEvent('typing_off') } const onKeyDown = event => { if (event.keyCode === 13 && event.shiftKey === false) { event.preventDefault() sendTextAreaText() stopTyping() } } const onKeyUp = () => { if (textArea.current.value === '') { stopTyping() return } if (!isTyping) { startTyping() } clearTimeoutWithReset(true) } const webviewRequestContext = { closeWebview: closeWebview, getString: stringId => props.getString(stringId, webchatState.session), params: webchatState.webviewParams || {}, session: webchatState.session || {}, } useEffect(() => { if (firstUpdate.current) { firstUpdate.current = false return } if (webchatState.isWebchatOpen && props.onOpen) props.onOpen() if (!webchatState.isWebchatOpen && props.onClose && !firstUpdate.current) { props.onClose() toggleEmojiPicker(false) togglePersistentMenu(false) } }, [webchatState.isWebchatOpen]) const isUserInputEnabled = () => { const isUserInputEnabled = getThemeProperty( WEBCHAT.CUSTOM_PROPERTIES.enableUserInput, props.enableUserInput !== undefined ? props.enableUserInput : true ) return isUserInputEnabled && !webchatState.isCoverComponentOpen } const userInputEnabled = isUserInputEnabled() const textArea = useRef(null) const userInputArea = () => { return ( userInputEnabled && ( <UserInputContainer style={{ ...getThemeProperty(WEBCHAT.CUSTOM_PROPERTIES.userInputStyle), }} className='user-input-container' > {webchatState.isEmojiPickerOpen && ( <OpenedEmojiPicker height={webchatState.theme.style.height} onEmojiClick={handleSelectedEmoji} onClick={handleEmojiClick} /> )} <PersistentMenu onClick={handleMenu} persistentMenu={props.persistentMenu} /> <TextAreaContainer> <Textarea inputRef={textArea} name='text' onFocus={() => { deviceAdapter.onFocus() }} onBlur={() => { deviceAdapter.onBlur() }} maxRows={4} wrap='soft' maxLength='1000' placeholder={getThemeProperty( WEBCHAT.CUSTOM_PROPERTIES.textPlaceholder, WEBCHAT.DEFAULTS.PLACEHOLDER )} autoFocus={false} onKeyDown={e => onKeyDown(e)} onKeyUp={onKeyUp} style={{ display: 'flex', fontSize: deviceAdapter.fontSize(14), width: '100%', border: 'none', resize: 'none', overflow: 'auto', outline: 'none', flex: '1 1 auto', padding: 10, paddingLeft: persistentMenuOptions ? 0 : 10, fontFamily: 'inherit', ...getThemeProperty( WEBCHAT.CUSTOM_PROPERTIES.userInputBoxStyle ), }} /> </TextAreaContainer> <EmojiPicker enableEmojiPicker={props.enableEmojiPicker} onClick={handleEmojiClick} /> <Attachment enableAttachments={props.enableAttachments} onChange={handleAttachment} accept={getFullMimeWhitelist().join(',')} /> <SendButton onClick={sendTextAreaText} /> </UserInputContainer> ) ) } const webchatWebview = () => ( <WebviewRequestContext.Provider value={webviewRequestContext}> <WebviewContainer style={{ ...getThemeProperty(WEBCHAT.CUSTOM_PROPERTIES.webviewStyle), ...mobileStyle, }} webview={webchatState.webview} /> </WebviewRequestContext.Provider> ) let mobileStyle = {} if (isMobile(getThemeProperty(WEBCHAT.CUSTOM_PROPERTIES.mobileBreakpoint))) { mobileStyle = getThemeProperty(WEBCHAT.CUSTOM_PROPERTIES.mobileStyle) || { width: '100%', height: '100%', right: 0, bottom: 0, borderRadius: 0, } } useEffect(() => { // Prod mode saveWebchatState(webchatState) }, [webchatState.themeUpdates]) // Only needed for dev/serve mode const updateWebchatDevSettings = settings => { // eslint-disable-next-line react-hooks/rules-of-hooks useEffect(() => { const themeUpdates = normalizeWebchatSettings(settings) updateTheme(merge(webchatState.theme, themeUpdates), themeUpdates) }, [webchatState.messagesJSON]) } const DarkenBackground = ({ component }) => { return ( <div> {darkBackgroundMenu && ( <DarkBackgroundMenu style={{ borderRadius: webchatState.theme.style.borderRadius, }} /> )} {component} </div> ) } const _renderCustomComponent = () => { if (!customComponent) return <></> else return customComponent } const WebchatComponent = ( <WebchatContext.Provider value={{ addMessage, getThemeProperty, openWebview, resolveCase, resetUnreadMessages, setLastMessageVisible, sendAttachment, sendInput, sendPayload, sendText, toggleWebchat, updateLatestInput, updateMessage, updateReplies, updateUser: updateSessionWithUser, updateWebchatDevSettings: updateWebchatDevSettings, webchatState, trackEvent: props.onTrackEvent, }} > {!webchatState.isWebchatOpen && <TriggerButton />} {webchatState.isWebchatOpen && ( <StyledWebchat // TODO: Distinguis between multiple instances of webchat, e.g. `${uniqueId}-botonic-webchat` role={ROLES.WEBCHAT} id={WEBCHAT.DEFAULTS.ID} width={webchatState.width} height={webchatState.height} style={{ ...webchatState.theme.style, ...mobileStyle, }} > <StyledWebchatHeader onCloseClick={() => { toggleWebchat(false) }} /> {webchatState.error.message && ( <ErrorMessageContainer> <ErrorMessage>{webchatState.error.message}</ErrorMessage> </ErrorMessageContainer> )} <WebchatMessageList /> {webchatState.replies && Object.keys(webchatState.replies).length > 0 && ( <WebchatReplies replies={webchatState.replies} /> )} {webchatState.isPersistentMenuOpen && ( <DarkenBackground component={persistentMenu()} /> )} {!webchatState.handoff && userInputArea()} {webchatState.webview && webchatWebview()} {webchatState.isCoverComponentOpen && coverComponent()} {webchatState.isCustomComponentRendered && customComponent && _renderCustomComponent()} </StyledWebchat> )} </WebchatContext.Provider> ) return props.shadowDOM ? ( <StyleSheetManager target={host}>{WebchatComponent}</StyleSheetManager> ) : ( WebchatComponent ) })