@botonic/react
Version:
Build Chatbots using React
553 lines • 26.3 kB
JavaScript
;
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