@botonic/react
Version:
Build Chatbots using React
509 lines • 24.7 kB
JavaScript
import { __awaiter } from "tslib";
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { BotonicAction, INPUT, params2queryString } from '@botonic/core';
import merge from 'lodash.merge';
import { forwardRef, useEffect, useImperativeHandle, useRef, useState, } from 'react';
import { StyleSheetManager, ThemeProvider } from 'styled-components';
import { v7 as uuidv7 } from 'uuid';
import { Audio, Document, Handoff, Image, normalizeWebchatSettings, Text, Video, } from '../components';
import { COLORS, MAX_ALLOWED_SIZE_MB, ROLES, WEBCHAT } from '../constants';
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, updateUserLocaleAndCountry, } from '../util/webchat';
import { ChatArea } from './chat-area';
import { OpenedPersistentMenu } from './components/opened-persistent-menu';
import { BotonicContainerId } from './constants';
import { useWebchat, WebchatContext } from './context';
import { CoverComponent } from './cover-component';
import { WebchatHeader } from './header';
import { useComponentWillMount, usePrevious, useScrollToBottom, useTyping, } from './hooks';
import { InputPanel } from './input-panel';
import { DarkBackgroundMenu, ErrorMessage, ErrorMessageContainer, StyledWebchat, } from './styles';
import { TriggerButton } from './trigger-button';
import { useStorageState } from './use-storage-state-hook';
import { getParsedAction } from './utils';
import { WebviewContainer } from './webview/index';
// eslint-disable-next-line complexity, react/display-name
const Webchat = forwardRef((props, ref) => {
var _a;
const { addMessage, addMessageComponent, clearMessages, doRenderCustomComponent, resetUnreadMessages, setCurrentAttachment, setError, setIsInputFocused, setLastMessageVisible, setOnline, toggleCoverComponent, toggleEmojiPicker, togglePersistentMenu, toggleWebchat, updateDevSettings, updateHandoff, updateLastMessageDate, updateLastRoutePath, updateLatestInput, updateMessage, updateReplies, updateSession, updateTheme, updateTyping, updateWebview, removeWebview, removeReplies, webchatState, webchatContainerRef, chatAreaRef, inputPanelRef, headerRef, repliesRef, scrollableMessagesListRef,
// eslint-disable-next-line react-hooks/rules-of-hooks
} = props.webchatHooks || useWebchat(props.theme);
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.`);
}
// TODO: Attach more files?
setCurrentAttachment(event.target.files[0]);
};
useEffect(() => {
if (webchatState.currentAttachment) {
sendAttachment(webchatState.currentAttachment);
}
}, [webchatState.currentAttachment]);
const sendUserInput = (input) => __awaiter(void 0, void 0, void 0, function* () {
if (props.onUserInput) {
resetUnreadMessages();
scrollToBottom();
props.onUserInput({
user: webchatState.session.user,
// TODO: Review if this input.sentBy exists in the frontend
input: input,
//@ts-ignore
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
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 => {
var _a, _b;
addMessage(message);
const newMessageComponent = msgToBotonic(Object.assign(Object.assign({}, message), { delay: 0, typing: 0 }), (_b = (_a = props.theme) === null || _a === void 0 ? void 0 : _a.message) === null || _b === void 0 ? void 0 : _b.customTypes);
//@ts-ignore
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(() => {
if (typeof props.onInit === 'function') {
props.onInit();
session.user = updateUserLocaleAndCountry(session.user);
}
}, 100);
}
}, []);
useEffect(() => {
if (!webchatState.isWebchatOpen) {
if (webchatState.isLastMessageVisible) {
resetUnreadMessages();
}
return;
}
}, [webchatState.isWebchatOpen]);
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,
]);
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();
const closeWebview = (options) => __awaiter(void 0, void 0, void 0, function* () {
var _b;
removeWebview();
if (userInputEnabled) {
(_b = textareaRef.current) === null || _b === void 0 ? void 0 : _b.focus();
}
if (options === null || options === void 0 ? void 0 : options.payload) {
yield sendPayload(options.payload);
}
else if (options === null || options === void 0 ? void 0 : options.path) {
const params = options.params ? params2queryString(options.params) : '';
yield sendPayload(`__PATH_PAYLOAD__${options.path}?${params}`);
}
});
const persistentMenuOptions = getThemeProperty(WEBCHAT.CUSTOM_PROPERTIES.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 => {
var _a;
// if is a text we check if it is a serialized RE
const blockInputs = (_a = webchatState.theme.userInput) === null || _a === void 0 ? void 0 : _a.blockInputs;
if (!Array.isArray(blockInputs))
return false;
for (const rule of blockInputs) {
if (getBlockInputs(rule, input.data)) {
addMessageComponent(_jsx(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-ignore
, Object.assign({
// Is necessary to add the id of the input
// to keep the input.id generated in the frontend as id of the message
// @ts-ignore
id: input.id, sentBy: SENDERS.user, blob: false, style: {
backgroundColor: COLORS.SCORPION_GRAY,
borderColor: COLORS.SCORPION_GRAY,
padding: '8px 12px',
} }, { children: rule.message })));
removeReplies();
return true;
}
}
return false;
};
const closeMenu = () => {
togglePersistentMenu(false);
};
const persistentMenu = () => {
return (_jsx(OpenedPersistentMenu, { onClick: closeMenu, options: persistentMenuOptions, borderRadius: webchatState.theme.style.borderRadius || '10px' }));
};
const coverComponent = webchatState.theme.coverComponent;
const coverComponentProps = (_a = webchatState.theme.coverComponent) === null || _a === void 0 ? void 0 : _a.props;
useEffect(() => {
if (!coverComponent)
return;
if (!botonicState ||
(botonicState.messages && botonicState.messages.length === 0))
toggleCoverComponent(true);
}, []);
const messageComponentFromInput = input => {
let messageComponent = null;
if (isText(input)) {
messageComponent = (_jsx(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-ignore
, Object.assign({
// Is necessary to add the id of the input
// to keep the input.id generated in the frontend as id of the message
// @ts-ignore
id: input.id,
// Is necessary to add the payload of the input when user clicks a button
// @ts-ignore
payload: input.payload, sentBy: SENDERS.user }, { children: input.data })));
}
else if (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: 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 = (input) => __awaiter(void 0, void 0, void 0, function* () {
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 = uuidv7();
const messageComponent = messageComponentFromInput(input);
if (messageComponent)
addMessageComponent(messageComponent);
if (isMedia(input))
input.data = yield 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) => {
console.log('userToUpdate', 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(Object.assign(Object.assign({}, r), { props: Object.assign(Object.assign({}, r.props), { isUnread }) }));
});
}
else if (response) {
addMessageComponent(Object.assign(Object.assign({}, response), { props: Object.assign(Object.assign({}, response.props), { isUnread }) }));
}
if (session) {
updateSession(merge(session, { user: webchatState.session.user }));
const action = session._botonic_action || '';
const handoff = action.startsWith(BotonicAction.CreateCase);
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),
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 = 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: (options) => __awaiter(void 0, void 0, void 0, function* () { return closeWebview(options); }),
}));
const resolveCase = () => {
updateHandoff(false);
updateSession(Object.assign(Object.assign({}, webchatState.session), { _botonic_action: undefined }));
};
const prevSession = usePrevious(webchatState.session);
useEffect(() => {
// Resume conversation after handoff
if ((prevSession === null || prevSession === void 0 ? void 0 : prevSession._botonic_action) && !webchatState.session._botonic_action) {
const action = getParsedAction(prevSession._botonic_action);
if (action === null || action === void 0 ? void 0 : action.on_finish)
sendPayload(action.on_finish);
}
}, [webchatState.session._botonic_action]);
const sendText = (text, payload) => __awaiter(void 0, void 0, void 0, function* () {
if (!text)
return;
const input = { type: INPUT.TEXT, data: text, payload };
yield sendInput(input);
});
const sendPayload = (payload) => __awaiter(void 0, void 0, void 0, function* () {
if (!payload)
return;
const input = { type: INPUT.POSTBACK, payload };
yield sendInput(input);
});
const sendAttachment = (attachment) => __awaiter(void 0, void 0, void 0, function* () {
if (attachment) {
const attachmentType = getMediaType(attachment.type);
if (!attachmentType)
return;
const input = {
type: attachmentType,
data: attachment,
};
yield sendInput(input);
setCurrentAttachment();
}
});
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);
return isUserInputEnabled && !webchatState.isCoverComponentOpen;
};
const userInputEnabled = isUserInputEnabled();
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 (_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 = (_jsx(WebchatContext.Provider, Object.assign({ value: {
addMessage,
getThemeProperty,
closeWebview,
openWebview,
resolveCase,
resetUnreadMessages,
setIsInputFocused,
setLastMessageVisible,
sendAttachment,
sendInput,
sendPayload,
sendText,
toggleWebchat,
toggleEmojiPicker,
togglePersistentMenu,
toggleCoverComponent,
updateLatestInput,
updateMessage,
updateReplies,
updateUser: updateSessionWithUser,
updateWebchatDevSettings: updateWebchatDevSettings,
trackEvent: props.onTrackEvent,
webchatState,
webchatContainerRef,
chatAreaRef,
inputPanelRef,
headerRef,
repliesRef,
scrollableMessagesListRef,
} }, { children: _jsxs(ThemeProvider, Object.assign({ theme: webchatState.theme }, { children: [!webchatState.isWebchatOpen && _jsx(TriggerButton, {}), webchatState.isWebchatOpen && (_jsxs(StyledWebchat, Object.assign({ id: BotonicContainerId.Webchat, ref: webchatContainerRef,
// TODO: Distinguish between multiple instances of webchat, e.g. `${uniqueId}-botonic-webchat`
role: ROLES.WEBCHAT }, { children: [_jsx(WebchatHeader, { ref: headerRef }), webchatState.isCoverComponentOpen ? (_jsx(CoverComponent, { component: coverComponent, componentProps: coverComponentProps })) : (_jsxs(_Fragment, { children: [webchatState.error.message && (_jsx(ErrorMessageContainer, { children: _jsx(ErrorMessage, { children: webchatState.error.message }) })), _jsx(ChatArea, {}), webchatState.isPersistentMenuOpen && (_jsx(DarkenBackground, { component: persistentMenu() })), !webchatState.handoff && userInputEnabled && (_jsx(InputPanel, { handleAttachment: handleAttachment, textareaRef: textareaRef, host: host, onUserInput: props.onUserInput })), webchatState.webview && _jsx(WebviewContainer, {}), webchatState.isCustomComponentRendered &&
customComponent &&
_renderCustomComponent()] }))] })))] })) })));
return props.shadowDOM ? (_jsx(StyleSheetManager, Object.assign({ target: host }, { children: WebchatComponent }))) : (WebchatComponent);
});
Webchat.displayName = 'Webchat';
export { Webchat };
//# sourceMappingURL=webchat.js.map