UNPKG

@botonic/react

Version:

Build Chatbots using React

621 lines 28.5 kB
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; 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, SENDERS, WEBCHAT, } from '../constants'; import { RequestContext, WebchatContext } from '../contexts'; 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 { TypingIndicator } from './components/typing-indicator'; 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 { webchatState, addMessage, addMessageComponent, updateMessage, updateReplies, updateLatestInput, updateTyping, updateWebview, updateSession, updateLastRoutePath, updateHandoff, updateTheme, updateDevSettings, toggleWebchat, toggleEmojiPicker, togglePersistentMenu, toggleCoverComponent, doRenderCustomComponent, setError, setOnline, clearMessages, openWebviewT, updateLastMessageDate, setCurrentAttachment, // 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], attachmentType: getMediaType(event.target.files[0].type), }); }; useEffect(() => { if (webchatState.currentAttachment) sendAttachment(webchatState.currentAttachment); }, [webchatState.currentAttachment]); const sendUserInput = async (input) => { props.onUserInput && 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(m => { addMessage(m); const newComponent = msgToBotonic(Object.assign(Object.assign({}, m), { delay: 0, typing: 0 }), (props.theme.message && props.theme.message.customTypes) || props.theme.customMessageTypes); if (newComponent) addMessageComponent(newComponent); }); } 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) return; deviceAdapter.init(host); scrollToBottom({ behavior: 'auto', 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 && options.payload) { sendPayload(options.payload); } else if (options && options.path) { let params = ''; if (options.params) 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(_jsx(Text, Object.assign({ id: input.id, from: SENDERS.user, blob: false, style: { backgroundColor: COLORS.SCORPION_GRAY, borderColor: COLORS.SCORPION_GRAY, padding: '8px 12px', } }, { children: rule.message }))); updateReplies(false); return true; } } return false; }; const closeMenu = () => { togglePersistentMenu(false); }; const persistentMenu = () => { return (_jsx(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 (_jsx(CoverComponent, Object.assign({ closeComponent: closeCoverComponent }, coverComponentProps))); return null; }; const messageComponentFromInput = input => { let messageComponent = null; if (isText(input)) { messageComponent = (_jsx(Text, Object.assign({ id: input.id, payload: input.payload, from: SENDERS.user }, { children: input.data }))); } else if (isMedia(input)) { const temporaryDisplayUrl = URL.createObjectURL(input.data); const mediaProps = { id: input.id, from: SENDERS.user, src: temporaryDisplayUrl, }; if (isImage(input)) { mediaProps.input = input; messageComponent = _jsx(Image, Object.assign({}, mediaProps)); } else if (isAudio(input)) messageComponent = _jsx(Audio, Object.assign({}, mediaProps)); else if (isVideo(input)) messageComponent = _jsx(Video, Object.assign({}, mediaProps)); else if (isDocument(input)) messageComponent = _jsx(Document, Object.assign({}, 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); if (Array.isArray(response)) response.map(r => addMessageComponent(r)); else if (response) addMessageComponent(response); if (session) { updateSession(merge(session, { user: webchatState.session.user })); const action = session._botonic_action || ''; const handoff = action.startsWith('create_case'); if (handoff && isDev) addMessageComponent(_jsx(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 => { const themeUpdates = normalizeWebchatSettings(settings); updateTheme(merge(webchatState.theme, themeUpdates), themeUpdates); }, })); const resolveCase = () => { updateHandoff(false); updateSession(Object.assign(Object.assign({}, 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 = { getString: stringId => props.getString(stringId, webchatState.session), setLocale: locale => props.getString(locale, webchatState.session), session: webchatState.session || {}, params: webchatState.webviewParams || {}, closeWebview: closeWebview, defaultDelay: props.defaultDelay || 0, defaultTyping: props.defaultTyping || 0, }; 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 webchatMessageList = () => (_jsx(WebchatMessageList, Object.assign({ style: { flex: 1 } }, { children: webchatState.typing && _jsx(TypingIndicator, {}) }))); const webchatReplies = () => _jsx(WebchatReplies, { replies: webchatState.replies }); 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 && (_jsxs(UserInputContainer, Object.assign({ style: Object.assign({}, getThemeProperty(WEBCHAT.CUSTOM_PROPERTIES.userInputStyle)), className: 'user-input-container' }, { children: [webchatState.isEmojiPickerOpen && (_jsx(OpenedEmojiPicker, { height: webchatState.theme.style.height, onEmojiClick: handleSelectedEmoji, onClick: handleEmojiClick })), _jsx(PersistentMenu, { onClick: handleMenu, persistentMenu: props.persistentMenu }), _jsx(TextAreaContainer, { children: _jsx(Textarea, { inputRef: textArea, name: 'text', onFocus: () => deviceAdapter.onFocus(host), onBlur: () => deviceAdapter.onBlur(), maxRows: 4, wrap: 'soft', maxLength: '1000', placeholder: getThemeProperty(WEBCHAT.CUSTOM_PROPERTIES.textPlaceholder, WEBCHAT.DEFAULTS.PLACEHOLDER), autoFocus: true, onKeyDown: e => onKeyDown(e), onKeyUp: onKeyUp, style: Object.assign({ 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)) }) }), _jsx(EmojiPicker, { enableEmojiPicker: props.enableEmojiPicker, onClick: handleEmojiClick }), _jsx(Attachment, { enableAttachments: props.enableAttachments, onChange: handleAttachment, accept: getFullMimeWhitelist().join(',') }), _jsx(SendButton, { onClick: sendTextAreaText })] })))); }; const webchatWebview = () => (_jsx(RequestContext.Provider, Object.assign({ value: webviewRequestContext }, { children: _jsx(WebviewContainer, { style: Object.assign(Object.assign({}, getThemeProperty(WEBCHAT.CUSTOM_PROPERTIES.webviewStyle)), mobileStyle), webview: webchatState.webview }) }))); 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); scrollToBottom({ host }); }, [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 (_jsxs("div", { children: [darkBackgroundMenu && (_jsx(DarkBackgroundMenu, { style: { borderRadius: webchatState.theme.style.borderRadius, } })), component] })); }; const _renderCustomComponent = () => { if (!customComponent) return _jsx(_Fragment, {}); else return customComponent; }; const WebchatComponent = (_jsxs(WebchatContext.Provider, Object.assign({ value: { addMessage, getThemeProperty, openWebview, resolveCase, sendAttachment, sendInput, sendPayload, sendText, toggleWebchat, updateLatestInput, updateMessage, updateReplies, updateUser: updateSessionWithUser, updateWebchatDevSettings: updateWebchatDevSettings, webchatState, } }, { children: [!webchatState.isWebchatOpen && _jsx(TriggerButton, {}), webchatState.isWebchatOpen && (_jsxs(StyledWebchat // TODO: Distinguis between multiple instances of webchat, e.g. `${uniqueId}-botonic-webchat` , Object.assign({ // 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: Object.assign(Object.assign({}, webchatState.theme.style), mobileStyle) }, { children: [_jsx(StyledWebchatHeader, { onCloseClick: () => { toggleWebchat(false); } }), webchatState.error.message && (_jsx(ErrorMessageContainer, { children: _jsx(ErrorMessage, { children: webchatState.error.message }) })), webchatMessageList(), webchatState.replies && Object.keys(webchatState.replies).length > 0 && webchatReplies(), webchatState.isPersistentMenuOpen && (_jsx(DarkenBackground, { component: persistentMenu() })), !webchatState.handoff && userInputArea(), webchatState.webview && webchatWebview(), webchatState.isCoverComponentOpen && coverComponent(), webchatState.isCustomComponentRendered && customComponent && _renderCustomComponent()] })))] }))); return props.shadowDOM ? (_jsx(StyleSheetManager, Object.assign({ target: host }, { children: WebchatComponent }))) : (WebchatComponent); }); //# sourceMappingURL=webchat.js.map