From 73d5359f8ef5e7c38ff0e2eb2ac7a875b09d4a1d Mon Sep 17 00:00:00 2001 From: Oriol Raventos Date: Fri, 13 Dec 2024 10:46:00 +0100 Subject: [PATCH 1/4] refactor(botonic-react): change extension to tsx --- packages/botonic-react/src/webchat/{webchat.jsx => webchat.tsx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/botonic-react/src/webchat/{webchat.jsx => webchat.tsx} (100%) diff --git a/packages/botonic-react/src/webchat/webchat.jsx b/packages/botonic-react/src/webchat/webchat.tsx similarity index 100% rename from packages/botonic-react/src/webchat/webchat.jsx rename to packages/botonic-react/src/webchat/webchat.tsx From 93ca993422c80cc892e777e2f6b726be84cfb7da Mon Sep 17 00:00:00 2001 From: Oriol Raventos Date: Wed, 18 Dec 2024 13:04:47 +0100 Subject: [PATCH 2/4] refactor(botonic-react): group the types in index-types files --- .../src/components/index-types.ts | 10 +- packages/botonic-react/src/index-types.ts | 112 ++++++++++++--- packages/botonic-react/src/index.ts | 2 +- packages/botonic-react/src/msg-to-botonic.jsx | 6 +- packages/botonic-react/src/webchat-app.tsx | 131 ++++++------------ .../botonic-react/src/webchat/index-types.ts | 13 +- 6 files changed, 149 insertions(+), 125 deletions(-) diff --git a/packages/botonic-react/src/components/index-types.ts b/packages/botonic-react/src/components/index-types.ts index 32d7c89477..d1962e6053 100644 --- a/packages/botonic-react/src/components/index-types.ts +++ b/packages/botonic-react/src/components/index-types.ts @@ -103,6 +103,12 @@ export interface BlobProps { imageStyle?: any } +export interface CustomMessageType { + (props: any): JSX.Element + customTypeName: string + deserialize(msg: any): JSX.Element +} + export interface ThemeProps extends StyleProp { coverComponent?: CoverComponentOptions mobileBreakpoint?: number @@ -115,13 +121,13 @@ export interface ThemeProps extends StyleProp { StyleProp & CustomProp // TODO: Review if this is needed hear, or only in message.customTypes? use the same type in both places - customMessageTypes?: React.ComponentType[] + customMessageTypes?: CustomMessageType[] message?: { bot?: BlobProps & ImageProp & StyleProp agent?: ImageProp user?: BlobProps & StyleProp // TODO: Review type used in cutomTypes should be a component exported by default with customMessage function - customTypes?: React.ComponentType[] + customTypes?: CustomMessageType[] } & StyleProp & { timestamps?: { withImage?: boolean diff --git a/packages/botonic-react/src/index-types.ts b/packages/botonic-react/src/index-types.ts index 445fb7981f..975f5b51d9 100644 --- a/packages/botonic-react/src/index-types.ts +++ b/packages/botonic-react/src/index-types.ts @@ -5,6 +5,7 @@ import { Plugin as CorePlugin, Route as CoreRoute, Routes as CoreRoutes, + ServerConfig, Session as CoreSession, SessionUser as CoreSessionUser, } from '@botonic/core' @@ -20,6 +21,8 @@ import { WebchatSettingsProps, Webview, } from './components/index-types' +import { CloseWebviewOptions } from './contexts' +import { UseWebchat } from './webchat/hooks/use-webchat' import { WebchatState } from './webchat/index-types' import { WebchatApp } from './webchat-app' @@ -51,32 +54,91 @@ export interface RequestContextInterface extends ActionRequest { setLocale: (locale: string) => string } -export interface CustomMessageType { - customTypeName: string +export interface WebchatRef { + addBotResponse: ({ + response, + session, + lastRoutePath, + }: AddBotResponseArgs) => void + setTyping: (typing: boolean) => void + addUserMessage: (message: any) => Promise + updateUser: (userToUpdate: any) => void + openWebchat: () => void + closeWebchat: () => void + toggleWebchat: () => void + openCoverComponent: () => void + closeCoverComponent: () => void + toggleCoverComponent: () => void + renderCustomComponent: (customComponent: any) => void + unmountCustomComponent: () => void + isOnline: () => boolean + setOnline: (online: boolean) => void + getMessages: () => { id: string; ack: number; unsentInput: CoreInput }[] // TODO: define MessagesJSON + clearMessages: () => void + getLastMessageUpdate: () => string | undefined + updateMessageInfo: (msgId: string, messageInfo: any) => void + updateWebchatSettings: (settings: WebchatSettingsProps) => void + closeWebview: (options?: CloseWebviewOptions) => Promise +} + +interface AddBotResponseArgs { + response: any + session?: any + lastRoutePath?: any } export interface WebchatArgs { - blockInputs?: BlockInputOption[] + theme?: ThemeProps + persistentMenu?: PersistentMenuTheme coverComponent?: CoverComponentOptions - defaultDelay?: number - defaultTyping?: number - enableAnimations?: boolean - enableAttachments?: boolean + blockInputs?: BlockInputOption[] enableEmojiPicker?: boolean + enableAttachments?: boolean enableUserInput?: boolean - shadowDOM?: boolean | (() => boolean) + enableAnimations?: boolean hostId?: string - getString?: (stringId: string, session: CoreSession) => string - onClose?: (app: WebchatApp, args: any) => void + shadowDOM?: boolean | (() => boolean) + defaultDelay?: number + defaultTyping?: number + storage?: Storage | null + storageKey?: string onInit?: (app: WebchatApp, args: any) => void - onMessage?: (app: WebchatApp, message: WebchatMessage) => void onOpen?: (app: WebchatApp, args: any) => void - onConnectionChange?: (app: WebchatApp, isOnline: boolean) => void + onClose?: (app: WebchatApp, args: any) => void + onMessage?: (app: WebchatApp, message: WebchatMessage) => void onTrackEvent?: TrackEventFunction + onConnectionChange?: (app: WebchatApp, isOnline: boolean) => void + appId?: string + visibility?: boolean | (() => boolean) | 'dynamic' + server?: ServerConfig +} + +export interface WebchatProps { + webchatHooks?: UseWebchat + initialSession?: any + initialDevSettings?: any + onStateChange: (args: OnStateChangeArgs) => void + + shadowDOM?: any + theme?: ThemeProps persistentMenu?: PersistentMenuTheme + coverComponent?: any + blockInputs?: any + enableEmojiPicker?: boolean + enableAttachments?: boolean + enableUserInput?: boolean + enableAnimations?: boolean storage?: Storage | null - storageKey?: any - theme?: ThemeProps + storageKey?: string | (() => string) + defaultDelay?: number + defaultTyping?: number + onInit?: (args?: any) => void + onOpen?: (args?: any) => void + onClose?: (args?: any) => void + onUserInput(args: OnUserInputArgs): Promise // TODO: Review this function and params types + onTrackEvent?: TrackEventFunction + host?: any + server?: ServerConfig } export type EventArgs = { [key: string]: any } @@ -123,7 +185,7 @@ export interface OnUserInputArgs { input: CoreInput lastRoutePath?: string session?: CoreSession - user: CoreSessionUser + user?: CoreSessionUser } export interface OnStateChangeArgs { @@ -137,18 +199,26 @@ export interface MessageInfo { type: 'update_webchat_settings' | 'sender_action' } -export interface Event { - action?: 'update_message_info' +export type Event = ConnectionChangeEvent | UpdateMessageInfoEvent + +interface ConnectionChangeEvent { + action: 'connectionChange' + online: boolean isError?: boolean message?: MessageInfo } +interface UpdateMessageInfoEvent { + action: 'update_message_info' + message: MessageInfo + isError?: boolean +} + // ClientInput: type for sendInput and updateLatestInput function without message_id and bot_interaction_id because backend set this values export type ClientInput = Omit export interface WebchatContextProps { addMessage: (message: WebchatMessage) => void - closeWebview: () => Promise getThemeProperty: (property: string, defaultValue?: any) => any openWebview: (webviewComponent: Webview, params?: any) => void resetUnreadMessages: () => void @@ -159,7 +229,7 @@ export interface WebchatContextProps { sendText: (text: string, payload?: string) => Promise setIsInputFocused: (isInputFocused: boolean) => void setLastMessageVisible: (isLastMessageVisible: boolean) => void - theme: ThemeProps + theme: ThemeProps // TODO: Review if theme is needed and used from WebchatContext toggleWebchat: (toggle: boolean) => void toggleEmojiPicker: (toggle: boolean) => void togglePersistentMenu: (toggle: boolean) => void @@ -169,9 +239,9 @@ export interface WebchatContextProps { updateReplies: (replies: boolean) => void updateUser: (user: Partial) => void updateWebchatDevSettings: (settings: WebchatSettingsProps) => void + trackEvent?: TrackEventFunction webchatState: WebchatState - trackEvent: TrackEventFunction - webchatRef: React.MutableRefObject + webchatRef: React.MutableRefObject // Rename this to webchatContainerRef chatAreaRef: React.MutableRefObject inputPanelRef: React.MutableRefObject headerRef: React.MutableRefObject diff --git a/packages/botonic-react/src/index.ts b/packages/botonic-react/src/index.ts index 4550ab5ba3..45c6545794 100644 --- a/packages/botonic-react/src/index.ts +++ b/packages/botonic-react/src/index.ts @@ -11,5 +11,5 @@ export { msgsToBotonic, msgToBotonic } from './msg-to-botonic' export { NodeApp } from './node-app' export * from './util' export * from './webchat' -export { WebchatApp, WebchatAppProps } from './webchat-app' +export { WebchatApp } from './webchat-app' export { WebviewApp } from './webview-app' diff --git a/packages/botonic-react/src/msg-to-botonic.jsx b/packages/botonic-react/src/msg-to-botonic.jsx index 1ce4b4a7e7..6bf0164a10 100644 --- a/packages/botonic-react/src/msg-to-botonic.jsx +++ b/packages/botonic-react/src/msg-to-botonic.jsx @@ -32,7 +32,7 @@ import { * @param customMessageTypes {{customTypeName}[]?} * @return {React.ReactNode} */ -export function msgToBotonic(msg, customMessageTypes) { +export function msgToBotonic(msg, customMessageTypes = []) { delete msg.display if (isCustom(msg)) { try { @@ -99,10 +99,6 @@ export function msgToBotonic(msg, customMessageTypes) { return null } -function rndStr() { - return Math.random().toString() -} - /** * @param msgs {object|object[]} * @param customMessageTypes {{customTypeName}[]?} diff --git a/packages/botonic-react/src/webchat-app.tsx b/packages/botonic-react/src/webchat-app.tsx index f7528cb659..96f0bc73f1 100644 --- a/packages/botonic-react/src/webchat-app.tsx +++ b/packages/botonic-react/src/webchat-app.tsx @@ -1,4 +1,4 @@ -import { HubtypeService, INPUT, Input, ServerConfig } from '@botonic/core' +import { HubtypeService, INPUT, ServerConfig } from '@botonic/core' import merge from 'lodash.merge' import React, { createRef } from 'react' import { createRoot, Root } from 'react-dom/client' @@ -14,81 +14,20 @@ import { WEBCHAT } from './constants' import { CloseWebviewOptions } from './contexts' import { ActionRequest, + Event, EventArgs, + OnStateChangeArgs, + OnUserInputArgs, SENDERS, Typing, + WebchatArgs, WebchatMessage, + WebchatRef, } from './index-types' import { msgToBotonic } from './msg-to-botonic' import { isShadowDOMSupported, onDOMLoaded } from './util/dom' -import { ErrorMessage } from './webchat/index-types' import { Webchat } from './webchat/webchat' -export interface WebchatAppProps { - 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: WebchatApp, args: any) => void - onOpen?: (app: WebchatApp, args: any) => void - onClose?: (app: WebchatApp, args: any) => void - onMessage?: (app: WebchatApp, message: WebchatMessage) => void - onTrackEvent?: ( - request: ActionRequest, - eventName: string, - args?: EventArgs - ) => Promise - onConnectionChange?: (app: WebchatApp, isOnline: boolean) => void - appId?: string - visibility?: boolean | (() => boolean) | 'dynamic' - server?: ServerConfig -} - -interface WebchatRef { - addBotResponse: ({ - response, - session, - lastRoutePath, - }: AddBotResponseArgs) => void - setTyping: (typing: boolean) => void - addUserMessage: (message: any) => Promise - updateUser: (userToUpdate: any) => void - openWebchat: () => void - closeWebchat: () => void - toggleWebchat: () => void - openCoverComponent: () => void - closeCoverComponent: () => void - renderCustomComponent: (customComponent: any) => void - unmountCustomComponent: () => void - toggleCoverComponent: () => void - openWebviewApi: (component: any) => void - setError: (error: ErrorMessage) => void - setOnline: (online: boolean) => void - getMessages: () => { id: string; ack: number; unsentInput: Input }[] // TODO: define MessagesJSON - isOnline: () => boolean - clearMessages: () => void - getLastMessageUpdate: () => string - updateMessageInfo: (msgId: string, messageInfo: any) => void - updateWebchatSettings: (settings: WebchatSettingsProps) => void - closeWebview: (options?: CloseWebviewOptions) => Promise -} - -interface AddBotResponseArgs { - response: any - session?: any - lastRoutePath?: any -} - export class WebchatApp { public theme?: ThemeProps public persistentMenu?: PersistentMenuTheme @@ -147,7 +86,7 @@ export class WebchatApp { appId, visibility, server, - }: WebchatAppProps) { + }: WebchatArgs) { this.theme = theme this.persistentMenu = persistentMenu this.coverComponent = coverComponent @@ -163,7 +102,7 @@ export class WebchatApp { 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 @@ -248,21 +187,35 @@ export class WebchatApp { this.onClose && this.onClose(this, ...args) } - async onUserInput({ user, input }) { + async onUserInput({ user, input }: OnUserInputArgs): Promise { + if (!user) return + this.onMessage && this.onMessage(this, { ...input, sentBy: SENDERS.user, isUnread: false, - }) - return this.hubtypeService.postMessage(user, input) + } as unknown as WebchatMessage) + this.hubtypeService.postMessage(user, input) + return + } + + async onTrackEventWebchat( + request: ActionRequest, + eventName: string, + args?: EventArgs + ): Promise { + if (this.onTrackEvent) { + await this.onTrackEvent(request, eventName, args) + } } async onConnectionRegained() { return this.hubtypeService.onConnectionRegained() } - onStateChange({ session: { user }, messagesJSON }) { + onStateChange(args: OnStateChangeArgs) { + const { user, messagesJSON } = args const lastMessage = messagesJSON[messagesJSON.length - 1] const lastMessageId = lastMessage && lastMessage.id const lastMessageUpdateDate = this.getLastMessageUpdate() @@ -272,7 +225,7 @@ export class WebchatApp { this.hubtypeService.lastMessageUpdateDate = lastMessageUpdateDate } - if (!this.hubtypeService && user) { + if (!this.hubtypeService) { this.hubtypeService = new HubtypeService({ appId: this.appId!, user, @@ -288,22 +241,26 @@ export class WebchatApp { } } - onServiceEvent(event: any) { + onServiceEvent(event: 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') { + } else if (event.action === 'update_message_info' && event.message?.id) { 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 { + // TODO: onMessage function should receive a WebchatMessage + // and message.type is typed as enum of INPUT + // INPUT not contain 'update_webchat_settings' or 'sender_action' + // so we need to cast it to unknown to avoid type error this.onMessage && this.onMessage(this, { sentBy: SENDERS.bot, ...event.message, - } as WebchatMessage) + } as unknown as WebchatMessage) this.addBotMessage(event.message) } } @@ -320,8 +277,7 @@ export class WebchatApp { const response = msgToBotonic( message, // TODO: Review if is neded allow declar customTypes inside and ouside theme - // @ts-ignore - this.theme?.message?.customTypes || this.theme?.customMessageTypes || [] + this.theme?.message?.customTypes || this.theme?.customMessageTypes ) this.webchatRef.current?.addBotResponse({ @@ -415,7 +371,7 @@ export class WebchatApp { } // eslint-disable-next-line complexity - getComponent(host: HTMLDivElement, optionsAtRuntime: WebchatAppProps = {}) { + getComponent(host: HTMLDivElement, optionsAtRuntime: WebchatArgs = {}) { let { theme = {}, persistentMenu, @@ -469,7 +425,6 @@ export class WebchatApp { 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)} + onUserInput={(...args: [any]) => this.onUserInput(...args)} // TODO: Review this function, and his params + onStateChange={(args: OnStateChangeArgs) => { + this.onStateChange(args) + }} onTrackEvent={( request: ActionRequest, eventName: string, args?: EventArgs - ) => this.onTrackEvent && this.onTrackEvent(request, eventName, args)} + ) => this.onTrackEventWebchat(request, eventName, args)} //TODO: Review if this implementation is correct server={server} /> ) @@ -513,7 +470,7 @@ export class WebchatApp { } async resolveWebchatVisibility( - optionsAtRuntime?: WebchatAppProps + optionsAtRuntime?: WebchatArgs ): Promise { if (!optionsAtRuntime) { // If optionsAtRuntime is not provided, always render the webchat @@ -548,13 +505,13 @@ export class WebchatApp { if (this.storage) this.storage.removeItem(this.storageKey) } - async render(dest: HTMLDivElement, optionsAtRuntime?: WebchatAppProps) { + async render(dest: HTMLDivElement, optionsAtRuntime?: WebchatArgs) { 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 = createRoot(container) this.reactRoot.render(webchatComponent) } }) diff --git a/packages/botonic-react/src/webchat/index-types.ts b/packages/botonic-react/src/webchat/index-types.ts index 91f96b1deb..8c891b7791 100644 --- a/packages/botonic-react/src/webchat/index-types.ts +++ b/packages/botonic-react/src/webchat/index-types.ts @@ -1,7 +1,7 @@ import type { Input as CoreInput, Session as CoreSession } from '@botonic/core' -import { RefObject } from 'react' import { Webview } from '../components/index-types' +import { WebchatArgs } from '../index-types' export interface WebchatStateTheme { headerTitle: string @@ -11,6 +11,7 @@ export interface WebchatStateTheme { textPlaceholder: string style: { fontFamily: string + borderRadius?: string } } @@ -33,7 +34,7 @@ export interface WebchatState { webview: Webview | null webviewParams: null session: Partial - lastRoutePath: string | null + lastRoutePath?: string handoff: boolean theme: WebchatStateTheme themeUpdates: Partial @@ -52,13 +53,7 @@ export interface WebchatState { isInputFocused: boolean } -// export interface WebchatProps extends WebchatArgs { -export interface WebchatProps { - ref: RefObject - onConnectionRegained?: () => Promise -} - -export interface WebchatDevProps extends WebchatProps { +export interface WebchatDevProps extends WebchatArgs { initialDevSettings?: { keepSessionOnReload?: boolean showSessionView?: boolean From a3438822e99d3a7570260c8bf059af9a000df2fd Mon Sep 17 00:00:00 2001 From: Oriol Raventos Date: Wed, 18 Dec 2024 13:07:10 +0100 Subject: [PATCH 3/4] refactor(botonic-react): use typescript in webchat file, remove unused functions and some action in reducer --- packages/botonic-react/src/contexts.tsx | 11 +- packages/botonic-react/src/webchat/actions.ts | 2 + .../botonic-react/src/webchat/global.d.ts | 3 + packages/botonic-react/src/webchat/styles.ts | 59 +++++ .../src/webchat/webchat-reducer.ts | 2 + .../botonic-react/src/webchat/webchat.tsx | 225 +++++++++--------- 6 files changed, 178 insertions(+), 124 deletions(-) create mode 100644 packages/botonic-react/src/webchat/global.d.ts create mode 100644 packages/botonic-react/src/webchat/styles.ts diff --git a/packages/botonic-react/src/contexts.tsx b/packages/botonic-react/src/contexts.tsx index b168a3f0a7..8876927256 100644 --- a/packages/botonic-react/src/contexts.tsx +++ b/packages/botonic-react/src/contexts.tsx @@ -27,23 +27,20 @@ export interface CloseWebviewOptions { export const WebviewRequestContext = createContext<{ closeWebview: (options?: CloseWebviewOptions) => Promise - getString: (stringId: string) => string + getString?: (stringId: string) => string params: Record - session: CoreSession + session: Partial }>({ closeWebview: async () => undefined, - getString: () => '', + getString: undefined, params: {} as Record, - session: {} as CoreSession, + session: {} as Partial, }) export const WebchatContext = createContext({ addMessage: () => { return }, - closeWebview: async () => { - return - }, getThemeProperty: () => { return }, // used to retrieve a specific property of the theme defined by the developer in his 'webchat/index.js' diff --git a/packages/botonic-react/src/webchat/actions.ts b/packages/botonic-react/src/webchat/actions.ts index 62bb8e864e..24442c02b1 100644 --- a/packages/botonic-react/src/webchat/actions.ts +++ b/packages/botonic-react/src/webchat/actions.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/naming-convention */ export enum WebchatAction { ADD_MESSAGE = 'addMessage', ADD_MESSAGE_COMPONENT = 'addMessageComponent', @@ -23,5 +24,6 @@ export enum WebchatAction { UPDATE_THEME = 'updateTheme', UPDATE_TYPING = 'updateTyping', UPDATE_WEBVIEW = 'updateWebview', + REMOVE_WEBVIEW = 'removeWebview', SET_IS_INPUT_FOCUSED = 'setIsInputFocused', } diff --git a/packages/botonic-react/src/webchat/global.d.ts b/packages/botonic-react/src/webchat/global.d.ts new file mode 100644 index 0000000000..8bf5188806 --- /dev/null +++ b/packages/botonic-react/src/webchat/global.d.ts @@ -0,0 +1,3 @@ +interface Window { + _botonicInsertStyles?: HTMLStyleElement[] +} diff --git a/packages/botonic-react/src/webchat/styles.ts b/packages/botonic-react/src/webchat/styles.ts new file mode 100644 index 0000000000..aa605db44c --- /dev/null +++ b/packages/botonic-react/src/webchat/styles.ts @@ -0,0 +1,59 @@ +import styled from 'styled-components' + +import { COLORS, WEBCHAT } from '../constants' + +interface StyledWebchatProps { + width: number + height: number +} + +export const StyledWebchat = styled.div` + 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-reducer.ts b/packages/botonic-react/src/webchat/webchat-reducer.ts index 7e10244d38..9d798e8726 100644 --- a/packages/botonic-react/src/webchat/webchat-reducer.ts +++ b/packages/botonic-react/src/webchat/webchat-reducer.ts @@ -10,6 +10,8 @@ export function webchatReducer( switch (action.type) { case WebchatAction.UPDATE_WEBVIEW: return { ...state, ...action.payload } + case WebchatAction.REMOVE_WEBVIEW: + return { ...state, webview: null, webviewParams: null } case WebchatAction.UPDATE_SESSION: return { ...state, session: { ...action.payload } } case WebchatAction.UPDATE_TYPING: diff --git a/packages/botonic-react/src/webchat/webchat.tsx b/packages/botonic-react/src/webchat/webchat.tsx index 5b87cfa65c..bc30caac95 100644 --- a/packages/botonic-react/src/webchat/webchat.tsx +++ b/packages/botonic-react/src/webchat/webchat.tsx @@ -3,6 +3,7 @@ import { INPUT, isMobile, params2queryString, + Session as CoreSession, } from '@botonic/core' import merge from 'lodash.merge' import React, { @@ -12,15 +13,26 @@ import React, { useRef, useState, } from 'react' -import styled, { StyleSheetManager } from 'styled-components' +import { StyleSheetManager } from 'styled-components' import { v7 as uuidv7 } from 'uuid' -import { Audio, Document, Image, Text, Video } from '../components' +import { + Audio, + Document, + Image, + Text, + Video, + WebchatSettingsProps, +} 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 { + CloseWebviewOptions, + WebchatContext, + WebviewRequestContext, +} from '../contexts' +import { SENDERS, WebchatProps, WebchatRef } from '../index-types' import { getMediaType, isAllowedSize, @@ -53,71 +65,26 @@ import { useTyping, useWebchat, } from './hooks' +import { WebchatState } from './index-types' 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' -const StyledWebchat = styled.div` - 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; -` - -const ErrorMessageContainer = styled.div` - position: relative; - display: flex; - z-index: 1; - justify-content: center; - width: 100%; -` - -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}; -` - -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%; -` - // eslint-disable-next-line complexity, react/display-name -export const Webchat = forwardRef((props, ref) => { +const Webchat = forwardRef((props, ref) => { const { addMessage, addMessageComponent, clearMessages, doRenderCustomComponent, - openWebviewT, resetUnreadMessages, setCurrentAttachment, setError, @@ -139,15 +106,17 @@ export const Webchat = forwardRef((props, ref) => { updateTheme, updateTyping, updateWebview, + removeWebview, webchatState, webchatRef, chatAreaRef, inputPanelRef, headerRef, + repliesRef, 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() @@ -168,7 +137,7 @@ export const Webchat = forwardRef((props, ref) => { const { scrollToBottom } = useScrollToBottom({ host }) - const saveWebchatState = webchatState => { + const saveWebchatState = (webchatState: WebchatState) => { storage && saveState( JSON.parse( @@ -184,22 +153,21 @@ export const Webchat = forwardRef((props, ref) => { ) } - const handleAttachment = event => { + const handleAttachment = (event: any) => { 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), - }) + + // TODO: Attach more files? + setCurrentAttachment(event.target.files[0]) } useEffect(() => { - if (webchatState.currentAttachment) + if (webchatState.currentAttachment) { sendAttachment(webchatState.currentAttachment) + } }, [webchatState.currentAttachment]) const sendUserInput = async input => { @@ -209,7 +177,9 @@ export const Webchat = forwardRef((props, ref) => { props.onUserInput({ user: webchatState.session.user, input: input, + //@ts-ignore session: webchatState.session, + // TODO: Review why we were passing lastRoutePath, is only for devMode? lastRoutePath: webchatState.lastRoutePath, }) } @@ -261,9 +231,9 @@ export const Webchat = forwardRef((props, ref) => { addMessage(message) const newMessageComponent = msgToBotonic( { ...message, delay: 0, typing: 0 }, - (props.theme.message && props.theme.message.customTypes) || - props.theme.customMessageTypes + props.theme?.message?.customTypes || props.theme?.customMessageTypes ) + //@ts-ignore if (newMessageComponent) addMessageComponent(newMessageComponent) }) } @@ -272,10 +242,18 @@ export const Webchat = forwardRef((props, ref) => { } else updateSession(merge(initialSession, session)) if (devSettings) updateDevSettings(devSettings) else if (initialDevSettings) updateDevSettings(initialDevSettings) - if (lastMessageUpdate) updateLastMessageDate(lastMessageUpdate) - if (themeUpdates !== undefined) + + if (lastMessageUpdate) { + updateLastMessageDate(lastMessageUpdate) + } + + if (themeUpdates !== undefined) { updateTheme(merge(props.theme, themeUpdates), themeUpdates) - if (props.onInit) setTimeout(() => props.onInit(), 100) + } + + if (props.onInit) { + setTimeout(() => props.onInit && props.onInit(), 100) + } }, []) useEffect(() => { @@ -288,8 +266,9 @@ export const Webchat = forwardRef((props, ref) => { }, [webchatState.isWebchatOpen]) useEffect(() => { - if (onStateChange && typeof onStateChange === 'function') { - onStateChange(webchatState) + const { messagesJSON, session } = webchatState + if (onStateChange && typeof onStateChange === 'function' && session.user) { + onStateChange({ messagesJSON, user: session.user }) } saveWebchatState(webchatState) }, [ @@ -322,12 +301,12 @@ export const Webchat = forwardRef((props, ref) => { updateWebview(webviewComponent, params) } - const textareaRef = useRef(null) + const textareaRef = useRef() - const closeWebview = async options => { - updateWebview() + const closeWebview = async (options?: CloseWebviewOptions) => { + removeWebview() if (userInputEnabled) { - textareaRef.current.focus() + textareaRef.current?.focus() } if (options?.payload) { await sendPayload(options.payload) @@ -369,7 +348,10 @@ export const Webchat = forwardRef((props, ref) => { if (getBlockInputs(rule, input.data)) { addMessageComponent( { }, []) const messageComponentFromInput = input => { - let messageComponent = null + let messageComponent: any = null if (isText(input)) { messageComponent = ( - //TODO: Remove id and payload from Text component - + {input.data} ) } else if (isMedia(input)) { const temporaryDisplayUrl = URL.createObjectURL(input.data) - const mediaProps = { + const mediaProps: { + id: string + sentBy: SENDERS + src: string + input?: any + } = { id: input.id, sentBy: SENDERS.user, src: temporaryDisplayUrl, @@ -448,7 +443,7 @@ export const Webchat = forwardRef((props, ref) => { return messageComponent } - const sendInput = async input => { + const sendInput = async (input: any) => { 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 @@ -468,7 +463,7 @@ export const Webchat = forwardRef((props, ref) => { https://stackoverflow.com/questions/37949981/call-child-method-from-parent */ - const updateSessionWithUser = userToUpdate => + const updateSessionWithUser = (userToUpdate: any) => updateSession(merge(webchatState.session, { user: userToUpdate })) useImperativeHandle(ref, () => ({ @@ -501,7 +496,7 @@ export const Webchat = forwardRef((props, ref) => { updateLastMessageDate(currentDateString()) }, - setTyping: typing => updateTyping(typing), + setTyping: (typing: boolean) => updateTyping(typing), addUserMessage: message => sendInput(message), updateUser: updateSessionWithUser, openWebchat: () => toggleWebchat(true), @@ -516,8 +511,6 @@ export const Webchat = forwardRef((props, ref) => { unmountCustomComponent: () => doRenderCustomComponent(false), toggleCoverComponent: () => toggleCoverComponent(!webchatState.isCoverComponentOpen), - openWebviewApi: component => openWebviewT(component), - setError, setOnline, getMessages: () => webchatState.messagesJSON, isOnline, @@ -526,15 +519,15 @@ export const Webchat = forwardRef((props, ref) => { updateReplies(false) }, getLastMessageUpdate: () => webchatState.lastMessageUpdate, - updateMessageInfo: (msgId, messageInfo) => { + updateMessageInfo: (msgId: string, messageInfo: any) => { const messageToUpdate = webchatState.messagesJSON.filter( - m => m.id == msgId + m => m.id === msgId )[0] const updatedMsg = merge(messageToUpdate, messageInfo) if (updatedMsg.ack === 1) delete updatedMsg.unsentInput updateMessage(updatedMsg) }, - updateWebchatSettings: settings => { + updateWebchatSettings: (settings: WebchatSettingsProps) => { if (settings.user) { updateSessionWithUser(settings.user) } @@ -542,57 +535,54 @@ export const Webchat = forwardRef((props, ref) => { updateTheme(merge(webchatState.theme, themeUpdates), themeUpdates) updateTyping(false) }, - closeWebview: closeWebview, + closeWebview: async (options?: CloseWebviewOptions) => + closeWebview(options), })) const resolveCase = () => { updateHandoff(false) - updateSession({ ...webchatState.session, _botonic_action: null }) + updateSession({ ...webchatState.session, _botonic_action: undefined }) } const prevSession = usePrevious(webchatState.session) useEffect(() => { // Resume conversation after handoff - if ( - prevSession && - prevSession._botonic_action && - !webchatState.session._botonic_action - ) { + if (prevSession?._botonic_action && !webchatState.session._botonic_action) { const action = getParsedAction(prevSession._botonic_action) - if (action && action.on_finish) sendPayload(action.on_finish) + if (action?.on_finish) sendPayload(action.on_finish) } }, [webchatState.session._botonic_action]) - const sendText = async (text, payload) => { + const sendText = async (text: string, payload?: string) => { if (!text) return const input = { type: INPUT.TEXT, data: text, payload } await sendInput(input) } - const sendPayload = async payload => { + const sendPayload = async (payload: string) => { if (!payload) return const input = { type: INPUT.POSTBACK, payload } await sendInput(input) } - const sendAttachment = async attachment => { - if (attachment.file) { - const attachmentType = getMediaType(attachment.file.type) + const sendAttachment = async (attachment: File) => { + if (attachment) { + const attachmentType = getMediaType(attachment.type) if (!attachmentType) return const input = { type: attachmentType, - data: attachment.file, + data: attachment, } await sendInput(input) - setCurrentAttachment(undefined) + setCurrentAttachment() } } const webviewRequestContext = { - closeWebview: closeWebview, - getString: stringId => props.getString(stringId, webchatState.session), - params: webchatState.webviewParams || {}, - session: webchatState.session || {}, + closeWebview: async (options?: CloseWebviewOptions) => + await closeWebview(options), + params: webchatState.webviewParams || ({} as Record), + session: webchatState.session || ({} as Partial), } useEffect(() => { @@ -631,6 +621,7 @@ export const Webchat = forwardRef((props, ref) => { /> ) + let mobileStyle = {} if (isMobile(getThemeProperty(WEBCHAT.CUSTOM_PROPERTIES.mobileBreakpoint))) { mobileStyle = getThemeProperty(WEBCHAT.CUSTOM_PROPERTIES.mobileStyle) || { @@ -699,12 +690,15 @@ export const Webchat = forwardRef((props, ref) => { updateReplies, updateUser: updateSessionWithUser, updateWebchatDevSettings: updateWebchatDevSettings, - webchatState, trackEvent: props.onTrackEvent, + webchatState, + // TODO: Review if need theme inside Context, already exist innside webchatState + theme, webchatRef, chatAreaRef, inputPanelRef, headerRef, + repliesRef, scrollableMessagesListRef, }} > @@ -723,13 +717,7 @@ export const Webchat = forwardRef((props, ref) => { ...mobileStyle, }} > - { - toggleWebchat(false) - }} - /> + {webchatState.isCoverComponentOpen ? ( { WebchatComponent ) }) + +Webchat.displayName = 'Webchat' +export { Webchat } From d11ba3554b4bc0c15029f700814623cf6efaddf8 Mon Sep 17 00:00:00 2001 From: Oriol Raventos Date: Wed, 18 Dec 2024 13:26:09 +0100 Subject: [PATCH 4/4] test(botonic-react): fix test --- packages/botonic-react/tests/webchat/storage.test.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/botonic-react/tests/webchat/storage.test.jsx b/packages/botonic-react/tests/webchat/storage.test.jsx index 6a24e3c9eb..675a287f3f 100644 --- a/packages/botonic-react/tests/webchat/storage.test.jsx +++ b/packages/botonic-react/tests/webchat/storage.test.jsx @@ -29,7 +29,7 @@ describe('TEST: storage', () => { expect(botonicState.session.user).toHaveProperty('id' && 'name') expect(botonicState).toHaveProperty('messages', []) expect(botonicState).toHaveProperty('session') - expect(botonicState).toHaveProperty('lastRoutePath', null) + expect(botonicState.lastRoutePath).toBe(undefined) expect(botonicState).toHaveProperty('devSettings', { keepSessionOnReload: false, })