From cc02e8375b145f79cfb6f9dd5a9167418ac326af Mon Sep 17 00:00:00 2001 From: MartinSchoeler Date: Tue, 17 Oct 2023 14:55:41 -0300 Subject: [PATCH 01/17] wip --- packages/livechat/src/definitions/agents.d.ts | 2 + packages/livechat/src/definitions/global.d.ts | 42 +++ packages/livechat/src/definitions/guest.d.ts | 6 + .../livechat/src/lib/{hooks.js => hooks.ts} | 101 +++++-- packages/livechat/src/lib/parentCall.ts | 4 +- packages/livechat/src/store/index.tsx | 21 +- .../livechat/src/{widget.js => widget.ts} | 284 ++++++++++++------ 7 files changed, 320 insertions(+), 140 deletions(-) create mode 100644 packages/livechat/src/definitions/guest.d.ts rename packages/livechat/src/lib/{hooks.js => hooks.ts} (60%) rename packages/livechat/src/{widget.js => widget.ts} (55%) diff --git a/packages/livechat/src/definitions/agents.d.ts b/packages/livechat/src/definitions/agents.d.ts index da1b812425742..fc8316bfa7704 100644 --- a/packages/livechat/src/definitions/agents.d.ts +++ b/packages/livechat/src/definitions/agents.d.ts @@ -1,5 +1,7 @@ // TODO: Fully type agents in livechat export type Agent = { + _id: string; + username: string; name?: string; status?: string; email?: string; diff --git a/packages/livechat/src/definitions/global.d.ts b/packages/livechat/src/definitions/global.d.ts index d765a7018adbd..f1663de172810 100644 --- a/packages/livechat/src/definitions/global.d.ts +++ b/packages/livechat/src/definitions/global.d.ts @@ -7,6 +7,48 @@ declare global { SERVER_URL: string; handleIframeClose?: () => void; expandCall?: () => void; + RocketChat: { + // TODO: Discover what the hell does "_" do + _: any; + url?: string; + // TODO: Type this + livechat: { + pageVisited; + setCustomField; + initialize; + setTheme; + setDepartment; + clearDepartment; + setGuestToken; + setGuestName; + setGuestEmail; + setAgent; + registerGuest; + setLanguage; + showWidget; + hideWidget; + maximizeWidget; + minimizeWidget; + setBusinessUnit; + clearBusinessUnit; + setParentUrl; + + // callbacks + onChatMaximized; + onChatMinimized; + onChatStarted; + onChatEnded; + onPrechatFormSubmit; + onOfflineFormSubmit; + onWidgetShown; + onWidgetHidden; + onAssignAgent; + onAgentStatusChange; + onQueuePositionChange; + onServiceOffline; + }; + }; + initRocket?: string[]; } interface Document { diff --git a/packages/livechat/src/definitions/guest.d.ts b/packages/livechat/src/definitions/guest.d.ts new file mode 100644 index 0000000000000..afccca145e742 --- /dev/null +++ b/packages/livechat/src/definitions/guest.d.ts @@ -0,0 +1,6 @@ +export type Guest = { + token: string; + name?: string; + email?: string; + [key: string]: unknown; +}; diff --git a/packages/livechat/src/lib/hooks.js b/packages/livechat/src/lib/hooks.ts similarity index 60% rename from packages/livechat/src/lib/hooks.js rename to packages/livechat/src/lib/hooks.ts index b988579acfa82..772c36ca10ad4 100644 --- a/packages/livechat/src/lib/hooks.js +++ b/packages/livechat/src/lib/hooks.ts @@ -1,7 +1,8 @@ import i18next from 'i18next'; import { Livechat } from '../api'; -import { store } from '../store'; +import type { StoreState } from '../store'; +import { initialState, store } from '../store'; import CustomFields from './customFields'; import { loadConfig, updateBusinessUnit } from './main'; import { parentCall } from './parentCall'; @@ -9,23 +10,29 @@ import { createToken } from './random'; import { loadMessages } from './room'; import Triggers from './triggers'; -const createOrUpdateGuest = async (guest) => { +const createOrUpdateGuest = async (guest: StoreState['guest']) => { + if (!guest) { + return; + } const { token } = guest; token && (await store.setState({ token })); const { visitor: user } = await Livechat.grantVisitor({ visitor: { ...guest } }); store.setState({ user }); }; -const updateIframeGuestData = (data) => { +const updateIframeGuestData = (data: Partial) => { const { iframe, iframe: { guest }, - user: _id, + user, token, } = store.state; - store.setState({ iframe: { ...iframe, guest: { ...guest, ...data } } }); - if (!_id) { + const iframeGuest = { ...guest, ...data } as StoreState['guest']; + + store.setState({ iframe: { ...iframe, guest: iframeGuest } }); + + if (!user) { return; } @@ -33,8 +40,18 @@ const updateIframeGuestData = (data) => { createOrUpdateGuest(guestData); }; +export type Api = typeof api; + +export type ApiMethods = keyof Api; + +export type ApiArgs = Method extends ApiMethods ? Parameters[0] : never; + +export type ApiMethodsAndArgs = { + [Method in ApiMethods]: ApiArgs; +}; + const api = { - pageVisited(info) { + pageVisited({ info }: { info: { change: string; title: string; location: { href: string } } }) { if (info.change === 'url') { Triggers.processRequest(info); } @@ -51,11 +68,11 @@ const api = { Livechat.sendVisitorNavigation({ token, rid, pageInfo: { change, title, location: { href } } }); }, - setCustomField(key, value, overwrite = true) { + setCustomField({ key, value, overwrite = true }: { key: string; value?: string; overwrite: boolean }) { CustomFields.setCustomField(key, value, overwrite); }, - setTheme({ color, fontColor, iconColor, title, offlineTitle } = {}) { + setTheme({ theme: { color, fontColor, iconColor, title, offlineTitle } }: { theme: StoreState['iframe']['theme'] }) { const { iframe, iframe: { theme }, @@ -75,7 +92,7 @@ const api = { }); }, - async setDepartment(value) { + async setDepartment({ value }: { value: string }) { const { user, config: { departments = [] }, @@ -89,7 +106,7 @@ const api = { updateIframeGuestData({ department }); if (defaultAgent && defaultAgent.department !== department) { - store.setState({ defaultAgent: null }); + store.setState({ defaultAgent: undefined }); } if (department !== existingDepartment) { @@ -98,8 +115,8 @@ const api = { } }, - async setBusinessUnit(newBusinessUnit) { - if (!newBusinessUnit || !newBusinessUnit.trim().length) { + async setBusinessUnit({ newBusinessUnit }: { newBusinessUnit: string }) { + if (!newBusinessUnit?.trim().length) { throw new Error('Error! Invalid business ids'); } @@ -117,7 +134,12 @@ const api = { updateIframeGuestData({ department: '' }); }, - setAgent({ _id, username, ...props } = {}) { + async clearWidgetData() { + const { minimized, visible, undocked, expanded, businessUnit, ...initial } = initialState(); + await store.setState(initial); + }, + + setAgent({ agent: { _id, username, ...props } }: { agent: StoreState['defaultAgent'] }) { if (!_id || !username) { return console.warn('The fields _id and username are mandatory.'); } @@ -132,11 +154,11 @@ const api = { }); }, - setExpanded(expanded) { + setExpanded({ expanded }: { expanded: StoreState['expanded'] }) { store.setState({ expanded }); }, - async setGuestToken(token) { + async setGuestToken({ token }: { token: StoreState['token'] }) { const { token: localToken, iframe, @@ -149,15 +171,15 @@ const api = { await loadConfig(); }, - setGuestName(name) { + setGuestName({ name }: { name: string }) { updateIframeGuestData({ name }); }, - setGuestEmail(email) { + setGuestEmail({ email }: { email: string }) { updateIframeGuestData({ email }); }, - registerGuest(data = {}) { + registerGuest({ data }: { data: StoreState['guest'] }) { if (typeof data !== 'object') { return; } @@ -167,13 +189,13 @@ const api = { } if (data.department) { - api.setDepartment(data.department); + api.setDepartment({ value: data.department }); } createOrUpdateGuest(data); }, - async setLanguage(language) { + async setLanguage({ language }: { language: StoreState['iframe']['language'] }) { const { iframe } = store.state; await store.setState({ iframe: { ...iframe, language } }); i18next.changeLanguage(language); @@ -200,32 +222,45 @@ const api = { store.setState({ minimized: false }); parentCall('openWidget'); }, - setParentUrl(parentUrl) { + + setParentUrl({ parentUrl }: { parentUrl: StoreState['parentUrl'] }) { store.setState({ parentUrl }); }, }; -const onNewMessage = (event) => { +function hasCorrectParams(fn: T, args: ApiArgs): boolean { + + +function onNewMessage(event: MessageEvent<{ src?: string; fn: T; args: ApiArgs }>) { if (event.source === event.target) { return; } - if (typeof event.data === 'object' && event.data.src !== undefined && event.data.src === 'rocketchat') { - if (api[event.data.fn] !== undefined && typeof api[event.data.fn] === 'function') { - const args = [].concat(event.data.args || []); - api[event.data.fn].apply(null, args); - } + if (!event.data || typeof event.data !== 'object') { + return; + } + + if (!event.data.src || event.data.src !== 'rocketchat') { + return; } -}; + + const { fn } = event.data; + + const { args } = event.data; + + // TODO: Refactor widget.js to ts and change their calls to use objects instead of ordered arguments + api[fn](args); +} class Hooks { + private _started: boolean; + constructor() { - if (!Hooks.instance) { - this._started = false; - Hooks.instance = this; + if (instance) { + throw new Error('Hooks already has an instance.'); } - return Hooks.instance; + this._started = false; } init() { diff --git a/packages/livechat/src/lib/parentCall.ts b/packages/livechat/src/lib/parentCall.ts index 5c2b63ac3487c..dd0ee4bca106f 100644 --- a/packages/livechat/src/lib/parentCall.ts +++ b/packages/livechat/src/lib/parentCall.ts @@ -1,4 +1,4 @@ -import { validCallbacks } from '../widget'; +import { VALIDCALLBACKS } from '../widget'; export const parentCall = (method: string, args: any = []) => { const data = { @@ -12,4 +12,4 @@ export const parentCall = (method: string, args: any = []) => { }; export const runCallbackEventEmitter = (callbackName: string, data: unknown) => - validCallbacks.includes(callbackName) && parentCall('callback', [callbackName, data]); + VALIDCALLBACKS.includes(callbackName) && parentCall('callback', [callbackName, data]); diff --git a/packages/livechat/src/store/index.tsx b/packages/livechat/src/store/index.tsx index d682d95b34c12..24bc4cc2913d3 100644 --- a/packages/livechat/src/store/index.tsx +++ b/packages/livechat/src/store/index.tsx @@ -1,13 +1,15 @@ +import type { ILivechatVisitor, ILivechatVisitorDTO } from '@rocket.chat/core-typings'; import type { ComponentChildren } from 'preact'; import { Component, createContext } from 'preact'; import type { CustomField } from '../components/Form/CustomFields'; +import type { Agent } from '../definitions/agents'; import type { Department } from '../definitions/departments'; import { parentCall } from '../lib/parentCall'; import { createToken } from '../lib/random'; import Store from './Store'; -type StoreState = { +export type StoreState = { token: string; typing: string[]; config: { @@ -32,16 +34,19 @@ type StoreState = { enabled?: boolean; }; messages: any[]; - user: any; + user?: ILivechatVisitor; + guest?: ILivechatVisitorDTO; sound: { src?: string; play?: boolean; enabled: boolean; }; iframe: { - guest: any; - theme: any; - visible: boolean; + guest?: ILivechatVisitorDTO; + theme?: any; + visible?: boolean; + department?: string; + language?: string; }; gdpr: { accepted: boolean; @@ -66,6 +71,8 @@ type StoreState = { lastReadMessageId?: any; triggerAgent?: any; queueInfo?: any; + defaultAgent?: Agent; + parentUrl?: string; }; export const initialState = (): StoreState => ({ @@ -80,14 +87,14 @@ export const initialState = (): StoreState => ({ resources: {}, }, messages: [], - user: null, + user: undefined, sound: { src: '', enabled: true, play: false, }, iframe: { - guest: {}, + guest: undefined, theme: {}, visible: true, }, diff --git a/packages/livechat/src/widget.js b/packages/livechat/src/widget.ts similarity index 55% rename from packages/livechat/src/widget.js rename to packages/livechat/src/widget.ts index fafccb1f9c225..66f0ff0d0bb90 100644 --- a/packages/livechat/src/widget.js +++ b/packages/livechat/src/widget.ts @@ -1,9 +1,11 @@ import mitt from 'mitt'; +import type { ApiArgs, ApiMethods } from './lib/hooks'; + const log = process.env.NODE_ENV === 'development' - ? (...args) => window.console.log('%cwidget%c', 'color: red', 'color: initial', ...args) - : () => {}; + ? (...args: any) => window.console.log('%cwidget%c', 'color: red', 'color: initial', ...args) + : () => undefined; const WIDGET_OPEN_WIDTH = 365; const WIDGET_OPEN_HEIGHT = 525; @@ -12,16 +14,16 @@ const WIDGET_MINIMIZED_HEIGHT = 54; const WIDGET_MARGIN = 16; window.RocketChat = window.RocketChat || { _: [] }; -const config = {}; -let widget; -let iframe; -let hookQueue = []; +const config: { url?: string } = {}; +let widget: HTMLDivElement | null; +let iframe: HTMLIFrameElement | null; +let hookQueue: [[ApiMethods, ApiArgs]?] = []; let ready = false; let smallScreen = false; -let scrollPosition; -let widget_height; +let scrollPosition: number; +let widgetHeight: number; -export const validCallbacks = [ +export const VALIDCALLBACKS = [ 'chat-maximized', 'chat-minimized', 'chat-started', @@ -38,15 +40,15 @@ export const validCallbacks = [ const callbacks = mitt(); -function registerCallback(eventName, fn) { - if (validCallbacks.indexOf(eventName) === -1) { +function registerCallback(eventName: string, fn: () => unknown) { + if (VALIDCALLBACKS.indexOf(eventName) === -1) { return false; } return callbacks.on(eventName, fn); } -function emitCallback(eventName, data) { +function emitCallback(eventName: string, data?: unknown) { if (typeof data !== 'undefined') { callbacks.emit(eventName, data); } else { @@ -55,19 +57,26 @@ function emitCallback(eventName, data) { } // hooks -function callHook(action, params) { +function callHook(action: T, params?: ApiArgs) { if (!ready) { return hookQueue.push([action, params]); } + if (!iframe?.contentWindow) { + throw new Error('Widget is not initialized'); + } const data = { src: 'rocketchat', fn: action, args: params, }; - iframe.contentWindow.postMessage(data, '*'); + iframe.contentWindow?.postMessage(data, '*'); } -const updateWidgetStyle = (isOpened) => { +const updateWidgetStyle = (isOpened: boolean) => { + if (!iframe || !widget) { + throw new Error('Widget is not initialized'); + } + const isFullscreen = smallScreen && widget.dataset.state !== 'triggered'; if (smallScreen && isOpened) { @@ -91,7 +100,7 @@ const updateWidgetStyle = (isOpened) => { * for widget.style.width */ - widget.style.height = isFullscreen ? '100%' : `${WIDGET_MARGIN + widget_height + WIDGET_MARGIN + WIDGET_MINIMIZED_HEIGHT}px`; + widget.style.height = isFullscreen ? '100%' : `${WIDGET_MARGIN + widgetHeight + WIDGET_MARGIN + WIDGET_MINIMIZED_HEIGHT}px`; widget.style.width = isFullscreen ? '100%' : `${WIDGET_MARGIN + WIDGET_OPEN_WIDTH + WIDGET_MARGIN}px`; } else { widget.style.left = 'auto'; @@ -100,7 +109,7 @@ const updateWidgetStyle = (isOpened) => { } }; -const createWidget = (url) => { +const createWidget = (url: string) => { widget = document.createElement('div'); widget.className = 'rocketchat-widget'; widget.style.position = 'fixed'; @@ -119,7 +128,6 @@ const createWidget = (url) => { iframe = document.createElement('iframe'); iframe.id = 'rocketchat-iframe'; - iframe.allowTransparency = 'true'; iframe.src = url; iframe.style.width = '100%'; iframe.style.height = '100%'; @@ -130,15 +138,15 @@ const createWidget = (url) => { widget.appendChild(container); document.body.appendChild(widget); - const handleMediaQueryTest = ({ matches }) => { + const handleMediaQueryTest = ({ matches }: { matches: boolean }) => { if (!widget) { return; } smallScreen = matches; updateWidgetStyle(widget.dataset.state === 'opened'); - callHook('setExpanded', smallScreen); - callHook('setParentUrl', window.location.href); + callHook('setExpanded', { expanded: smallScreen }); + callHook('setParentUrl', { parentUrl: window.location.href }); }; const mediaQueryList = window.matchMedia('screen and (max-device-width: 480px)'); @@ -147,24 +155,35 @@ const createWidget = (url) => { }; const openWidget = () => { + if (!iframe || !widget) { + throw new Error('Widget is not initialized'); + } + if (widget.dataset.state === 'opened') { return; } - widget_height = WIDGET_OPEN_HEIGHT; + widgetHeight = WIDGET_OPEN_HEIGHT; widget.dataset.state = 'opened'; updateWidgetStyle(true); iframe.focus(); emitCallback('chat-maximized'); }; -const resizeWidget = (height) => { - widget_height = height; +const resizeWidget = (height: number) => { + if (!widget) { + throw new Error('Widget is not initialized'); + } + widgetHeight = height; widget.dataset.state = 'triggered'; updateWidgetStyle(true); }; function closeWidget() { + if (!iframe || !widget) { + throw new Error('Widget is not initialized'); + } + if (widget.dataset.state === 'closed') { return; } @@ -174,7 +193,23 @@ function closeWidget() { emitCallback('chat-minimized'); } -const api = { +type ApiTypes = { + popup: Window | null; + ready: () => void; + minimizeWindow: () => void; + restoreWindow: () => void; + openPopout: () => void; + openWidget: () => void; + resizeWidget: (height: number) => void; + removeWidget: () => void; + callback: (eventName: string, data?: unknown) => void; + showWidget: () => void; + hideWidget: () => void; + resetDocumentStyle: () => void; + setFullScreenDocumentMobile: () => void; +}; + +const api: ApiTypes = { popup: null, ready() { @@ -201,24 +236,27 @@ const api = { openPopout() { closeWidget(); + if (!config.url) { + throw new Error('Config.url is not set!'); + } api.popup = window.open( `${config.url}${config.url.lastIndexOf('?') > -1 ? '&' : '?'}mode=popout`, 'livechat-popout', - `width=${WIDGET_OPEN_WIDTH}, height=${widget_height}, toolbars=no`, + `width=${WIDGET_OPEN_WIDTH}, height=${widgetHeight}, toolbars=no`, ); - api.popup.focus(); + api.popup?.focus(); }, openWidget() { openWidget(); }, - resizeWidget(height) { + resizeWidget(height: number) { resizeWidget(height); }, removeWidget() { - document.body.removeChild(widget); + document.body.removeChild(widget as Node); }, callback(eventName, data) { @@ -226,11 +264,17 @@ const api = { }, showWidget() { + if (!iframe) { + throw new Error('Widget is not initialized'); + } iframe.style.display = 'initial'; emitCallback('show-widget'); }, hideWidget() { + if (!iframe) { + throw new Error('Widget is not initialized'); + } iframe.style.display = 'none'; emitCallback('hide-widget'); }, @@ -244,63 +288,65 @@ const api = { }, }; -function pageVisited(change) { +function pageVisited(change: string) { callHook('pageVisited', { - change, - location: JSON.parse(JSON.stringify(document.location)), - title: document.title, + info: { + change, + location: JSON.parse(JSON.stringify(document.location)), + title: document.title, + }, }); } -function setCustomField(key, value, overwrite) { +function setCustomField(key: string, value: string, overwrite: boolean) { if (typeof overwrite === 'undefined') { overwrite = true; } - callHook('setCustomField', [key, value, overwrite]); + callHook('setCustomField', { key, value, overwrite }); } -function setTheme(theme) { - callHook('setTheme', theme); +function setTheme(theme: ApiArgs<'setTheme'>['theme']) { + callHook('setTheme', { theme }); } -function setDepartment(department) { - callHook('setDepartment', department); +function setDepartment(department: ApiArgs<'setDepartment'>['value']) { + callHook('setDepartment', { value: department }); } -function setBusinessUnit(businessUnit) { - callHook('setBusinessUnit', businessUnit); +function setBusinessUnit(businessUnit: ApiArgs<'setBusinessUnit'>['newBusinessUnit']) { + callHook('setBusinessUnit', { newBusinessUnit: businessUnit }); } function clearBusinessUnit() { callHook('clearBusinessUnit'); } -function setGuestToken(token) { - callHook('setGuestToken', token); +function setGuestToken(token: ApiArgs<'setGuestToken'>['token']) { + callHook('setGuestToken', { token }); } -function setGuestName(name) { - callHook('setGuestName', name); +function setGuestName(name: ApiArgs<'setGuestName'>['name']) { + callHook('setGuestName', { name }); } -function setGuestEmail(email) { - callHook('setGuestEmail', email); +function setGuestEmail(email: ApiArgs<'setGuestEmail'>['email']) { + callHook('setGuestEmail', { email }); } -function registerGuest(guest) { - callHook('registerGuest', guest); +function registerGuest(guest: ApiArgs<'registerGuest'>['data']) { + callHook('registerGuest', { data: guest }); } function clearDepartment() { callHook('clearDepartment'); } -function setAgent(agent) { - callHook('setAgent', agent); +function setAgent(agent: ApiArgs<'setAgent'>['agent']) { + callHook('setAgent', { agent }); } -function setLanguage(language) { - callHook('setLanguage', language); +function setLanguage(language: ApiArgs<'setLanguage'>['language']) { + callHook('setLanguage', { language }); } function showWidget() { @@ -319,19 +365,42 @@ function minimizeWidget() { callHook('minimizeWidget'); } -function setParentUrl(url) { - callHook('setParentUrl', url); +function setParentUrl(url?: ApiArgs<'setParentUrl'>['parentUrl']) { + callHook('setParentUrl', { parentUrl: url }); } -function initialize(params) { +function isDefined(val: T | undefined | null): val is T { + return val !== undefined && val !== null; +} + +function initialize(params: { + customField?: ApiArgs<'setCustomField'>; + setCustomFields?: ApiArgs<'setCustomField'>[]; + theme?: ApiArgs<'setTheme'>['theme']; + department?: ApiArgs<'setDepartment'>['value']; + businessUnit?: ApiArgs<'setBusinessUnit'>['newBusinessUnit']; + guestToken?: ApiArgs<'setGuestToken'>['token']; + guestName?: ApiArgs<'setGuestName'>['name']; + guestEmail?: ApiArgs<'setGuestEmail'>['email']; + registerGuest?: ApiArgs<'registerGuest'>['data']; + language?: ApiArgs<'setLanguage'>['language']; + agent?: ApiArgs<'setAgent'>['agent']; + parentUrl?: ApiArgs<'setParentUrl'>['parentUrl']; +}) { for (const method in params) { if (!params.hasOwnProperty(method)) { continue; } + const param = params[method as keyof typeof params]; + + if (!isDefined(param)) { + continue; + } + switch (method) { case 'customField': - const { key, value, overwrite } = params[method]; + const { key, value, overwrite } = param; setCustomField(key, value, overwrite); continue; case 'setCustomFields': @@ -339,41 +408,41 @@ function initialize(params) { console.log('Error: Invalid parameters. Value must be an array of objects'); continue; } - params[method].forEach((data) => { - const { key, value, overwrite } = data; + param.forEach((data: ApiArgs<'setCustomField'>) => { + const { key, value = '', overwrite } = data; setCustomField(key, value, overwrite); }); continue; case 'theme': - setTheme(params[method]); + setTheme(param); continue; case 'department': - setDepartment(params[method]); + setDepartment(param); continue; case 'businessUnit': { - setBusinessUnit(params[method]); + setBusinessUnit(param); continue; } case 'guestToken': - setGuestToken(params[method]); + setGuestToken(param); continue; case 'guestName': - setGuestName(params[method]); + setGuestName(param); continue; case 'guestEmail': - setGuestEmail(params[method]); + setGuestEmail(param); continue; case 'registerGuest': - registerGuest(params[method]); + registerGuest(param); continue; case 'language': - setLanguage(params[method]); + setLanguage(param); continue; case 'agent': - setAgent(params[method]); + setAgent(param); continue; case 'parentUrl': - setParentUrl(params[method]); + setParentUrl(param); continue; default: continue; @@ -381,25 +450,44 @@ function initialize(params) { } } -const currentPage = { +const currentPage: { href: string | null; title: string | null } = { href: null, title: null, }; +function onNewMessage(msg: MessageEvent<{ src?: string; fn: T; args: ApiArgs }>) { + if (msg.source === msg.target) { + return; + } + + if (!msg.data || typeof msg.data !== 'object') { + return; + } + + if (!msg.data.src || msg.data.src !== 'rocketchat') { + return; + } + + const { fn } = msg.data; + + const { args } = msg.data; + + // TODO: Refactor widget.js to ts and change their calls to use objects instead of ordered arguments + log(`api.${msg.data.fn}`, ...args); + api[fn](args); +} + +// (msg) => { +// if (typeof msg.data === 'object' && msg.data.src !== undefined && msg.data.src === 'rocketchat') { +// if (api[msg.data.fn] !== undefined && typeof api[msg.data.fn] === 'function') { +// const args = [].concat(msg.data.args || []); +// log(`api.${msg.data.fn}`, ...args); +// api[msg.data.fn].apply(null, args); +// } +// } + const attachMessageListener = () => { - window.addEventListener( - 'message', - (msg) => { - if (typeof msg.data === 'object' && msg.data.src !== undefined && msg.data.src === 'rocketchat') { - if (api[msg.data.fn] !== undefined && typeof api[msg.data.fn] === 'function') { - const args = [].concat(msg.data.args || []); - log(`api.${msg.data.fn}`, ...args); - api[msg.data.fn].apply(null, args); - } - } - }, - false, - ); + window.addEventListener('message', onNewMessage, false); }; const trackNavigation = () => { @@ -416,7 +504,7 @@ const trackNavigation = () => { }, 800); }; -const init = (url) => { +const init = (url: string) => { const trimmedUrl = url.trim(); if (!trimmedUrl) { return; @@ -440,7 +528,7 @@ if (typeof window.RocketChat.url !== 'undefined') { const queue = window.RocketChat._; -window.RocketChat._.push = function (c) { +window.RocketChat._.push = function (c: () => void) { c.call(window.RocketChat.livechat); }; window.RocketChat = window.RocketChat._.push; @@ -469,45 +557,45 @@ window.RocketChat.livechat = { setParentUrl, // callbacks - onChatMaximized(fn) { + onChatMaximized(fn: () => unknown) { registerCallback('chat-maximized', fn); }, - onChatMinimized(fn) { + onChatMinimized(fn: () => unknown) { registerCallback('chat-minimized', fn); }, - onChatStarted(fn) { + onChatStarted(fn: () => unknown) { registerCallback('chat-started', fn); }, - onChatEnded(fn) { + onChatEnded(fn: () => unknown) { registerCallback('chat-ended', fn); }, - onPrechatFormSubmit(fn) { + onPrechatFormSubmit(fn: () => unknown) { registerCallback('pre-chat-form-submit', fn); }, - onOfflineFormSubmit(fn) { + onOfflineFormSubmit(fn: () => unknown) { registerCallback('offline-form-submit', fn); }, - onWidgetShown(fn) { + onWidgetShown(fn: () => unknown) { registerCallback('show-widget', fn); }, - onWidgetHidden(fn) { + onWidgetHidden(fn: () => unknown) { registerCallback('hide-widget', fn); }, - onAssignAgent(fn) { + onAssignAgent(fn: () => unknown) { registerCallback('assign-agent', fn); }, - onAgentStatusChange(fn) { + onAgentStatusChange(fn: () => unknown) { registerCallback('agent-status-change', fn); }, - onQueuePositionChange(fn) { + onQueuePositionChange(fn: () => unknown) { registerCallback('queue-position-change', fn); }, - onServiceOffline(fn) { + onServiceOffline(fn: () => unknown) { registerCallback('no-agent-online', fn); }, }; // proccess queue -queue.forEach((c) => { +queue.forEach((c: () => void) => { c.call(window.RocketChat.livechat); }); From 55f7642923bc077f9e4ba417e81d1fa809b971b0 Mon Sep 17 00:00:00 2001 From: MartinSchoeler Date: Thu, 9 Nov 2023 17:22:27 -0300 Subject: [PATCH 02/17] wip --- packages/livechat/src/lib/hooks.ts | 71 ++++++++++++++++-------------- packages/livechat/src/widget.ts | 48 ++++++++++++++------ 2 files changed, 74 insertions(+), 45 deletions(-) diff --git a/packages/livechat/src/lib/hooks.ts b/packages/livechat/src/lib/hooks.ts index 1570ee36b82cf..3571005b939af 100644 --- a/packages/livechat/src/lib/hooks.ts +++ b/packages/livechat/src/lib/hooks.ts @@ -44,14 +44,17 @@ export type Api = typeof api; export type ApiMethods = keyof Api; -export type ApiArgs = Method extends ApiMethods ? Parameters[0] : never; +type ApiParams = Parameters; -export type ApiMethodsAndArgs = { - [Method in ApiMethods]: ApiArgs; +export type ApiArgs = ApiParams extends infer U ? (U extends any ? U : never) : never; + +export type ApiMethodsAndArgs = { + fn: N; + args: ApiArgs; }; const api = { - pageVisited({ info }: { info: { change: string; title: string; location: { href: string } } }) { + pageVisited: (info: { change: string; title: string; location: { href: string } }) => { if (info.change === 'url') { Triggers.processRequest(info); } @@ -68,11 +71,11 @@ const api = { Livechat.sendVisitorNavigation({ token, rid, pageInfo: { change, title, location: { href } } }); }, - setCustomField({ key, value, overwrite = true }: { key: string; value?: string; overwrite: boolean }) { + setCustomField: (key: string, value = '', overwrite: boolean) => { CustomFields.setCustomField(key, value, overwrite); }, - setTheme({ theme: { color, fontColor, iconColor, title, offlineTitle } }: { theme: StoreState['iframe']['theme'] }) { + setTheme: ({ theme: { color, fontColor, iconColor, title, offlineTitle } }: { theme: StoreState['iframe']['theme'] }) => { const { iframe, iframe: { theme }, @@ -92,7 +95,7 @@ const api = { }); }, - async setDepartment({ value }: { value: string }) { + setDepartment: async (value: string) => { const { user, config: { departments = [] }, @@ -115,7 +118,7 @@ const api = { } }, - async setBusinessUnit({ newBusinessUnit }: { newBusinessUnit: string }) { + setBusinessUnit: async (newBusinessUnit: string) => { if (!newBusinessUnit?.trim().length) { throw new Error('Error! Invalid business ids'); } @@ -125,21 +128,22 @@ const api = { return existingBusinessUnit !== newBusinessUnit && updateBusinessUnit(newBusinessUnit); }, - async clearBusinessUnit() { + clearBusinessUnit: async () => { const { businessUnit } = store.state; return businessUnit && updateBusinessUnit(); }, - clearDepartment() { + clearDepartment: () => { updateIframeGuestData({ department: '' }); }, - async clearWidgetData() { + clearWidgetData: async () => { const { minimized, visible, undocked, expanded, businessUnit, ...initial } = initialState(); await store.setState(initial); }, - setAgent({ agent: { _id, username, ...props } }: { agent: StoreState['defaultAgent'] }) { + setAgent: (agent: StoreState['defaultAgent']) => { + const { _id, username, ...props } = agent; if (!_id || !username) { return console.warn('The fields _id and username are mandatory.'); } @@ -154,11 +158,11 @@ const api = { }); }, - setExpanded({ expanded }: { expanded: StoreState['expanded'] }) { + setExpanded: (expanded: StoreState['expanded']) => { store.setState({ expanded }); }, - async setGuestToken(token: string) { + setGuestToken: async (token: string) => { const { token: localToken } = store.state; if (token === localToken) { return; @@ -167,15 +171,15 @@ const api = { await loadConfig(); }, - setGuestName({ name }: { name: string }) { + setGuestName: (name: string) => { updateIframeGuestData({ name }); }, - setGuestEmail({ email }: { email: string }) { + setGuestEmail: (email: string) => { updateIframeGuestData({ email }); }, - registerGuest({ data }: { data: StoreState['guest'] }) { + registerGuest: (data: StoreState['guest']) => { if (typeof data !== 'object') { return; } @@ -191,40 +195,48 @@ const api = { createOrUpdateGuest(data); }, - async setLanguage({ language }: { language: StoreState['iframe']['language'] }) { + setLanguage: async ({ language }: { language: StoreState['iframe']['language'] }) => { const { iframe } = store.state; await store.setState({ iframe: { ...iframe, language } }); i18next.changeLanguage(language); }, - showWidget() { + showWidget: () => { const { iframe } = store.state; store.setState({ iframe: { ...iframe, visible: true } }); parentCall('showWidget'); }, - hideWidget() { + hideWidget: () => { const { iframe } = store.state; store.setState({ iframe: { ...iframe, visible: false } }); parentCall('hideWidget'); }, - minimizeWidget() { + minimizeWidge: () => { store.setState({ minimized: true }); parentCall('closeWidget'); }, - maximizeWidget() { + maximizeWidget: () => { store.setState({ minimized: false }); parentCall('openWidget'); }, - setParentUrl({ parentUrl }: { parentUrl: StoreState['parentUrl'] }) { + setParentUrl: (parentUrl: StoreState['parentUrl']) => { store.setState({ parentUrl }); }, }; -function onNewMessage(event: MessageEvent<{ src?: string; fn: T; args: ApiArgs }>) { +function onNewMessage(event: ApiMethodsAndArgs) { + const fn = api[event.fn]; + + // There is an existing issue with overload resolution with type union arguments please see https://github.com/microsoft/TypeScript/issues/14107 + // @ts-ignore: A spread argument must either have a tuple type or be passed to a rest parameter + fn(...event.args); +} + +function onNewMessageHandler(event: MessageEvent) { if (event.source === event.target) { return; } @@ -237,12 +249,7 @@ function onNewMessage(event: MessageEvent<{ src?: string; return; } - const { fn } = event.data; - - const { args } = event.data; - - // TODO: Refactor widget.js to ts and change their calls to use objects instead of ordered arguments - api[fn](args); + return onNewMessage(event.data as { fn: T; args: ApiArgs }); } class Hooks { @@ -262,12 +269,12 @@ class Hooks { } this._started = true; - window.addEventListener('message', onNewMessage, false); + window.addEventListener('message', onNewMessageHandler, false); } reset() { this._started = false; - window.removeEventListener('message', onNewMessage, false); + window.removeEventListener('message', onNewMessageHandler, false); } } diff --git a/packages/livechat/src/widget.ts b/packages/livechat/src/widget.ts index 66f0ff0d0bb90..b26b5c70b84e4 100644 --- a/packages/livechat/src/widget.ts +++ b/packages/livechat/src/widget.ts @@ -1,6 +1,6 @@ import mitt from 'mitt'; -import type { ApiArgs, ApiMethods } from './lib/hooks'; +import type { ApiArgs, ApiMethods, ApiMethodsAndArgs } from './lib/hooks'; const log = process.env.NODE_ENV === 'development' @@ -309,7 +309,7 @@ function setTheme(theme: ApiArgs<'setTheme'>['theme']) { callHook('setTheme', { theme }); } -function setDepartment(department: ApiArgs<'setDepartment'>['value']) { +function setDepartment(department: ApiArgs<'setDepartment'>[0]['value']) { callHook('setDepartment', { value: department }); } @@ -455,27 +455,49 @@ const currentPage: { href: string | null; title: string | null } = { title: null, }; -function onNewMessage(msg: MessageEvent<{ src?: string; fn: T; args: ApiArgs }>) { - if (msg.source === msg.target) { +function onNewMessage(event: ApiMethodsAndArgs) { + const fn = api[event.fn]; + + fn(event.args[0]); +} + +function onNewMessageHandler(event: MessageEvent) { + if (event.source === event.target) { return; } - if (!msg.data || typeof msg.data !== 'object') { + if (!event.data || typeof event.data !== 'object') { return; } - if (!msg.data.src || msg.data.src !== 'rocketchat') { + if (!event.data.src || event.data.src !== 'rocketchat') { return; } - const { fn } = msg.data; + return onNewMessage(event.data as { fn: T; args: ApiArgs }); +} - const { args } = msg.data; +// function onNewMessage(msg: MessageEvent<{ src?: string; fn: T; args: ApiArgs }>) { +// if (msg.source === msg.target) { +// return; +// } - // TODO: Refactor widget.js to ts and change their calls to use objects instead of ordered arguments - log(`api.${msg.data.fn}`, ...args); - api[fn](args); -} +// if (!msg.data || typeof msg.data !== 'object') { +// return; +// } + +// if (!msg.data.src || msg.data.src !== 'rocketchat') { +// return; +// } + +// const { fn } = msg.data; + +// const { args } = msg.data; + +// // TODO: Refactor widget.js to ts and change their calls to use objects instead of ordered arguments +// log(`api.${msg.data.fn}`, ...args); +// api[fn](args); +// } // (msg) => { // if (typeof msg.data === 'object' && msg.data.src !== undefined && msg.data.src === 'rocketchat') { @@ -487,7 +509,7 @@ function onNewMessage(msg: MessageEvent<{ src?: string; fn // } const attachMessageListener = () => { - window.addEventListener('message', onNewMessage, false); + window.addEventListener('message', onNewMessageHandler, false); }; const trackNavigation = () => { From 8727742ecde844088f694198d50240254cee210c Mon Sep 17 00:00:00 2001 From: MartinSchoeler Date: Fri, 10 Nov 2023 14:00:26 -0300 Subject: [PATCH 03/17] Fix TS --- packages/livechat/src/lib/hooks.ts | 18 ++- packages/livechat/src/widget.ts | 183 +++++++++++++---------------- 2 files changed, 93 insertions(+), 108 deletions(-) diff --git a/packages/livechat/src/lib/hooks.ts b/packages/livechat/src/lib/hooks.ts index 3571005b939af..c16194013e848 100644 --- a/packages/livechat/src/lib/hooks.ts +++ b/packages/livechat/src/lib/hooks.ts @@ -17,7 +17,10 @@ const createOrUpdateGuest = async (guest: StoreState['guest']) => { const { token } = guest; token && (await store.setState({ token })); const { visitor: user } = await Livechat.grantVisitor({ visitor: { ...guest } }); - store.setState({ user }); + if (!user) { + return; + } + store.setState({ user } as Omit); }; const updateIframeGuestData = (data: Partial) => { @@ -46,7 +49,7 @@ export type ApiMethods = keyof Api; type ApiParams = Parameters; -export type ApiArgs = ApiParams extends infer U ? (U extends any ? U : never) : never; +export type ApiArgs = ApiParams extends infer U ? (U extends any[] ? U : never) : never; export type ApiMethodsAndArgs = { fn: N; @@ -143,7 +146,12 @@ const api = { }, setAgent: (agent: StoreState['defaultAgent']) => { + if (!agent) { + return; + } + const { _id, username, ...props } = agent; + if (!_id || !username) { return console.warn('The fields _id and username are mandatory.'); } @@ -189,7 +197,7 @@ const api = { } if (data.department) { - api.setDepartment({ value: data.department }); + api.setDepartment(data.department); } createOrUpdateGuest(data); @@ -213,7 +221,7 @@ const api = { parentCall('hideWidget'); }, - minimizeWidge: () => { + minimizeWidget: () => { store.setState({ minimized: true }); parentCall('closeWidget'); }, @@ -232,7 +240,7 @@ function onNewMessage(event: ApiMethodsAndArgs) { const fn = api[event.fn]; // There is an existing issue with overload resolution with type union arguments please see https://github.com/microsoft/TypeScript/issues/14107 - // @ts-ignore: A spread argument must either have a tuple type or be passed to a rest parameter + // @ts-expect-error: A spread argument must either have a tuple type or be passed to a rest parameter fn(...event.args); } diff --git a/packages/livechat/src/widget.ts b/packages/livechat/src/widget.ts index b26b5c70b84e4..cae9b812746be 100644 --- a/packages/livechat/src/widget.ts +++ b/packages/livechat/src/widget.ts @@ -1,11 +1,6 @@ import mitt from 'mitt'; -import type { ApiArgs, ApiMethods, ApiMethodsAndArgs } from './lib/hooks'; - -const log = - process.env.NODE_ENV === 'development' - ? (...args: any) => window.console.log('%cwidget%c', 'color: red', 'color: initial', ...args) - : () => undefined; +import type { ApiArgs, ApiMethods } from './lib/hooks'; const WIDGET_OPEN_WIDTH = 365; const WIDGET_OPEN_HEIGHT = 525; @@ -57,7 +52,7 @@ function emitCallback(eventName: string, data?: unknown) { } // hooks -function callHook(action: T, params?: ApiArgs) { +function callHook(action: T, params: ApiArgs | [] = []) { if (!ready) { return hookQueue.push([action, params]); } @@ -145,8 +140,8 @@ const createWidget = (url: string) => { smallScreen = matches; updateWidgetStyle(widget.dataset.state === 'opened'); - callHook('setExpanded', { expanded: smallScreen }); - callHook('setParentUrl', { parentUrl: window.location.href }); + callHook('setExpanded', [smallScreen]); + callHook('setParentUrl', [window.location.href]); }; const mediaQueryList = window.matchMedia('screen and (max-device-width: 480px)'); @@ -216,7 +211,9 @@ const api: ApiTypes = { ready = true; if (hookQueue.length > 0) { hookQueue.forEach(function (hookParams) { - callHook.apply(this, hookParams); + // There is an existing issue with overload resolution with type union arguments please see https://github.com/microsoft/TypeScript/issues/14107 + // @ts-expect-error: A spread argument must either have a tuple type or be passed to a rest parameter + callHook(...hookParams); }); hookQueue = []; } @@ -289,64 +286,68 @@ const api: ApiTypes = { }; function pageVisited(change: string) { - callHook('pageVisited', { - info: { + callHook('pageVisited', [ + { change, location: JSON.parse(JSON.stringify(document.location)), title: document.title, }, - }); + ]); } function setCustomField(key: string, value: string, overwrite: boolean) { if (typeof overwrite === 'undefined') { overwrite = true; } - callHook('setCustomField', { key, value, overwrite }); + if (!key) { + return; + } + + callHook('setCustomField', [key, value, overwrite]); } -function setTheme(theme: ApiArgs<'setTheme'>['theme']) { - callHook('setTheme', { theme }); +function setTheme(theme: ApiArgs<'setTheme'>[0]) { + callHook('setTheme', [theme]); } -function setDepartment(department: ApiArgs<'setDepartment'>[0]['value']) { - callHook('setDepartment', { value: department }); +function setDepartment(department: ApiArgs<'setDepartment'>[0]) { + callHook('setDepartment', [department]); } -function setBusinessUnit(businessUnit: ApiArgs<'setBusinessUnit'>['newBusinessUnit']) { - callHook('setBusinessUnit', { newBusinessUnit: businessUnit }); +function setBusinessUnit(businessUnit: ApiArgs<'setBusinessUnit'>[0]) { + callHook('setBusinessUnit', [businessUnit]); } function clearBusinessUnit() { callHook('clearBusinessUnit'); } -function setGuestToken(token: ApiArgs<'setGuestToken'>['token']) { - callHook('setGuestToken', { token }); +function setGuestToken(token: ApiArgs<'setGuestToken'>[0]) { + callHook('setGuestToken', [token]); } -function setGuestName(name: ApiArgs<'setGuestName'>['name']) { - callHook('setGuestName', { name }); +function setGuestName(name: ApiArgs<'setGuestName'>[0]) { + callHook('setGuestName', [name]); } -function setGuestEmail(email: ApiArgs<'setGuestEmail'>['email']) { - callHook('setGuestEmail', { email }); +function setGuestEmail(email: ApiArgs<'setGuestEmail'>[0]) { + callHook('setGuestEmail', [email]); } -function registerGuest(guest: ApiArgs<'registerGuest'>['data']) { - callHook('registerGuest', { data: guest }); +function registerGuest(guest: ApiArgs<'registerGuest'>[0]) { + callHook('registerGuest', [guest]); } function clearDepartment() { callHook('clearDepartment'); } -function setAgent(agent: ApiArgs<'setAgent'>['agent']) { - callHook('setAgent', { agent }); +function setAgent(agent: ApiArgs<'setAgent'>[0]) { + callHook('setAgent', [agent]); } -function setLanguage(language: ApiArgs<'setLanguage'>['language']) { - callHook('setLanguage', { language }); +function setLanguage(lang: ApiArgs<'setLanguage'>[0]) { + callHook('setLanguage', [lang]); } function showWidget() { @@ -365,8 +366,8 @@ function minimizeWidget() { callHook('minimizeWidget'); } -function setParentUrl(url?: ApiArgs<'setParentUrl'>['parentUrl']) { - callHook('setParentUrl', { parentUrl: url }); +function setParentUrl(url?: ApiArgs<'setParentUrl'>[0]) { + callHook('setParentUrl', [url]); } function isDefined(val: T | undefined | null): val is T { @@ -376,16 +377,16 @@ function isDefined(val: T | undefined | null): val is T { function initialize(params: { customField?: ApiArgs<'setCustomField'>; setCustomFields?: ApiArgs<'setCustomField'>[]; - theme?: ApiArgs<'setTheme'>['theme']; - department?: ApiArgs<'setDepartment'>['value']; - businessUnit?: ApiArgs<'setBusinessUnit'>['newBusinessUnit']; - guestToken?: ApiArgs<'setGuestToken'>['token']; - guestName?: ApiArgs<'setGuestName'>['name']; - guestEmail?: ApiArgs<'setGuestEmail'>['email']; - registerGuest?: ApiArgs<'registerGuest'>['data']; - language?: ApiArgs<'setLanguage'>['language']; - agent?: ApiArgs<'setAgent'>['agent']; - parentUrl?: ApiArgs<'setParentUrl'>['parentUrl']; + theme?: ApiArgs<'setTheme'>[0]; + department?: ApiArgs<'setDepartment'>[0]; + businessUnit?: ApiArgs<'setBusinessUnit'>[0]; + guestToken?: ApiArgs<'setGuestToken'>[0]; + guestName?: ApiArgs<'setGuestName'>[0]; + guestEmail?: ApiArgs<'setGuestEmail'>[0]; + registerGuest?: ApiArgs<'registerGuest'>[0]; + language?: ApiArgs<'setLanguage'>[0]; + agent?: ApiArgs<'setAgent'>[0]; + parentUrl?: ApiArgs<'setParentUrl'>[0]; }) { for (const method in params) { if (!params.hasOwnProperty(method)) { @@ -400,49 +401,55 @@ function initialize(params: { switch (method) { case 'customField': - const { key, value, overwrite } = param; + const customFieldParam = param as ApiArgs<'setCustomField'>; + const key = customFieldParam[0]; + const value = customFieldParam[1] || ''; + const overwrite = customFieldParam[2]; setCustomField(key, value, overwrite); continue; case 'setCustomFields': - if (!Array.isArray(params[method])) { + const customFieldsParam = param as ApiArgs<'setCustomField'>[]; + if (!Array.isArray(customFieldsParam)) { console.log('Error: Invalid parameters. Value must be an array of objects'); continue; } - param.forEach((data: ApiArgs<'setCustomField'>) => { - const { key, value = '', overwrite } = data; + customFieldsParam.forEach((data: ApiArgs<'setCustomField'>) => { + const key = data[0]; + const value = data[1] || ''; + const overwrite = data[2]; setCustomField(key, value, overwrite); }); continue; case 'theme': - setTheme(param); + setTheme(param as ApiArgs<'setTheme'>[0]); continue; case 'department': - setDepartment(param); + setDepartment(param as ApiArgs<'setDepartment'>[0]); continue; case 'businessUnit': { - setBusinessUnit(param); + setBusinessUnit(param as ApiArgs<'setBusinessUnit'>[0]); continue; } case 'guestToken': - setGuestToken(param); + setGuestToken(param as ApiArgs<'setGuestToken'>[0]); continue; case 'guestName': - setGuestName(param); + setGuestName(param as ApiArgs<'setGuestName'>[0]); continue; case 'guestEmail': - setGuestEmail(param); + setGuestEmail(param as ApiArgs<'setGuestEmail'>[0]); continue; case 'registerGuest': - registerGuest(param); + registerGuest(param as ApiArgs<'registerGuest'>[0]); continue; case 'language': - setLanguage(param); + setLanguage(param as ApiArgs<'setLanguage'>[0]); continue; case 'agent': - setAgent(param); + setAgent(param as ApiArgs<'setAgent'>[0]); continue; case 'parentUrl': - setParentUrl(param); + setParentUrl(param as ApiArgs<'setParentUrl'>[0]); continue; default: continue; @@ -455,61 +462,29 @@ const currentPage: { href: string | null; title: string | null } = { title: null, }; -function onNewMessage(event: ApiMethodsAndArgs) { - const fn = api[event.fn]; - - fn(event.args[0]); -} - -function onNewMessageHandler(event: MessageEvent) { - if (event.source === event.target) { +function onNewMessage(msg: MessageEvent<{ src?: string; fn: T; args: ApiArgs }>) { + if (msg.source === msg.target) { return; } - if (!event.data || typeof event.data !== 'object') { + if (!msg.data || typeof msg.data !== 'object') { return; } - if (!event.data.src || event.data.src !== 'rocketchat') { + if (!msg.data.src || msg.data.src !== 'rocketchat') { return; } - return onNewMessage(event.data as { fn: T; args: ApiArgs }); -} - -// function onNewMessage(msg: MessageEvent<{ src?: string; fn: T; args: ApiArgs }>) { -// if (msg.source === msg.target) { -// return; -// } + const { fn } = msg.data; -// if (!msg.data || typeof msg.data !== 'object') { -// return; -// } - -// if (!msg.data.src || msg.data.src !== 'rocketchat') { -// return; -// } - -// const { fn } = msg.data; - -// const { args } = msg.data; - -// // TODO: Refactor widget.js to ts and change their calls to use objects instead of ordered arguments -// log(`api.${msg.data.fn}`, ...args); -// api[fn](args); -// } - -// (msg) => { -// if (typeof msg.data === 'object' && msg.data.src !== undefined && msg.data.src === 'rocketchat') { -// if (api[msg.data.fn] !== undefined && typeof api[msg.data.fn] === 'function') { -// const args = [].concat(msg.data.args || []); -// log(`api.${msg.data.fn}`, ...args); -// api[msg.data.fn].apply(null, args); -// } -// } + const { args } = msg.data; + // There is an existing issue with overload resolution with type union arguments please see https://github.com/microsoft/TypeScript/issues/14107 + // @ts-expect-error: A spread argument must either have a tuple type or be passed to a rest parameter + api[fn](...args); +} const attachMessageListener = () => { - window.addEventListener('message', onNewMessageHandler, false); + window.addEventListener('message', onNewMessage, false); }; const trackNavigation = () => { @@ -555,8 +530,7 @@ window.RocketChat._.push = function (c: () => void) { }; window.RocketChat = window.RocketChat._.push; -// exports -window.RocketChat.livechat = { +const livechatWidgetAPI = { // methods pageVisited, setCustomField, @@ -617,6 +591,9 @@ window.RocketChat.livechat = { }, }; +// exports +window.RocketChat.livechat = livechatWidgetAPI; + // proccess queue queue.forEach((c: () => void) => { c.call(window.RocketChat.livechat); From dca38d52911f8dd60428026adf3e09243719d7c6 Mon Sep 17 00:00:00 2001 From: MartinSchoeler Date: Fri, 10 Nov 2023 14:58:53 -0300 Subject: [PATCH 04/17] Small Fixes --- packages/livechat/src/lib/hooks.ts | 3 ++- packages/livechat/src/widget.ts | 2 +- packages/livechat/webpack.config.ts | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/livechat/src/lib/hooks.ts b/packages/livechat/src/lib/hooks.ts index c16194013e848..1c7d616b94657 100644 --- a/packages/livechat/src/lib/hooks.ts +++ b/packages/livechat/src/lib/hooks.ts @@ -238,10 +238,11 @@ const api = { function onNewMessage(event: ApiMethodsAndArgs) { const fn = api[event.fn]; + const args = !Array.isArray(event.args) ? [event.args] : event.args; // There is an existing issue with overload resolution with type union arguments please see https://github.com/microsoft/TypeScript/issues/14107 // @ts-expect-error: A spread argument must either have a tuple type or be passed to a rest parameter - fn(...event.args); + fn(...args); } function onNewMessageHandler(event: MessageEvent) { diff --git a/packages/livechat/src/widget.ts b/packages/livechat/src/widget.ts index cae9b812746be..80629d7310781 100644 --- a/packages/livechat/src/widget.ts +++ b/packages/livechat/src/widget.ts @@ -477,7 +477,7 @@ function onNewMessage(msg: MessageEvent<{ src?: string; fn const { fn } = msg.data; - const { args } = msg.data; + const args = !Array.isArray(msg.data.args) ? [msg.data.args] : msg.data.args; // There is an existing issue with overload resolution with type union arguments please see https://github.com/microsoft/TypeScript/issues/14107 // @ts-expect-error: A spread argument must either have a tuple type or be passed to a rest parameter diff --git a/packages/livechat/webpack.config.ts b/packages/livechat/webpack.config.ts index ecc404e7187f4..39c3ad9105112 100644 --- a/packages/livechat/webpack.config.ts +++ b/packages/livechat/webpack.config.ts @@ -162,7 +162,7 @@ const config = (_env: any, args: webpack.WebpackOptionsNormalized): webpack.Conf { ...common(args), entry: { - script: _('./src/widget.js'), + script: _('./src/widget.ts'), } as webpack.Entry, output: { path: _('./dist'), From 54b417a2d957242646b5e259115b0413b049d02a Mon Sep 17 00:00:00 2001 From: Martin Schoeler Date: Fri, 10 Nov 2023 15:08:22 -0300 Subject: [PATCH 05/17] Create orange-dragons-fly.md --- .changeset/orange-dragons-fly.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/orange-dragons-fly.md diff --git a/.changeset/orange-dragons-fly.md b/.changeset/orange-dragons-fly.md new file mode 100644 index 0000000000000..ac38a42e2aea5 --- /dev/null +++ b/.changeset/orange-dragons-fly.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/livechat": patch +--- + +chore: Refactor & Improve Livechat Widget API +Refactors and adds better error handling to the widget's API calls From 94fb80cd9033081d998e32bdcc03dd3a9662b1ac Mon Sep 17 00:00:00 2001 From: Martin Schoeler Date: Tue, 30 Jan 2024 16:00:22 -0300 Subject: [PATCH 06/17] fix ts --- packages/livechat/src/routes/Register/index.tsx | 6 ++++-- .../livechat/src/routes/SwitchDepartment/index.tsx | 10 ++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/livechat/src/routes/Register/index.tsx b/packages/livechat/src/routes/Register/index.tsx index a4c483db12822..73d06b7c720aa 100644 --- a/packages/livechat/src/routes/Register/index.tsx +++ b/packages/livechat/src/routes/Register/index.tsx @@ -16,6 +16,7 @@ import CustomFields from '../../lib/customFields'; import { validateEmail } from '../../lib/email'; import { parentCall } from '../../lib/parentCall'; import { StoreContext } from '../../store'; +import type { StoreState } from '../../store'; import styles from './styles.scss'; // Custom field as in the form payload @@ -42,7 +43,7 @@ export const Register = ({ screenProps }: { screenProps: { [key: string]: unknow customFields = [], }, iframe: { - guest: { department: guestDepartment, name: guestName, email: guestEmail }, + guest: { department: guestDepartment = undefined, name: guestName = undefined, email: guestEmail = undefined } = {}, theme: { color: customColor, fontColor: customFontColor, iconColor: customIconColor, title: customTitle }, }, loading = false, @@ -84,7 +85,8 @@ export const Register = ({ screenProps }: { screenProps: { [key: string]: unknow await dispatch({ loading: true, department }); try { const { visitor: user } = await Livechat.grantVisitor({ visitor: { ...fields, token } }); - await dispatch({ user }); + await dispatch({ user } as Omit); + parentCall('callback', ['pre-chat-form-submit', fields]); registerCustomFields(customFields); } finally { diff --git a/packages/livechat/src/routes/SwitchDepartment/index.tsx b/packages/livechat/src/routes/SwitchDepartment/index.tsx index d2e8c93ec1806..a4ea63c44460a 100644 --- a/packages/livechat/src/routes/SwitchDepartment/index.tsx +++ b/packages/livechat/src/routes/SwitchDepartment/index.tsx @@ -15,6 +15,7 @@ import Screen from '../../components/Screen'; import { createClassName } from '../../helpers/createClassName'; import { loadConfig } from '../../lib/main'; import { createToken } from '../../lib/random'; +import type { StoreState } from '../../store'; import { StoreContext } from '../../store'; import styles from './styles.scss'; @@ -60,7 +61,8 @@ const SwitchDepartment = ({ screenProps }: { screenProps: { [key: string]: unkno if (!room) { const { visitor: user } = await Livechat.grantVisitor({ visitor: { department, token } }); - await dispatch({ user, alerts: (alerts.push({ id: createToken(), children: t('department_switched'), success: true }), alerts) }); + await dispatch({ user } as Omit); + await dispatch({ alerts: (alerts.push({ id: createToken(), children: t('department_switched'), success: true }), alerts) }); return route('/'); } @@ -84,7 +86,11 @@ const SwitchDepartment = ({ screenProps }: { screenProps: { [key: string]: unkno throw t('no_available_agents_to_transfer'); } - await dispatch({ iframe: { ...iframe, guest: { ...guest, department } }, loading: false }); + await dispatch({ iframe: { ...iframe, guest: { ...guest, department } }, loading: false } as { + iframe: StoreState['iframe']; + guest: StoreState['iframe']['guest']; + loading: boolean; + }); await loadConfig(); await ModalManager.alert({ From 4ceabb3aeb82ec118212066f51a4b753131839cb Mon Sep 17 00:00:00 2001 From: Martin Schoeler Date: Wed, 31 Jan 2024 14:08:44 -0300 Subject: [PATCH 07/17] handle undefined --- packages/livechat/src/lib/hooks.ts | 2 +- packages/livechat/src/widget.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/livechat/src/lib/hooks.ts b/packages/livechat/src/lib/hooks.ts index b2caa90e46dd7..3c81f5e9dbdb8 100644 --- a/packages/livechat/src/lib/hooks.ts +++ b/packages/livechat/src/lib/hooks.ts @@ -241,7 +241,7 @@ const api = { function onNewMessage(event: ApiMethodsAndArgs) { const fn = api[event.fn]; - const args = !Array.isArray(event.args) ? [event.args] : event.args; + const args = event.args && !Array.isArray(event.args) ? [event.args] : event.args; // There is an existing issue with overload resolution with type union arguments please see https://github.com/microsoft/TypeScript/issues/14107 // @ts-expect-error: A spread argument must either have a tuple type or be passed to a rest parameter diff --git a/packages/livechat/src/widget.ts b/packages/livechat/src/widget.ts index 18972a1f0e8bf..c7daa91ec47dd 100644 --- a/packages/livechat/src/widget.ts +++ b/packages/livechat/src/widget.ts @@ -485,7 +485,7 @@ function onNewMessage(msg: MessageEvent<{ src?: string; fn const { fn } = msg.data; - const args = !Array.isArray(msg.data.args) ? [msg.data.args] : msg.data.args; + const args = msg.data.args && !Array.isArray(msg.data.args) ? [msg.data.args] : msg.data.args; // There is an existing issue with overload resolution with type union arguments please see https://github.com/microsoft/TypeScript/issues/14107 // @ts-expect-error: A spread argument must either have a tuple type or be passed to a rest parameter From 73d3f2462e28a75bdb03c9e49e3c027d1f455efc Mon Sep 17 00:00:00 2001 From: Aleksander Nicacio da Silva Date: Thu, 8 Feb 2024 15:46:36 -0300 Subject: [PATCH 08/17] refactor: adjusted widget and hooks types --- packages/livechat/src/helpers/isDefined.ts | 3 + packages/livechat/src/lib/hooks.ts | 35 +- packages/livechat/src/lib/parentCall.ts | 4 +- packages/livechat/src/widget.ts | 539 +++++++++++---------- 4 files changed, 304 insertions(+), 277 deletions(-) create mode 100644 packages/livechat/src/helpers/isDefined.ts diff --git a/packages/livechat/src/helpers/isDefined.ts b/packages/livechat/src/helpers/isDefined.ts new file mode 100644 index 0000000000000..d4884b460e097 --- /dev/null +++ b/packages/livechat/src/helpers/isDefined.ts @@ -0,0 +1,3 @@ +export function isDefined(val: T | undefined | null): val is T { + return val !== undefined && val !== null; +} diff --git a/packages/livechat/src/lib/hooks.ts b/packages/livechat/src/lib/hooks.ts index 3c81f5e9dbdb8..819f8e6cc2939 100644 --- a/packages/livechat/src/lib/hooks.ts +++ b/packages/livechat/src/lib/hooks.ts @@ -3,6 +3,7 @@ import i18next from 'i18next'; import { Livechat } from '../api'; import type { StoreState } from '../store'; import { initialState, store } from '../store'; +import type { LivechatMessageEventData } from '../widget'; import CustomFields from './customFields'; import { loadConfig, updateBusinessUnit } from './main'; import { parentCall } from './parentCall'; @@ -45,18 +46,7 @@ const updateIframeGuestData = (data: Partial) => { createOrUpdateGuest(guestData); }; -export type Api = typeof api; - -export type ApiMethods = keyof Api; - -type ApiParams = Parameters; - -export type ApiArgs = ApiParams extends infer U ? (U extends any[] ? U : never) : never; - -export type ApiMethodsAndArgs = { - fn: N; - args: ApiArgs; -}; +export type HooksWidgetAPI = typeof api; const api = { pageVisited: (info: { change: string; title: string; location: { href: string } }) => { @@ -239,16 +229,7 @@ const api = { }, }; -function onNewMessage(event: ApiMethodsAndArgs) { - const fn = api[event.fn]; - const args = event.args && !Array.isArray(event.args) ? [event.args] : event.args; - - // There is an existing issue with overload resolution with type union arguments please see https://github.com/microsoft/TypeScript/issues/14107 - // @ts-expect-error: A spread argument must either have a tuple type or be passed to a rest parameter - fn(...args); -} - -function onNewMessageHandler(event: MessageEvent) { +function onNewMessageHandler(event: MessageEvent>) { if (event.source === event.target) { return; } @@ -261,7 +242,15 @@ function onNewMessageHandler(event: MessageEvent) { return; } - return onNewMessage(event.data as { fn: T; args: ApiArgs }); + const { fn, args } = event.data; + + if (!api.hasOwnProperty(fn)) { + return; + } + + // There is an existing issue with overload resolution with type union arguments please see https://github.com/microsoft/TypeScript/issues/14107 + // @ts-expect-error: A spread argument must either have a tuple type or be passed to a rest parameter + api[fn](...args); } class Hooks { diff --git a/packages/livechat/src/lib/parentCall.ts b/packages/livechat/src/lib/parentCall.ts index dd0ee4bca106f..76a330763a212 100644 --- a/packages/livechat/src/lib/parentCall.ts +++ b/packages/livechat/src/lib/parentCall.ts @@ -1,4 +1,4 @@ -import { VALIDCALLBACKS } from '../widget'; +import { VALID_CALLBACKS } from '../widget'; export const parentCall = (method: string, args: any = []) => { const data = { @@ -12,4 +12,4 @@ export const parentCall = (method: string, args: any = []) => { }; export const runCallbackEventEmitter = (callbackName: string, data: unknown) => - VALIDCALLBACKS.includes(callbackName) && parentCall('callback', [callbackName, data]); + VALID_CALLBACKS.includes(callbackName) && parentCall('callback', [callbackName, data]); diff --git a/packages/livechat/src/widget.ts b/packages/livechat/src/widget.ts index c7daa91ec47dd..6effdb54e680a 100644 --- a/packages/livechat/src/widget.ts +++ b/packages/livechat/src/widget.ts @@ -1,6 +1,47 @@ +import type { UserStatus } from '@rocket.chat/core-typings'; +import type { LivechatRoomEvents } from '@rocket.chat/ddp-client/dist/livechat/types/LivechatSDK'; import mitt from 'mitt'; -import type { ApiArgs, ApiMethods } from './lib/hooks'; +import { isDefined } from './helpers/isDefined'; +import type { HooksWidgetAPI } from './lib/hooks'; +import type { StoreState } from './store'; + +type InternalWidgetAPI = { + popup: Window | null; + ready: () => void; + minimizeWindow: () => void; + restoreWindow: () => void; + openPopout: () => void; + openWidget: () => void; + resizeWidget: (height: number) => void; + removeWidget: () => void; + callback: (eventName: string, data?: unknown) => void; + showWidget: () => void; + hideWidget: () => void; + resetDocumentStyle: () => void; + setFullScreenDocumentMobile: () => void; +}; + +export type LivechatMessageEventData> = { + src?: string; + fn: keyof ApiType; + args: Parameters; +}; + +type InitializeParams = { + customField: [key: string, value: string, overwrite?: boolean]; + setCustomFields: [key: string, value: string, overwrite?: boolean][]; + theme: { theme: StoreState['iframe']['theme'] }; + department: string; + businessUnit: string; + guestToken: string; + guestName: string; + guestEmail: string; + registerGuest: StoreState['guest']; + language: string; + agent: StoreState['defaultAgent']; + parentUrl: string; +}; const WIDGET_OPEN_WIDTH = 365; const WIDGET_OPEN_HEIGHT = 525; @@ -12,13 +53,13 @@ window.RocketChat = window.RocketChat || { _: [] }; const config: { url?: string } = {}; let widget: HTMLDivElement | null; let iframe: HTMLIFrameElement | null; -let hookQueue: [[ApiMethods, ApiArgs]?] = []; +let hookQueue: [keyof HooksWidgetAPI, Parameters][] = []; let ready = false; let smallScreen = false; let scrollPosition: number; let widgetHeight: number; -export const VALIDCALLBACKS = [ +export const VALID_CALLBACKS = [ 'chat-maximized', 'chat-minimized', 'chat-started', @@ -36,7 +77,7 @@ export const VALIDCALLBACKS = [ const callbacks = mitt(); function registerCallback(eventName: string, fn: () => unknown) { - if (VALIDCALLBACKS.indexOf(eventName) === -1) { + if (VALID_CALLBACKS.indexOf(eventName) === -1) { return false; } @@ -56,21 +97,36 @@ function clearAllCallbacks() { } // hooks -function callHook(action: T, params: ApiArgs | [] = []) { +function callHook(action: keyof HooksWidgetAPI, ...params: Parameters) { if (!ready) { return hookQueue.push([action, params]); } + if (!iframe?.contentWindow) { throw new Error('Widget is not initialized'); } + const data = { src: 'rocketchat', fn: action, args: params, }; + iframe.contentWindow?.postMessage(data, '*'); } +function processHookQueue() { + if (!hookQueue.length) { + return; + } + + hookQueue.forEach(([action, params = []]) => { + callHook(action, ...params); + }); + + hookQueue = []; +} + const updateWidgetStyle = (isOpened: boolean) => { if (!iframe || !widget) { throw new Error('Widget is not initialized'); @@ -144,8 +200,8 @@ const createWidget = (url: string) => { smallScreen = matches; updateWidgetStyle(widget.dataset.state === 'opened'); - callHook('setExpanded', [smallScreen]); - callHook('setParentUrl', [window.location.href]); + callHook('setExpanded', smallScreen); + callHook('setParentUrl', window.location.href); }; const mediaQueryList = window.matchMedia('screen and (max-device-width: 480px)'); @@ -192,114 +248,15 @@ function closeWidget() { emitCallback('chat-minimized'); } -type ApiTypes = { - popup: Window | null; - ready: () => void; - minimizeWindow: () => void; - restoreWindow: () => void; - openPopout: () => void; - openWidget: () => void; - resizeWidget: (height: number) => void; - removeWidget: () => void; - callback: (eventName: string, data?: unknown) => void; - showWidget: () => void; - hideWidget: () => void; - resetDocumentStyle: () => void; - setFullScreenDocumentMobile: () => void; -}; - -const api: ApiTypes = { - popup: null, - - ready() { - ready = true; - if (hookQueue.length > 0) { - hookQueue.forEach(function (hookParams) { - // There is an existing issue with overload resolution with type union arguments please see https://github.com/microsoft/TypeScript/issues/14107 - // @ts-expect-error: A spread argument must either have a tuple type or be passed to a rest parameter - callHook(...hookParams); - }); - hookQueue = []; - } - }, - - minimizeWindow() { - closeWidget(); - }, - - restoreWindow() { - if (api.popup && api.popup.closed !== true) { - api.popup.close(); - api.popup = null; - } - openWidget(); - }, - - openPopout() { - closeWidget(); - if (!config.url) { - throw new Error('Config.url is not set!'); - } - api.popup = window.open( - `${config.url}${config.url.lastIndexOf('?') > -1 ? '&' : '?'}mode=popout`, - 'livechat-popout', - `width=${WIDGET_OPEN_WIDTH}, height=${widgetHeight}, toolbars=no`, - ); - api.popup?.focus(); - }, - - openWidget() { - openWidget(); - }, - - resizeWidget(height: number) { - resizeWidget(height); - }, - - removeWidget() { - document.body.removeChild(widget as Node); - }, - - callback(eventName, data) { - emitCallback(eventName, data); - }, - - showWidget() { - if (!iframe) { - throw new Error('Widget is not initialized'); - } - iframe.style.display = 'initial'; - emitCallback('show-widget'); - }, - - hideWidget() { - if (!iframe) { - throw new Error('Widget is not initialized'); - } - iframe.style.display = 'none'; - emitCallback('hide-widget'); - }, - - resetDocumentStyle() { - document.body.classList.remove('rc-livechat-mobile-full-screen'); - }, - - setFullScreenDocumentMobile() { - smallScreen && document.body.classList.add('rc-livechat-mobile-full-screen'); - }, -}; - function pageVisited(change: string) { - callHook('pageVisited', [ - { - change, - location: JSON.parse(JSON.stringify(document.location)), - title: document.title, - }, - ]); + callHook('pageVisited', { + change, + location: JSON.parse(JSON.stringify(document.location)), + title: document.title, + }); } -function setCustomField(key: string, value: string, overwrite: boolean) { +function setCustomField(key: string, value = '', overwrite = true) { if (typeof overwrite === 'undefined') { overwrite = true; } @@ -307,51 +264,62 @@ function setCustomField(key: string, value: string, overwrite: boolean) { return; } - callHook('setCustomField', [key, value, overwrite]); + callHook('setCustomField', key, value, overwrite); } -function setTheme(theme: ApiArgs<'setTheme'>[0]) { - callHook('setTheme', [theme]); +function setCustomFields(fields: [key: string, value: string, overwrite?: boolean][]) { + if (!Array.isArray(fields)) { + console.log('Error: Invalid parameters. Value must be an array of objects'); + return; + } + + fields.forEach(([key, value, overwrite = true]) => { + setCustomField(key, value, overwrite); + }); +} + +function setTheme(theme: { theme: StoreState['iframe']['theme'] }) { + callHook('setTheme', theme); } -function setDepartment(department: ApiArgs<'setDepartment'>[0]) { - callHook('setDepartment', [department]); +function setDepartment(department: string) { + callHook('setDepartment', department); } -function setBusinessUnit(businessUnit: ApiArgs<'setBusinessUnit'>[0]) { - callHook('setBusinessUnit', [businessUnit]); +function setBusinessUnit(businessUnit: string) { + callHook('setBusinessUnit', businessUnit); } function clearBusinessUnit() { callHook('clearBusinessUnit'); } -function setGuestToken(token: ApiArgs<'setGuestToken'>[0]) { - callHook('setGuestToken', [token]); +function setGuestToken(token: string) { + callHook('setGuestToken', token); } -function setGuestName(name: ApiArgs<'setGuestName'>[0]) { - callHook('setGuestName', [name]); +function setGuestName(name: string) { + callHook('setGuestName', name); } -function setGuestEmail(email: ApiArgs<'setGuestEmail'>[0]) { - callHook('setGuestEmail', [email]); +function setGuestEmail(email: string) { + callHook('setGuestEmail', email); } -function registerGuest(guest: ApiArgs<'registerGuest'>[0]) { - callHook('registerGuest', [guest]); +function registerGuest(guest: StoreState['guest']) { + callHook('registerGuest', guest); } function clearDepartment() { callHook('clearDepartment'); } -function setAgent(agent: ApiArgs<'setAgent'>[0]) { - callHook('setAgent', [agent]); +function setAgent(agent: StoreState['defaultAgent']) { + callHook('setAgent', agent); } -function setLanguage(lang: ApiArgs<'setLanguage'>[0]) { - callHook('setLanguage', [lang]); +function setLanguage(lang: string) { + callHook('setLanguage', lang); } function showWidget() { @@ -374,90 +342,58 @@ function minimizeWidget() { emitCallback('chat-minimized'); } -function setParentUrl(url?: ApiArgs<'setParentUrl'>[0]) { - callHook('setParentUrl', [url]); -} - -function isDefined(val: T | undefined | null): val is T { - return val !== undefined && val !== null; +function setParentUrl(url: string) { + callHook('setParentUrl', url); } -function initialize(params: { - customField?: ApiArgs<'setCustomField'>; - setCustomFields?: ApiArgs<'setCustomField'>[]; - theme?: ApiArgs<'setTheme'>[0]; - department?: ApiArgs<'setDepartment'>[0]; - businessUnit?: ApiArgs<'setBusinessUnit'>[0]; - guestToken?: ApiArgs<'setGuestToken'>[0]; - guestName?: ApiArgs<'setGuestName'>[0]; - guestEmail?: ApiArgs<'setGuestEmail'>[0]; - registerGuest?: ApiArgs<'registerGuest'>[0]; - language?: ApiArgs<'setLanguage'>[0]; - agent?: ApiArgs<'setAgent'>[0]; - parentUrl?: ApiArgs<'setParentUrl'>[0]; -}) { - for (const method in params) { - if (!params.hasOwnProperty(method)) { +function initialize(initParams: Partial) { + for (const initKey in initParams) { + if (!initParams.hasOwnProperty(initKey)) { continue; } - const param = params[method as keyof typeof params]; + const params = initParams[initKey as keyof InitializeParams]; - if (!isDefined(param)) { + if (!isDefined(params)) { continue; } - switch (method) { + switch (initKey) { case 'customField': - const customFieldParam = param as ApiArgs<'setCustomField'>; - const key = customFieldParam[0]; - const value = customFieldParam[1] || ''; - const overwrite = customFieldParam[2]; - setCustomField(key, value, overwrite); + setCustomField(...(params as InitializeParams['customField'])); continue; case 'setCustomFields': - const customFieldsParam = param as ApiArgs<'setCustomField'>[]; - if (!Array.isArray(customFieldsParam)) { - console.log('Error: Invalid parameters. Value must be an array of objects'); - continue; - } - customFieldsParam.forEach((data: ApiArgs<'setCustomField'>) => { - const key = data[0]; - const value = data[1] || ''; - const overwrite = data[2]; - setCustomField(key, value, overwrite); - }); + setCustomFields(params as InitializeParams['setCustomFields']); continue; case 'theme': - setTheme(param as ApiArgs<'setTheme'>[0]); + setTheme(params as InitializeParams['theme']); continue; case 'department': - setDepartment(param as ApiArgs<'setDepartment'>[0]); + setDepartment(params as InitializeParams['department']); continue; - case 'businessUnit': { - setBusinessUnit(param as ApiArgs<'setBusinessUnit'>[0]); + case 'businessUnit': + setBusinessUnit(params as InitializeParams['businessUnit']); continue; - } case 'guestToken': - setGuestToken(param as ApiArgs<'setGuestToken'>[0]); + setGuestToken(params as InitializeParams['guestToken']); continue; case 'guestName': - setGuestName(param as ApiArgs<'setGuestName'>[0]); + setGuestName(params as InitializeParams['guestName']); continue; case 'guestEmail': - setGuestEmail(param as ApiArgs<'setGuestEmail'>[0]); + setGuestEmail(params as InitializeParams['guestEmail']); continue; case 'registerGuest': - registerGuest(param as ApiArgs<'registerGuest'>[0]); + registerGuest(params as InitializeParams['registerGuest']); continue; case 'language': - setLanguage(param as ApiArgs<'setLanguage'>[0]); + setLanguage(params as InitializeParams['language']); continue; case 'agent': - setAgent(param as ApiArgs<'setAgent'>[0]); + setAgent(params as InitializeParams['agent']); continue; case 'parentUrl': - setParentUrl(param as ApiArgs<'setParentUrl'>[0]); + setParentUrl(params as InitializeParams['parentUrl']); continue; default: continue; @@ -465,32 +401,192 @@ function initialize(params: { } } +const api: InternalWidgetAPI = { + popup: null, + + openWidget, + + resizeWidget, + + ready() { + ready = true; + processHookQueue(); + }, + + minimizeWindow() { + closeWidget(); + }, + + restoreWindow() { + if (api.popup && api.popup.closed !== true) { + api.popup.close(); + api.popup = null; + } + openWidget(); + }, + + openPopout() { + closeWidget(); + if (!config.url) { + throw new Error('Config.url is not set!'); + } + api.popup = window.open( + `${config.url}${config.url.lastIndexOf('?') > -1 ? '&' : '?'}mode=popout`, + 'livechat-popout', + `width=${WIDGET_OPEN_WIDTH}, height=${widgetHeight}, toolbars=no`, + ); + api.popup?.focus(); + }, + + removeWidget() { + document.body.removeChild(widget as Node); + }, + + callback(eventName, data) { + emitCallback(eventName, data); + }, + + showWidget() { + if (!iframe) { + throw new Error('Widget is not initialized'); + } + iframe.style.display = 'initial'; + emitCallback('show-widget'); + }, + + hideWidget() { + if (!iframe) { + throw new Error('Widget is not initialized'); + } + iframe.style.display = 'none'; + emitCallback('hide-widget'); + }, + + resetDocumentStyle() { + document.body.classList.remove('rc-livechat-mobile-full-screen'); + }, + + setFullScreenDocumentMobile() { + smallScreen && document.body.classList.add('rc-livechat-mobile-full-screen'); + }, +}; + +const livechatWidgetAPI = { + // initParams + initialize, + pageVisited, + setCustomField, + setTheme, + setDepartment, + clearDepartment, + setGuestToken, + setGuestName, + setGuestEmail, + setAgent, + registerGuest, + setLanguage, + showWidget, + hideWidget, + maximizeWidget, + minimizeWidget, + setBusinessUnit, + clearBusinessUnit, + setParentUrl, + clearAllCallbacks, + + // callbacks + onChatMaximized(fn: () => void) { + registerCallback('chat-maximized', fn); + }, + onChatMinimized(fn: () => void) { + registerCallback('chat-minimized', fn); + }, + onChatStarted(fn: () => void) { + registerCallback('chat-started', fn); + }, + onChatEnded(fn: () => void) { + registerCallback('chat-ended', fn); + }, + onPrechatFormSubmit( + fn: () => { + name: string; + email: string; + department?: string; + }, + ) { + registerCallback('pre-chat-form-submit', fn); + }, + onOfflineFormSubmit( + fn: () => { + name: string; + email: string; + department?: string; + message: string; + }, + ) { + registerCallback('offline-form-submit', fn); + }, + onWidgetShown(fn: () => void) { + registerCallback('show-widget', fn); + }, + onWidgetHidden(fn: () => void) { + registerCallback('hide-widget', fn); + }, + onAssignAgent( + fn: () => { + name: string | undefined; + username: string | undefined; + status: UserStatus | undefined; + }, + ) { + registerCallback('assign-agent', fn); + }, + onAgentStatusChange( + fn: () => { + name: string | undefined; + username: string | undefined; + status: UserStatus | undefined; + }, + ) { + registerCallback('agent-status-change', fn); + }, + onQueuePositionChange(fn: () => LivechatRoomEvents<'queueData' | 'agentData'>) { + registerCallback('queue-position-change', fn); + }, + onServiceOffline(fn: () => void) { + registerCallback('no-agent-online', fn); + }, +}; + const currentPage: { href: string | null; title: string | null } = { href: null, title: null, }; -function onNewMessage(msg: MessageEvent<{ src?: string; fn: T; args: ApiArgs }>) { - if (msg.source === msg.target) { +function onNewMessage(event: MessageEvent>>) { + if (event.source === event.target) { return; } - if (!msg.data || typeof msg.data !== 'object') { + if (!event.data || typeof event.data !== 'object') { return; } - if (!msg.data.src || msg.data.src !== 'rocketchat') { + if (!event.data.src || event.data.src !== 'rocketchat') { return; } - const { fn } = msg.data; + const { fn, args } = event.data; - const args = msg.data.args && !Array.isArray(msg.data.args) ? [msg.data.args] : msg.data.args; + if (!api.hasOwnProperty(fn)) { + return; + } // There is an existing issue with overload resolution with type union arguments please see https://github.com/microsoft/TypeScript/issues/14107 // @ts-expect-error: A spread argument must either have a tuple type or be passed to a rest parameter api[fn](...args); } + const attachMessageListener = () => { window.addEventListener('message', onNewMessage, false); }; @@ -536,69 +632,8 @@ const queue = window.RocketChat._; window.RocketChat._.push = function (c: () => void) { c.call(window.RocketChat.livechat); }; -window.RocketChat = window.RocketChat._.push; -const livechatWidgetAPI = { - // methods - pageVisited, - setCustomField, - initialize, - setTheme, - setDepartment, - clearDepartment, - setGuestToken, - setGuestName, - setGuestEmail, - setAgent, - registerGuest, - setLanguage, - showWidget, - hideWidget, - maximizeWidget, - minimizeWidget, - setBusinessUnit, - clearBusinessUnit, - setParentUrl, - clearAllCallbacks, - - // callbacks - onChatMaximized(fn: () => unknown) { - registerCallback('chat-maximized', fn); - }, - onChatMinimized(fn: () => unknown) { - registerCallback('chat-minimized', fn); - }, - onChatStarted(fn: () => unknown) { - registerCallback('chat-started', fn); - }, - onChatEnded(fn: () => unknown) { - registerCallback('chat-ended', fn); - }, - onPrechatFormSubmit(fn: () => unknown) { - registerCallback('pre-chat-form-submit', fn); - }, - onOfflineFormSubmit(fn: () => unknown) { - registerCallback('offline-form-submit', fn); - }, - onWidgetShown(fn: () => unknown) { - registerCallback('show-widget', fn); - }, - onWidgetHidden(fn: () => unknown) { - registerCallback('hide-widget', fn); - }, - onAssignAgent(fn: () => unknown) { - registerCallback('assign-agent', fn); - }, - onAgentStatusChange(fn: () => unknown) { - registerCallback('agent-status-change', fn); - }, - onQueuePositionChange(fn: () => unknown) { - registerCallback('queue-position-change', fn); - }, - onServiceOffline(fn: () => unknown) { - registerCallback('no-agent-online', fn); - }, -}; +window.RocketChat = window.RocketChat._.push; // exports window.RocketChat.livechat = livechatWidgetAPI; From 2aae9d168b39848bbd44b8aa2347dabe4056fb4b Mon Sep 17 00:00:00 2001 From: Aleksander Nicacio da Silva Date: Fri, 9 Feb 2024 17:29:18 -0300 Subject: [PATCH 09/17] chore: minor fixes --- packages/livechat/src/lib/hooks.ts | 6 +++--- .../livechat/src/routes/SwitchDepartment/index.tsx | 12 +++++------- packages/livechat/src/store/index.tsx | 8 ++++---- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/packages/livechat/src/lib/hooks.ts b/packages/livechat/src/lib/hooks.ts index 819f8e6cc2939..a4ef58aa333b6 100644 --- a/packages/livechat/src/lib/hooks.ts +++ b/packages/livechat/src/lib/hooks.ts @@ -66,11 +66,11 @@ const api = { Livechat.sendVisitorNavigation({ token, rid, pageInfo: { change, title, location: { href } } }); }, - setCustomField: (key: string, value = '', overwrite: boolean) => { + setCustomField: (key: string, value = '', overwrite = true) => { CustomFields.setCustomField(key, value, overwrite); }, - setTheme: ({ theme: { color, fontColor, iconColor, title, offlineTitle } }: { theme: StoreState['iframe']['theme'] }) => { + setTheme: ({ color, fontColor, iconColor, title, offlineTitle }: StoreState['iframe']['theme']) => { const { iframe, iframe: { theme }, @@ -196,7 +196,7 @@ const api = { await createOrUpdateGuest(data); }, - setLanguage: async ({ language }: { language: StoreState['iframe']['language'] }) => { + setLanguage: async (language: StoreState['iframe']['language']) => { const { iframe } = store.state; await store.setState({ iframe: { ...iframe, language } }); i18next.changeLanguage(language); diff --git a/packages/livechat/src/routes/SwitchDepartment/index.tsx b/packages/livechat/src/routes/SwitchDepartment/index.tsx index a4ea63c44460a..73b815de454cc 100644 --- a/packages/livechat/src/routes/SwitchDepartment/index.tsx +++ b/packages/livechat/src/routes/SwitchDepartment/index.tsx @@ -61,8 +61,10 @@ const SwitchDepartment = ({ screenProps }: { screenProps: { [key: string]: unkno if (!room) { const { visitor: user } = await Livechat.grantVisitor({ visitor: { department, token } }); - await dispatch({ user } as Omit); - await dispatch({ alerts: (alerts.push({ id: createToken(), children: t('department_switched'), success: true }), alerts) }); + await dispatch({ + user: user as StoreState['user'], + alerts: (alerts.push({ id: createToken(), children: t('department_switched'), success: true }), alerts), + }); return route('/'); } @@ -86,11 +88,7 @@ const SwitchDepartment = ({ screenProps }: { screenProps: { [key: string]: unkno throw t('no_available_agents_to_transfer'); } - await dispatch({ iframe: { ...iframe, guest: { ...guest, department } }, loading: false } as { - iframe: StoreState['iframe']; - guest: StoreState['iframe']['guest']; - loading: boolean; - }); + await dispatch({ iframe: { ...iframe, guest: { ...guest, department } }, loading: false } as Pick); await loadConfig(); await ModalManager.alert({ diff --git a/packages/livechat/src/store/index.tsx b/packages/livechat/src/store/index.tsx index 6bc496f347347..c953b34f9b932 100644 --- a/packages/livechat/src/store/index.tsx +++ b/packages/livechat/src/store/index.tsx @@ -1,4 +1,4 @@ -import type { ILivechatVisitor, ILivechatVisitorDTO } from '@rocket.chat/core-typings'; +import type { ILivechatVisitor, ILivechatVisitorDTO, Serialized } from '@rocket.chat/core-typings'; import type { ComponentChildren } from 'preact'; import { Component, createContext } from 'preact'; @@ -34,15 +34,15 @@ export type StoreState = { enabled?: boolean; }; messages: any[]; - user?: ILivechatVisitor; - guest?: ILivechatVisitorDTO; + user?: Serialized; + guest?: Serialized; sound: { src?: string; play?: boolean; enabled: boolean; }; iframe: { - guest?: ILivechatVisitorDTO; + guest?: Serialized; theme?: any; visible?: boolean; department?: string; From db0b6bd550749d378a49cbc2a4959c340e32f13a Mon Sep 17 00:00:00 2001 From: Aleksander Nicacio da Silva Date: Sat, 10 Feb 2024 16:00:55 -0300 Subject: [PATCH 10/17] chore: adjusted webpack config --- packages/livechat/webpack.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/livechat/webpack.config.ts b/packages/livechat/webpack.config.ts index a93272696df3c..a33660198e878 100644 --- a/packages/livechat/webpack.config.ts +++ b/packages/livechat/webpack.config.ts @@ -162,7 +162,7 @@ const config = (_env: any, args: webpack.WebpackOptionsNormalized): webpack.Conf { ...common(args), entry: { - script: _('./src/widget.ts'), + 'rocketchat-livechat.min': _('./src/widget.ts'), } as webpack.Entry, output: { path: _('./dist'), From cc6112feaef0ff116bbe3a58e51cc54659e7e073 Mon Sep 17 00:00:00 2001 From: Aleksander Nicacio da Silva Date: Wed, 14 Feb 2024 15:15:05 -0300 Subject: [PATCH 11/17] fix: revert api method signature change --- packages/livechat/src/widget.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/livechat/src/widget.ts b/packages/livechat/src/widget.ts index 6effdb54e680a..e43bd39438445 100644 --- a/packages/livechat/src/widget.ts +++ b/packages/livechat/src/widget.ts @@ -31,7 +31,7 @@ export type LivechatMessageEventData> = { type InitializeParams = { customField: [key: string, value: string, overwrite?: boolean]; setCustomFields: [key: string, value: string, overwrite?: boolean][]; - theme: { theme: StoreState['iframe']['theme'] }; + theme: StoreState['iframe']['theme']; department: string; businessUnit: string; guestToken: string; @@ -278,7 +278,7 @@ function setCustomFields(fields: [key: string, value: string, overwrite?: boolea }); } -function setTheme(theme: { theme: StoreState['iframe']['theme'] }) { +function setTheme(theme: StoreState['iframe']['theme']) { callHook('setTheme', theme); } From a1f6b9dc6ea6dad2ee8c6630e89ff6b7af21508b Mon Sep 17 00:00:00 2001 From: Aleksander Nicacio da Silva Date: Thu, 15 Feb 2024 12:34:18 -0300 Subject: [PATCH 12/17] chore: added typing to the theme object --- packages/livechat/src/store/index.tsx | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/livechat/src/store/index.tsx b/packages/livechat/src/store/index.tsx index c953b34f9b932..67af47e70ccdb 100644 --- a/packages/livechat/src/store/index.tsx +++ b/packages/livechat/src/store/index.tsx @@ -14,7 +14,24 @@ export type StoreState = { typing: string[]; config: { messages: any; - theme: any; + theme: { + title?: string; + color?: string; + offlineTitle?: string; + offlineColor?: string; + actionLinks?: { + webrtc: { + actionLinksAlignment: string; + i18nLabel: string; + label: string; + method_id: string; + }[]; + jitsi: { + icon: string; + i18nLabel: string; + }[]; + }; + }; triggers: any[]; resources: any; settings: { @@ -43,7 +60,13 @@ export type StoreState = { }; iframe: { guest?: Serialized; - theme?: any; + theme: { + title?: string; + color?: string; + fontColor?: string; + iconColor?: string; + offlineTitle?: string; + }; visible?: boolean; department?: string; language?: string; From 0617ce9192d21909d1fa7abeaf1604461f1a60ce Mon Sep 17 00:00:00 2001 From: Aleksander Nicacio da Silva Date: Thu, 15 Feb 2024 19:20:27 -0300 Subject: [PATCH 13/17] refactor: created ScreenContext and removed redundant connectors --- packages/livechat/.storybook/helpers.tsx | 5 +- packages/livechat/src/components/App/App.tsx | 128 ++-------------- .../src/components/Screen/ScreenProvider.tsx | 142 +++++++++++++++++ .../livechat/src/components/Screen/index.js | 143 +++++++++--------- .../livechat/src/helpers/canRenderMessage.ts | 5 +- packages/livechat/src/helpers/formatAgent.ts | 32 ++++ .../livechat/src/routes/Chat/connector.tsx | 89 +++-------- .../livechat/src/routes/Chat/container.js | 119 +-------------- packages/livechat/src/routes/Chat/stories.tsx | 3 +- .../src/routes/ChatFinished/component.js | 38 ----- .../src/routes/ChatFinished/component.tsx | 42 +++++ .../src/routes/ChatFinished/container.js | 38 ----- .../src/routes/ChatFinished/container.tsx | 24 +++ .../src/routes/ChatFinished/stories.tsx | 3 +- .../{component.js => component.tsx} | 26 ++-- .../src/routes/GDPRAgreement/container.js | 41 ----- .../src/routes/GDPRAgreement/container.tsx | 21 +++ .../src/routes/GDPRAgreement/stories.tsx | 3 +- .../src/routes/LeaveMessage/index.tsx | 15 +- .../livechat/src/routes/Register/index.tsx | 23 +-- .../livechat/src/routes/Register/stories.tsx | 5 +- .../src/routes/SwitchDepartment/index.tsx | 6 +- .../src/routes/SwitchDepartment/stories.tsx | 3 +- .../{component.js => component.tsx} | 32 ++-- .../src/routes/TriggerMessage/container.js | 67 -------- .../src/routes/TriggerMessage/container.tsx | 39 +++++ .../src/routes/TriggerMessage/index.ts | 2 +- .../src/routes/TriggerMessage/stories.tsx | 3 +- .../src/routes/TriggerMessage/styles.scss | 2 +- packages/livechat/src/store/index.tsx | 27 +++- 30 files changed, 492 insertions(+), 634 deletions(-) create mode 100644 packages/livechat/src/components/Screen/ScreenProvider.tsx create mode 100644 packages/livechat/src/helpers/formatAgent.ts delete mode 100644 packages/livechat/src/routes/ChatFinished/component.js create mode 100644 packages/livechat/src/routes/ChatFinished/component.tsx delete mode 100644 packages/livechat/src/routes/ChatFinished/container.js create mode 100644 packages/livechat/src/routes/ChatFinished/container.tsx rename packages/livechat/src/routes/GDPRAgreement/{component.js => component.tsx} (80%) delete mode 100644 packages/livechat/src/routes/GDPRAgreement/container.js create mode 100644 packages/livechat/src/routes/GDPRAgreement/container.tsx rename packages/livechat/src/routes/TriggerMessage/{component.js => component.tsx} (55%) delete mode 100644 packages/livechat/src/routes/TriggerMessage/container.js create mode 100644 packages/livechat/src/routes/TriggerMessage/container.tsx diff --git a/packages/livechat/.storybook/helpers.tsx b/packages/livechat/.storybook/helpers.tsx index 12c896dccf55c..82d6c5bf74fa5 100644 --- a/packages/livechat/.storybook/helpers.tsx +++ b/packages/livechat/.storybook/helpers.tsx @@ -3,12 +3,15 @@ import { type DecoratorFunction } from '@storybook/csf'; import type { Args, PreactFramework } from '@storybook/preact'; import { loremIpsum as originalLoremIpsum } from 'lorem-ipsum'; +import { ScreenContext } from '../src/components/Screen/ScreenProvider'; import gazzoAvatar from './assets/gazzo.jpg'; import martinAvatar from './assets/martin.jpg'; import tassoAvatar from './assets/tasso.jpg'; export const screenDecorator: DecoratorFunction = (storyFn) => ( -
{storyFn()}
+
+ {storyFn()} +
); export const screenProps = () => ({ diff --git a/packages/livechat/src/components/App/App.tsx b/packages/livechat/src/components/App/App.tsx index cfaa52b94999d..685b781cb3e38 100644 --- a/packages/livechat/src/components/App/App.tsx +++ b/packages/livechat/src/components/App/App.tsx @@ -2,12 +2,10 @@ import type { ILivechatTrigger } from '@rocket.chat/core-typings'; import i18next from 'i18next'; import { Component } from 'preact'; import Router, { route } from 'preact-router'; -import { parse } from 'query-string'; import { withTranslation } from 'react-i18next'; import type { Department } from '../../definitions/departments'; import { setInitCookies } from '../../helpers/cookies'; -import { isActiveSession } from '../../helpers/isActiveSession'; import { isRTL } from '../../helpers/isRTL'; import { visibility } from '../../helpers/visibility'; import history from '../../history'; @@ -25,7 +23,7 @@ import Register from '../../routes/Register'; import SwitchDepartment from '../../routes/SwitchDepartment'; import TriggerMessage from '../../routes/TriggerMessage'; import type { Dispatch } from '../../store'; -import store from '../../store'; +import { ScreenProvider } from '../Screen/ScreenProvider'; type AppProps = { config: { @@ -75,26 +73,6 @@ type AppState = { poppedOut: boolean; }; -export type ScreenPropsType = { - notificationsEnabled: boolean; - minimized: boolean; - expanded: boolean; - windowed: boolean; - sound: unknown; - alerts: unknown; - modal: unknown; - nameDefault: string; - emailDefault: string; - departmentDefault: string; - onEnableNotifications: () => unknown; - onDisableNotifications: () => unknown; - onMinimize: () => unknown; - onRestore: () => unknown; - onOpenWindow: () => unknown; - onDismissAlert: () => unknown; - dismissNotification: () => void; -}; - export class App extends Component { state = { initialized: false, @@ -150,49 +128,6 @@ export class App extends Component { Triggers.processTriggers(); } - protected handleEnableNotifications = () => { - const { dispatch, sound = {} } = this.props; - dispatch({ sound: { ...sound, enabled: true } }); - }; - - protected handleDisableNotifications = () => { - const { dispatch, sound = {} } = this.props; - dispatch({ sound: { ...sound, enabled: false } }); - }; - - protected handleMinimize = () => { - parentCall('minimizeWindow'); - const { dispatch } = this.props; - dispatch({ minimized: true }); - }; - - protected handleRestore = () => { - parentCall('restoreWindow'); - const { dispatch, undocked } = this.props; - const dispatchRestore = () => dispatch({ minimized: false, undocked: false }); - const dispatchEvent = () => { - dispatchRestore(); - store.off('storageSynced', dispatchEvent); - }; - if (undocked) { - store.on('storageSynced', dispatchEvent); - } else { - dispatchRestore(); - } - Triggers.callbacks?.emit('chat-opened-by-visitor'); - }; - - protected handleOpenWindow = () => { - parentCall('openPopout'); - const { dispatch } = this.props; - dispatch({ undocked: true, minimized: false }); - }; - - protected handleDismissAlert = (id: string) => { - const { dispatch, alerts = [] } = this.props; - dispatch({ alerts: alerts.filter((alert) => alert.id !== id) }); - }; - protected handleVisibilityChange = async () => { const { dispatch } = this.props; dispatch({ visible: !visibility.hidden }); @@ -202,19 +137,20 @@ export class App extends Component { this.forceUpdate(); }; - protected dismissNotification = () => !isActiveSession(); - protected initWidget() { const { minimized, iframe: { visible }, dispatch, } = this.props; + parentCall(minimized ? 'minimizeWindow' : 'restoreWindow'); parentCall(visible ? 'showWidget' : 'hideWidget'); visibility.addListener(this.handleVisibilityChange); + this.handleVisibilityChange(); + window.addEventListener('beforeunload', () => { visibility.removeListener(this.handleVisibilityChange); dispatch({ minimized: true, undocked: false }); @@ -223,16 +159,6 @@ export class App extends Component { i18next.on('languageChanged', this.handleLanguageChange); } - protected checkPoppedOutWindow() { - // Checking if the window is poppedOut and setting parent minimized if yes for the restore purpose - const { dispatch } = this.props; - const poppedOut = parse(window.location.search).mode === 'popout'; - this.setState({ poppedOut }); - if (poppedOut) { - dispatch({ minimized: false }); - } - } - protected async initialize() { // TODO: split these behaviors into composable components await Connection.init(); @@ -241,7 +167,6 @@ export class App extends Component { Hooks.init(); this.handleTriggers(); this.initWidget(); - this.checkPoppedOutWindow(); this.setState({ initialized: true }); parentCall('ready'); } @@ -268,44 +193,23 @@ export class App extends Component { } } - render = ({ sound, undocked, minimized, expanded, alerts, modal, iframe }: AppProps, { initialized, poppedOut }: AppState) => { + render = (_: AppProps, { initialized }: AppState) => { if (!initialized) { return null; } - const { department, name, email } = iframe.guest || {}; - - const screenProps = { - notificationsEnabled: sound?.enabled, - minimized: !poppedOut && (minimized || undocked), - expanded: !minimized && expanded, - windowed: !minimized && poppedOut, - sound, - alerts, - modal, - nameDefault: name, - emailDefault: email, - departmentDefault: department, - onEnableNotifications: this.handleEnableNotifications, - onDisableNotifications: this.handleDisableNotifications, - onMinimize: this.handleMinimize, - onRestore: this.handleRestore, - onOpenWindow: this.handleOpenWindow, - onDismissAlert: this.handleDismissAlert, - dismissNotification: this.dismissNotification, - }; - return ( - - - - - {/* TODO: Find a better way to avoid prop drilling with that amout of props (perhaps create a screen context/provider) */} - - - - - + + + + + + + + + + + ); }; } diff --git a/packages/livechat/src/components/Screen/ScreenProvider.tsx b/packages/livechat/src/components/Screen/ScreenProvider.tsx new file mode 100644 index 0000000000000..eb0a7e1a232b8 --- /dev/null +++ b/packages/livechat/src/components/Screen/ScreenProvider.tsx @@ -0,0 +1,142 @@ +import type { FunctionalComponent } from 'preact'; +import { createContext } from 'preact'; +import { useCallback, useContext, useEffect, useState } from 'preact/hooks'; +import { parse } from 'query-string'; + +import { isActiveSession } from '../../helpers/isActiveSession'; +import { parentCall } from '../../lib/parentCall'; +import Triggers from '../../lib/triggers'; +import store, { StoreContext } from '../../store'; + +export type ScreenContextValue = { + notificationsEnabled: boolean; + minimized: boolean; + expanded: boolean; + windowed: boolean; + sound: unknown; + alerts: unknown; + modal: unknown; + nameDefault: string; + emailDefault: string; + departmentDefault: string; + onEnableNotifications: () => unknown; + onDisableNotifications: () => unknown; + onMinimize: () => unknown; + onRestore: () => unknown; + onOpenWindow: () => unknown; + onDismissAlert: () => unknown; + dismissNotification: () => void; + theme?: { + color: string; + fontColor: string; + iconColor: string; + }; +}; + +export const ScreenContext = createContext({ + theme: { + color: '', + fontColor: '', + iconColor: '', + }, + notificationsEnabled: true, + minimized: true, + windowed: false, + onEnableNotifications: () => undefined, + onDisableNotifications: () => undefined, + onMinimize: () => undefined, + onRestore: () => undefined, + onOpenWindow: () => undefined, +} as ScreenContextValue); + +export const ScreenProvider: FunctionalComponent = ({ children }) => { + const { dispatch, config, sound, minimized = true, undocked, expanded = false, alerts, modal, iframe } = useContext(StoreContext); + const { department, name, email } = iframe.guest || {}; + const { color } = config.theme || {}; + const { color: customColor, fontColor: customFontColor, iconColor: customIconColor } = iframe.theme || {}; + + const [poppedOut, setPopedOut] = useState(false); + + const handleEnableNotifications = () => { + dispatch({ sound: { ...sound, enabled: true } }); + }; + + const handleDisableNotifications = () => { + dispatch({ sound: { ...sound, enabled: false } }); + }; + + const handleMinimize = () => { + parentCall('minimizeWindow'); + dispatch({ minimized: true }); + }; + + const handleRestore = () => { + parentCall('restoreWindow'); + const dispatchRestore = () => dispatch({ minimized: false, undocked: false }); + + const dispatchEvent = () => { + dispatchRestore(); + store.off('storageSynced', dispatchEvent); + }; + + if (undocked) { + store.on('storageSynced', dispatchEvent); + } else { + dispatchRestore(); + } + + Triggers.callbacks?.emit('chat-opened-by-visitor'); + }; + + const handleOpenWindow = () => { + parentCall('openPopout'); + dispatch({ undocked: true, minimized: false }); + }; + + const handleDismissAlert = (id: string) => { + dispatch({ alerts: alerts.filter((alert) => alert.id !== id) }); + }; + + const dismissNotification = () => !isActiveSession(); + + const checkPoppedOutWindow = useCallback(() => { + // Checking if the window is poppedOut and setting parent minimized if yes for the restore purpose + const poppedOut = parse(window.location.search).mode === 'popout'; + setPopedOut(poppedOut); + + if (poppedOut) { + dispatch({ minimized: false }); + } + }, [dispatch]); + + useEffect(() => { + checkPoppedOutWindow(); + }, [checkPoppedOutWindow]); + + const screenProps = { + theme: { + color: color || customColor, + fontColor: customFontColor, + iconColor: customIconColor, + }, + notificationsEnabled: sound?.enabled, + minimized: !poppedOut && (minimized || undocked), + expanded: !minimized && expanded, + windowed: !minimized && poppedOut, + sound, + alerts, + modal, + nameDefault: name, + emailDefault: email, + departmentDefault: department, + onEnableNotifications: handleEnableNotifications, + onDisableNotifications: handleDisableNotifications, + onMinimize: handleMinimize, + onRestore: handleRestore, + onOpenWindow: handleOpenWindow, + onDismissAlert: handleDismissAlert, + dismissNotification, + }; + + return {children}; +}; diff --git a/packages/livechat/src/components/Screen/index.js b/packages/livechat/src/components/Screen/index.js index e4f4aff1ea4c1..aaf5c826f0119 100644 --- a/packages/livechat/src/components/Screen/index.js +++ b/packages/livechat/src/components/Screen/index.js @@ -1,4 +1,4 @@ -import { useEffect } from 'preact/hooks'; +import { useContext, useEffect } from 'preact/hooks'; import { createClassName } from '../../helpers/createClassName'; import ChatIcon from '../../icons/chat.svg'; @@ -8,6 +8,7 @@ import { Footer, FooterContent, PoweredBy } from '../Footer'; import { PopoverContainer } from '../Popover'; import { Sound } from '../Sound'; import ScreenHeader from './Header'; +import { ScreenContext } from './ScreenProvider'; import styles from './styles.scss'; export const ScreenContent = ({ children, nopadding, triggered = false, full = false }) => ( @@ -74,81 +75,77 @@ const CssVar = ({ theme }) => { }; /** @type {{ (props: any) => JSX.Element; Content: (props: any) => JSX.Element; Footer: (props: any) => JSX.Element }} */ -export const Screen = ({ - theme = {}, - agent, - title, - notificationsEnabled, - minimized = false, - expanded = false, - windowed = false, - children, - className, - alerts, - modal, - unread, - sound, - onDismissAlert, - onEnableNotifications, - onDisableNotifications, - onMinimize, - onRestore, - onOpenWindow, - onSoundStop, - queueInfo, - dismissNotification, - triggered = false, -}) => ( -
- - {triggered && ( - - )} -
- - {!triggered && ( - - )} +export const Screen = ({ title, agent, children, className, unread, triggered = false, queueInfo, onSoundStop }) => { + const { + theme = {}, + notificationsEnabled, + minimized = false, + expanded = false, + windowed = false, + alerts, + modal, + sound, + onDismissAlert, + onEnableNotifications, + onDisableNotifications, + onMinimize, + onRestore, + onOpenWindow, + dismissNotification, + } = useContext(ScreenContext); - {modal} - {children} - -
+ return ( +
+ + {triggered && ( + + )} +
+ + {!triggered && ( + + )} - + {modal} + {children} + +
- {sound && } -
-); + + + {sound && } +
+ ); +}; Screen.Content = ScreenContent; Screen.Footer = ScreenFooter; diff --git a/packages/livechat/src/helpers/canRenderMessage.ts b/packages/livechat/src/helpers/canRenderMessage.ts index ea09408936943..75ab8604caf94 100644 --- a/packages/livechat/src/helpers/canRenderMessage.ts +++ b/packages/livechat/src/helpers/canRenderMessage.ts @@ -26,5 +26,6 @@ const msgTypesNotRendered = [ export const canRenderMessage = ({ t }: { t: string }) => !msgTypesNotRendered.includes(t); -export const canRenderTriggerMessage = (user: { token: string }) => (message: { trigger?: boolean; triggerAfterRegistration?: boolean }) => - !message.trigger || (!user && !message.triggerAfterRegistration) || (user && message.triggerAfterRegistration); +export const canRenderTriggerMessage = + (user: { token: string } | undefined) => (message: { trigger?: boolean; triggerAfterRegistration?: boolean }) => + !message.trigger || (!user && !message.triggerAfterRegistration) || (user && message.triggerAfterRegistration); diff --git a/packages/livechat/src/helpers/formatAgent.ts b/packages/livechat/src/helpers/formatAgent.ts new file mode 100644 index 0000000000000..249733b12c647 --- /dev/null +++ b/packages/livechat/src/helpers/formatAgent.ts @@ -0,0 +1,32 @@ +import { getAvatarUrl } from './baseUrl'; + +type AgentType = { + _id: string; + name: string; + status: string; + emails: [{ address: string }]; + username: string; + phone: [{ phoneNumber: string }]; + customFields: { phone: string }; +}; + +export const formatAgent = (agent: AgentType) => { + if (!agent) { + return; + } + + return { + _id: agent._id, + name: agent.name, + status: agent.status, + email: agent.emails?.[0]?.address, + username: agent.username, + phone: agent.phone?.[0]?.phoneNumber || agent.customFields?.phone, + avatar: agent.username + ? { + description: agent.username, + src: getAvatarUrl(agent.username), + } + : undefined, + }; +}; diff --git a/packages/livechat/src/routes/Chat/connector.tsx b/packages/livechat/src/routes/Chat/connector.tsx index 9493133f44b30..a0897964ddb79 100644 --- a/packages/livechat/src/routes/Chat/connector.tsx +++ b/packages/livechat/src/routes/Chat/connector.tsx @@ -1,47 +1,13 @@ -import type { ComponentProps } from 'preact'; +import type { TFunction } from 'i18next'; +import type { FunctionalComponent } from 'preact'; import { withTranslation } from 'react-i18next'; import { ChatContainer } from '.'; -import { getAvatarUrl } from '../../helpers/baseUrl'; -import { canRenderMessage } from '../../helpers/canRenderMessage'; +import { canRenderMessage, canRenderTriggerMessage } from '../../helpers/canRenderMessage'; +import { formatAgent } from '../../helpers/formatAgent'; import { Consumer } from '../../store'; -type ChatConnectorProps = Omit< - ComponentProps, - | 'theme' - | 'title' - | 'sound' - | 'token' - | 'user' - | 'agent' - | 'room' - | 'messages' - | 'noMoreMessages' - | 'emoji' - | 'uploads' - | 'typingUsernames' - | 'loading' - | 'showConnecting' - | 'connecting' - | 'dispatch' - | 'departments' - | 'allowSwitchingDepartments' - | 'conversationFinishedMessage' - | 'allowRemoveUserData' - | 'alerts' - | 'visible' - | 'unread' - | 'lastReadMessageId' - | 'guest' - | 'triggerAgent' - | 'queueInfo' - | 'registrationFormEnabled' - | 'nameFieldRegistrationForm' - | 'emailFieldRegistrationForm' - | 'limitTextLength' ->; - -const ChatConnector = ({ ref, ...props }: ChatConnectorProps) => ( +export const ChatConnector: FunctionalComponent<{ path: string; default: boolean; t: TFunction }> = ({ ref, t }) => ( {({ config: { @@ -56,13 +22,10 @@ const ChatConnector = ({ ref, ...props }: ChatConnectorProps) => ( limitTextLength, } = {}, messages: { conversationFinishedMessage } = {}, - theme: { color, title } = {}, + theme: { title } = {}, departments = {}, }, - iframe: { - theme: { color: customColor, fontColor: customFontColor, iconColor: customIconColor, title: customTitle } = {}, - guest, - } = {}, + iframe: { theme: { title: customTitle } = {}, guest } = {}, token, agent, sound, @@ -79,40 +42,19 @@ const ChatConnector = ({ ref, ...props }: ChatConnectorProps) => ( lastReadMessageId, triggerAgent, queueInfo, + incomingCallAlert, + ongoingCall, + messageListPosition, }) => ( ( dispatch={dispatch} departments={departments} allowSwitchingDepartments={allowSwitchingDepartments} - conversationFinishedMessage={conversationFinishedMessage || props.t('conversation_finished')} + conversationFinishedMessage={conversationFinishedMessage || t('conversation_finished')} allowRemoveUserData={allowRemoveUserData} alerts={alerts} visible={visible} @@ -144,6 +86,9 @@ const ChatConnector = ({ ref, ...props }: ChatConnectorProps) => ( nameFieldRegistrationForm={nameFieldRegistrationForm} emailFieldRegistrationForm={emailFieldRegistrationForm} limitTextLength={limitTextLength} + incomingCallAlert={incomingCallAlert} + ongoingCall={ongoingCall} + messageListPosition={messageListPosition} /> )} diff --git a/packages/livechat/src/routes/Chat/container.js b/packages/livechat/src/routes/Chat/container.js index 2d23645fb49ec..a6a9ba8451c2c 100644 --- a/packages/livechat/src/routes/Chat/container.js +++ b/packages/livechat/src/routes/Chat/container.js @@ -5,7 +5,7 @@ import { withTranslation } from 'react-i18next'; import { Livechat } from '../../api'; import { ModalManager } from '../../components/Modal'; import { getAvatarUrl } from '../../helpers/baseUrl'; -import { canRenderMessage, canRenderTriggerMessage } from '../../helpers/canRenderMessage'; +import { canRenderMessage } from '../../helpers/canRenderMessage'; import { debounce } from '../../helpers/debounce'; import { throttle } from '../../helpers/throttle'; import { upsert } from '../../helpers/upsert'; @@ -15,7 +15,6 @@ import { getLastReadMessage, loadConfig, processUnread, shouldMarkAsUnread } fro import { parentCall, runCallbackEventEmitter } from '../../lib/parentCall'; import { createToken } from '../../lib/random'; import { initRoom, closeChat, loadMessages, loadMoreMessages, defaultRoomParams, getGreetingMessages } from '../../lib/room'; -import { Consumer } from '../../store'; import Chat from './component'; class ChatContainer extends Component { @@ -373,118 +372,4 @@ class ChatContainer extends Component { ); } -export const ChatConnector = ({ ref, t, ...props }) => ( - - {({ - config: { - settings: { - fileUpload: uploads, - allowSwitchingDepartments, - forceAcceptDataProcessingConsent: allowRemoveUserData, - showConnecting, - registrationForm, - nameFieldRegistrationForm, - emailFieldRegistrationForm, - limitTextLength, - } = {}, - messages: { conversationFinishedMessage } = {}, - theme: { color, title } = {}, - departments = {}, - }, - iframe: { - theme: { color: customColor, fontColor: customFontColor, iconColor: customIconColor, title: customTitle } = {}, - guest, - } = {}, - token, - agent, - sound, - user, - room, - messages, - noMoreMessages, - typing, - loading, - dispatch, - alerts, - visible, - unread, - lastReadMessageId, - triggerAgent, - queueInfo, - incomingCallAlert, - ongoingCall, - messageListPosition, - }) => ( - - )} - -); - -export default withTranslation()(ChatConnector); +export default withTranslation()(ChatContainer); diff --git a/packages/livechat/src/routes/Chat/stories.tsx b/packages/livechat/src/routes/Chat/stories.tsx index 2c91e6b5c1320..c2adcce878cd7 100644 --- a/packages/livechat/src/routes/Chat/stories.tsx +++ b/packages/livechat/src/routes/Chat/stories.tsx @@ -2,7 +2,7 @@ import { action } from '@storybook/addon-actions'; import type { Meta, Story } from '@storybook/preact'; import type { ComponentProps } from 'preact'; -import { screenProps, avatarResolver, beepAudio, screenDecorator } from '../../../.storybook/helpers'; +import { avatarResolver, beepAudio, screenDecorator } from '../../../.storybook/helpers'; import Chat from './component'; const now = new Date(Date.parse('2021-01-01T00:00:00.000Z')); @@ -53,7 +53,6 @@ export default { onBottom: action('bottom'), onUpload: action('upload'), onSubmit: action('submit'), - ...screenProps(), }, decorators: [screenDecorator], parameters: { diff --git a/packages/livechat/src/routes/ChatFinished/component.js b/packages/livechat/src/routes/ChatFinished/component.js deleted file mode 100644 index 8873bd56fa0f9..0000000000000 --- a/packages/livechat/src/routes/ChatFinished/component.js +++ /dev/null @@ -1,38 +0,0 @@ -import { Component } from 'preact'; -import { withTranslation } from 'react-i18next'; - -import { Button } from '../../components/Button'; -import { ButtonGroup } from '../../components/ButtonGroup'; -import Screen from '../../components/Screen'; -import { createClassName } from '../../helpers/createClassName'; -import styles from './styles.scss'; - -class ChatFinished extends Component { - handleClick = () => { - const { onRedirectChat } = this.props; - onRedirectChat && onRedirectChat(); - }; - - render = ({ color, title, greeting, message, onRedirectChat, t, ...props }) => { - const defaultGreeting = t('thanks_for_talking_with_us'); - const defaultMessage = t('if_you_have_any_other_questions_just_press_the_but'); - - return ( - - -

{greeting || defaultGreeting}

-

{message || defaultMessage}

- - - - -
- -
- ); - }; -} - -export default withTranslation()(ChatFinished); diff --git a/packages/livechat/src/routes/ChatFinished/component.tsx b/packages/livechat/src/routes/ChatFinished/component.tsx new file mode 100644 index 0000000000000..3b13f1407cd0f --- /dev/null +++ b/packages/livechat/src/routes/ChatFinished/component.tsx @@ -0,0 +1,42 @@ +import { withTranslation } from 'react-i18next'; + +import { Button } from '../../components/Button'; +import { ButtonGroup } from '../../components/ButtonGroup'; +import Screen from '../../components/Screen'; +import { createClassName } from '../../helpers/createClassName'; +import styles from './styles.scss'; + +type ChatFinishedProps = { + title: string; + greeting?: string; + message?: string; + onRedirectChat?: () => void; + t: (s: string) => string; +}; + +const ChatFinished = ({ title, greeting, message, onRedirectChat, t }: ChatFinishedProps) => { + const handleClick = () => { + onRedirectChat?.(); + }; + + const defaultGreeting = t('thanks_for_talking_with_us'); + const defaultMessage = t('if_you_have_any_other_questions_just_press_the_but'); + + return ( + + +

{greeting || defaultGreeting}

+

{message || defaultMessage}

+ + + + +
+ +
+ ); +}; + +export default withTranslation()(ChatFinished); diff --git a/packages/livechat/src/routes/ChatFinished/container.js b/packages/livechat/src/routes/ChatFinished/container.js deleted file mode 100644 index 601bfe48e8572..0000000000000 --- a/packages/livechat/src/routes/ChatFinished/container.js +++ /dev/null @@ -1,38 +0,0 @@ -import { Component } from 'preact'; -import { route } from 'preact-router'; -import { withTranslation } from 'react-i18next'; - -import { Consumer } from '../../store'; -import ChatFinished from './component'; - -class ChatFinishedContainer extends Component { - handleRedirect = () => { - route('/'); - }; - - render = (props) => ; -} - -const ChatFinishedConnector = ({ ref, t, ...props }) => ( - - {({ - config: { messages: { conversationFinishedMessage: greeting, conversationFinishedText: message } = {}, theme: { color } = {} } = {}, - iframe: { theme: { color: customColor, fontColor: customFontColor, iconColor: customIconColor } = {} } = {}, - }) => ( - - )} - -); - -export default withTranslation()(ChatFinishedConnector); diff --git a/packages/livechat/src/routes/ChatFinished/container.tsx b/packages/livechat/src/routes/ChatFinished/container.tsx new file mode 100644 index 0000000000000..b19481fb221d4 --- /dev/null +++ b/packages/livechat/src/routes/ChatFinished/container.tsx @@ -0,0 +1,24 @@ +import type { TFunction } from 'i18next'; +import type { FunctionalComponent } from 'preact'; +import { route } from 'preact-router'; +import { useContext } from 'preact/hooks'; +import { withTranslation } from 'react-i18next'; + +import { StoreContext } from '../../store'; +import ChatFinished from './component'; + +const ChatFinishedContainer: FunctionalComponent<{ path: string; t: TFunction }> = ({ ref, t }) => { + const { + config: { + messages: { conversationFinishedMessage: greeting, conversationFinishedText: message }, + }, + } = useContext(StoreContext); + + const handleRedirect = () => { + route('/'); + }; + + return ; +}; + +export default withTranslation()(ChatFinishedContainer); diff --git a/packages/livechat/src/routes/ChatFinished/stories.tsx b/packages/livechat/src/routes/ChatFinished/stories.tsx index 6a4cc0f2b259b..18c4c88ab41cd 100644 --- a/packages/livechat/src/routes/ChatFinished/stories.tsx +++ b/packages/livechat/src/routes/ChatFinished/stories.tsx @@ -2,7 +2,7 @@ import { action } from '@storybook/addon-actions'; import type { Meta, Story } from '@storybook/preact'; import type { ComponentProps } from 'preact'; -import { screenProps, loremIpsum, screenDecorator } from '../../../.storybook/helpers'; +import { loremIpsum, screenDecorator } from '../../../.storybook/helpers'; import ChatFinished from './component'; export default { @@ -13,7 +13,6 @@ export default { greeting: '', message: '', onRedirectChat: action('redirectChat'), - ...screenProps(), }, decorators: [screenDecorator], parameters: { diff --git a/packages/livechat/src/routes/GDPRAgreement/component.js b/packages/livechat/src/routes/GDPRAgreement/component.tsx similarity index 80% rename from packages/livechat/src/routes/GDPRAgreement/component.js rename to packages/livechat/src/routes/GDPRAgreement/component.tsx index b9333cc6bf5f4..b18988baafd52 100644 --- a/packages/livechat/src/routes/GDPRAgreement/component.js +++ b/packages/livechat/src/routes/GDPRAgreement/component.tsx @@ -1,3 +1,4 @@ +import type { TFunction } from 'i18next'; import { Component } from 'preact'; import { Trans, withTranslation } from 'react-i18next'; @@ -8,23 +9,22 @@ import Screen from '../../components/Screen'; import { createClassName } from '../../helpers/createClassName'; import styles from './styles.scss'; -class GDPR extends Component { +type GDPRProps = { + title: string; + consentText: string; + instructions: string; + onAgree: () => void; + t: TFunction; +}; + +class GDPR extends Component { handleClick = () => { const { onAgree } = this.props; - onAgree && onAgree(); + onAgree?.(); }; - render = ({ - color, - title, - consentText, - instructions, - // eslint-disable-next-line no-unused-vars - onAgree, - t, - ...props - }) => ( - + render = ({ title, consentText, instructions, t }: GDPRProps) => ( + {consentText ? (

diff --git a/packages/livechat/src/routes/GDPRAgreement/container.js b/packages/livechat/src/routes/GDPRAgreement/container.js deleted file mode 100644 index 82c290eb55e9c..0000000000000 --- a/packages/livechat/src/routes/GDPRAgreement/container.js +++ /dev/null @@ -1,41 +0,0 @@ -import { Component } from 'preact'; -import { route } from 'preact-router'; -import { withTranslation } from 'react-i18next'; - -import { Consumer } from '../../store'; -import GDPRAgreement from './component'; - -class GDPRContainer extends Component { - handleAgree = async () => { - const { dispatch } = this.props; - await dispatch({ gdpr: { accepted: true } }); - route('/'); - }; - - render = (props) => ; -} - -const GDPRConnector = ({ ref, t, ...props }) => ( - - {({ - config: { theme: { color } = {}, messages: { dataProcessingConsentText: consentText } = {} } = {}, - iframe: { theme: { color: customColor, fontColor: customFontColor, iconColor: customIconColor } = {} } = {}, - dispatch, - }) => ( - - )} - -); - -export default withTranslation()(GDPRConnector); diff --git a/packages/livechat/src/routes/GDPRAgreement/container.tsx b/packages/livechat/src/routes/GDPRAgreement/container.tsx new file mode 100644 index 0000000000000..8dcbb83009b23 --- /dev/null +++ b/packages/livechat/src/routes/GDPRAgreement/container.tsx @@ -0,0 +1,21 @@ +import type { TFunction } from 'i18next'; +import type { FunctionalComponent } from 'preact'; +import { route } from 'preact-router'; +import { useContext } from 'preact/hooks'; +import { withTranslation } from 'react-i18next'; + +import { StoreContext } from '../../store'; +import GDPRAgreement from './component'; + +const GDPRContainer: FunctionalComponent<{ t: TFunction }> = ({ ref, t }) => { + const { config: { messages: { dataProcessingConsentText: consentText = '' } = {} } = {}, dispatch } = useContext(StoreContext); + + const handleAgree = async () => { + await dispatch({ gdpr: { accepted: true } }); + route('/'); + }; + + return ; +}; + +export default withTranslation()(GDPRContainer); diff --git a/packages/livechat/src/routes/GDPRAgreement/stories.tsx b/packages/livechat/src/routes/GDPRAgreement/stories.tsx index fb0139cd2e53e..95de5492be73a 100644 --- a/packages/livechat/src/routes/GDPRAgreement/stories.tsx +++ b/packages/livechat/src/routes/GDPRAgreement/stories.tsx @@ -2,7 +2,7 @@ import { action } from '@storybook/addon-actions'; import type { Meta, Story } from '@storybook/preact'; import type { ComponentProps } from 'preact'; -import { screenDecorator, screenProps } from '../../../.storybook/helpers'; +import { screenDecorator } from '../../../.storybook/helpers'; import GDPRAgreement from './component'; export default { @@ -13,7 +13,6 @@ export default { consentText: '', instructions: '', onAgree: action('agree'), - ...screenProps(), }, decorators: [screenDecorator], parameters: { diff --git a/packages/livechat/src/routes/LeaveMessage/index.tsx b/packages/livechat/src/routes/LeaveMessage/index.tsx index 8f209de029db7..7bfcdabdb70cd 100644 --- a/packages/livechat/src/routes/LeaveMessage/index.tsx +++ b/packages/livechat/src/routes/LeaveMessage/index.tsx @@ -1,3 +1,4 @@ +import type { FunctionalComponent } from 'preact'; import { useContext, useRef } from 'preact/hooks'; import type { JSXInternal } from 'preact/src/jsx'; import type { FieldValues, SubmitHandler } from 'react-hook-form'; @@ -21,12 +22,12 @@ import { createToken } from '../../lib/random'; import { StoreContext } from '../../store'; import styles from './styles.scss'; -const LeaveMessage = ({ screenProps }: { screenProps: { [key: string]: unknown }; path: string }) => { +const LeaveMessage: FunctionalComponent<{ path: string }> = () => { const { config: { departments = [], messages: { offlineMessage, offlineSuccessMessage, offlineUnavailableMessage }, - theme: { offlineTitle: title, offlineColor: color }, + theme: { offlineTitle: title }, settings: { displayOfflineForm }, }, iframe, @@ -56,7 +57,9 @@ const LeaveMessage = ({ screenProps }: { screenProps: { [key: string]: unknown } ...(department && { department }), message, }; + await dispatch({ loading: true }); + try { // TODO: Remove intersection after ts refactor of parseOfflineMessage const payload = parseOfflineMessage(fields) as FormValues & { host: string }; @@ -82,13 +85,7 @@ const LeaveMessage = ({ screenProps }: { screenProps: { [key: string]: unknown } const defaultUnavailableMessage = ''; // TODO return ( - +

diff --git a/packages/livechat/src/routes/Register/index.tsx b/packages/livechat/src/routes/Register/index.tsx index 2c50d51855a8c..42280e6bc895e 100644 --- a/packages/livechat/src/routes/Register/index.tsx +++ b/packages/livechat/src/routes/Register/index.tsx @@ -1,3 +1,4 @@ +import type { FunctionalComponent } from 'preact'; import { route } from 'preact-router'; import { useContext, useEffect, useRef } from 'preact/hooks'; import type { JSXInternal } from 'preact/src/jsx'; @@ -22,7 +23,7 @@ import styles from './styles.scss'; // Custom field as in the form payload type FormPayloadCustomField = { [key: string]: string }; -export const Register = ({ screenProps }: { screenProps: { [key: string]: unknown }; path: string }) => { +export const Register: FunctionalComponent<{ path: string }> = () => { const { t } = useTranslation(); const topRef = useRef(null); @@ -39,13 +40,10 @@ export const Register = ({ screenProps }: { screenProps: { [key: string]: unknow departments = [], messages: { registrationFormMessage: message }, settings: { nameFieldRegistrationForm: hasNameField, emailFieldRegistrationForm: hasEmailField }, - theme: { title, color }, + theme: { title }, customFields = [], }, - iframe: { - guest: { department: guestDepartment = undefined, name: guestName = undefined, email: guestEmail = undefined } = {}, - theme: { color: customColor, fontColor: customFontColor, iconColor: customIconColor, title: customTitle }, - }, + iframe: { guest: { department: guestDepartment = undefined, name: guestName = undefined, email: guestEmail = undefined } = {} }, loading = false, token, dispatch, @@ -83,6 +81,7 @@ export const Register = ({ screenProps }: { screenProps: { [key: string]: unknow }; dispatch({ loading: true, department }); + try { const { visitor: user } = await Livechat.grantVisitor({ visitor: { ...fields, token } }); await dispatch({ user } as Omit); @@ -109,17 +108,7 @@ export const Register = ({ screenProps }: { screenProps: { [key: string]: unknow }, [user?._id]); return ( - +
{ +const SwitchDepartment: FunctionalComponent<{ path: string }> = () => { const { t } = useTranslation(); const { config: { messages: { switchDepartmentMessage }, departments: deps = [], - theme: { color }, }, iframe: { guest }, iframe, @@ -114,7 +114,7 @@ const SwitchDepartment = ({ screenProps }: { screenProps: { [key: string]: unkno const defaultMessage = t('choose_a_department_1'); return ( - +

{switchDepartmentMessage || defaultMessage}

diff --git a/packages/livechat/src/routes/SwitchDepartment/stories.tsx b/packages/livechat/src/routes/SwitchDepartment/stories.tsx index 37405c97bbc7f..c7409bc89695e 100644 --- a/packages/livechat/src/routes/SwitchDepartment/stories.tsx +++ b/packages/livechat/src/routes/SwitchDepartment/stories.tsx @@ -2,7 +2,7 @@ import { action } from '@storybook/addon-actions'; import type { Meta, Story } from '@storybook/preact'; import type { ComponentProps } from 'preact'; -import { screenDecorator, screenProps } from '../../../.storybook/helpers'; +import { screenDecorator } from '../../../.storybook/helpers'; import SwitchDepartment from './index'; export default { @@ -28,7 +28,6 @@ export default { loading: false, onSubmit: action('submit'), onCancel: action('cancel'), - ...screenProps(), }, decorators: [screenDecorator], parameters: { diff --git a/packages/livechat/src/routes/TriggerMessage/component.js b/packages/livechat/src/routes/TriggerMessage/component.tsx similarity index 55% rename from packages/livechat/src/routes/TriggerMessage/component.js rename to packages/livechat/src/routes/TriggerMessage/component.tsx index d31ccb506f8f2..86e65f1d11e09 100644 --- a/packages/livechat/src/routes/TriggerMessage/component.js +++ b/packages/livechat/src/routes/TriggerMessage/component.tsx @@ -1,15 +1,29 @@ +import type { TFunction } from 'i18next'; +import type { RefObject } from 'preact'; import { Component, createRef } from 'preact'; import { withTranslation } from 'react-i18next'; import Screen from '../../components/Screen'; +import type { ScreenContextValue } from '../../components/Screen/ScreenProvider'; import { createClassName } from '../../helpers/createClassName'; import { parentCall } from '../../lib/parentCall'; +import type { StoreState } from '../../store'; import styles from './styles.scss'; -class TriggerMessage extends Component { +type TriggerMessageProps = { + title: string; + messages: StoreState['messages']; + onStartChat: () => void; + t: TFunction; + theme: ScreenContextValue['theme']; +}; + +class TriggerMessage extends Component { state = {}; - constructor(props) { + ref: RefObject; + + constructor(props: TriggerMessageProps) { super(props); this.ref = createRef(); } @@ -24,16 +38,14 @@ class TriggerMessage extends Component { parentCall('resizeWidget', height); } - render({ title, messages, loading, onStartChat = () => {}, departments, t, ...props }) { + render = ({ title, messages, onStartChat = () => undefined, t }: TriggerMessageProps) => { const defaultTitle = t('messages'); - const { - theme: { color }, - } = props; + const { theme: { color } = {} } = this.props; + return ( - + - {messages && - messages.map((message) => message.msg &&

{message.msg}

)} + {messages?.map((message) => message.msg &&

{message.msg}

)}

@@ -43,7 +55,7 @@ class TriggerMessage extends Component {
); - } + }; } export default withTranslation()(TriggerMessage); diff --git a/packages/livechat/src/routes/TriggerMessage/container.js b/packages/livechat/src/routes/TriggerMessage/container.js deleted file mode 100644 index 655279d11ea8f..0000000000000 --- a/packages/livechat/src/routes/TriggerMessage/container.js +++ /dev/null @@ -1,67 +0,0 @@ -import { Component } from 'preact'; -import { route } from 'preact-router'; - -import { getAvatarUrl } from '../../helpers/baseUrl'; -import { canRenderMessage } from '../../helpers/canRenderMessage'; -import { parentCall } from '../../lib/parentCall'; -import { Consumer } from '../../store'; -import TriggerMessage from './component'; - -export class TriggerMessageContainer extends Component { - handleStart(props) { - parentCall('setFullScreenDocumentMobile'); - parentCall('openWidget'); - props.onRestore(); - route('/'); - } - - render = (props) => { - parentCall('resetDocumentStyle'); - return this.handleStart(props)} {...props} />; - }; -} - -/** @type {function({ ref?: any; path?: string }): any} */ -export const TriggerMessageConnector = ({ ref, ...props }) => ( - - {({ - config: { theme: { color } = {} } = {}, - iframe: { theme: { color: customColor, fontColor: customFontColor, iconColor: customIconColor } = {} } = {}, - messages, - agent, - unread, - }) => ( - - )} - -); - -export default TriggerMessageConnector; diff --git a/packages/livechat/src/routes/TriggerMessage/container.tsx b/packages/livechat/src/routes/TriggerMessage/container.tsx new file mode 100644 index 0000000000000..62a3a1927a0ec --- /dev/null +++ b/packages/livechat/src/routes/TriggerMessage/container.tsx @@ -0,0 +1,39 @@ +import type { FunctionalComponent } from 'preact'; +import { route } from 'preact-router'; +import { useContext, useEffect } from 'preact/hooks'; + +import { ScreenContext } from '../../components/Screen/ScreenProvider'; +import { canRenderMessage } from '../../helpers/canRenderMessage'; +import { formatAgent } from '../../helpers/formatAgent'; +import { parentCall } from '../../lib/parentCall'; +import { StoreContext } from '../../store'; +import TriggerMessage from './component'; + +export const TriggerMessageContainer: FunctionalComponent<{ path: string }> = ({ ref }) => { + const { messages, agent, unread } = useContext(StoreContext); + const { theme, onRestore } = useContext(ScreenContext); + + const handleStart = () => { + parentCall('setFullScreenDocumentMobile'); + parentCall('openWidget'); + onRestore(); + route('/'); + }; + + useEffect(() => { + parentCall('resetDocumentStyle'); + }, []); + + return ( + handleStart()} + /> + ); +}; + +export default TriggerMessageContainer; diff --git a/packages/livechat/src/routes/TriggerMessage/index.ts b/packages/livechat/src/routes/TriggerMessage/index.ts index 71a0794e88cca..fad21836af806 100644 --- a/packages/livechat/src/routes/TriggerMessage/index.ts +++ b/packages/livechat/src/routes/TriggerMessage/index.ts @@ -1,3 +1,3 @@ export { default as TriggerMessage } from './component'; -export { TriggerMessageContainer, TriggerMessageConnector } from './container'; +export { TriggerMessageContainer } from './container'; export { default } from './container'; diff --git a/packages/livechat/src/routes/TriggerMessage/stories.tsx b/packages/livechat/src/routes/TriggerMessage/stories.tsx index 8ee5291e8e44b..dedf0abca764c 100644 --- a/packages/livechat/src/routes/TriggerMessage/stories.tsx +++ b/packages/livechat/src/routes/TriggerMessage/stories.tsx @@ -2,7 +2,7 @@ import { action } from '@storybook/addon-actions'; import type { Meta, Story } from '@storybook/preact'; import type { ComponentProps } from 'preact'; -import { screenDecorator, screenProps } from '../../../.storybook/helpers'; +import { screenDecorator } from '../../../.storybook/helpers'; import TriggerMessage from './component'; const now = new Date(Date.parse('2021-01-01T00:00:00.000Z')); @@ -28,7 +28,6 @@ export default { title: '', onSubmit: action('submit'), onCancel: action('cancel'), - ...screenProps(), }, decorators: [screenDecorator], parameters: { diff --git a/packages/livechat/src/routes/TriggerMessage/styles.scss b/packages/livechat/src/routes/TriggerMessage/styles.scss index e55ae2fe1a478..3f6ad59169535 100644 --- a/packages/livechat/src/routes/TriggerMessage/styles.scss +++ b/packages/livechat/src/routes/TriggerMessage/styles.scss @@ -34,7 +34,7 @@ cursor: pointer; text-decoration: none; - color: $color-blue; + color: var(--color, $color-blue); border: 0; background: none; diff --git a/packages/livechat/src/store/index.tsx b/packages/livechat/src/store/index.tsx index 67af47e70ccdb..7f31ba739e046 100644 --- a/packages/livechat/src/store/index.tsx +++ b/packages/livechat/src/store/index.tsx @@ -97,6 +97,7 @@ export type StoreState = { defaultAgent?: Agent; parentUrl?: string; connecting?: boolean; + messageListPosition?: 'top' | 'bottom' | 'free'; }; export const initialState = (): StoreState => ({ @@ -175,16 +176,30 @@ if (process.env.NODE_ENV === 'development') { }); } -export type Dispatch = (partialState: Partial) => void; +export type Dispatch = typeof store.setState; -type StoreContextValue = StoreState & { dispatch: Dispatch }; +type StoreContextValue = StoreState & { + dispatch: Dispatch; + on: typeof store.on; + off: typeof store.off; +}; -export const StoreContext = createContext({ ...store.state, dispatch: store.setState.bind(store) }); +export const StoreContext = createContext({ + ...store.state, + dispatch: store.setState.bind(store), + on: store.on.bind(store), + off: store.off.bind(store), +}); export class Provider extends Component { static displayName = 'StoreProvider'; - state = { ...store.state, dispatch: store.setState.bind(store) }; + state = { + ...store.state, + dispatch: store.setState.bind(store), + on: store.on.bind(store), + off: store.off.bind(store), + }; handleStoreChange = () => { this.setState({ ...store.state }); @@ -198,7 +213,9 @@ export class Provider extends Component { store.off('change', this.handleStoreChange); } - render = ({ children }: { children: ComponentChildren }) => {children}; + render = ({ children }: { children: ComponentChildren }) => { + return {children}; + }; } export const { Consumer } = StoreContext; From 573da9a1350663fc75685b9d93a70e26835c8fa6 Mon Sep 17 00:00:00 2001 From: Aleksander Nicacio da Silva Date: Fri, 16 Feb 2024 15:29:38 -0300 Subject: [PATCH 14/17] chore: adjusted setTheme method --- packages/livechat/src/lib/hooks.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/livechat/src/lib/hooks.ts b/packages/livechat/src/lib/hooks.ts index a4ef58aa333b6..163e319733c6c 100644 --- a/packages/livechat/src/lib/hooks.ts +++ b/packages/livechat/src/lib/hooks.ts @@ -70,21 +70,17 @@ const api = { CustomFields.setCustomField(key, value, overwrite); }, - setTheme: ({ color, fontColor, iconColor, title, offlineTitle }: StoreState['iframe']['theme']) => { + setTheme: (theme: StoreState['iframe']['theme']) => { const { iframe, - iframe: { theme }, + iframe: { theme: currentTheme }, } = store.state; store.setState({ iframe: { ...iframe, theme: { + ...currentTheme, ...theme, - color, - fontColor, - iconColor, - title, - offlineTitle, }, }, }); From 82759a9ba746b4b96938171aba2dfddbbdefb7a3 Mon Sep 17 00:00:00 2001 From: Aleksander Nicacio da Silva Date: Mon, 26 Feb 2024 12:42:57 -0300 Subject: [PATCH 15/17] refactor: changed the way arguments are passed to parentCall --- packages/livechat/src/lib/parentCall.ts | 4 ++-- packages/livechat/src/lib/room.js | 10 +++++----- packages/livechat/src/lib/triggers.js | 2 +- packages/livechat/src/routes/LeaveMessage/index.tsx | 2 +- packages/livechat/src/routes/Register/index.tsx | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/livechat/src/lib/parentCall.ts b/packages/livechat/src/lib/parentCall.ts index 76a330763a212..4b03e22927a51 100644 --- a/packages/livechat/src/lib/parentCall.ts +++ b/packages/livechat/src/lib/parentCall.ts @@ -1,6 +1,6 @@ import { VALID_CALLBACKS } from '../widget'; -export const parentCall = (method: string, args: any = []) => { +export const parentCall = (method: string, ...args: any[]) => { const data = { src: 'rocketchat', fn: method, @@ -12,4 +12,4 @@ export const parentCall = (method: string, args: any = []) => { }; export const runCallbackEventEmitter = (callbackName: string, data: unknown) => - VALID_CALLBACKS.includes(callbackName) && parentCall('callback', [callbackName, data]); + VALID_CALLBACKS.includes(callbackName) && parentCall('callback', callbackName, data); diff --git a/packages/livechat/src/lib/room.js b/packages/livechat/src/lib/room.js index ad447726d9b00..a7161c3e8edc3 100644 --- a/packages/livechat/src/lib/room.js +++ b/packages/livechat/src/lib/room.js @@ -139,28 +139,28 @@ export const initRoom = async () => { if (servedBy) { roomAgent = await Livechat.agent(rid); await store.setState({ agent: roomAgent, queueInfo: null }); - parentCall('callback', ['assign-agent', normalizeAgent(roomAgent)]); + parentCall('callback', 'assign-agent', normalizeAgent(roomAgent)); } } if (queueInfo) { - parentCall('callback', ['queue-position-change', queueInfo]); + parentCall('callback', 'queue-position-change', queueInfo); } Livechat.onAgentChange(rid, async (agent) => { await store.setState({ agent, queueInfo: null }); - parentCall('callback', ['assign-agent', normalizeAgent(agent)]); + parentCall('callback', 'assign-agent', normalizeAgent(agent)); }); Livechat.onAgentStatusChange(rid, (status) => { const { agent } = store.state; agent && store.setState({ agent: { ...agent, status } }); - parentCall('callback', ['agent-status-change', normalizeAgent(agent)]); + parentCall('callback', 'agent-status-change', normalizeAgent(agent)); }); Livechat.onQueuePositionChange(rid, async (queueInfo) => { await store.setState({ queueInfo }); - parentCall('callback', ['queue-position-change', queueInfo]); + parentCall('callback', 'queue-position-change', queueInfo); }); setCookies(rid, token); diff --git a/packages/livechat/src/lib/triggers.js b/packages/livechat/src/lib/triggers.js index efa9e9bdc65d6..cf73a74f20bec 100644 --- a/packages/livechat/src/lib/triggers.js +++ b/packages/livechat/src/lib/triggers.js @@ -157,7 +157,7 @@ class Triggers { if (agent && agent._id) { await store.setState({ agent }); - parentCall('callback', ['assign-agent', normalizeAgent(agent)]); + parentCall('callback', 'assign-agent', normalizeAgent(agent)); } const foundCondition = trigger.conditions.find((c) => ['chat-opened-by-visitor', 'after-guest-registration'].includes(c.name)); diff --git a/packages/livechat/src/routes/LeaveMessage/index.tsx b/packages/livechat/src/routes/LeaveMessage/index.tsx index 7bfcdabdb70cd..7a8cdab0d0e2c 100644 --- a/packages/livechat/src/routes/LeaveMessage/index.tsx +++ b/packages/livechat/src/routes/LeaveMessage/index.tsx @@ -67,7 +67,7 @@ const LeaveMessage: FunctionalComponent<{ path: string }> = () => { await ModalManager.alert({ text: offlineSuccessMessage || text, }); - parentCall('callback', ['offline-form-submit', fields]); + parentCall('callback', 'offline-form-submit', fields); return true; } catch (error: unknown) { const errorMessage = (error as { error: string })?.error; diff --git a/packages/livechat/src/routes/Register/index.tsx b/packages/livechat/src/routes/Register/index.tsx index 42280e6bc895e..8582bde03d04b 100644 --- a/packages/livechat/src/routes/Register/index.tsx +++ b/packages/livechat/src/routes/Register/index.tsx @@ -86,7 +86,7 @@ export const Register: FunctionalComponent<{ path: string }> = () => { const { visitor: user } = await Livechat.grantVisitor({ visitor: { ...fields, token } }); await dispatch({ user } as Omit); - parentCall('callback', ['pre-chat-form-submit', fields]); + parentCall('callback', 'pre-chat-form-submit', fields); registerCustomFields(customFields); } finally { dispatch({ loading: false }); From 3eae6ea1f7f651151e80bd44cae668312e7c8a1f Mon Sep 17 00:00:00 2001 From: Aleksander Nicacio da Silva Date: Mon, 26 Feb 2024 12:43:52 -0300 Subject: [PATCH 16/17] fix: color priority being given to config instead of iframe --- packages/livechat/src/components/Screen/ScreenProvider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/livechat/src/components/Screen/ScreenProvider.tsx b/packages/livechat/src/components/Screen/ScreenProvider.tsx index eb0a7e1a232b8..8243879f2549d 100644 --- a/packages/livechat/src/components/Screen/ScreenProvider.tsx +++ b/packages/livechat/src/components/Screen/ScreenProvider.tsx @@ -115,7 +115,7 @@ export const ScreenProvider: FunctionalComponent = ({ children }) => { const screenProps = { theme: { - color: color || customColor, + color: customColor || color, fontColor: customFontColor, iconColor: customIconColor, }, From 654cb1973a2b366fc0d8f3c90718dc3552469fd1 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 29 Feb 2024 08:01:33 -0500 Subject: [PATCH 17/17] Apply suggestions from code review --- .changeset/orange-dragons-fly.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/orange-dragons-fly.md b/.changeset/orange-dragons-fly.md index ac38a42e2aea5..a1ca0faa90b69 100644 --- a/.changeset/orange-dragons-fly.md +++ b/.changeset/orange-dragons-fly.md @@ -1,5 +1,5 @@ --- -"@rocket.chat/livechat": patch +"@rocket.chat/livechat": minor --- chore: Refactor & Improve Livechat Widget API