UNPKG

@botonic/react

Version:

Build Chatbots using React

553 lines 26.3 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.Webchat = void 0; const tslib_1 = require("tslib"); const jsx_runtime_1 = require("react/jsx-runtime"); /* eslint-disable @typescript-eslint/no-use-before-define */ const core_1 = require("@botonic/core"); const lodash_merge_1 = tslib_1.__importDefault(require("lodash.merge")); const react_1 = require("react"); const styled_components_1 = require("styled-components"); const uuid_1 = require("uuid"); const components_1 = require("../components"); const constants_1 = require("../constants"); const index_types_1 = require("../index-types"); const message_utils_1 = require("../message-utils"); const msg_to_botonic_1 = require("../msg-to-botonic"); const environment_1 = require("../util/environment"); const regexs_1 = require("../util/regexs"); const webchat_1 = require("../util/webchat"); const chat_area_1 = require("./chat-area"); const opened_persistent_menu_1 = require("./components/opened-persistent-menu"); const constants_2 = require("./constants"); const context_1 = require("./context"); const cover_component_1 = require("./cover-component"); const header_1 = require("./header"); const hooks_1 = require("./hooks"); const input_panel_1 = require("./input-panel"); const styles_1 = require("./styles"); const trigger_button_1 = require("./trigger-button"); const use_storage_state_hook_1 = require("./use-storage-state-hook"); const utils_1 = require("./utils"); const index_1 = require("./webview/index"); const Webchat = (0, react_1.forwardRef)((props, ref) => { const { addMessage, addMessageComponent, clearMessages, doRenderCustomComponent, resetUnreadMessages, setCurrentAttachment, setError, setIsInputFocused, setLastMessageVisible, setOnline, toggleCoverComponent, toggleEmojiPicker, togglePersistentMenu, toggleWebchat, updateCustomMessageProps, updateDevSettings, updateHandoff, updateLastMessageDate, updateLastRoutePath, updateLatestInput, updateMessage, updateReplies, updateSession, updateTheme, updateTyping, updateWebview, removeWebview, removeReplies, webchatState, webchatContainerRef, chatAreaRef, inputPanelRef, headerRef, repliesRef, scrollableMessagesListRef, } = props.webchatHooks || (0, context_1.useWebchat)(props.theme); const firstUpdate = (0, react_1.useRef)(true); const isOnline = () => webchatState.online; const currentDateString = () => new Date().toISOString(); const theme = (0, lodash_merge_1.default)(webchatState.theme, props.theme); const { initialSession, initialDevSettings, onStateChange } = props; const getThemeProperty = (0, webchat_1._getThemeProperty)(theme); const [customComponent, setCustomComponent] = (0, react_1.useState)(null); const storage = props.storage; const storageKey = typeof props.storageKey === 'function' ? props.storageKey() : props.storageKey; const [botonicState, saveState] = (0, use_storage_state_hook_1.useStorageState)(storage, storageKey); const host = props.host || document.body; const { scrollToBottom } = (0, hooks_1.useScrollToBottom)({ host }); const saveWebchatState = (webchatState) => { storage && saveState(JSON.parse((0, regexs_1.stringifyWithRegexs)({ messages: webchatState.messagesJSON, session: webchatState.session, lastRoutePath: webchatState.lastRoutePath, devSettings: webchatState.devSettings, lastMessageUpdate: webchatState.lastMessageUpdate, themeUpdates: webchatState.themeUpdates, }))); }; const handleAttachment = (event) => { if (!(0, message_utils_1.isAllowedSize)(event.target.files[0].size)) { throw new Error(`The file is too large. A maximum of ${constants_1.MAX_ALLOWED_SIZE_MB}MB is allowed.`); } // TODO: Attach more files? setCurrentAttachment(event.target.files[0]); }; (0, react_1.useEffect)(() => { if (webchatState.currentAttachment) { sendAttachment(webchatState.currentAttachment); } }, [webchatState.currentAttachment]); const sendUserInput = async (input) => { if (props.onUserInput) { resetUnreadMessages(); scrollToBottom(); props.onUserInput({ user: webchatState.session.user, // TODO: Review if this input.sentBy exists in the frontend input: input, //@ts-expect-error session: webchatState.session, // TODO: Review why we were passing lastRoutePath, is only for devMode? lastRoutePath: webchatState.lastRoutePath, }); } }; // Load styles stored in window._botonicInsertStyles by Webpack // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: this is a complex useEffect that needs to be refactored, but it's not a problem for now (0, hooks_1.useComponentWillMount)(() => { if (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?.includes('emoji-picker-react')) { host.appendChild(style.cloneNode(true)); } } } }); // Load initial state from storage (0, react_1.useEffect)(() => { let { messages, session, lastRoutePath, devSettings, lastMessageUpdate, themeUpdates, } = botonicState || {}; session = (0, webchat_1.initSession)(session); updateSession(session); if ((0, webchat_1.shouldKeepSessionOnReload)({ initialDevSettings, devSettings })) { if (messages) { messages.forEach(message => { addMessage(message); const newMessageComponent = (0, msg_to_botonic_1.msgToBotonic)({ ...message, delay: 0, typing: 0 }, props.theme?.message?.customTypes); if (newMessageComponent) { addMessageComponent(newMessageComponent); } }); } if (initialSession) { updateSession((0, lodash_merge_1.default)(initialSession, session)); } if (lastRoutePath) { updateLastRoutePath(lastRoutePath); } } else { updateSession((0, lodash_merge_1.default)(initialSession, session)); } if (devSettings) { updateDevSettings(devSettings); } else if (initialDevSettings) { updateDevSettings(initialDevSettings); } if (lastMessageUpdate) { updateLastMessageDate(lastMessageUpdate); } if (themeUpdates !== undefined) { updateTheme((0, lodash_merge_1.default)(props.theme, themeUpdates), themeUpdates); } if (props.onInit) { setTimeout(() => { if (typeof props.onInit === 'function') { props.onInit(); session.user = (0, webchat_1.updateUserLocaleAndCountry)(session.user); } }, 100); } }, []); (0, react_1.useEffect)(() => { if (!webchatState.isWebchatOpen) { if (webchatState.isLastMessageVisible) { resetUnreadMessages(); } return; } }, [webchatState.isWebchatOpen]); (0, react_1.useEffect)(() => { const { messagesJSON, session } = webchatState; if (onStateChange && typeof onStateChange === 'function' && session.user) { onStateChange({ messagesJSON, user: session.user }); } saveWebchatState(webchatState); }, [ webchatState.messagesJSON, webchatState.session, webchatState.lastRoutePath, webchatState.devSettings, webchatState.lastMessageUpdate, ]); (0, react_1.useEffect)(() => { if (!webchatState.online) { setError({ message: (0, webchat_1.getServerErrorMessage)(props.server), }); } else { if (!firstUpdate.current) { setError(undefined); } } }, [webchatState.online]); (0, hooks_1.useTyping)({ webchatState, updateTyping, updateMessage, host }); (0, react_1.useEffect)(() => { updateTheme((0, lodash_merge_1.default)(props.theme, theme, webchatState.themeUpdates)); }, [props.theme, webchatState.themeUpdates]); const openWebview = (webviewComponent, params) => { updateWebview(webviewComponent, params); }; const textareaRef = (0, react_1.useRef)(); const closeWebview = async (options) => { removeWebview(); if (userInputEnabled) { textareaRef.current?.focus(); } if (options?.payload) { await sendPayload(options.payload); } else if (options?.path) { const params = options.params ? (0, core_1.params2queryString)(options.params) : ''; await sendPayload(`__PATH_PAYLOAD__${options.path}?${params}`); } }; const persistentMenuOptions = getThemeProperty(constants_1.WEBCHAT.CUSTOM_PROPERTIES.persistentMenu); const darkBackgroundMenu = getThemeProperty(constants_1.WEBCHAT.CUSTOM_PROPERTIES.darkBackgroundMenu); const getBlockInputs = (rule, inputData) => { const processedInput = rule.preprocess ? rule.preprocess(inputData) : inputData; return rule.match.some(regex => { if (typeof regex === 'string') { regex = (0, regexs_1.deserializeRegex)(regex); } return regex.test(processedInput); }); }; const checkBlockInput = input => { // if is a text we check if it is a serialized RE const blockInputs = webchatState.theme.userInput?.blockInputs; if (!Array.isArray(blockInputs)) { return false; } for (const rule of blockInputs) { if (getBlockInputs(rule, input.data)) { addMessageComponent((0, jsx_runtime_1.jsx)(components_1.Text // Is necessary to add the id of the input // to keep the input.id generated in the frontend as id of the message // @ts-expect-error input.id is not typed , { // Is necessary to add the id of the input // to keep the input.id generated in the frontend as id of the message // @ts-expect-error input.id is not typed id: input.id, sentBy: index_types_1.SENDERS.user, blob: false, style: { backgroundColor: constants_1.COLORS.SCORPION_GRAY, borderColor: constants_1.COLORS.SCORPION_GRAY, padding: '8px 12px', }, children: rule.message })); removeReplies(); return true; } } return false; }; const closeMenu = () => { togglePersistentMenu(false); }; const persistentMenu = () => { return ((0, jsx_runtime_1.jsx)(opened_persistent_menu_1.OpenedPersistentMenu, { onClick: closeMenu, options: persistentMenuOptions, borderRadius: webchatState.theme.style.borderRadius || '10px' })); }; const coverComponent = webchatState.theme.coverComponent; const coverComponentProps = webchatState.theme.coverComponent?.props; (0, react_1.useEffect)(() => { if (!coverComponent) { return; } if (!botonicState || (botonicState.messages && botonicState.messages.length === 0)) { toggleCoverComponent(true); } }, []); // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: messageComponentFromInput is a complex const messageComponentFromInput = input => { let messageComponent = null; if ((0, message_utils_1.isText)(input)) { messageComponent = ((0, jsx_runtime_1.jsx)(components_1.Text // Is necessary to add the id of the input // to keep the input.id generated in the frontend as id of the message // @ts-expect-error input.id is not typed , { // Is necessary to add the id of the input // to keep the input.id generated in the frontend as id of the message // @ts-expect-error input.id is not typed id: input.id, // Is necessary to add the payload of the input when user clicks a button payload: input.payload, sentBy: index_types_1.SENDERS.user, children: input.data })); } else if ((0, message_utils_1.isMedia)(input)) { const temporaryDisplayUrl = URL.createObjectURL(input.data); // TODO: We should use URL.revokeObjectURL(temporaryDisplayUrl) when the component is unmounted // https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL_static#memory_management const mediaProps = { id: input.id, sentBy: index_types_1.SENDERS.user, src: temporaryDisplayUrl, }; if ((0, message_utils_1.isImage)(input)) { mediaProps.input = input; messageComponent = (0, jsx_runtime_1.jsx)(components_1.Image, { ...mediaProps }); } else if ((0, message_utils_1.isAudio)(input)) { messageComponent = (0, jsx_runtime_1.jsx)(components_1.Audio, { ...mediaProps }); } else if ((0, message_utils_1.isVideo)(input)) { messageComponent = (0, jsx_runtime_1.jsx)(components_1.Video, { ...mediaProps }); } else if ((0, message_utils_1.isDocument)(input)) { messageComponent = (0, jsx_runtime_1.jsx)(components_1.Document, { ...mediaProps }); } } return messageComponent; }; const sendInput = async (input) => { if (!input || Object.keys(input).length === 0) { return; } if ((0, message_utils_1.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 ((0, message_utils_1.isText)(input) && checkBlockInput(input)) { return; } if (!input.id) { input.id = (0, uuid_1.v7)(); } const messageComponent = messageComponentFromInput(input); if (messageComponent) { addMessageComponent(messageComponent); } if ((0, message_utils_1.isMedia)(input)) { input.data = await (0, message_utils_1.readDataURL)(input.data); } sendUserInput(input); updateLatestInput(input); isOnline() && updateLastMessageDate(currentDateString()); removeReplies(); 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((0, lodash_merge_1.default)(webchatState.session, { user: userToUpdate })); }; (0, react_1.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((0, lodash_merge_1.default)(session, { user: webchatState.session.user })); const action = session._botonic_action || ''; const handoff = action.startsWith(core_1.BotonicAction.CreateCase); if (handoff && environment_1.isDev) { addMessageComponent((0, jsx_runtime_1.jsx)(components_1.Handoff, {})); } updateHandoff(handoff); } if (lastRoutePath) { updateLastRoutePath(lastRoutePath); } updateLastMessageDate(currentDateString()); }, addSystemResponse: ({ response }) => { if (Array.isArray(response)) { response.forEach(r => { addMessageComponent({ ...r, props: { ...r.props, isUnread: false } }); }); } else if (response) { addMessageComponent({ ...response, props: { ...response.props, isUnread: false }, }); } 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), setOnline, getMessages: () => webchatState.messagesJSON, isOnline, clearMessages: () => { clearMessages(); removeReplies(); }, getLastMessageUpdate: () => webchatState.lastMessageUpdate, updateMessageInfo: (msgId, messageInfo) => { const messageToUpdate = webchatState.messagesJSON.filter(m => m.id === msgId)[0]; const updatedMsg = (0, lodash_merge_1.default)(messageToUpdate, messageInfo); if (updatedMsg.ack === 1) { delete updatedMsg.unsentInput; } updateMessage(updatedMsg); }, updateWebchatSettings: (settings) => { if (settings.user) { updateSessionWithUser(settings.user); } const themeUpdates = (0, components_1.normalizeWebchatSettings)(settings); updateTheme((0, lodash_merge_1.default)(webchatState.theme, themeUpdates), themeUpdates); updateTyping(false); }, closeWebview: async (options) => closeWebview(options), })); const resolveCase = () => { updateHandoff(false); updateSession({ ...webchatState.session, _botonic_action: undefined }); }; const prevSession = (0, hooks_1.usePrevious)(webchatState.session); (0, react_1.useEffect)(() => { // Resume conversation after handoff if (prevSession?._botonic_action && !webchatState.session._botonic_action) { const action = (0, utils_1.getParsedAction)(prevSession._botonic_action); if (action?.on_finish) { sendPayload(action.on_finish); } } }, [webchatState.session._botonic_action]); const sendText = async (text, payload) => { if (!text) { return; } const input = { type: core_1.INPUT.TEXT, data: text, payload }; await sendInput(input); }; const sendPayload = async (payload) => { if (!payload) { return; } const input = { type: core_1.INPUT.POSTBACK, payload }; await sendInput(input); }; const sendAttachment = async (attachment) => { if (attachment) { const attachmentType = (0, message_utils_1.getMediaType)(attachment.type); if (!attachmentType) { return; } const input = { type: attachmentType, data: attachment, }; await sendInput(input); setCurrentAttachment(); } }; (0, react_1.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(constants_1.WEBCHAT.CUSTOM_PROPERTIES.enableUserInput); return isUserInputEnabled && !webchatState.isCoverComponentOpen; }; const userInputEnabled = isUserInputEnabled(); (0, react_1.useEffect)(() => { // Prod mode saveWebchatState(webchatState); }, [webchatState.themeUpdates]); // Only needed for dev/serve mode const updateWebchatDevSettings = settings => { (0, react_1.useEffect)(() => { const themeUpdates = (0, components_1.normalizeWebchatSettings)(settings); updateTheme((0, lodash_merge_1.default)(webchatState.theme, themeUpdates), themeUpdates); }, [webchatState.messagesJSON]); }; const DarkenBackground = ({ component }) => { return ((0, jsx_runtime_1.jsxs)("div", { children: [darkBackgroundMenu && ((0, jsx_runtime_1.jsx)(styles_1.DarkBackgroundMenu, { style: { borderRadius: webchatState.theme.style.borderRadius, } })), component] })); }; const _renderCustomComponent = () => { if (!customComponent) { return (0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, {}); } else { return customComponent; } }; const WebchatComponent = ((0, jsx_runtime_1.jsx)(context_1.WebchatContext.Provider, { value: { addMessage, getThemeProperty, closeWebview, openWebview, resolveCase, resetUnreadMessages, setIsInputFocused, setLastMessageVisible, sendAttachment, sendInput, sendPayload, sendText, toggleWebchat, toggleEmojiPicker, togglePersistentMenu, toggleCoverComponent, updateCustomMessageProps, updateLatestInput, updateMessage, updateReplies, updateUser: updateSessionWithUser, updateWebchatDevSettings: updateWebchatDevSettings, trackEvent: props.onTrackEvent, previewUtils: props.previewUtils, webchatState, webchatContainerRef, chatAreaRef, inputPanelRef, headerRef, repliesRef, scrollableMessagesListRef, }, children: (0, jsx_runtime_1.jsxs)(styled_components_1.ThemeProvider, { theme: webchatState.theme, children: [!webchatState.isWebchatOpen && (0, jsx_runtime_1.jsx)(trigger_button_1.TriggerButton, {}), webchatState.isWebchatOpen && ((0, jsx_runtime_1.jsxs)(styles_1.StyledWebchat, { id: constants_2.BotonicContainerId.Webchat, ref: webchatContainerRef, // TODO: Distinguish between multiple instances of webchat, e.g. `${uniqueId}-botonic-webchat` role: constants_1.ROLES.WEBCHAT, children: [(0, jsx_runtime_1.jsx)(header_1.WebchatHeader, { ref: headerRef }), webchatState.isCoverComponentOpen ? ((0, jsx_runtime_1.jsx)(cover_component_1.CoverComponent, { component: coverComponent, componentProps: coverComponentProps })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [webchatState.error.message && ((0, jsx_runtime_1.jsx)(styles_1.ErrorMessageContainer, { children: (0, jsx_runtime_1.jsx)(styles_1.ErrorMessage, { children: webchatState.error.message }) })), (0, jsx_runtime_1.jsx)(chat_area_1.ChatArea, {}), webchatState.isPersistentMenuOpen && ((0, jsx_runtime_1.jsx)(DarkenBackground, { component: persistentMenu() })), !webchatState.handoff && userInputEnabled && ((0, jsx_runtime_1.jsx)(input_panel_1.InputPanel, { handleAttachment: handleAttachment, textareaRef: textareaRef, host: host, onUserInput: props.onUserInput })), webchatState.webview && ((0, jsx_runtime_1.jsx)(index_1.WebviewContainer, { localWebviews: props.localWebviews })), webchatState.isCustomComponentRendered && customComponent && _renderCustomComponent()] }))] }))] }) })); return props.shadowDOM ? ((0, jsx_runtime_1.jsx)(styled_components_1.StyleSheetManager, { target: host, children: WebchatComponent })) : (WebchatComponent); }); exports.Webchat = Webchat; Webchat.displayName = 'Webchat'; //# sourceMappingURL=webchat.js.map