From 01f3d91240478e75ced16fa75136e4a88aef5d84 Mon Sep 17 00:00:00 2001 From: Oriol Raventos Date: Mon, 25 Nov 2024 16:12:36 +0100 Subject: [PATCH] WIP --- package-lock.json | 10 + packages/botonic-react/package.json | 1 + packages/botonic-react/src/index-types.ts | 4 +- .../botonic-react/src/webchat-app-typed.tsx | 497 ++++++++++++ .../src/webchat/webchat-typed/styles.ts | 54 ++ .../webchat/webchat-typed/webchat-typed.tsx | 728 ++++++++++++++++++ .../botonic-react/src/webchat/webchat.jsx | 5 +- 7 files changed, 1295 insertions(+), 4 deletions(-) create mode 100644 packages/botonic-react/src/webchat-app-typed.tsx create mode 100644 packages/botonic-react/src/webchat/webchat-typed/styles.ts create mode 100644 packages/botonic-react/src/webchat/webchat-typed/webchat-typed.tsx diff --git a/package-lock.json b/package-lock.json index 49dced564..9a2c0fb22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5535,6 +5535,15 @@ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.13.tgz", "integrity": "sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg==" }, + "node_modules/@types/lodash.merge": { + "version": "4.6.9", + "resolved": "https://registry.npmjs.org/@types/lodash.merge/-/lodash.merge-4.6.9.tgz", + "integrity": "sha512-23sHDPmzd59kUgWyKGiOMO2Qb9YtqRO/x4IhkgNUiPQ1+5MUVqi6bCZeq9nBJ17msjIMbEIO5u+XW4Kz6aGUhQ==", + "dev": true, + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/long": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", @@ -28095,6 +28104,7 @@ "@babel/preset-react": "^7.25.9", "@testing-library/react": "^16.0.1", "@types/jest": "^29.5.14", + "@types/lodash.merge": "^4.6.9", "@types/parse5": "^7.0.0", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", diff --git a/packages/botonic-react/package.json b/packages/botonic-react/package.json index 8fe6712e5..bd4340f27 100644 --- a/packages/botonic-react/package.json +++ b/packages/botonic-react/package.json @@ -39,6 +39,7 @@ "@babel/preset-react": "^7.25.9", "@testing-library/react": "^16.0.1", "@types/jest": "^29.5.14", + "@types/lodash.merge": "^4.6.9", "@types/parse5": "^7.0.0", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", diff --git a/packages/botonic-react/src/index-types.ts b/packages/botonic-react/src/index-types.ts index 70eea40f5..3d9217f55 100644 --- a/packages/botonic-react/src/index-types.ts +++ b/packages/botonic-react/src/index-types.ts @@ -79,8 +79,8 @@ export interface WebchatArgs { theme?: ThemeProps } -type EventArgs = { [key: string]: any } -type TrackEventFunction = ( +export type EventArgs = { [key: string]: any } +export type TrackEventFunction = ( request: ActionRequest, eventName: string, args?: EventArgs diff --git a/packages/botonic-react/src/webchat-app-typed.tsx b/packages/botonic-react/src/webchat-app-typed.tsx new file mode 100644 index 000000000..d6221d5c1 --- /dev/null +++ b/packages/botonic-react/src/webchat-app-typed.tsx @@ -0,0 +1,497 @@ +import { HubtypeService, INPUT } from '@botonic/core' +import merge from 'lodash.merge' +import React, { createRef } from 'react' +import { createRoot, Root } from 'react-dom/client' + +import { + BlockInputOption, + CoverComponentOptions, + PersistentMenuTheme, + ThemeProps, +} from './components/index-types' +import { WEBCHAT } from './constants' +import { + ActionRequest, + EventArgs, + SENDERS, + Typing, + WebchatMessage, +} from './index-types' +import { isShadowDOMSupported, onDOMLoaded } from './util' +import { Webchat } from './webchat/webchat' + +// declared in @botonic/core/src/hubtype-service.ts +interface ServerConfig { + activityTimeout?: number + pongTimeout?: number +} + +export interface WebchatAppArgs { + theme?: ThemeProps + persistentMenu?: PersistentMenuTheme + coverComponent?: CoverComponentOptions + blockInputs?: BlockInputOption[] + enableEmojiPicker?: boolean + enableAttachments?: boolean + enableUserInput?: boolean + enableAnimations?: boolean + hostId?: string + shadowDOM?: boolean | (() => boolean) + defaultDelay?: number + defaultTyping?: number + storage?: Storage | null + storageKey?: string + onInit?: (app: WebchatAppTyped, args: any) => void + onOpen?: (app: WebchatAppTyped, args: any) => void + onClose?: (app: WebchatAppTyped, args: any) => void + onMessage?: (app: WebchatAppTyped, message: WebchatMessage) => void + onTrackEvent?: ( + request: ActionRequest, + eventName: string, + args?: EventArgs + ) => Promise + onConnectionChange?: (app: WebchatAppTyped, isOnline: boolean) => void + appId?: string + visibility?: boolean | (() => void) | 'dynamic' + server?: ServerConfig +} + +export class WebchatAppTyped { + public theme?: ThemeProps + public persistentMenu?: PersistentMenuTheme + public coverComponent?: CoverComponentOptions + public blockInputs?: BlockInputOption[] + public enableEmojiPicker?: boolean + public enableAttachments?: boolean + public enableUserInput?: boolean + public enableAnimations?: boolean + public hostId?: string + public shadowDOM?: boolean | (() => boolean) + public defaultDelay?: number + public defaultTyping?: number + public storage?: Storage | null + public storageKey: string + public onInit?: (app: WebchatAppTyped, args: any) => void + public onOpen?: (app: WebchatAppTyped, args: any) => void + public onClose?: (app: WebchatAppTyped, args: any) => void + public onMessage?: (app: WebchatAppTyped, message: WebchatMessage) => void + public onTrackEvent?: ( + request: ActionRequest, + eventName: string, + args?: EventArgs + ) => Promise + public onConnectionChange?: (app: WebchatAppTyped, isOnline: boolean) => void + public appId?: string + public visibility?: boolean | (() => void) | 'dynamic' + public server?: ServerConfig + public webchatRef: React.RefObject + + private reactRoot: Root | null = null + private host: (HTMLElement | null) | ShadowRoot = null + private hubtypeService: HubtypeService + + constructor({ + theme = {}, + persistentMenu, + coverComponent, + blockInputs, + enableEmojiPicker, + enableAttachments, + enableUserInput, + enableAnimations, + hostId = 'root', + shadowDOM = false, + defaultDelay, + defaultTyping, + storage, + storageKey, + onInit, + onOpen, + onClose, + onMessage, + onTrackEvent, + onConnectionChange, + appId, + visibility, + server, + }: WebchatAppArgs) { + this.theme = theme + this.persistentMenu = persistentMenu + this.coverComponent = coverComponent + this.blockInputs = blockInputs + this.enableEmojiPicker = enableEmojiPicker + this.enableAttachments = enableAttachments + this.enableUserInput = enableUserInput + this.enableAnimations = enableAnimations + + this.shadowDOM = Boolean( + typeof shadowDOM === 'function' ? shadowDOM() : shadowDOM + ) + if (this.shadowDOM && !isShadowDOMSupported()) { + console.warn('[botonic] ShadowDOM not supported on this browser') + this.shadowDOM = false + } // Review this + + this.hostId = hostId || WEBCHAT.DEFAULTS.HOST_ID + this.defaultDelay = defaultDelay + this.defaultTyping = defaultTyping + this.storage = storage === undefined ? localStorage : storage + this.storageKey = storageKey || WEBCHAT.DEFAULTS.STORAGE_KEY + this.onInit = onInit + this.onOpen = onOpen + this.onClose = onClose + this.onMessage = onMessage + this.onTrackEvent = onTrackEvent + this.onConnectionChange = onConnectionChange + this.visibility = visibility + this.server = server + this.webchatRef = createRef() + this.appId = appId + + this.host = null + this.reactRoot = null + } + createRootElement(host: HTMLElement | null) { + // Create root element
if not exists + // Create shadowDOM to root element if needed + if (this.hostId) { + if (host) { + if (host.id && this.hostId) { + if (host.id !== this.hostId) { + console.warn( + `[botonic] Host ID "${host.id}" don't match 'hostId' option: ${this.hostId}. Using value: ${host.id}.` + ) + this.hostId = host.id + } + } else if (host.id) { + this.hostId = host.id + } else if (this.hostId) { + host.id = this.hostId + } + } else { + host = document.getElementById(this.hostId) + } + if (!host) { + host = document.createElement('div') + host.id = this.hostId + if (document.body.firstChild) + document.body.insertBefore(host, document.body.firstChild) + else document.body.appendChild(host) + } + this.host = this.shadowDOM ? host.attachShadow({ mode: 'open' }) : host + } + } + + getReactMountNode( + node: (HTMLElement | null) | ShadowRoot + ): Element | DocumentFragment { + if (node === null) { + node = this.host + } + + if (node === null) { + throw new Error('Host element not found') + } + + if ('shadowRoot' in node && node.shadowRoot !== null) { + return node.shadowRoot + } + + return node + } + + onInitWebchat(...args: [any]) { + this.onInit && this.onInit(this, ...args) + } + + onOpenWebchat(...args: [any]) { + this.onOpen && this.onOpen(this, ...args) + } + + onCloseWebchat(...args: [any]) { + this.onClose && this.onClose(this, ...args) + } + + async onUserInput({ user, input }) { + this.onMessage && + this.onMessage(this, { + sentBy: SENDERS.user, + message: input, + isUnread: false, + }) + return this.hubtypeService.postMessage(user, input) + } + + async onConnectionRegained() { + return this.hubtypeService.onConnectionRegained() + } + + onStateChange({ session: { user }, messagesJSON }) { + const lastMessage = messagesJSON[messagesJSON.length - 1] + const lastMessageId = lastMessage && lastMessage.id + const lastMessageUpdateDate = this.getLastMessageUpdate() + if (this.hubtypeService) { + this.hubtypeService.lastMessageId = lastMessageId + this.hubtypeService.lastMessageUpdateDate = lastMessageUpdateDate + } else if (!this.hubtypeService && user) { + this.hubtypeService = new HubtypeService({ + appId: this.appId, + user, + lastMessageId, + lastMessageUpdateDate, + onEvent: event => this.onServiceEvent(event), + unsentInputs: () => + this.webchatRef.current + .getMessages() + .filter(msg => msg.ack === 0 && msg.unsentInput), + server: this.server, + }) + } + } + + onServiceEvent(event) { + if (event.action === 'connectionChange') { + this.onConnectionChange && this.onConnectionChange(this, event.online) + this.webchatRef.current.setOnline(event.online) + } else if (event.action === 'update_message_info') { + this.updateMessageInfo(event.message.id, event.message) + } else if (event.message?.type === 'update_webchat_settings') { + this.updateWebchatSettings(event.message.data) + } else if (event.message?.type === 'sender_action') { + this.setTyping(event.message.data === Typing.On) + } else { + this.onMessage && + this.onMessage(this, { sentBy: SENDERS.bot, message: event.message }) + this.addBotMessage(event.message) + } + } + + updateUser(user) { + this.webchatRef.current.updateUser(user) + } + + addBotMessage(message) { + message.ack = 0 + message.isUnread = true + message.sentBy = message.sent_by?.split('message_sent_by_')[1] + delete message.sent_by + const response = msgToBotonic( + message, + this.theme.message?.customTypes || this.theme.customMessageTypes + ) + + this.webchatRef.current.addBotResponse({ + response, + }) + } + + addBotText(text) { + this.addBotMessage({ type: INPUT.TEXT, data: text }) + } + + addUserMessage(message) { + this.webchatRef.current.addUserMessage(message) + } + + addUserText(text) { + this.addUserMessage({ type: INPUT.TEXT, data: text }) + } + + addUserPayload(payload) { + this.addUserMessage({ type: INPUT.POSTBACK, payload }) + } + + setTyping(typing) { + this.webchatRef.current.setTyping(typing) + } + + open() { + this.webchatRef.current.openWebchat() + } + + close() { + this.webchatRef.current.closeWebchat() + } + + async closeWebview() { + await this.webchatRef.current.closeWebview() + } + + toggle() { + this.webchatRef.current.toggleWebchat() + } + + openCoverComponent() { + this.webchatRef.current.openCoverComponent() + } + + closeCoverComponent() { + this.webchatRef.current.closeCoverComponent() + } + + renderCustomComponent(_customComponent) { + this.webchatRef.current.renderCustomComponent(_customComponent) + } + + unmountCustomComponent() { + this.webchatRef.current.unmountCustomComponent() + } + + toggleCoverComponent() { + this.webchatRef.current.toggleCoverComponent() + } + + getMessages() { + return this.webchatRef.current.getMessages() + } + + clearMessages() { + this.webchatRef.current.clearMessages() + } + + async getVisibility() { + return this.resolveWebchatVisibility({ + appId: this.appId, + visibility: this.visibility, + }) + } + + getLastMessageUpdate() { + return this.webchatRef.current.getLastMessageUpdate() + } + + updateMessageInfo(msgId, messageInfo) { + return this.webchatRef.current.updateMessageInfo(msgId, messageInfo) + } + + updateWebchatSettings(settings) { + return this.webchatRef.current.updateWebchatSettings(settings) + } + + async isWebchatVisible({ appId }) { + try { + const { status } = await HubtypeService.getWebchatVisibility({ + appId, + }) + return status === 200 + } catch (e) { + return false + } + } + + isOnline() { + return this.webchatRef.current.isOnline() + } + + async resolveWebchatVisibility(optionsAtRuntime) { + let { appId, visibility } = optionsAtRuntime + visibility = visibility || this.visibility + if (visibility === undefined || visibility === true) return true + if (typeof visibility === 'function' && visibility()) return true + if (visibility === 'dynamic' && (await this.isWebchatVisible({ appId }))) + return true + return false + } + + // eslint-disable-next-line complexity + getComponent(host, optionsAtRuntime: WebchatAppArgs = {}) { + let { + theme = {}, + persistentMenu, + coverComponent, + blockInputs, + enableAttachments, + enableUserInput, + enableAnimations, + enableEmojiPicker, + defaultDelay, + defaultTyping, + storage, + storageKey, + onInit, + onOpen, + onClose, + onMessage, + onConnectionChange, + onTrackEvent, + appId, + visibility, + server, + hostId, + ...webchatOptions + } = optionsAtRuntime + theme = merge(this.theme, theme) + persistentMenu = persistentMenu || this.persistentMenu + coverComponent = coverComponent || this.coverComponent + blockInputs = blockInputs || this.blockInputs + enableEmojiPicker = enableEmojiPicker || this.enableEmojiPicker + enableAttachments = enableAttachments || this.enableAttachments + enableUserInput = enableUserInput || this.enableUserInput + enableAnimations = enableAnimations || this.enableAnimations + defaultDelay = defaultDelay || this.defaultDelay + defaultTyping = defaultTyping || this.defaultTyping + server = server || this.server + this.storage = storage || this.storage + this.storageKey = storageKey || this.storageKey + this.onInit = onInit || this.onInit + this.onOpen = onOpen || this.onOpen + this.onClose = onClose || this.onClose + this.onMessage = onMessage || this.onMessage + this.onTrackEvent = onTrackEvent || this.onTrackEvent + this.onConnectionChange = onConnectionChange || this.onConnectionChange + this.visibility = visibility || this.visibility + this.appId = appId || this.appId + this.hostId = hostId || this.hostId + this.createRootElement(host) + + return ( + this.onInitWebchat(...args)} + onOpen={(...args: [any]) => this.onOpenWebchat(...args)} + onClose={(...args: [any]) => this.onCloseWebchat(...args)} + onUserInput={(...args: [any]) => this.onUserInput(...args)} + onStateChange={webchatState => this.onStateChange(webchatState)} + onTrackEvent={( + request: ActionRequest, + eventName: string, + args?: EventArgs + ) => this.onTrackEvent && this.onTrackEvent(request, eventName, args)} + server={server} + /> + ) + } + + destroy() { + if (this.hubtypeService) this.hubtypeService.destroyPusher() + this.reactRoot?.unmount() + if (this.storage) this.storage.removeItem(this.storageKey) + } + + async render(dest: HTMLDivElement, optionsAtRuntime = {}) { + onDOMLoaded(async () => { + const isVisible = await this.resolveWebchatVisibility(optionsAtRuntime) + if (isVisible) { + const webchatComponent = this.getComponent(dest, optionsAtRuntime) + const container = this.getReactMountNode(dest) + this.reactRoot = createRoot(container) // createRoot(container!) if you use TypeScript + this.reactRoot.render(webchatComponent) + } + }) + } +} diff --git a/packages/botonic-react/src/webchat/webchat-typed/styles.ts b/packages/botonic-react/src/webchat/webchat-typed/styles.ts new file mode 100644 index 000000000..3b034b374 --- /dev/null +++ b/packages/botonic-react/src/webchat/webchat-typed/styles.ts @@ -0,0 +1,54 @@ +import styled from 'styled-components' + +import { COLORS, WEBCHAT } from '../../constants' + +export const StyledWebchat = styled.div<{ height: string; width: string }>` + 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; + justify-content: space-between; + overflow: hidden; +` + +export const ErrorMessageContainer = styled.div` + position: relative; + display: flex; + z-index: 1; + justify-content: center; + width: 100%; +` + +export 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}; +` + +export 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%; +` diff --git a/packages/botonic-react/src/webchat/webchat-typed/webchat-typed.tsx b/packages/botonic-react/src/webchat/webchat-typed/webchat-typed.tsx new file mode 100644 index 000000000..082ab99c4 --- /dev/null +++ b/packages/botonic-react/src/webchat/webchat-typed/webchat-typed.tsx @@ -0,0 +1,728 @@ +import { + BotonicAction, + INPUT, + isMobile, + params2queryString, +} from '@botonic/core' +import merge from 'lodash.merge' +import React, { + forwardRef, + useEffect, + useImperativeHandle, + useRef, + useState, +} from 'react' +import { StyleSheetManager } from 'styled-components' +import { v7 as uuidv7 } 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 { + getMediaType, + isAllowedSize, + isAudio, + isDocument, + isImage, + isMedia, + isText, + isVideo, + readDataURL, +} from '../../message-utils' +import { msgToBotonic } from '../../msg-to-botonic' +import { isDev } from '../../util/environment' +import { deserializeRegex, stringifyWithRegexs } from '../../util/regexs' +import { + _getThemeProperty, + getServerErrorMessage, + initSession, + shouldKeepSessionOnReload, +} from '../../util/webchat' +import { ChatArea } from '../chat-area' +import { OpenedPersistentMenu } from '../components/opened-persistent-menu' +import { BotonicContainerId } from '../constants' +import { CoverComponent } from '../cover-component' +import { WebchatHeader } from '../header' +import { + useComponentWillMount, + usePrevious, + useScrollToBottom, + useTyping, + useWebchat, +} from '../hooks' +import { InputPanel } from '../input-panel' +import { TriggerButton } from '../trigger-button' +import { useStorageState } from '../use-storage-state-hook' +import { getParsedAction } from '../utils' +import { WebviewContainer } from '../webview' +import { + DarkBackgroundMenu, + ErrorMessage, + ErrorMessageContainer, + StyledWebchat, +} from './styles' + +// eslint-disable-next-line complexity, react/display-name +export const WebchatTyped = forwardRef((props: WebchatTypeProps, 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, + webchatRef, + chatAreaRef, + inputPanelRef, + headerRef, + scrollableMessagesListRef, + + // 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 { scrollToBottom } = useScrollToBottom({ host }) + + 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() + props.onUserInput({ + user: webchatState.session.user, + input: input, + 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 + } + }, [webchatState.isWebchatOpen]) + + useEffect(() => { + if (onStateChange && typeof onStateChange === 'function') { + onStateChange(webchatState) + } + saveWebchatState(webchatState) + }, [ + webchatState.messagesJSON, + webchatState.session, + webchatState.lastRoutePath, + webchatState.devSettings, + webchatState.lastMessageUpdate, + ]) + + useEffect(() => { + 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 textareaRef = useRef(null) + + const closeWebview = async options => { + updateWebview() + if (userInputEnabled) { + textareaRef.current.focus() + } + if (options?.payload) { + await sendPayload(options.payload) + } else if (options?.path) { + const params = options.params ? params2queryString(options.params) : '' + await sendPayload(`__PATH_PAYLOAD__${options.path}?${params}`) + } + } + + const persistentMenuOptions = getThemeProperty( + WEBCHAT.CUSTOM_PROPERTIES.persistentMenu, + props.persistentMenu + ) + + const darkBackgroundMenu = getThemeProperty( + 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 = 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( + + {rule.message} + + ) + updateReplies(false) + return true + } + } + return false + } + + const closeMenu = () => { + togglePersistentMenu(false) + } + + const persistentMenu = () => { + return ( + + ) + } + + const getCoverComponent = () => { + return getThemeProperty( + WEBCHAT.CUSTOM_PROPERTIES.coverComponent, + props.coverComponent && + (props.coverComponent.component || props.coverComponent) + ) + } + const coverComponent = getCoverComponent() + const coverComponentProps = props.coverComponent?.props + + useEffect(() => { + if (!coverComponent) return + if ( + !botonicState || + (botonicState.messages && botonicState.messages.length === 0) + ) + toggleCoverComponent(true) + }, []) + + const messageComponentFromInput = input => { + let messageComponent: any = null + if (isText(input)) { + messageComponent = {input.data} + } 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 = + } else if (isAudio(input)) messageComponent =