Skip to content

Commit

Permalink
chore: Refactor & Improve Livechat Widget API (RocketChat#30924)
Browse files Browse the repository at this point in the history
  • Loading branch information
MartinSchoeler authored Feb 29, 2024
1 parent 0f68bdb commit d92c0c7
Show file tree
Hide file tree
Showing 41 changed files with 1,016 additions and 907 deletions.
6 changes: 6 additions & 0 deletions .changeset/orange-dragons-fly.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@rocket.chat/livechat": minor
---

chore: Refactor & Improve Livechat Widget API
Refactors and adds better error handling to the widget's API calls
5 changes: 4 additions & 1 deletion packages/livechat/.storybook/helpers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<PreactFramework, Args> = (storyFn) => (
<div style={{ display: 'flex', width: 365, height: 500 }}>{storyFn()}</div>
<div style={{ display: 'flex', width: 365, height: 500 }}>
<ScreenContext.Provider value={screenProps()}>{storyFn()}</ScreenContext.Provider>
</div>
);

export const screenProps = () => ({
Expand Down
128 changes: 16 additions & 112 deletions packages/livechat/src/components/App/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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: {
Expand Down Expand Up @@ -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<AppProps, AppState> {
state = {
initialized: false,
Expand Down Expand Up @@ -150,49 +128,6 @@ export class App extends Component<AppProps, AppState> {
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 });
Expand All @@ -202,19 +137,20 @@ export class App extends Component<AppProps, AppState> {
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 });
Expand All @@ -223,16 +159,6 @@ export class App extends Component<AppProps, AppState> {
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();
Expand All @@ -241,7 +167,6 @@ export class App extends Component<AppProps, AppState> {
Hooks.init();
this.handleTriggers();
this.initWidget();
this.checkPoppedOutWindow();
this.setState({ initialized: true });
parentCall('ready');
}
Expand All @@ -268,44 +193,23 @@ export class App extends Component<AppProps, AppState> {
}
}

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 (
<Router history={history} onChange={this.handleRoute}>
<ChatConnector default path='/' {...screenProps} />
<ChatFinished path='/chat-finished' {...screenProps} />
<GDPRAgreement path='/gdpr' {...screenProps} />
{/* TODO: Find a better way to avoid prop drilling with that amout of props (perhaps create a screen context/provider) */}
<LeaveMessage path='/leave-message' screenProps={screenProps} />
<Register path='/register' screenProps={screenProps} />
<SwitchDepartment path='/switch-department' screenProps={screenProps} />
<TriggerMessage path='/trigger-messages' {...screenProps} />
</Router>
<ScreenProvider>
<Router history={history} onChange={this.handleRoute}>
<ChatConnector path='/' default />
<ChatFinished path='/chat-finished' />
<GDPRAgreement path='/gdpr' />
<LeaveMessage path='/leave-message' />
<Register path='/register' />
<SwitchDepartment path='/switch-department' />
<TriggerMessage path='/trigger-messages' />
</Router>
</ScreenProvider>
);
};
}
Expand Down
142 changes: 142 additions & 0 deletions packages/livechat/src/components/Screen/ScreenProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<ScreenContextValue>({
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: customColor || color,
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 <ScreenContext.Provider value={screenProps}>{children}</ScreenContext.Provider>;
};
Loading

0 comments on commit d92c0c7

Please sign in to comment.