import type { DistributiveOmit } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import { Random } from '@rocket.chat/random'; import { imperativeModal } from '@rocket.chat/ui-client'; import type { RouterContext, IActionManager } from '@rocket.chat/ui-contexts'; import type * as UiKit from '@rocket.chat/ui-kit'; import { t } from 'i18next'; import type { ContextType } from 'react'; import { lazy } from 'react'; import { sdk } from './SDKClient'; import * as banners from './banners'; import { UiKitTriggerTimeoutError } from './errors/UiKitTriggerTimeoutError'; import { dispatchToastMessage } from './toast'; import { exhaustiveCheck } from '../../lib/utils/exhaustiveCheck'; const UiKitModal = lazy(() => import('../views/modal/uikit/UiKitModal')); export class ActionManager implements IActionManager { protected static TRIGGER_TIMEOUT = 5000; protected static TRIGGER_TIMEOUT_ERROR = 'TRIGGER_TIMEOUT_ERROR'; protected events = new Emitter<{ busy: { busy: boolean }; [viewId: string]: any }>(); protected appIdByTriggerId = new Map(); protected viewInstances = new Map< string, { payload?: { view: UiKit.ContextualBarView; }; close: () => void; } >(); public constructor(protected router: ContextType) {} protected invalidateTriggerId(id: string) { const appId = this.appIdByTriggerId.get(id); this.appIdByTriggerId.delete(id); return appId; } public on(viewId: string, listener: (data: any) => void): void; public on(eventName: 'busy', listener: ({ busy }: { busy: boolean }) => void): void; public on(eventName: string, listener: (data: any) => void) { return this.events.on(eventName, listener); } public off(viewId: string, listener: (data: any) => any): void; public off(eventName: 'busy', listener: ({ busy }: { busy: boolean }) => void): void; public off(eventName: string, listener: (data: any) => void) { return this.events.off(eventName, listener); } public notifyBusy() { this.events.emit('busy', { busy: true }); } public notifyIdle() { this.events.emit('busy', { busy: false }); } public generateTriggerId(appId: string | undefined) { const triggerId = Random.id(); this.appIdByTriggerId.set(triggerId, appId); setTimeout(() => this.invalidateTriggerId(triggerId), ActionManager.TRIGGER_TIMEOUT); return triggerId; } public async emitInteraction(appId: string, userInteraction: DistributiveOmit) { const triggerId = this.generateTriggerId(appId); return this.runWithTimeout( async () => { let interaction: UiKit.ServerInteraction | undefined; try { interaction = (await sdk.rest.post(`/apps/ui.interaction/${appId}`, { ...userInteraction, triggerId, })) as UiKit.ServerInteraction; this.handleServerInteraction(interaction); } finally { switch (userInteraction.type) { case 'viewSubmit': if (!!interaction && !['errors', 'modal.update', 'contextual_bar.update'].includes(interaction.type)) this.disposeView(userInteraction.viewId); break; case 'viewClosed': if (!!interaction && interaction.type !== 'errors') this.disposeView(userInteraction.payload.viewId); break; } } }, { triggerId, appId, ...('viewId' in userInteraction ? { viewId: userInteraction.viewId } : {}) }, ); } protected async runWithTimeout(task: () => Promise, details: { triggerId: string; appId: string; viewId?: string }) { this.notifyBusy(); let timer: ReturnType | undefined; try { const taskPromise = task(); const timeoutPromise = new Promise((_, reject) => { timer = setTimeout(() => { reject(new UiKitTriggerTimeoutError('Timeout', details)); }, ActionManager.TRIGGER_TIMEOUT); }); return await Promise.race([taskPromise, timeoutPromise]); } catch (error) { if (error instanceof UiKitTriggerTimeoutError) { dispatchToastMessage({ type: 'error', message: t('UIKit_Interaction_Timeout'), }); if (details.viewId) { this.disposeView(details.viewId); } } } finally { if (timer) clearTimeout(timer); this.notifyIdle(); } } public handleServerInteraction(interaction: UiKit.ServerInteraction): UiKit.ServerInteraction['type'] | undefined { const { triggerId } = interaction; const appId = this.invalidateTriggerId(triggerId); if (!appId) { return; } switch (interaction.type) { case 'errors': { const { type, triggerId, viewId, appId, errors } = interaction; this.events.emit(interaction.viewId, { type, triggerId, viewId, appId, errors, }); break; } case 'modal.open': { const { view } = interaction; this.openModal(view); break; } case 'modal.update': case 'contextual_bar.update': { const { type, triggerId, appId, view } = interaction; this.events.emit(view.id, { type, triggerId, viewId: view.id, appId, view, }); break; } case 'modal.close': { break; } case 'banner.open': { const { type, triggerId, ...view } = interaction; this.openBanner(view); break; } case 'banner.update': { const { type, triggerId, appId, view } = interaction; this.events.emit(view.viewId, { type, triggerId, viewId: view.viewId, appId, view, }); break; } case 'banner.close': { const { viewId } = interaction; this.disposeView(viewId); break; } case 'contextual_bar.open': { const { view } = interaction; this.openContextualBar(view); break; } case 'contextual_bar.close': { const { view } = interaction; this.disposeView(view.id); break; } default: exhaustiveCheck(interaction); } return interaction.type; } public getInteractionPayloadByViewId(viewId: UiKit.ContextualBarView['id']) { if (!viewId) { throw new Error('No viewId provided when checking for `user interaction payload`'); } return this.viewInstances.get(viewId)?.payload; } public openView(surface: 'modal', view: UiKit.ModalView): void; public openView(surface: 'banner', view: UiKit.BannerView): void; public openView(surface: 'contextual_bar', view: UiKit.ContextualBarView): void; public openView(surface: string, view: UiKit.View) { switch (surface) { case 'modal': this.openModal(view as UiKit.ModalView); break; case 'banner': this.openBanner(view as UiKit.BannerView); break; case 'contextual_bar': this.openContextualBar(view as UiKit.ContextualBarView); break; } } private openModal(view: UiKit.ModalView) { const instance = imperativeModal.open({ component: UiKitModal, props: { key: view.id, initialView: view, }, }); this.viewInstances.set(view.id, { close: () => { instance.close(); this.viewInstances.delete(view.id); }, }); } private openBanner(view: UiKit.BannerView) { banners.open(view); this.viewInstances.set(view.viewId, { close: () => { banners.closeById(view.viewId); }, }); } private openContextualBar(view: UiKit.ContextualBarView) { this.viewInstances.set(view.id, { payload: { view, }, close: () => { this.viewInstances.delete(view.id); }, }); const routeName = this.router.getRouteName(); const routeParams = this.router.getRouteParameters(); if (!routeName) { return; } this.router.navigate({ name: routeName, params: { ...routeParams, tab: 'app', context: view.id, }, }); } public disposeView(viewId: UiKit.ModalView['id']) { const instance = this.viewInstances.get(viewId); instance?.close?.(); this.viewInstances.delete(viewId); } }