RocketChat/Rocket.Chat

View on GitHub
packages/livechat/src/lib/hooks.ts

Summary

Maintainability
D
2 days
Test Coverage
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';
import { createToken } from './random';
import { loadMessages } from './room';
import Triggers from './triggers';

const evaluateChangesAndLoadConfigByFields = async (fn: () => Promise<void>) => {
    const oldStore = JSON.parse(
        JSON.stringify({
            user: store.state.user || {},
            token: store.state.token,
        }),
    );
    await fn();

    /**
     * it solves the issues where the registerGuest is called every time the widget is opened
     * and the guest is already registered. If there is nothing different in the data,
     * it will not call the loadConfig again.
     *
     * if user changes, it will call loadConfig
     * if department changes, it will call loadConfig
     * if token changes, it will call loadConfig
     */

    if (oldStore.user._id !== store.state.user?._id) {
        await loadConfig();
        await loadMessages();
        return;
    }

    if (oldStore.token !== store.state.token) {
        await loadConfig();
        await loadMessages();
    }
};

const createOrUpdateGuest = async (guest: StoreState['guest']) => {
    if (!guest) {
        return;
    }

    const { token } = guest;
    token && (await store.setState({ token }));

    const {
        iframe: { defaultDepartment },
    } = store.state;
    if (defaultDepartment && !guest.department) {
        guest.department = defaultDepartment;
    }

    const { visitor: user } = await Livechat.grantVisitor({ visitor: { ...guest } });

    if (!user) {
        return;
    }
    store.setState({ user } as Omit<StoreState['user'], 'ts'>);
    Triggers.callbacks?.emit('chat-visitor-registered');
};

const updateIframeGuestData = (data: Partial<StoreState['guest']>) => {
    const {
        iframe,
        iframe: { guest },
        user,
        token,
    } = store.state;

    const iframeGuest = { ...guest, ...data } as StoreState['guest'];

    store.setState({ iframe: { ...iframe, guest: iframeGuest || {} } });

    if (!user) {
        return;
    }

    const guestData = { token, ...data };
    createOrUpdateGuest(guestData);
};

export type HooksWidgetAPI = typeof api;

const updateIframeData = (data: Partial<StoreState['iframe']>) => {
    const { iframe } = store.state;

    if (data.guest) {
        throw new Error('Guest data changes not allowed. Use updateIframeGuestData instead.');
    }

    const iframeData = { ...iframe, ...data };

    store.setState({ iframe: { ...iframeData } });
};

const api = {
    pageVisited(info: { change: string; title: string; location: { href: string } }) {
        const { token, room } = store.state;
        const { _id: rid } = room || {};

        const {
            change,
            title,
            location: { href },
        } = info;

        Livechat.sendVisitorNavigation({ token, rid, pageInfo: { change, title, location: { href } } });
    },

    setCustomField: (key: string, value = '', overwrite = true) => {
        CustomFields.setCustomField(key, value, overwrite);
    },

    setTheme: (theme: StoreState['iframe']['theme']) => {
        const {
            iframe,
            iframe: { theme: currentTheme },
        } = store.state;

        store.setState({
            iframe: {
                ...iframe,
                theme: {
                    ...currentTheme,
                    ...theme,
                },
            },
        });
    },

    setDepartment: async (value: string) => {
        const {
            config: { departments = [] },
            defaultAgent,
        } = store.state;

        const department = departments.find((dep) => dep._id === value || dep.name === value)?._id || '';

        if (!department) {
            console.warn(
                'The selected department is invalid. Check departments configuration to ensure the department exists, is enabled and has at least 1 agent',
            );
        }

        updateIframeData({ defaultDepartment: department });

        if (defaultAgent && defaultAgent.department !== department) {
            store.setState({ defaultAgent: undefined });
        }
    },

    setBusinessUnit: async (newBusinessUnit: string) => {
        if (!newBusinessUnit?.trim().length) {
            throw new Error('Error! Invalid business ids');
        }

        const { businessUnit: existingBusinessUnit } = store.state;

        return existingBusinessUnit !== newBusinessUnit && updateBusinessUnit(newBusinessUnit);
    },

    clearBusinessUnit: async () => {
        const { businessUnit } = store.state;
        return businessUnit && updateBusinessUnit();
    },

    clearDepartment: () => {
        updateIframeGuestData({ department: '' });
    },

    clearWidgetData: async () => {
        const { minimized, visible, undocked, expanded, businessUnit, ...initial } = initialState();
        await store.setState(initial);
    },

    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.');
        }

        store.setState({
            defaultAgent: {
                ...props,
                _id,
                username,
                ts: Date.now(),
            },
        });
    },

    setExpanded: (expanded: StoreState['expanded']) => {
        store.setState({ expanded });
    },

    setGuestToken: async (token: string) => {
        const { token: localToken } = store.state;
        if (token === localToken) {
            return;
        }

        await evaluateChangesAndLoadConfigByFields(async () => {
            await createOrUpdateGuest({ token });
        });
    },

    setGuestName: (name: string) => {
        updateIframeGuestData({ name });
    },

    setGuestEmail: (email: string) => {
        updateIframeGuestData({ email });
    },

    registerGuest: async (data: StoreState['guest']) => {
        if (typeof data !== 'object') {
            return;
        }

        await evaluateChangesAndLoadConfigByFields(async () => {
            if (!data.token) {
                data.token = createToken();
            }
            const {
                iframe: { defaultDepartment },
            } = store.state;

            if (defaultDepartment && !data.department) {
                data.department = defaultDepartment;
            }

            Livechat.unsubscribeAll();

            await createOrUpdateGuest(data);
        });
    },

    transferChat: async (department: string) => {
        const {
            config: { departments = [] },
            room,
        } = store.state;

        const dep = departments.find((dep) => dep._id === department || dep.name === department)?._id || '';

        if (!dep) {
            throw new Error(
                'The selected department is invalid. Check departments configuration to ensure the department exists, is enabled and has at least 1 agent',
            );
        }
        if (!room) {
            throw new Error("Conversation has not been started yet, can't transfer");
        }

        const { _id: rid } = room;
        await Livechat.transferChat({ rid, department: dep });
    },

    setLanguage: async (language: StoreState['iframe']['language']) => {
        const { iframe } = store.state;
        await store.setState({ iframe: { ...iframe, language } });
        i18next.changeLanguage(language);
    },

    showWidget: () => {
        const { iframe } = store.state;
        store.setState({ iframe: { ...iframe, visible: true } });
        parentCall('showWidget');
    },

    hideWidget: () => {
        const { iframe } = store.state;
        store.setState({ iframe: { ...iframe, visible: false } });
        parentCall('hideWidget');
    },

    minimizeWidget: () => {
        store.setState({ minimized: true });
        parentCall('closeWidget');
    },

    maximizeWidget: () => {
        store.setState({ minimized: false });
        parentCall('openWidget');
    },

    setParentUrl: (parentUrl: StoreState['parentUrl']) => {
        store.setState({ parentUrl });
    },

    setGuestMetadata(metadata: StoreState['iframe']['guestMetadata']) {
        const { iframe } = store.state;
        store.setState({ iframe: { ...iframe, guestMetadata: metadata } });
    },

    setHiddenSystemMessages: (hiddenSystemMessages: StoreState['iframe']['hiddenSystemMessages']) => {
        const { iframe } = store.state;
        store.setState({ iframe: { ...iframe, hiddenSystemMessages } });
    },
};

function onNewMessageHandler(event: MessageEvent<LivechatMessageEventData<HooksWidgetAPI>>) {
    if (event.source === event.target) {
        return;
    }

    if (!event.data || typeof event.data !== 'object') {
        return;
    }

    if (!event.data.src || event.data.src !== 'rocketchat') {
        return;
    }

    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 {
    private _started: boolean;

    constructor() {
        if (instance) {
            throw new Error('Hooks already has an instance.');
        }

        this._started = false;
    }

    init() {
        if (this._started) {
            return;
        }

        this._started = true;
        window.addEventListener('message', onNewMessageHandler, false);
    }

    reset() {
        this._started = false;
        window.removeEventListener('message', onNewMessageHandler, false);
    }
}

const instance = new Hooks();
export default instance;