diff --git a/packages/botonic-react/src/experimental/constants.js b/packages/botonic-react/src/experimental/constants.js new file mode 100644 index 0000000000..4b14fbade1 --- /dev/null +++ b/packages/botonic-react/src/experimental/constants.js @@ -0,0 +1,189 @@ +import BotonicLogo from '../assets/botonic_react_logo100x100.png' + +export const SENDERS = { + bot: 'bot', + user: 'user', +} + +export const COLORS = { + // http://chir.ag/projects/name-that-color + APPLE_GREEN: 'rgba(58, 156, 53, 1)', + BLEACHED_CEDAR_PURPLE: 'rgba(46, 32, 59, 1)', + BOTONIC_BLUE: 'rgba(0, 153, 255, 1)', + CACTUS_GREEN: 'rgba(96, 115, 94, 1)', + CONCRETE_WHITE: 'rgba(243, 243, 243, 1)', + CURIOUS_BLUE: 'rgba(38, 139, 210, 1)', + DAINTREE_BLUE: 'rgba(0, 43, 53, 1)', + ERROR_RED: 'rgba(255, 43, 94)', + FRINGY_FLOWER_GREEN: 'rgba(198, 231, 192, 1)', + GRAY: 'rgba(129, 129, 129, 1)', + LIGHT_GRAY: 'rgba(209, 209, 209, 1)', + MID_GRAY: 'rgba(105, 105, 115, 1)', + PIGEON_POST_BLUE_ALPHA_0_5: 'rgba(176, 196, 222, 0.5)', + SCORPION_GRAY: 'rgba(87, 87, 87, 1)', + SEASHELL_WHITE: 'rgba(241, 240, 240, 1)', + SILVER: 'rgba(200, 200, 200, 1)', + SOLID_BLACK_ALPHA_0_2: 'rgba(0, 0, 0, 0.2)', + SOLID_BLACK_ALPHA_0_5: 'rgba(0, 0, 0, 0.5)', + SOLID_BLACK: 'rgba(0, 0, 0, 1)', + SOLID_WHITE_ALPHA_0_2: 'rgba(255, 255, 255, 0.2)', + SOLID_WHITE_ALPHA_0_8: 'rgba(255, 255, 255, 0.8)', + SOLID_WHITE: 'rgba(255, 255, 255, 1)', + TASMAN_GRAY: 'rgba(209, 216, 207, 1)', + TRANSPARENT: 'rgba(0, 0, 0, 0)', + WILD_SAND_WHITE: 'rgba(244, 244, 244, 1)', +} + +export const WEBCHAT = { + DEFAULTS: { + WIDTH: 300, + HEIGHT: 450, + TITLE: 'Botonic', + LOGO: BotonicLogo, + PLACEHOLDER: 'Ask me something...', + FONT_FAMILY: "'Noto Sans JP', sans-serif", + BORDER_RADIUS_TOP_CORNERS: '6px 6px 0px 0px', + ELEMENT_WIDTH: 222, + ELEMENT_MARGIN_RIGHT: 6, + STORAGE_KEY: 'botonicState', + HOST_ID: 'root', + ID: 'botonic-webchat', + BUTTON_AUTO_DISABLE: false, + BUTTON_DISABLED_STYLE: { + opacity: 0.5, + cursor: 'auto', + pointerEvents: 'none', + }, + }, + SELECTORS: { + SCROLLABLE_CONTENT: '#botonic-scrollable-content', + SIMPLEBAR_CONTENT: '.simplebar-content', + SIMPLEBAR_WRAPPER: '.simplebar-content-wrapper', + }, + CUSTOM_PROPERTIES: { + // General + enableAnimations: 'animations.enable', + markdownStyle: 'markdownStyle', + scrollbar: 'scrollbar', + // Mobile + mobileBreakpoint: 'mobileBreakpoint', + mobileStyle: 'mobileStyle', + // Webviews + webviewHeaderStyle: 'webview.header.style', + webviewStyle: 'webview.style', + // Brand + brandColor: 'brand.color', + brandImage: 'brand.image', + // Header + customHeader: 'header.custom', + headerImage: 'header.image', + headerStyle: 'header.style', + headerSubtitle: 'header.subtitle', + headerTitle: 'header.title', + // Bot Message + botMessageBackground: 'message.bot.style.background', + botMessageBlobTick: 'message.bot.blobTick', + botMessageBlobTickStyle: 'message.bot.blobTickStyle', + botMessageBlobWidth: 'message.bot.blobWidth', + botMessageBorderColor: 'message.bot.style.borderColor', + botMessageImage: 'message.bot.image', + botMessageImageStyle: 'message.bot.imageStyle', + botMessageStyle: 'message.bot.style', + // User Message + customMessageTypes: 'message.customTypes', + messageStyle: 'message.style', + userMessageBackground: 'message.user.style.background', + userMessageBlobTick: 'message.user.blobTick', + userMessageBlobTickStyle: 'message.user.blobTickStyle', + userMessageBorderColor: 'message.user.style.borderColor', + userMessageStyle: 'message.user.style', + // Timestamps + enableMessageTimestamps: 'message.timestamps.enable', + messageTimestampsFormat: 'message.timestamps.format', + messageTimestampsStyle: 'message.timestamps.style', + // Intro + customIntro: 'intro.custom', + introImage: 'intro.image', + introStyle: 'intro.style', + // Buttons + buttonHoverBackground: 'button.hoverBackground', + buttonHoverTextColor: 'button.hoverTextColor', + buttonMessageType: 'button.messageType', + buttonStyle: 'button.style', + buttonDisabledStyle: 'button.disabledstyle', + buttonAutoDisable: 'button.autodisable', + buttonStyleBackground: 'button.style.background', + buttonStyleColor: 'button.style.color', + customButton: 'button.custom', + // Replies + alignReplies: 'replies.align', + customReply: 'reply.custom', + replyStyle: 'reply.style', + wrapReplies: 'replies.wrap', + // TriggerButton + customTrigger: 'triggerButton.custom', + triggerButtonImage: 'triggerButton.image', + triggerButtonStyle: 'triggerButton.style', + // User Input + blockInputs: 'userInput.blockInputs', + documentDownload: 'documentDownload', + customMenuButton: 'userInput.menuButton.custom', + customPersistentMenu: 'userInput.menu.custom', + customSendButton: 'userInput.sendButton.custom', + darkBackgroundMenu: 'userInput.menu.darkBackground', + enableAttachments: 'userInput.attachments.enable', + enableEmojiPicker: 'userInput.emojiPicker.enable', + enableSendButton: 'userInput.sendButton.enable', + enableUserInput: 'userInput.enable', + persistentMenu: 'userInput.persistentMenu', + textPlaceholder: 'userInput.box.placeholder', + userInputBoxStyle: 'userInput.box.style', + userInputStyle: 'userInput.style', + // Cover Component + coverComponent: 'coverComponent.component', + coverComponentProps: 'coverComponent.props', + // Carousel + customCarouselLeftArrow: 'carousel.arrow.left', + customCarouselRightArrow: 'carousel.arrow.right', + enableCarouselArrows: 'carousel.enableArrows', + }, +} + +export const MIME_WHITELIST = { + audio: ['audio/mpeg', 'audio/mp3'], + document: ['application/pdf'], + image: ['image/jpeg', 'image/png'], + video: ['video/mp4', 'video/quicktime'], +} + +export const MAX_ALLOWED_SIZE_MB = 10 + +export const ROLES = { + ATTACHMENT_ICON: 'attachment-icon', + EMOJI_PICKER_ICON: 'emoji-picker-icon', + EMOJI_PICKER: 'emoji-picker', + HEADER: 'header', + MESSAGE_LIST: 'message-list', + PERSISTENT_MENU_ICON: 'persistent-menu-icon', + PERSISTENT_MENU: 'persistent-menu', + SEND_BUTTON_ICON: 'send-button-icon', + WEBCHAT: 'webchat', + TRIGGER_BUTTON: 'trigger-button', + TYPING_INDICATOR: 'typing-indicator', + TEXT_BOX: 'textbox', + WEBVIEW: 'webview', + WEBVIEW_HEADER: 'webview-header', + MESSAGE: 'message', + IMAGE_MESSAGE: 'image-message', + AUDIO_MESSAGE: 'audio-message', + VIDEO_MESSAGE: 'video-message', + DOCUMENT_MESSAGE: 'document-message', + RAW_MESSAGE: 'raw-message', +} + +export const COMPONENT_TYPE = { + TEXT: 'Text', + BUTTON: 'Button', + REPLY: 'Reply', + CAROUSEL: 'Carousel', +} diff --git a/packages/botonic-react/src/experimental/contexts.jsx b/packages/botonic-react/src/experimental/contexts.jsx new file mode 100644 index 0000000000..f4a4f9b8df --- /dev/null +++ b/packages/botonic-react/src/experimental/contexts.jsx @@ -0,0 +1,36 @@ +import React from 'react' + +import { webchatInitialState } from './webchat/hooks' + +export const RequestContext = React.createContext({ + getString: () => '', + setLocale: () => '', + session: {}, + params: {}, + input: {}, + defaultDelay: 0, + defaultTyping: 0, +}) + +export const WebchatContext = React.createContext({ + sendText: text => {}, + sendAttachment: attachment => {}, + sendPayload: payload => {}, + sendInput: input => {}, + setReplies: replies => {}, + openWebview: webviewComponent => {}, + addMessage: message => {}, + updateMessage: message => {}, + updateReplies: replies => {}, + updateLatestInput: input => {}, + closeWebview: () => {}, + toggleWebchat: () => {}, + getThemeProperty: property => undefined, // used to retrieve a specific property of the theme defined by the developer in his 'webchat/index.js' + resolveCase: () => {}, + theme: {}, + webchatState: webchatInitialState, + updateWebchatDevSettings: settings => { + return {} + }, + updateUser: user => {}, +}) diff --git a/packages/botonic-react/src/experimental/dev-app.jsx b/packages/botonic-react/src/experimental/dev-app.jsx index 37bfe4da67..5b7ec22341 100644 --- a/packages/botonic-react/src/experimental/dev-app.jsx +++ b/packages/botonic-react/src/experimental/dev-app.jsx @@ -2,9 +2,9 @@ import merge from 'lodash.merge' import React from 'react' import { render } from 'react-dom' -import { SENDERS } from '../constants' -import { onDOMLoaded } from '../util/dom' +import { SENDERS } from './constants' import { ReactBot } from './react-bot' +import { onDOMLoaded } from './util/dom' import { WebchatDev } from './webchat/webchat-dev' import { WebchatApp } from './webchat-app' diff --git a/packages/botonic-react/src/experimental/msg-to-botonic.jsx b/packages/botonic-react/src/experimental/msg-to-botonic.jsx index b9d974f308..7d86ba958a 100644 --- a/packages/botonic-react/src/experimental/msg-to-botonic.jsx +++ b/packages/botonic-react/src/experimental/msg-to-botonic.jsx @@ -1,5 +1,12 @@ import React from 'react' +import { Button } from '../components/button' +import { ButtonsDisabler } from '../components/buttons-disabler' +import { Element } from '../components/element' +import { Pic } from '../components/pic' +import { Reply } from '../components/reply' +import { Subtitle } from '../components/subtitle' +import { Title } from '../components/title' import { isAudio, isCarousel, @@ -9,14 +16,7 @@ import { isLocation, isText, isVideo, -} from '../../src/message-utils' -import { Button } from '../components/button' -import { ButtonsDisabler } from '../components/buttons-disabler' -import { Element } from '../components/element' -import { Pic } from '../components/pic' -import { Reply } from '../components/reply' -import { Subtitle } from '../components/subtitle' -import { Title } from '../components/title' +} from '../message-utils' // Experimental import { Audio } from './components/audio' import { Carousel } from './components/carousel' diff --git a/packages/botonic-react/src/experimental/react-bot.jsx b/packages/botonic-react/src/experimental/react-bot.jsx index 1ca99f982f..4d0a3b85b4 100644 --- a/packages/botonic-react/src/experimental/react-bot.jsx +++ b/packages/botonic-react/src/experimental/react-bot.jsx @@ -1,8 +1,8 @@ import { CoreBot } from '@botonic/core' import React from 'react' -import { RequestContext } from '../contexts' import { Text } from './components/text' +import { RequestContext } from './contexts' export class ReactBot extends CoreBot { constructor(options) { diff --git a/packages/botonic-react/src/experimental/util/dom.js b/packages/botonic-react/src/experimental/util/dom.js new file mode 100644 index 0000000000..f7c6b0e8ed --- /dev/null +++ b/packages/botonic-react/src/experimental/util/dom.js @@ -0,0 +1,55 @@ +import { WEBCHAT } from '../constants' + +export const getScrollableArea = webchatElement => { + const getArea = area => { + const botonicScrollableContent = webchatElement.querySelector( + WEBCHAT.SELECTORS.SCROLLABLE_CONTENT + ) + const scrollableArea = + botonicScrollableContent && botonicScrollableContent.querySelector(area) + return scrollableArea + } + return { + full: getArea(WEBCHAT.SELECTORS.SIMPLEBAR_CONTENT), + visible: getArea(WEBCHAT.SELECTORS.SIMPLEBAR_WRAPPER), + } +} + +export const scrollToBottom = ({ + timeout = 200, + behavior = 'smooth', + host, +} = {}) => { + const webchatElement = getWebchatElement(host) + if (!webchatElement) return + const frame = getScrollableArea(webchatElement).visible + if (frame) { + setTimeout( + () => frame.scrollTo({ top: frame.scrollHeight, behavior: behavior }), + timeout + ) + } +} + +export const getWebchatElement = host => + host && host.querySelector(`#${WEBCHAT.DEFAULTS.ID}`) + +// https://stackoverflow.com/questions/9457891/how-to-detect-if-domcontentloaded-was-fired +export const onDOMLoaded = callback => { + if (/complete|interactive|loaded/.test(document.readyState)) { + // In case the document has finished parsing, document's readyState will + // be one of "complete", "interactive" or (non-standard) "loaded". + callback() + } else { + // The document is not ready yet, so wait for the DOMContentLoaded event + document.addEventListener('DOMContentLoaded', callback, false) + } +} + +export const isShadowDOMSupported = () => { + try { + return document.head.createShadowRoot || document.head.attachShadow + } catch (e) { + return false + } +} diff --git a/packages/botonic-react/src/experimental/webchat-app.jsx b/packages/botonic-react/src/experimental/webchat-app.jsx index ecdcdcabc3..c8356af188 100644 --- a/packages/botonic-react/src/experimental/webchat-app.jsx +++ b/packages/botonic-react/src/experimental/webchat-app.jsx @@ -3,9 +3,9 @@ import merge from 'lodash.merge' import React, { createRef } from 'react' import { render } from 'react-dom' -import { SENDERS, WEBCHAT } from '../constants' -import { isShadowDOMSupported, onDOMLoaded } from '../util/dom' +import { SENDERS, WEBCHAT } from './constants' import { msgToBotonic } from './msg-to-botonic' +import { isShadowDOMSupported, onDOMLoaded } from './util/dom' import { Webchat } from './webchat/webchat' export class WebchatApp { diff --git a/packages/botonic-react/src/experimental/webchat/actions.jsx b/packages/botonic-react/src/experimental/webchat/actions.jsx new file mode 100644 index 0000000000..2c11e7acc9 --- /dev/null +++ b/packages/botonic-react/src/experimental/webchat/actions.jsx @@ -0,0 +1,22 @@ +export const ADD_MESSAGE = 'addMessage' +export const ADD_MESSAGE_COMPONENT = 'addMessageComponent' +export const UPDATE_MESSAGE = 'updateMessage' +export const UPDATE_REPLIES = 'updateReplies' +export const UPDATE_LATEST_INPUT = 'updateLatestInput' +export const UPDATE_TYPING = 'updateTyping' +export const UPDATE_WEBVIEW = 'updateWebview' +export const UPDATE_SESSION = 'updateSession' +export const UPDATE_LAST_ROUTE_PATH = 'updateLastRoutePath' +export const UPDATE_HANDOFF = 'updateHandoff' +export const UPDATE_THEME = 'updateTheme' +export const UPDATE_DEV_SETTINGS = 'updateDevSettings' +export const TOGGLE_WEBCHAT = 'toggleWebchat' +export const TOGGLE_EMOJI_PICKER = 'toggleEmojiPicker' +export const TOGGLE_PERSISTENT_MENU = 'togglePersistentMenu' +export const TOGGLE_COVER_COMPONENT = 'toggleCoverComponent' +export const SET_ERROR = 'setError' +export const CLEAR_MESSAGES = 'clearMessages' +export const UPDATE_LAST_MESSAGE_DATE = 'updateLastMessageDate' +export const SET_CURRENT_ATTACHMENT = 'setCurrentAttachment' +export const SET_ONLINE = 'setOnline' +export const UPDATE_JWT = 'updateJwt' diff --git a/packages/botonic-react/src/experimental/webchat/hooks.js b/packages/botonic-react/src/experimental/webchat/hooks.js new file mode 100644 index 0000000000..ea0d5067d8 --- /dev/null +++ b/packages/botonic-react/src/experimental/webchat/hooks.js @@ -0,0 +1,258 @@ +import { useEffect, useMemo, useReducer, useRef, useState } from 'react' + +import { COLORS, WEBCHAT } from '../constants' +import { scrollToBottom } from '../util/dom' +import { + ADD_MESSAGE, + ADD_MESSAGE_COMPONENT, + CLEAR_MESSAGES, + SET_CURRENT_ATTACHMENT, + SET_ERROR, + SET_ONLINE, + TOGGLE_COVER_COMPONENT, + TOGGLE_EMOJI_PICKER, + TOGGLE_PERSISTENT_MENU, + TOGGLE_WEBCHAT, + UPDATE_DEV_SETTINGS, + UPDATE_HANDOFF, + UPDATE_JWT, + UPDATE_LAST_MESSAGE_DATE, + UPDATE_LAST_ROUTE_PATH, + UPDATE_LATEST_INPUT, + UPDATE_MESSAGE, + UPDATE_REPLIES, + UPDATE_SESSION, + UPDATE_THEME, + UPDATE_TYPING, + UPDATE_WEBVIEW, +} from './actions' +import { webchatReducer } from './webchat-reducer' + +export const webchatInitialState = { + width: WEBCHAT.DEFAULTS.WIDTH, + height: WEBCHAT.DEFAULTS.HEIGHT, + messagesJSON: [], + messagesComponents: [], + replies: [], + latestInput: {}, + typing: false, + webview: null, + webviewParams: null, + session: { user: null }, + lastRoutePath: null, + handoff: false, + theme: { + headerTitle: WEBCHAT.DEFAULTS.TITLE, + brandColor: COLORS.BOTONIC_BLUE, + brandImage: WEBCHAT.DEFAULTS.LOGO, + triggerButtonImage: undefined, + textPlaceholder: WEBCHAT.DEFAULTS.PLACEHOLDER, + style: { + fontFamily: WEBCHAT.DEFAULTS.FONT_FAMILY, + }, + }, + themeUpdates: {}, + error: {}, + online: true, + devSettings: { keepSessionOnReload: false }, + isWebchatOpen: false, + isEmojiPickerOpen: false, + isPersistentMenuOpen: false, + isCoverComponentOpen: false, + lastMessageUpdate: undefined, + currentAttachment: undefined, + jwt: null, +} + +export function useWebchat() { + const [webchatState, webchatDispatch] = useReducer( + webchatReducer, + webchatInitialState + ) + + const addMessage = message => + webchatDispatch({ type: ADD_MESSAGE, payload: message }) + const addMessageComponent = message => + webchatDispatch({ type: ADD_MESSAGE_COMPONENT, payload: message }) + const updateMessage = message => + webchatDispatch({ type: UPDATE_MESSAGE, payload: message }) + const updateReplies = replies => + webchatDispatch({ type: UPDATE_REPLIES, payload: replies }) + const updateLatestInput = input => + webchatDispatch({ type: UPDATE_LATEST_INPUT, payload: input }) + const updateTyping = typing => + webchatDispatch({ type: UPDATE_TYPING, payload: typing }) + const updateWebview = (webview, params) => + webchatDispatch({ + type: UPDATE_WEBVIEW, + payload: { webview, webviewParams: params }, + }) + const updateSession = session => { + webchatDispatch({ + type: UPDATE_SESSION, + payload: session, + }) + } + + const updateLastRoutePath = path => + webchatDispatch({ + type: UPDATE_LAST_ROUTE_PATH, + payload: path, + }) + const updateHandoff = handoff => + webchatDispatch({ + type: UPDATE_HANDOFF, + payload: handoff, + }) + const updateTheme = (theme, themeUpdates = undefined) => { + const payload = + themeUpdates !== undefined ? { theme, themeUpdates } : { theme } + webchatDispatch({ + type: UPDATE_THEME, + payload, + }) + } + const updateDevSettings = settings => + webchatDispatch({ + type: UPDATE_DEV_SETTINGS, + payload: settings, + }) + const toggleWebchat = toggle => + webchatDispatch({ + type: TOGGLE_WEBCHAT, + payload: toggle, + }) + const toggleEmojiPicker = toggle => + webchatDispatch({ + type: TOGGLE_EMOJI_PICKER, + payload: toggle, + }) + const togglePersistentMenu = toggle => + webchatDispatch({ + type: TOGGLE_PERSISTENT_MENU, + payload: toggle, + }) + const toggleCoverComponent = toggle => + webchatDispatch({ + type: TOGGLE_COVER_COMPONENT, + payload: toggle, + }) + const setError = error => + webchatDispatch({ + type: SET_ERROR, + payload: error, + }) + const setOnline = online => + webchatDispatch({ + type: SET_ONLINE, + payload: online, + }) + + const clearMessages = () => { + webchatDispatch({ + type: CLEAR_MESSAGES, + }) + } + const updateLastMessageDate = date => { + webchatDispatch({ + type: UPDATE_LAST_MESSAGE_DATE, + payload: date, + }) + } + const setCurrentAttachment = attachment => { + webchatDispatch({ + type: SET_CURRENT_ATTACHMENT, + payload: attachment, + }) + } + + const updateJwt = jwt => { + webchatDispatch({ + type: UPDATE_JWT, + payload: jwt, + }) + } + + return { + webchatState, + webchatDispatch, + addMessage, + addMessageComponent, + updateMessage, + updateReplies, + updateLatestInput, + updateTyping, + updateWebview, + updateSession, + updateLastRoutePath, + updateHandoff, + updateTheme, + updateDevSettings, + toggleWebchat, + toggleEmojiPicker, + togglePersistentMenu, + toggleCoverComponent, + setError, + setOnline, + clearMessages, + updateLastMessageDate, + setCurrentAttachment, + updateJwt, + } +} + +export function useTyping({ webchatState, updateTyping, updateMessage, host }) { + useEffect(() => { + let delayTimeout, typingTimeout + scrollToBottom({ host }) + try { + const nextMsg = webchatState.messagesJSON.filter(m => !m.display)[0] + if (nextMsg.delay && nextMsg.typing) { + delayTimeout = setTimeout( + () => updateTyping(true), + nextMsg.delay * 1000 + ) + } else if (nextMsg.typing) updateTyping(true) + const totalDelay = nextMsg.delay + nextMsg.typing + if (totalDelay) + typingTimeout = setTimeout(() => { + updateMessage({ ...nextMsg, display: true }) + updateTyping(false) + }, totalDelay * 1000) + } catch (e) {} + return () => { + clearTimeout(delayTimeout) + clearTimeout(typingTimeout) + } + }, [webchatState.messagesJSON, webchatState.typing]) +} + +export function usePrevious(value) { + const ref = useRef() + useEffect(() => { + ref.current = value + }) + return ref.current +} + +export function useComponentVisible(initialIsVisible, onClickOutside) { + const [isComponentVisible, setIsComponentVisible] = useState(initialIsVisible) + const ref = useRef(null) + const handleClickOutside = event => { + if (ref.current && !ref.current.contains(event.target)) { + setIsComponentVisible(false) + onClickOutside() + } + } + useEffect(() => { + document.addEventListener('click', handleClickOutside, false) + return () => { + document.removeEventListener('click', handleClickOutside, false) + } + }) + return { ref, isComponentVisible, setIsComponentVisible } +} + +export const useComponentWillMount = func => { + useMemo(func, []) +} diff --git a/packages/botonic-react/src/experimental/webchat/messages-reducer.js b/packages/botonic-react/src/experimental/webchat/messages-reducer.js new file mode 100644 index 0000000000..2fbb6c06d9 --- /dev/null +++ b/packages/botonic-react/src/experimental/webchat/messages-reducer.js @@ -0,0 +1,86 @@ +import { + ADD_MESSAGE, + ADD_MESSAGE_COMPONENT, + CLEAR_MESSAGES, + UPDATE_LAST_MESSAGE_DATE, + UPDATE_MESSAGE, + UPDATE_REPLIES, +} from './actions' + +export const messagesReducer = (state, action) => { + switch (action.type) { + case ADD_MESSAGE: + return addMessageReducer(state, action) + case ADD_MESSAGE_COMPONENT: + return { + ...state, + messagesComponents: [ + ...(state.messagesComponents || []), + action.payload, + ], + } + case UPDATE_MESSAGE: + return updateMessageReducer(state, action) + case UPDATE_REPLIES: + return { ...state, replies: action.payload } + case CLEAR_MESSAGES: + return { + ...state, + messagesJSON: [], + messagesComponents: [], + } + case UPDATE_LAST_MESSAGE_DATE: + return { + ...state, + lastMessageUpdate: action.payload, + } + default: + throw new Error() + } +} + +function updateMessageReducer(state, action) { + const msgIndex = state.messagesJSON.map(m => m.id).indexOf(action.payload.id) + if (msgIndex > -1) { + const msgComponent = state.messagesComponents[msgIndex] + let updatedMessageComponents = {} + if (msgComponent) { + const updatedMsgComponent = { + ...msgComponent, + ...{ + props: { ...msgComponent.props, ack: action.payload.ack }, + }, + } + updatedMessageComponents = { + messagesComponents: [ + ...state.messagesComponents.slice(0, msgIndex), + { ...updatedMsgComponent }, + ...state.messagesComponents.slice(msgIndex + 1), + ], + } + } + return { + ...state, + messagesJSON: [ + ...state.messagesJSON.slice(0, msgIndex), + { ...action.payload }, + ...state.messagesJSON.slice(msgIndex + 1), + ], + ...updatedMessageComponents, + } + } + + return state +} + +function addMessageReducer(state, action) { + if ( + state.messagesJSON && + state.messagesJSON.find(m => m.id === action.payload.id) + ) + return state + return { + ...state, + messagesJSON: [...(state.messagesJSON || []), action.payload], + } +} diff --git a/packages/botonic-react/src/experimental/webchat/session-view.jsx b/packages/botonic-react/src/experimental/webchat/session-view.jsx new file mode 100644 index 0000000000..85d254b703 --- /dev/null +++ b/packages/botonic-react/src/experimental/webchat/session-view.jsx @@ -0,0 +1,160 @@ +import React from 'react' +import JSONTree from 'react-json-tree' +import styled from 'styled-components' + +import { COLORS } from '../constants' +import { useWebchat } from './hooks' + +const AttributeContainer = styled.div` + display: flex; + flex: none; + padding: 12px; + padding-bottom: 0px; + font-size: 12px; + font-weight: 600; + color: ${COLORS.SOLID_WHITE}; + align-items: center; +` + +const Label = styled.div` + flex: none; +` + +const Value = styled.div` + flex: 1 1 auto; + max-height: 20px; + font-size: 16px; + font-weight: 400; + margin-left: 6px; + color: ${COLORS.CURIOUS_BLUE}; + overflow-x: hidden; +` + +const SessionViewAttribute = props => ( + + + {props.value} + +) + +const SessionViewContent = styled.div` + position: relative; + width: ${props => (props.show ? '100%' : '0%')}; + height: 100%; + display: flex; + background-color: ${COLORS.DAINTREE_BLUE}; + font-family: Arial, Helvetica, sans-serif; + flex-direction: column; + z-index: 100000; + transition: all 0.2s ease-in-out; +` + +const ToggleTab = styled.div` + position: absolute; + top: 10px; + right: -32px; + width: 32px; + height: 32px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: ${COLORS.SOLID_WHITE_ALPHA_0_8}; + font-size: 14px; + font-weight: 600; + background-color: ${COLORS.DAINTREE_BLUE}; + flex-direction: column; + z-index: 100000; + border-top-right-radius: 6px; + border-bottom-right-radius: 6px; +` + +const ContentContainer = styled.div` + overflow-y: auto; + overflow-x: auto; + display: flex; + flex: 1 1 auto; + flex-direction: column; +` + +const Title = styled.div` + padding: 12px; + text-align: center; + color: ${COLORS.SOLID_WHITE}; + border-bottom: 1px solid ${COLORS.SOLID_WHITE_ALPHA_0_2}; +` + +const SessionContainer = styled.div` + flex: 1 1 auto; + height: 100%; + overflow-y: auto; +` + +const KeepSessionContainer = styled.div` + flex: none; + padding: 12px; + color: ${COLORS.SOLID_WHITE_ALPHA_0_8}; + font-size: 12px; +` + +export const SessionView = props => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const { webchatState, updateDevSettings } = props.webchatHooks || useWebchat() + const { latestInput: input, session, lastRoutePath } = webchatState + const toggleSessionView = () => + updateDevSettings({ + ...webchatState.devSettings, + showSessionView: !webchatState.devSettings.showSessionView, + }) + const toggleKeepSessionOnReload = () => + updateDevSettings({ + ...webchatState.devSettings, + keepSessionOnReload: !webchatState.devSettings.keepSessionOnReload, + }) + return ( + + + {webchatState.devSettings.showSessionView ? '⇤' : '⇥'} + + + Botonic Dev Console + + + + + + + + + + + + + + ) +} diff --git a/packages/botonic-react/src/experimental/webchat/webchat-dev.jsx b/packages/botonic-react/src/experimental/webchat/webchat-dev.jsx index 2d591ad7c2..57c4b20d37 100644 --- a/packages/botonic-react/src/experimental/webchat/webchat-dev.jsx +++ b/packages/botonic-react/src/experimental/webchat/webchat-dev.jsx @@ -5,7 +5,6 @@ import React, { forwardRef, useEffect, useState } from 'react' import { createPortal } from 'react-dom' import styled from 'styled-components' -import { useWebchat } from '../../webchat/hooks' import { SessionView } from '../../webchat/session-view' import MessengerLogo from './assets/messenger.svg' import Open from './assets/open.svg' @@ -13,6 +12,7 @@ import OpenNewWindow from './assets/open-new-window.svg' import TelegramLogo from './assets/telegram.svg' import WebchatLogo from './assets/webchat.svg' import WhatsappLogo from './assets/whatsapp.svg' +import { useWebchat } from './hooks' import { Webchat } from './webchat' export const DebugTab = styled.div` diff --git a/packages/botonic-react/src/experimental/webchat/webchat-reducer.js b/packages/botonic-react/src/experimental/webchat/webchat-reducer.js new file mode 100644 index 0000000000..06e9a0e4d4 --- /dev/null +++ b/packages/botonic-react/src/experimental/webchat/webchat-reducer.js @@ -0,0 +1,61 @@ +import { + SET_CURRENT_ATTACHMENT, + SET_ERROR, + SET_ONLINE, + TOGGLE_COVER_COMPONENT, + TOGGLE_EMOJI_PICKER, + TOGGLE_PERSISTENT_MENU, + TOGGLE_WEBCHAT, + UPDATE_DEV_SETTINGS, + UPDATE_HANDOFF, + UPDATE_JWT, + UPDATE_LAST_ROUTE_PATH, + UPDATE_LATEST_INPUT, + UPDATE_SESSION, + UPDATE_THEME, + UPDATE_TYPING, + UPDATE_WEBVIEW, +} from './actions' +import { messagesReducer } from './messages-reducer' + +export function webchatReducer(state, action) { + switch (action.type) { + case UPDATE_WEBVIEW: + return { ...state, ...action.payload } + case UPDATE_SESSION: + return { ...state, session: { ...action.payload } } + case UPDATE_TYPING: + return { ...state, typing: action.payload } + case UPDATE_THEME: + return { + ...state, + ...action.payload, + } + case UPDATE_HANDOFF: + return { ...state, handoff: action.payload } + case TOGGLE_WEBCHAT: + return { ...state, isWebchatOpen: action.payload } + case TOGGLE_EMOJI_PICKER: + return { ...state, isEmojiPickerOpen: action.payload } + case TOGGLE_PERSISTENT_MENU: + return { ...state, isPersistentMenuOpen: action.payload } + case TOGGLE_COVER_COMPONENT: + return { ...state, isCoverComponentOpen: action.payload } + case SET_ERROR: + return { ...state, error: action.payload || {} } + case SET_ONLINE: + return { ...state, online: action.payload } + case UPDATE_DEV_SETTINGS: + return { ...state, devSettings: { ...action.payload } } + case UPDATE_LATEST_INPUT: + return { ...state, latestInput: action.payload } + case UPDATE_LAST_ROUTE_PATH: + return { ...state, lastRoutePath: action.payload } + case SET_CURRENT_ATTACHMENT: + return { ...state, currentAttachment: action.payload } + case UPDATE_JWT: + return { ...state, jwt: action.payload } + default: + return messagesReducer(state, action) + } +} diff --git a/packages/botonic-react/src/experimental/webchat/webchat.jsx b/packages/botonic-react/src/experimental/webchat/webchat.jsx index 31385fdeff..9694184206 100644 --- a/packages/botonic-react/src/experimental/webchat/webchat.jsx +++ b/packages/botonic-react/src/experimental/webchat/webchat.jsx @@ -230,6 +230,8 @@ export const Webchat = forwardRef((props, ref) => { stringifyWithRegexs({ messages: webchatState.messagesJSON, session: webchatState.session, + botState: webchatState.botState, + user: webchatState.user, lastRoutePath: webchatState.lastRoutePath, devSettings: webchatState.devSettings, lastMessageUpdate: webchatState.lastMessageUpdate,