diff --git a/apps/meteor/app/apps/server/bridges/uiInteraction.ts b/apps/meteor/app/apps/server/bridges/uiInteraction.ts index b51c3be8ae3b..8e94f66e9617 100644 --- a/apps/meteor/app/apps/server/bridges/uiInteraction.ts +++ b/apps/meteor/app/apps/server/bridges/uiInteraction.ts @@ -1,11 +1,12 @@ import type { IUIKitInteraction } from '@rocket.chat/apps-engine/definition/uikit'; import type { IUser } from '@rocket.chat/apps-engine/definition/users'; -import { UiInteractionBridge as UiIntBridge } from '@rocket.chat/apps-engine/server/bridges/UiInteractionBridge'; +import { UiInteractionBridge as AppsEngineUiInteractionBridge } from '@rocket.chat/apps-engine/server/bridges/UiInteractionBridge'; import { api } from '@rocket.chat/core-services'; +import type { UiKit } from '@rocket.chat/core-typings'; import type { AppServerOrchestrator } from '../../../../ee/server/apps/orchestrator'; -export class UiInteractionBridge extends UiIntBridge { +export class UiInteractionBridge extends AppsEngineUiInteractionBridge { constructor(private readonly orch: AppServerOrchestrator) { super(); } @@ -19,6 +20,6 @@ export class UiInteractionBridge extends UiIntBridge { throw new Error('Invalid app provided'); } - void api.broadcast('notify.uiInteraction', user.id, interaction); + void api.broadcast('notify.uiInteraction', user.id, interaction as UiKit.ServerInteraction); } } diff --git a/apps/meteor/app/ui-message/client/ActionManager.js b/apps/meteor/app/ui-message/client/ActionManager.js deleted file mode 100644 index ebe9d1aed093..000000000000 --- a/apps/meteor/app/ui-message/client/ActionManager.js +++ /dev/null @@ -1,261 +0,0 @@ -import { UIKitIncomingInteractionType } from '@rocket.chat/apps-engine/definition/uikit'; -import { UIKitInteractionTypes } from '@rocket.chat/core-typings'; -import { Emitter } from '@rocket.chat/emitter'; -import { Random } from '@rocket.chat/random'; -import { lazy } from 'react'; - -import * as banners from '../../../client/lib/banners'; -import { imperativeModal } from '../../../client/lib/imperativeModal'; -import { dispatchToastMessage } from '../../../client/lib/toast'; -import { router } from '../../../client/providers/RouterProvider'; -import { sdk } from '../../utils/client/lib/SDKClient'; -import { t } from '../../utils/lib/i18n'; - -const UiKitModal = lazy(() => import('../../../client/views/modal/uikit/UiKitModal')); - -export const events = new Emitter(); - -export const on = (...args) => { - events.on(...args); -}; - -export const off = (...args) => { - events.off(...args); -}; - -const TRIGGER_TIMEOUT = 5000; - -const TRIGGER_TIMEOUT_ERROR = 'TRIGGER_TIMEOUT_ERROR'; - -const triggersId = new Map(); - -const instances = new Map(); - -const invalidateTriggerId = (id) => { - const appId = triggersId.get(id); - triggersId.delete(id); - return appId; -}; - -export const generateTriggerId = (appId) => { - const triggerId = Random.id(); - triggersId.set(triggerId, appId); - setTimeout(invalidateTriggerId, TRIGGER_TIMEOUT, triggerId); - return triggerId; -}; - -export const handlePayloadUserInteraction = (type, { /* appId,*/ triggerId, ...data }) => { - if (!triggersId.has(triggerId)) { - return; - } - const appId = invalidateTriggerId(triggerId); - if (!appId) { - return; - } - - const { view } = data; - let { viewId } = data; - - if (view && view.id) { - viewId = view.id; - } - - if (!viewId) { - return; - } - - if ([UIKitInteractionTypes.ERRORS].includes(type)) { - events.emit(viewId, { - type, - triggerId, - viewId, - appId, - ...data, - }); - return UIKitInteractionTypes.ERRORS; - } - - if ( - [UIKitInteractionTypes.BANNER_UPDATE, UIKitInteractionTypes.MODAL_UPDATE, UIKitInteractionTypes.CONTEXTUAL_BAR_UPDATE].includes(type) - ) { - events.emit(viewId, { - type, - triggerId, - viewId, - appId, - ...data, - }); - return type; - } - - if ([UIKitInteractionTypes.MODAL_OPEN].includes(type)) { - const instance = imperativeModal.open({ - component: UiKitModal, - props: { - triggerId, - viewId, - appId, - ...data, - }, - }); - - instances.set(viewId, { - close() { - instance.close(); - instances.delete(viewId); - }, - }); - - return UIKitInteractionTypes.MODAL_OPEN; - } - - if ([UIKitInteractionTypes.CONTEXTUAL_BAR_OPEN].includes(type)) { - instances.set(viewId, { - payload: { - type, - triggerId, - appId, - viewId, - ...data, - }, - close() { - instances.delete(viewId); - }, - }); - - router.navigate({ - name: router.getRouteName(), - params: { - ...router.getRouteParameters(), - tab: 'app', - context: viewId, - }, - }); - - return UIKitInteractionTypes.CONTEXTUAL_BAR_OPEN; - } - - if ([UIKitInteractionTypes.BANNER_OPEN].includes(type)) { - banners.open(data); - instances.set(viewId, { - close() { - banners.closeById(viewId); - }, - }); - return UIKitInteractionTypes.BANNER_OPEN; - } - - if ([UIKitIncomingInteractionType.BANNER_CLOSE].includes(type)) { - const instance = instances.get(viewId); - - if (instance) { - instance.close(); - } - return UIKitIncomingInteractionType.BANNER_CLOSE; - } - - if ([UIKitIncomingInteractionType.CONTEXTUAL_BAR_CLOSE].includes(type)) { - const instance = instances.get(viewId); - - if (instance) { - instance.close(); - } - return UIKitIncomingInteractionType.CONTEXTUAL_BAR_CLOSE; - } - - return UIKitInteractionTypes.MODAL_ClOSE; -}; - -export const triggerAction = async ({ type, actionId, appId, rid, mid, viewId, container, tmid, ...rest }) => - new Promise(async (resolve, reject) => { - events.emit('busy', { busy: true }); - - const triggerId = generateTriggerId(appId); - - const payload = rest.payload || rest; - - setTimeout(reject, TRIGGER_TIMEOUT, [TRIGGER_TIMEOUT_ERROR, { triggerId, appId }]); - - const { type: interactionType, ...data } = await (async () => { - try { - return await sdk.rest.post(`/apps/ui.interaction/${appId}`, { - type, - actionId, - payload, - container, - mid, - rid, - tmid, - triggerId, - viewId, - }); - } catch (e) { - reject(e); - return {}; - } finally { - events.emit('busy', { busy: false }); - } - })(); - - return resolve(handlePayloadUserInteraction(interactionType, data)); - }); - -export const triggerBlockAction = (options) => triggerAction({ type: UIKitIncomingInteractionType.BLOCK, ...options }); - -export const triggerActionButtonAction = (options) => - triggerAction({ type: UIKitIncomingInteractionType.ACTION_BUTTON, ...options }).catch(async (reason) => { - if (Array.isArray(reason) && reason[0] === TRIGGER_TIMEOUT_ERROR) { - dispatchToastMessage({ - type: 'error', - message: t('UIKit_Interaction_Timeout'), - }); - } - }); - -export const triggerSubmitView = async ({ viewId, ...options }) => { - const close = () => { - const instance = instances.get(viewId); - - if (instance) { - instance.close(); - } - }; - - try { - const result = await triggerAction({ - type: UIKitIncomingInteractionType.VIEW_SUBMIT, - viewId, - ...options, - }); - if (!result || UIKitInteractionTypes.MODAL_CLOSE === result) { - close(); - } - } catch { - close(); - } -}; - -export const triggerCancel = async ({ view, ...options }) => { - const instance = instances.get(view.id); - try { - await triggerAction({ type: UIKitIncomingInteractionType.VIEW_CLOSED, view, ...options }); - } finally { - if (instance) { - instance.close(); - } - } -}; - -export const getUserInteractionPayloadByViewId = (viewId) => { - if (!viewId) { - throw new Error('No viewId provided when checking for `user interaction payload`'); - } - - const instance = instances.get(viewId); - - if (!instance) { - return {}; - } - - return instance.payload; -}; diff --git a/apps/meteor/app/ui-message/client/ActionManager.ts b/apps/meteor/app/ui-message/client/ActionManager.ts new file mode 100644 index 000000000000..14650c3e12a0 --- /dev/null +++ b/apps/meteor/app/ui-message/client/ActionManager.ts @@ -0,0 +1,237 @@ +import type { DistributiveOmit, UiKit } from '@rocket.chat/core-typings'; +import { Emitter } from '@rocket.chat/emitter'; +import { Random } from '@rocket.chat/random'; +import type { ActionManagerContext, RouterContext } from '@rocket.chat/ui-contexts'; +import type { ContextType } from 'react'; +import { lazy } from 'react'; + +import * as banners from '../../../client/lib/banners'; +import { imperativeModal } from '../../../client/lib/imperativeModal'; +import { router } from '../../../client/providers/RouterProvider'; +import { sdk } from '../../utils/client/lib/SDKClient'; +import { UiKitTriggerTimeoutError } from './UiKitTriggerTimeoutError'; + +const UiKitModal = lazy(() => import('../../../client/views/modal/uikit/UiKitModal')); + +type ActionManagerType = Exclude, undefined>; + +export class ActionManager implements ActionManagerType { + protected static TRIGGER_TIMEOUT = 5000; + + protected static TRIGGER_TIMEOUT_ERROR = 'TRIGGER_TIMEOUT_ERROR'; + + protected events = new Emitter<{ busy: { busy: boolean }; [viewId: string]: any }>(); + + protected triggersId = 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.triggersId.get(id); + this.triggersId.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 generateTriggerId(appId: string | undefined) { + const triggerId = Random.id(); + this.triggersId.set(triggerId, appId); + setTimeout(() => this.invalidateTriggerId(triggerId), ActionManager.TRIGGER_TIMEOUT); + return triggerId; + } + + public async emitInteraction(appId: string, userInteraction: DistributiveOmit) { + this.events.emit('busy', { busy: true }); + + const triggerId = this.generateTriggerId(appId); + + let timeout: ReturnType | undefined; + + await Promise.race([ + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new UiKitTriggerTimeoutError('Timeout', { triggerId, appId })), ActionManager.TRIGGER_TIMEOUT); + }), + sdk.rest + .post(`/apps/ui.interaction/${appId}`, { + ...userInteraction, + triggerId, + }) + .then((interaction) => this.handleServerInteraction(interaction)), + ]).finally(() => { + if (timeout) clearTimeout(timeout); + this.events.emit('busy', { busy: false }); + }); + } + + public handleServerInteraction(interaction: UiKit.ServerInteraction) { + const { triggerId } = interaction; + + if (!this.triggersId.has(triggerId)) { + return; + } + + 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; + const instance = imperativeModal.open({ + component: UiKitModal, + props: { + key: view.id, + initialView: interaction.view, + }, + }); + + this.viewInstances.set(view.id, { + close: () => { + instance.close(); + this.viewInstances.delete(view.id); + }, + }); + 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; + banners.open(view); + this.viewInstances.set(view.viewId, { + close: () => { + banners.closeById(view.viewId); + }, + }); + 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.viewInstances.get(viewId)?.close(); + + break; + } + + case 'contextual_bar.open': { + const { view } = interaction; + 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) { + break; + } + + this.router.navigate({ + name: routeName, + params: { + ...routeParams, + tab: 'app', + context: view.id, + }, + }); + break; + } + + case 'contextual_bar.close': { + const { view } = interaction; + this.viewInstances.get(view.id)?.close(); + break; + } + } + + 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 disposeView(viewId: UiKit.ModalView['id'] | UiKit.BannerView['viewId'] | UiKit.ContextualBarView['id']) { + const instance = this.viewInstances.get(viewId); + instance?.close?.(); + this.viewInstances.delete(viewId); + } +} + +/** @deprecated consumer should use the context instead */ +export const actionManager = new ActionManager(router); diff --git a/apps/meteor/app/ui-message/client/UiKitTriggerTimeoutError.ts b/apps/meteor/app/ui-message/client/UiKitTriggerTimeoutError.ts new file mode 100644 index 000000000000..75b035d822a1 --- /dev/null +++ b/apps/meteor/app/ui-message/client/UiKitTriggerTimeoutError.ts @@ -0,0 +1,7 @@ +import { RocketChatError } from '../../../client/lib/errors/RocketChatError'; + +export class UiKitTriggerTimeoutError extends RocketChatError<'trigger-timeout'> { + constructor(message = 'Timeout', details: { triggerId: string; appId: string }) { + super('trigger-timeout', message, details); + } +} diff --git a/apps/meteor/app/ui/client/lib/ChatMessages.ts b/apps/meteor/app/ui/client/lib/ChatMessages.ts index 4a4b04f11833..4563bae81d52 100644 --- a/apps/meteor/app/ui/client/lib/ChatMessages.ts +++ b/apps/meteor/app/ui/client/lib/ChatMessages.ts @@ -18,7 +18,7 @@ import { setHighlightMessage, clearHighlightMessage, } from '../../../../client/views/room/MessageList/providers/messageHighlightSubscription'; -import * as ActionManager from '../../../ui-message/client/ActionManager'; +import { actionManager } from '../../../ui-message/client/ActionManager'; import { UserAction } from './UserAction'; type DeepWritable = T extends (...args: any) => any @@ -150,7 +150,7 @@ export class ChatMessages implements ChatAPI { this.uid = params.uid; this.data = createDataAPI({ rid, tmid }); this.uploads = createUploadsAPI({ rid, tmid }); - this.ActionManager = ActionManager; + this.ActionManager = actionManager; const unimplemented = () => { throw new Error('Flow is not implemented'); diff --git a/apps/meteor/client/UIKit/hooks/useUIKitHandleAction.tsx b/apps/meteor/client/UIKit/hooks/useUIKitHandleAction.tsx deleted file mode 100644 index 6a97f18a7936..000000000000 --- a/apps/meteor/client/UIKit/hooks/useUIKitHandleAction.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { UIKitIncomingInteractionContainerType } from '@rocket.chat/apps-engine/definition/uikit/UIKitIncomingInteractionContainer'; -import type { UiKitPayload, UIKitActionEvent } from '@rocket.chat/core-typings'; -import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; - -import { useUiKitActionManager } from '../../hooks/useUiKitActionManager'; - -const useUIKitHandleAction = (state: S): ((event: UIKitActionEvent) => Promise) => { - const actionManager = useUiKitActionManager(); - return useMutableCallback(async ({ blockId, value, appId, actionId }) => { - if (!appId) { - throw new Error('useUIKitHandleAction - invalid appId'); - } - return actionManager.triggerBlockAction({ - container: { - type: UIKitIncomingInteractionContainerType.VIEW, - id: state.viewId || state.appId, - }, - actionId, - appId, - value, - blockId, - }); - }); -}; - -export { useUIKitHandleAction }; diff --git a/apps/meteor/client/UIKit/hooks/useUIKitHandleClose.tsx b/apps/meteor/client/UIKit/hooks/useUIKitHandleClose.tsx deleted file mode 100644 index 672e1b311b5d..000000000000 --- a/apps/meteor/client/UIKit/hooks/useUIKitHandleClose.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import type { UIKitInteractionType } from '@rocket.chat/apps-engine/definition/uikit'; -import type { UiKitPayload } from '@rocket.chat/core-typings'; -import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { useToastMessageDispatch } from '@rocket.chat/ui-contexts'; - -import { useUiKitActionManager } from '../../hooks/useUiKitActionManager'; - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const emptyFn = (_error: any, _result: UIKitInteractionType | void): void => undefined; - -const useUIKitHandleClose = (state: S, fn = emptyFn): (() => Promise) => { - const actionManager = useUiKitActionManager(); - const dispatchToastMessage = useToastMessageDispatch(); - return useMutableCallback(() => - actionManager - .triggerCancel({ - appId: state.appId, - viewId: state.viewId, - view: { - ...state, - id: state.viewId, - }, - isCleared: true, - }) - .then((result) => fn(undefined, result)) - .catch((error) => { - dispatchToastMessage({ type: 'error', message: error }); - fn(error, undefined); - return Promise.reject(error); - }), - ); -}; - -export { useUIKitHandleClose }; diff --git a/apps/meteor/client/UIKit/hooks/useUIKitStateManager.tsx b/apps/meteor/client/UIKit/hooks/useUIKitStateManager.tsx deleted file mode 100644 index 26b329f2ea60..000000000000 --- a/apps/meteor/client/UIKit/hooks/useUIKitStateManager.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import type { UIKitUserInteractionResult, UiKitPayload } from '@rocket.chat/core-typings'; -import { isErrorType } from '@rocket.chat/core-typings'; -import { useSafely } from '@rocket.chat/fuselage-hooks'; -import { useEffect, useState } from 'react'; - -import { useUiKitActionManager } from '../../hooks/useUiKitActionManager'; - -const useUIKitStateManager = (initialState: S): S => { - const actionManager = useUiKitActionManager(); - const [state, setState] = useSafely(useState(initialState)); - - const { viewId } = state; - - useEffect(() => { - const handleUpdate = ({ ...data }: UIKitUserInteractionResult): void => { - if (isErrorType(data)) { - const { errors } = data; - setState((state) => ({ ...state, errors })); - return; - } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { type, ...rest } = data; - setState(rest as any); - }; - - actionManager.on(viewId, handleUpdate); - - return (): void => { - actionManager.off(viewId, handleUpdate); - }; - }, [setState, viewId]); - - return state; -}; - -export { useUIKitStateManager }; diff --git a/apps/meteor/client/hooks/useUiKitActionManager.ts b/apps/meteor/client/UIKit/hooks/useUiKitActionManager.ts similarity index 100% rename from apps/meteor/client/hooks/useUiKitActionManager.ts rename to apps/meteor/client/UIKit/hooks/useUiKitActionManager.ts diff --git a/apps/meteor/client/UIKit/hooks/useUiKitView.ts b/apps/meteor/client/UIKit/hooks/useUiKitView.ts new file mode 100644 index 000000000000..2d0d1512bc17 --- /dev/null +++ b/apps/meteor/client/UIKit/hooks/useUiKitView.ts @@ -0,0 +1,93 @@ +import type { UiKit } from '@rocket.chat/core-typings'; +import { useSafely } from '@rocket.chat/fuselage-hooks'; +import { extractInitialStateFromLayout } from '@rocket.chat/fuselage-ui-kit'; +import type { Dispatch } from 'react'; +import { useEffect, useMemo, useReducer, useState } from 'react'; + +import { useUiKitActionManager } from './useUiKitActionManager'; + +const reduceValues = ( + values: { [actionId: string]: { value: unknown; blockId?: string } }, + { actionId, payload }: { actionId: string; payload: { value: unknown; blockId?: string } }, +): { [actionId: string]: { value: unknown; blockId?: string } } => ({ + ...values, + [actionId]: payload, +}); + +const getViewId = (view: UiKit.View): string => { + if ('id' in view && typeof view.id === 'string') { + return view.id; + } + + if ('viewId' in view && typeof view.viewId === 'string') { + return view.viewId; + } + + throw new Error('Invalid view'); +}; + +const getViewFromInteraction = (interaction: UiKit.ServerInteraction): UiKit.View | undefined => { + if ('view' in interaction && typeof interaction.view === 'object') { + return interaction.view; + } + + if (interaction.type === 'banner.open') { + return interaction; + } + + return undefined; +}; + +type UseUiKitViewReturnType = { + view: TView; + errors?: { [field: string]: string }[]; + values: { [actionId: string]: { value: unknown; blockId?: string } }; + updateValues: Dispatch<{ actionId: string; payload: { value: unknown; blockId?: string } }>; + state: { + [blockId: string]: { + [key: string]: unknown; + }; + }; +}; + +export function useUiKitView(initialView: S): UseUiKitViewReturnType { + const [errors, setErrors] = useSafely(useState<{ [field: string]: string }[] | undefined>()); + const [values, updateValues] = useSafely(useReducer(reduceValues, initialView.blocks, extractInitialStateFromLayout)); + const [view, updateView] = useSafely(useState(initialView)); + const actionManager = useUiKitActionManager(); + + const state = useMemo(() => { + return Object.entries(values).reduce<{ [blockId: string]: { [actionId: string]: unknown } }>((obj, [key, payload]) => { + if (!payload?.blockId) { + return obj; + } + + const { blockId, value } = payload; + obj[blockId] = obj[blockId] || {}; + obj[blockId][key] = value; + + return obj; + }, {}); + }, [values]); + + const viewId = getViewId(view); + + useEffect(() => { + const handleUpdate = (interaction: UiKit.ServerInteraction): void => { + if (interaction.type === 'errors') { + setErrors(interaction.errors); + return; + } + + updateView((view) => ({ ...view, ...getViewFromInteraction(interaction) })); + }; + + actionManager.on(viewId, handleUpdate); + + return (): void => { + actionManager.off(viewId, handleUpdate); + }; + }, [actionManager, setErrors, updateView, viewId]); + + return { view, errors, values, updateValues, state }; +} diff --git a/apps/meteor/client/components/ActionManagerBusyState.tsx b/apps/meteor/client/components/ActionManagerBusyState.tsx index 0374254a7de9..033b200a2aa7 100644 --- a/apps/meteor/client/components/ActionManagerBusyState.tsx +++ b/apps/meteor/client/components/ActionManagerBusyState.tsx @@ -3,7 +3,7 @@ import { Box } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; import React, { useEffect, useState } from 'react'; -import { useUiKitActionManager } from '../hooks/useUiKitActionManager'; +import { useUiKitActionManager } from '../UIKit/hooks/useUiKitActionManager'; const ActionManagerBusyState = () => { const t = useTranslation(); @@ -15,10 +15,12 @@ const ActionManagerBusyState = () => { return; } - actionManager.on('busy', ({ busy }: { busy: boolean }) => setBusy(busy)); + const handleBusyStateChange = ({ busy }: { busy: boolean }) => setBusy(busy); + + actionManager.on('busy', handleBusyStateChange); return () => { - actionManager.off('busy'); + actionManager.off('busy', handleBusyStateChange); }; }, [actionManager]); diff --git a/apps/meteor/client/components/message/uikit/UiKitMessageBlock.tsx b/apps/meteor/client/components/message/uikit/UiKitMessageBlock.tsx index d59314ae8198..6d86e724b95f 100644 --- a/apps/meteor/client/components/message/uikit/UiKitMessageBlock.tsx +++ b/apps/meteor/client/components/message/uikit/UiKitMessageBlock.tsx @@ -1,12 +1,12 @@ -import { UIKitIncomingInteractionContainerType } from '@rocket.chat/apps-engine/definition/uikit/UIKitIncomingInteractionContainer'; import type { IMessage, IRoom } from '@rocket.chat/core-typings'; import { MessageBlock } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { UiKitComponent, UiKitMessage as UiKitMessageSurfaceRender, UiKitContext } from '@rocket.chat/fuselage-ui-kit'; import type { MessageSurfaceLayout } from '@rocket.chat/ui-kit'; import type { ContextType, ReactElement } from 'react'; -import React from 'react'; +import React, { useMemo } from 'react'; +import { useUiKitActionManager } from '../../../UIKit/hooks/useUiKitActionManager'; import { useVideoConfDispatchOutgoing, useVideoConfIsCalling, @@ -15,27 +15,16 @@ import { useVideoConfManager, useVideoConfSetPreferences, } from '../../../contexts/VideoConfContext'; -import { useUiKitActionManager } from '../../../hooks/useUiKitActionManager'; import { useVideoConfWarning } from '../../../views/room/contextualBar/VideoConference/hooks/useVideoConfWarning'; import GazzodownText from '../../GazzodownText'; -let patched = false; -const patchMessageParser = () => { - if (patched) { - return; - } - - patched = true; -}; - type UiKitMessageBlockProps = { + rid: IRoom['_id']; mid: IMessage['_id']; blocks: MessageSurfaceLayout; - rid: IRoom['_id']; - appId?: string | boolean; // TODO: this is a hack while the context value is not properly typed }; -const UiKitMessageBlock = ({ mid: _mid, blocks, rid, appId }: UiKitMessageBlockProps): ReactElement => { +const UiKitMessageBlock = ({ rid, mid, blocks }: UiKitMessageBlockProps): ReactElement => { const joinCall = useVideoConfJoinCall(); const setPreferences = useVideoConfSetPreferences(); const isCalling = useVideoConfIsCalling(); @@ -61,44 +50,47 @@ const UiKitMessageBlock = ({ mid: _mid, blocks, rid, appId }: UiKitMessageBlockP const actionManager = useUiKitActionManager(); // TODO: this structure is attrociously wrong; we should revisit this - const context: ContextType = { - // @ts-ignore Property 'mid' does not exist on type 'ActionParams'. - action: ({ actionId, value, blockId, mid = _mid, appId }, event) => { - if (appId === 'videoconf-core') { - event.preventDefault(); - setPreferences({ mic: true, cam: false }); - if (actionId === 'join') { - return joinCall(blockId); - } + const contextValue = useMemo( + (): ContextType => ({ + action: ({ appId, actionId, blockId, value }, event) => { + if (appId === 'videoconf-core') { + event.preventDefault(); + setPreferences({ mic: true, cam: false }); + if (actionId === 'join') { + return joinCall(blockId); + } - if (actionId === 'callBack') { - return handleOpenVideoConf(blockId); + if (actionId === 'callBack') { + return handleOpenVideoConf(blockId); + } } - } - actionManager?.triggerBlockAction({ - blockId, - actionId, - value, - mid, - rid, - appId, - container: { - type: UIKitIncomingInteractionContainerType.MESSAGE, - id: mid, - }, - }); - }, - // @ts-ignore Type 'string | boolean | undefined' is not assignable to type 'string'. - appId, - rid, - }; - - patchMessageParser(); // TODO: this is a hack + actionManager.emitInteraction(appId, { + type: 'blockAction', + actionId, + payload: { + blockId, + value, + }, + container: { + type: 'message', + id: mid, + }, + rid, + mid, + }); + }, + appId: '', // TODO: this is a hack + rid, + state: () => undefined, // TODO: this is a hack + values: {}, // TODO: this is a hack + }), + [actionManager, handleOpenVideoConf, joinCall, mid, rid, setPreferences], + ); return ( - + diff --git a/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx b/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx index 2b54588c6263..b22627bea8d2 100644 --- a/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx +++ b/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx @@ -61,7 +61,7 @@ const RoomMessageContent = ({ message, unread, all, mention, searchText }: RoomM )} {normalizedMessage.blocks && ( - + )} {!!normalizedMessage?.attachments?.length && } diff --git a/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx b/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx index 655f96639929..57835ec75e0c 100644 --- a/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx +++ b/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx @@ -49,7 +49,7 @@ const ThreadMessageContent = ({ message }: ThreadMessageContentProps): ReactElem )} {normalizedMessage.blocks && ( - + )} {normalizedMessage.attachments && } diff --git a/apps/meteor/client/hooks/useAppActionButtons.ts b/apps/meteor/client/hooks/useAppActionButtons.ts index 28d62ef1b75a..d039b2bd7c71 100644 --- a/apps/meteor/client/hooks/useAppActionButtons.ts +++ b/apps/meteor/client/hooks/useAppActionButtons.ts @@ -1,20 +1,22 @@ import type { IUIActionButton, UIActionButtonContext } from '@rocket.chat/apps-engine/definition/ui'; import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks'; -import { useEndpoint, useSingleStream, useUserId } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useSingleStream, useToastMessageDispatch, useUserId } from '@rocket.chat/ui-contexts'; import type { UseQueryResult } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { UiKitTriggerTimeoutError } from '../../app/ui-message/client/UiKitTriggerTimeoutError'; import type { MessageActionConfig, MessageActionContext } from '../../app/ui-utils/client/lib/MessageAction'; import type { MessageBoxAction } from '../../app/ui-utils/client/lib/messageBox'; import { Utilities } from '../../ee/lib/misc/Utilities'; +import { useUiKitActionManager } from '../UIKit/hooks/useUiKitActionManager'; import type { GenericMenuItemProps } from '../components/GenericMenu/GenericMenuItem'; import { useApplyButtonFilters, useApplyButtonAuthFilter } from './useApplyButtonFilters'; -import { useUiKitActionManager } from './useUiKitActionManager'; const getIdForActionButton = ({ appId, actionId }: IUIActionButton): string => `${appId}/${actionId}`; -export const useAppActionButtons = (context?: `${UIActionButtonContext}`) => { +export const useAppActionButtons = (context?: TContext) => { const queryClient = useQueryClient(); const apps = useSingleStream('apps'); @@ -24,7 +26,14 @@ export const useAppActionButtons = (context?: `${UIActionButtonContext}`) => { const result = useQuery(['apps', 'actionButtons'], () => getActionButtons(), { ...(context && { - select: (data) => data.filter((button) => button.context === context), + select: (data) => + data.filter( + ( + button, + ): button is IUIActionButton & { + context: UIActionButtonContext extends infer X ? (X extends TContext ? X : never) : never; + } => button.context === context, + ), }), staleTime: Infinity, }); @@ -55,6 +64,8 @@ export const useAppActionButtons = (context?: `${UIActionButtonContext}`) => { export const useMessageboxAppsActionButtons = () => { const result = useAppActionButtons('messageBoxAction'); const actionManager = useUiKitActionManager(); + const dispatchToastMessage = useToastMessageDispatch(); + const { t } = useTranslation(); const applyButtonFilters = useApplyButtonFilters(); @@ -69,19 +80,31 @@ export const useMessageboxAppsActionButtons = () => { id: getIdForActionButton(action), label: Utilities.getI18nKeyForApp(action.labelI18n, action.appId), action: (params) => { - void actionManager.triggerActionButtonAction({ - rid: params.rid, - tmid: params.tmid, - actionId: action.actionId, - appId: action.appId, - payload: { context: action.context, message: params.chat.composer?.text }, - }); + void actionManager + .emitInteraction(action.appId, { + type: 'actionButton', + rid: params.rid, + tmid: params.tmid, + actionId: action.actionId, + payload: { context: action.context, message: params.chat.composer?.text ?? '' }, + }) + .catch(async (reason) => { + if (reason instanceof UiKitTriggerTimeoutError) { + dispatchToastMessage({ + type: 'error', + message: t('UIKit_Interaction_Timeout'), + }); + return; + } + + return reason; + }); }, }; return item; }), - [actionManager, applyButtonFilters, result.data], + [actionManager, applyButtonFilters, dispatchToastMessage, result.data, t], ); return { ...result, @@ -92,6 +115,8 @@ export const useMessageboxAppsActionButtons = () => { export const useUserDropdownAppsActionButtons = () => { const result = useAppActionButtons('userDropdownAction'); const actionManager = useUiKitActionManager(); + const dispatchToastMessage = useToastMessageDispatch(); + const { t } = useTranslation(); const applyButtonFilters = useApplyButtonAuthFilter(); @@ -107,15 +132,27 @@ export const useUserDropdownAppsActionButtons = () => { // icon: action.icon as GenericMenuItemProps['icon'], content: action.labelI18n, onClick: () => { - actionManager.triggerActionButtonAction({ - actionId: action.actionId, - appId: action.appId, - payload: { context: action.context }, - }); + void actionManager + .emitInteraction(action.appId, { + type: 'actionButton', + actionId: action.actionId, + payload: { context: action.context }, + }) + .catch(async (reason) => { + if (reason instanceof UiKitTriggerTimeoutError) { + dispatchToastMessage({ + type: 'error', + message: t('UIKit_Interaction_Timeout'), + }); + return; + } + + return reason; + }); }, }; }), - [actionManager, applyButtonFilters, result.data], + [actionManager, applyButtonFilters, dispatchToastMessage, result.data, t], ); return { ...result, @@ -127,6 +164,8 @@ export const useMessageActionAppsActionButtons = (context?: MessageActionContext const result = useAppActionButtons('messageAction'); const actionManager = useUiKitActionManager(); const applyButtonFilters = useApplyButtonFilters(); + const dispatchToastMessage = useToastMessageDispatch(); + const { t } = useTranslation(); const data = useMemo( () => result.data @@ -148,20 +187,32 @@ export const useMessageActionAppsActionButtons = (context?: MessageActionContext type: 'apps', variant: action.variant, action: (_, params) => { - void actionManager.triggerActionButtonAction({ - rid: params.message.rid, - tmid: params.message.tmid, - mid: params.message._id, - actionId: action.actionId, - appId: action.appId, - payload: { context: action.context }, - }); + void actionManager + .emitInteraction(action.appId, { + type: 'actionButton', + rid: params.message.rid, + tmid: params.message.tmid, + mid: params.message._id, + actionId: action.actionId, + payload: { context: action.context }, + }) + .catch(async (reason) => { + if (reason instanceof UiKitTriggerTimeoutError) { + dispatchToastMessage({ + type: 'error', + message: t('UIKit_Interaction_Timeout'), + }); + return; + } + + return reason; + }); }, }; return item; }), - [actionManager, applyButtonFilters, context, result.data], + [actionManager, applyButtonFilters, context, dispatchToastMessage, result.data, t], ); return { ...result, diff --git a/apps/meteor/client/hooks/useAppUiKitInteraction.ts b/apps/meteor/client/hooks/useAppUiKitInteraction.ts index 84849f592a48..e620d34a141d 100644 --- a/apps/meteor/client/hooks/useAppUiKitInteraction.ts +++ b/apps/meteor/client/hooks/useAppUiKitInteraction.ts @@ -1,16 +1,8 @@ -import type { UIKitInteractionType } from '@rocket.chat/apps-engine/definition/uikit'; +import type { UiKit } from '@rocket.chat/core-typings'; import { useStream, useUserId } from '@rocket.chat/ui-contexts'; import { useEffect } from 'react'; -export const useAppUiKitInteraction = ( - handlePayloadUserInteraction: ( - type: UIKitInteractionType, - data: { - triggerId: string; - appId: string; - }, - ) => void, -) => { +export const useAppUiKitInteraction = (handleServerInteraction: (interaction: UiKit.ServerInteraction) => void) => { const notifyUser = useStream('notify-user'); const uid = useUserId(); @@ -19,8 +11,9 @@ export const useAppUiKitInteraction = ( return; } - return notifyUser(`${uid}/uiInteraction`, ({ type, ...data }) => { - handlePayloadUserInteraction(type, data); + return notifyUser(`${uid}/uiInteraction`, (interaction) => { + // @ts-ignore + handleServerInteraction(interaction); }); - }, [notifyUser, uid, handlePayloadUserInteraction]); + }, [notifyUser, uid, handleServerInteraction]); }; diff --git a/apps/meteor/client/lib/banners.ts b/apps/meteor/client/lib/banners.ts index 89310da2e3c7..91185450a21a 100644 --- a/apps/meteor/client/lib/banners.ts +++ b/apps/meteor/client/lib/banners.ts @@ -1,4 +1,4 @@ -import type { UiKitBannerPayload } from '@rocket.chat/core-typings'; +import type { UiKit } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import type { Keys as IconName } from '@rocket.chat/icons'; @@ -15,7 +15,7 @@ export type LegacyBannerPayload = { onClose?: () => Promise | void; }; -type BannerPayload = LegacyBannerPayload | UiKitBannerPayload; +type BannerPayload = LegacyBannerPayload | UiKit.BannerView; export const isLegacyPayload = (payload: BannerPayload): payload is LegacyBannerPayload => !('blocks' in payload); @@ -35,7 +35,7 @@ export const open = (payload: BannerPayload): void => { if (isLegacyPayload(_payload)) { return _payload.id === (payload as LegacyBannerPayload).id; } - return (_payload as UiKitBannerPayload).viewId === (payload as UiKitBannerPayload).viewId; + return _payload.viewId === (payload as UiKit.BannerView).viewId; }); if (index === -1) { diff --git a/apps/meteor/client/lib/chats/flows/processSlashCommand.ts b/apps/meteor/client/lib/chats/flows/processSlashCommand.ts index 1551f8eb1f57..c9922162a67c 100644 --- a/apps/meteor/client/lib/chats/flows/processSlashCommand.ts +++ b/apps/meteor/client/lib/chats/flows/processSlashCommand.ts @@ -4,7 +4,7 @@ import { escapeHTML } from '@rocket.chat/string-helpers'; import { hasAtLeastOnePermission } from '../../../../app/authorization/client'; import { settings } from '../../../../app/settings/client'; -import { generateTriggerId } from '../../../../app/ui-message/client/ActionManager'; +import { actionManager } from '../../../../app/ui-message/client/ActionManager'; import { slashCommands } from '../../../../app/utils/client'; import { sdk } from '../../../../app/utils/client/lib/SDKClient'; import { t } from '../../../../app/utils/lib/i18n'; @@ -78,7 +78,7 @@ export const processSlashCommand = async (chat: ChatAPI, message: IMessage): Pro params: [{ eventName: 'slashCommandsStats', timestamp: Date.now(), command: commandName }], }); - const triggerId = generateTriggerId(appId); + const triggerId = actionManager.generateTriggerId(appId); const data = { cmd: commandName, diff --git a/apps/meteor/client/lib/utils/preventSyntheticEvent.ts b/apps/meteor/client/lib/utils/preventSyntheticEvent.ts new file mode 100644 index 000000000000..773b53a1a88c --- /dev/null +++ b/apps/meteor/client/lib/utils/preventSyntheticEvent.ts @@ -0,0 +1,9 @@ +import type { SyntheticEvent } from 'react'; + +export const preventSyntheticEvent = (e: SyntheticEvent): void => { + if (e) { + (e.nativeEvent || e).stopImmediatePropagation(); + e.stopPropagation(); + e.preventDefault(); + } +}; diff --git a/apps/meteor/client/polyfills/index.ts b/apps/meteor/client/polyfills/index.ts index f07d828a4602..bc91265b04ba 100644 --- a/apps/meteor/client/polyfills/index.ts +++ b/apps/meteor/client/polyfills/index.ts @@ -4,3 +4,4 @@ import './childNodeRemove'; import './cssVars'; import './customEventPolyfill'; import './hoverTouchClick'; +import './promiseFinally'; diff --git a/apps/meteor/client/polyfills/promiseFinally.ts b/apps/meteor/client/polyfills/promiseFinally.ts new file mode 100644 index 000000000000..ab826c2bd0ba --- /dev/null +++ b/apps/meteor/client/polyfills/promiseFinally.ts @@ -0,0 +1,16 @@ +if (!Promise.prototype.finally) { + // eslint-disable-next-line no-extend-native + Promise.prototype.finally = function (callback) { + if (typeof callback !== 'function') { + return this.then(callback, callback); + } + const P = (this.constructor as PromiseConstructor) || Promise; + return this.then( + (value) => P.resolve(callback()).then(() => value), + (err) => + P.resolve(callback()).then(() => { + throw err; + }), + ); + }; +} diff --git a/apps/meteor/client/providers/ActionManagerProvider.tsx b/apps/meteor/client/providers/ActionManagerProvider.tsx index 8faa55260f13..e8961ec357e9 100644 --- a/apps/meteor/client/providers/ActionManagerProvider.tsx +++ b/apps/meteor/client/providers/ActionManagerProvider.tsx @@ -2,7 +2,7 @@ import { ActionManagerContext } from '@rocket.chat/ui-contexts'; import type { ReactNode, ReactElement } from 'react'; import React from 'react'; -import * as ActionManager from '../../app/ui-message/client/ActionManager'; +import { actionManager } from '../../app/ui-message/client/ActionManager'; import { useAppActionButtons } from '../hooks/useAppActionButtons'; import { useAppSlashCommands } from '../hooks/useAppSlashCommands'; import { useAppTranslations } from '../hooks/useAppTranslations'; @@ -16,9 +16,9 @@ const ActionManagerProvider = ({ children }: ActionManagerProviderProps): ReactE useAppTranslations(); useAppActionButtons(); useAppSlashCommands(); - useAppUiKitInteraction(ActionManager.handlePayloadUserInteraction); + useAppUiKitInteraction(actionManager.handleServerInteraction.bind(actionManager)); - return {children}; + return {children}; }; export default ActionManagerProvider; diff --git a/apps/meteor/client/views/admin/moderation/helpers/ContextMessage.tsx b/apps/meteor/client/views/admin/moderation/helpers/ContextMessage.tsx index 114cde52c1d2..500ae78a6d26 100644 --- a/apps/meteor/client/views/admin/moderation/helpers/ContextMessage.tsx +++ b/apps/meteor/client/views/admin/moderation/helpers/ContextMessage.tsx @@ -75,7 +75,7 @@ const ContextMessage = ({ ) : ( message.msg )} - {message.blocks && } + {message.blocks && } {message.attachments && } diff --git a/apps/meteor/client/views/banners/BannerRegion.tsx b/apps/meteor/client/views/banners/BannerRegion.tsx index c5394f787229..b79c156db842 100644 --- a/apps/meteor/client/views/banners/BannerRegion.tsx +++ b/apps/meteor/client/views/banners/BannerRegion.tsx @@ -22,7 +22,7 @@ const BannerRegion = (): ReactElement | null => { return ; } - return ; + return ; }; export default BannerRegion; diff --git a/apps/meteor/client/views/banners/UiKitBanner.tsx b/apps/meteor/client/views/banners/UiKitBanner.tsx index 7cb52dd8d3c9..64a602d548dc 100644 --- a/apps/meteor/client/views/banners/UiKitBanner.tsx +++ b/apps/meteor/client/views/banners/UiKitBanner.tsx @@ -1,55 +1,93 @@ -import type { UIKitActionEvent, UiKitBannerProps } from '@rocket.chat/core-typings'; +import type { UiKit } from '@rocket.chat/core-typings'; import { Banner, Icon } from '@rocket.chat/fuselage'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { UiKitContext, bannerParser, UiKitBanner as UiKitBannerSurfaceRender, UiKitComponent } from '@rocket.chat/fuselage-ui-kit'; -import type { Keys as IconName } from '@rocket.chat/icons'; -import type { LayoutBlock } from '@rocket.chat/ui-kit'; -import type { FC, ReactElement, ContextType } from 'react'; +import { useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import type { ReactElement, ContextType } from 'react'; import React, { useMemo } from 'react'; -import { useUIKitHandleAction } from '../../UIKit/hooks/useUIKitHandleAction'; -import { useUIKitHandleClose } from '../../UIKit/hooks/useUIKitHandleClose'; -import { useUIKitStateManager } from '../../UIKit/hooks/useUIKitStateManager'; +import { useUiKitActionManager } from '../../UIKit/hooks/useUiKitActionManager'; +import { useUiKitView } from '../../UIKit/hooks/useUiKitView'; import MarkdownText from '../../components/MarkdownText'; -import * as banners from '../../lib/banners'; // TODO: move this to fuselage-ui-kit itself bannerParser.mrkdwn = ({ text }): ReactElement => ; -const UiKitBanner: FC = ({ payload }) => { - const state = useUIKitStateManager(payload); +type UiKitBannerProps = { + key: UiKit.BannerView['viewId']; // force re-mount when viewId changes + initialView: UiKit.BannerView; +}; + +const UiKitBanner = ({ initialView }: UiKitBannerProps) => { + const { view, values, state } = useUiKitView(initialView); const icon = useMemo(() => { - if (state.icon) { - return ; + if (view.icon) { + return ; } return null; - }, [state.icon]); + }, [view.icon]); - const handleClose = useUIKitHandleClose(state, () => banners.close()); + const dispatchToastMessage = useToastMessageDispatch(); + const handleClose = useMutableCallback(() => { + void actionManager + .emitInteraction(view.appId, { + type: 'viewClosed', + payload: { + viewId: view.viewId, + view: { + ...view, + id: view.viewId, + state, + }, + isCleared: true, + }, + }) + .catch((error) => { + dispatchToastMessage({ type: 'error', message: error }); + return Promise.reject(error); + }) + .finally(() => { + actionManager.disposeView(view.viewId); + }); + }); - const action = useUIKitHandleAction(state); + const actionManager = useUiKitActionManager(); - const contextValue = useMemo>( - () => ({ - action: async (event): Promise => { - if (!event.viewId) { + const contextValue = useMemo( + (): ContextType => ({ + action: async ({ appId, viewId, actionId, blockId, value }) => { + if (!appId || !viewId) { return; } - await action(event as UIKitActionEvent); - banners.closeById(state.viewId); + + await actionManager.emitInteraction(appId, { + type: 'blockAction', + actionId, + container: { + type: 'view', + id: viewId, + }, + payload: { + blockId, + value, + }, + }); + + actionManager.disposeView(view.viewId); }, state: (): void => undefined, - appId: state.appId, - values: {}, + appId: view.appId, + values: values as any, }), - [action, state.appId, state.viewId], + [view, values, actionManager], ); return ( - + - + ); diff --git a/apps/meteor/client/views/banners/hooks/useRemoteBanners.ts b/apps/meteor/client/views/banners/hooks/useRemoteBanners.ts index ff42d4ae9ace..ebed89e06037 100644 --- a/apps/meteor/client/views/banners/hooks/useRemoteBanners.ts +++ b/apps/meteor/client/views/banners/hooks/useRemoteBanners.ts @@ -1,5 +1,5 @@ import { BannerPlatform } from '@rocket.chat/core-typings'; -import type { IBanner, Serialized, UiKitBannerPayload } from '@rocket.chat/core-typings'; +import type { IBanner, Serialized, UiKit } from '@rocket.chat/core-typings'; import { useEndpoint, useStream, useUserId, ServerContext } from '@rocket.chat/ui-contexts'; import { useContext, useEffect } from 'react'; @@ -22,7 +22,7 @@ export const useRemoteBanners = () => { const { signal } = controller; - const mapBanner = (banner: Serialized): UiKitBannerPayload => ({ + const mapBanner = (banner: Serialized): UiKit.BannerView => ({ ...banner.view, viewId: banner.view.viewId || banner._id, }); diff --git a/apps/meteor/client/views/modal/uikit/ModalBlock.tsx b/apps/meteor/client/views/modal/uikit/ModalBlock.tsx index 7993355206e7..bd0876fc49ad 100644 --- a/apps/meteor/client/views/modal/uikit/ModalBlock.tsx +++ b/apps/meteor/client/views/modal/uikit/ModalBlock.tsx @@ -1,4 +1,4 @@ -import type { IUIKitSurface } from '@rocket.chat/apps-engine/definition/uikit'; +import type { UiKit } from '@rocket.chat/core-typings'; import { Modal, AnimatedVisibility, Button, Box } from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; import { UiKitComponent, UiKitModal, modalParser } from '@rocket.chat/fuselage-ui-kit'; @@ -38,7 +38,7 @@ const focusableElementsStringInvalid = ` [contenteditable]:invalid`; type ModalBlockParams = { - view: IUIKitSurface & { showIcon?: boolean }; + view: UiKit.ModalView; errors: any; appId: string; onSubmit: FormEventHandler; @@ -55,7 +55,7 @@ const KeyboardCode = new Map([ ['TAB', 9], ]); -const ModalBlock = ({ view, errors, appId, onSubmit, onClose, onCancel }: ModalBlockParams): ReactElement => { +const ModalBlock = ({ view, errors, onSubmit, onClose, onCancel }: ModalBlockParams): ReactElement => { const id = `modal_id_${useUniqueId()}`; const ref = useRef(null); @@ -165,7 +165,7 @@ const ModalBlock = ({ view, errors, appId, onSubmit, onClose, onCancel }: ModalB - {view.showIcon ? : null} + {view.showIcon ? : null} {modalParser.text(view.title, BlockContext.NONE, 0)} @@ -182,7 +182,7 @@ const ModalBlock = ({ view, errors, appId, onSubmit, onClose, onCancel }: ModalB )} {view.submit && ( - )} diff --git a/apps/meteor/client/views/modal/uikit/UiKitModal.tsx b/apps/meteor/client/views/modal/uikit/UiKitModal.tsx index b985f94b09b9..52aaa49ed009 100644 --- a/apps/meteor/client/views/modal/uikit/UiKitModal.tsx +++ b/apps/meteor/client/views/modal/uikit/UiKitModal.tsx @@ -1,139 +1,130 @@ -import { UIKitIncomingInteractionContainerType } from '@rocket.chat/apps-engine/definition/uikit/UIKitIncomingInteractionContainer'; +import type { UiKit } from '@rocket.chat/core-typings'; import { useDebouncedCallback, useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { UiKitContext } from '@rocket.chat/fuselage-ui-kit'; import { MarkupInteractionContext } from '@rocket.chat/gazzodown'; -import type { LayoutBlock } from '@rocket.chat/ui-kit'; -import type { ContextType, ReactElement, ReactEventHandler } from 'react'; -import React from 'react'; +import type { ContextType, FormEvent } from 'react'; +import React, { useMemo } from 'react'; -import { useUiKitActionManager } from '../../../hooks/useUiKitActionManager'; +import { useUiKitActionManager } from '../../../UIKit/hooks/useUiKitActionManager'; +import { useUiKitView } from '../../../UIKit/hooks/useUiKitView'; import { detectEmoji } from '../../../lib/utils/detectEmoji'; +import { preventSyntheticEvent } from '../../../lib/utils/preventSyntheticEvent'; import ModalBlock from './ModalBlock'; -import type { ActionManagerState } from './hooks/useActionManagerState'; -import { useActionManagerState } from './hooks/useActionManagerState'; -import { useValues } from './hooks/useValues'; -const UiKitModal = (props: ActionManagerState): ReactElement => { - const actionManager = useUiKitActionManager(); - const state = useActionManagerState(props); - - const { appId, viewId, mid: _mid, errors, view } = state; - - const [values, updateValues] = useValues(view.blocks as LayoutBlock[]); +type UiKitModalProps = { + key: UiKit.ModalView['id']; // force re-mount when viewId changes + initialView: UiKit.ModalView; +}; - const groupStateByBlockId = (values: { value: unknown; blockId: string }[]) => - Object.entries(values).reduce((obj, [key, { blockId, value }]) => { - obj[blockId] = obj[blockId] || {}; - obj[blockId][key] = value; +const UiKitModal = ({ initialView }: UiKitModalProps) => { + const actionManager = useUiKitActionManager(); + const { view, errors, values, updateValues, state } = useUiKitView(initialView); - return obj; - }, {}); + const emitInteraction = useMemo(() => actionManager.emitInteraction.bind(actionManager), [actionManager]); + const debouncedEmitInteraction = useDebouncedCallback(emitInteraction, 700); - const prevent: ReactEventHandler = (e) => { - if (e) { - (e.nativeEvent || e).stopImmediatePropagation(); - e.stopPropagation(); - e.preventDefault(); - } - }; + // TODO: this structure is atrociously wrong; we should revisit this + const contextValue = useMemo( + (): ContextType => ({ + action: async ({ actionId, viewId, appId, dispatchActionConfig, blockId, value }) => { + if (!appId || !viewId) { + return; + } - const debouncedBlockAction = useDebouncedCallback((actionId, appId, value, blockId, mid) => { - actionManager.triggerBlockAction({ - container: { - type: UIKitIncomingInteractionContainerType.VIEW, - id: viewId, - }, - actionId, - appId, - value, - blockId, - mid, - }); - }, 700); + const emit = dispatchActionConfig?.includes('on_character_entered') ? debouncedEmitInteraction : emitInteraction; - // TODO: this structure is atrociously wrong; we should revisit this - const context: ContextType = { - // @ts-expect-error Property 'mid' does not exist on type 'ActionParams'. - action: ({ actionId, appId, value, blockId, mid = _mid, dispatchActionConfig }) => { - if (Array.isArray(dispatchActionConfig) && dispatchActionConfig.includes('on_character_entered')) { - debouncedBlockAction(actionId, appId, value, blockId, mid); - } else { - actionManager.triggerBlockAction({ + await emit(appId, { + type: 'blockAction', + actionId, container: { - type: UIKitIncomingInteractionContainerType.VIEW, + type: 'view', id: viewId, }, + payload: { + blockId, + value, + }, + }); + }, + state: ({ actionId, value, /* ,appId, */ blockId = 'default' }) => { + updateValues({ actionId, - appId, - value, - blockId, - mid, + payload: { + blockId, + value, + }, }); - } - }, + }, + ...view, + values, + viewId: view.id, + }), + [debouncedEmitInteraction, emitInteraction, updateValues, values, view], + ); - state: ({ actionId, value, /* ,appId, */ blockId = 'default' }) => { - updateValues({ - actionId, + const handleSubmit = useMutableCallback((e: FormEvent) => { + preventSyntheticEvent(e); + void actionManager + .emitInteraction(view.appId, { + type: 'viewSubmit', payload: { - blockId, - value, + view: { + ...view, + state, + }, }, + viewId: view.id, + }) + .finally(() => { + actionManager.disposeView(view.id); }); - }, - ...state, - values, - }; - - const handleSubmit = useMutableCallback((e) => { - prevent(e); - actionManager.triggerSubmitView({ - viewId, - appId, - payload: { - view: { - ...view, - id: viewId, - state: groupStateByBlockId(values), - }, - }, - }); }); - const handleCancel = useMutableCallback((e) => { - prevent(e); - actionManager.triggerCancel({ - viewId, - appId, - view: { - ...view, - id: viewId, - state: groupStateByBlockId(values), - }, - }); + const handleCancel = useMutableCallback((e: FormEvent) => { + preventSyntheticEvent(e); + void actionManager + .emitInteraction(view.appId, { + type: 'viewClosed', + payload: { + viewId: view.id, + view: { + ...view, + state, + }, + isCleared: false, + }, + }) + .finally(() => { + actionManager.disposeView(view.id); + }); }); const handleClose = useMutableCallback(() => { - actionManager.triggerCancel({ - viewId, - appId, - view: { - ...view, - id: viewId, - state: groupStateByBlockId(values), - }, - isCleared: true, - }); + void actionManager + .emitInteraction(view.appId, { + type: 'viewClosed', + payload: { + viewId: view.id, + view: { + ...view, + state, + }, + isCleared: true, + }, + }) + .finally(() => { + actionManager.disposeView(view.id); + }); }); return ( - + - + ); diff --git a/apps/meteor/client/views/modal/uikit/getButtonStyle.ts b/apps/meteor/client/views/modal/uikit/getButtonStyle.ts index 4a78cb5e250a..89b489fd66fc 100644 --- a/apps/meteor/client/views/modal/uikit/getButtonStyle.ts +++ b/apps/meteor/client/views/modal/uikit/getButtonStyle.ts @@ -1,6 +1,6 @@ -import type { IUIKitSurface } from '@rocket.chat/apps-engine/definition/uikit'; +import type { ButtonElement } from '@rocket.chat/ui-kit'; // TODO: Move to fuselage-ui-kit -export const getButtonStyle = (view: IUIKitSurface): { danger: boolean } | { primary: boolean } => { - return view.submit?.style === 'danger' ? { danger: true } : { primary: true }; +export const getButtonStyle = (buttonElement: ButtonElement): { danger: boolean } | { primary: boolean } => { + return buttonElement?.style === 'danger' ? { danger: true } : { primary: true }; }; diff --git a/apps/meteor/client/views/modal/uikit/hooks/useActionManagerState.ts b/apps/meteor/client/views/modal/uikit/hooks/useActionManagerState.ts deleted file mode 100644 index fb1da19010e3..000000000000 --- a/apps/meteor/client/views/modal/uikit/hooks/useActionManagerState.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { IUIKitSurface } from '@rocket.chat/apps-engine/definition/uikit'; -import { useEffect, useState } from 'react'; - -import { useUiKitActionManager } from '../../../../hooks/useUiKitActionManager'; - -export type ActionManagerState = { - viewId: string; - type: 'errors' | string; - appId: string; - mid: string; - errors: Record; - view: IUIKitSurface; -}; - -export const useActionManagerState = (initialState: ActionManagerState) => { - const actionManager = useUiKitActionManager(); - const [state, setState] = useState(initialState); - - const { viewId } = state; - - useEffect(() => { - const handleUpdate = ({ type, errors, ...data }: ActionManagerState) => { - if (type === 'errors') { - setState((state) => ({ ...state, errors, type })); - return; - } - - setState({ ...data, type, errors }); - }; - - actionManager.on(viewId, handleUpdate); - - return () => { - actionManager.off(viewId, handleUpdate); - }; - }, [actionManager, viewId]); - - return state; -}; diff --git a/apps/meteor/client/views/modal/uikit/hooks/useValues.ts b/apps/meteor/client/views/modal/uikit/hooks/useValues.ts deleted file mode 100644 index 34a8eb0c5ae2..000000000000 --- a/apps/meteor/client/views/modal/uikit/hooks/useValues.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import type { LayoutBlock } from '@rocket.chat/ui-kit'; -import { useReducer } from 'react'; - -type LayoutBlockWithElement = Extract; -type LayoutBlockWithElements = Extract; -type ElementFromLayoutBlock = LayoutBlockWithElement['element'] | LayoutBlockWithElements['elements'][number]; - -const hasElementInBlock = (block: LayoutBlock): block is LayoutBlockWithElement => 'element' in block; -const hasElementsInBlock = (block: LayoutBlock): block is LayoutBlockWithElements => 'elements' in block; -const hasInitialValueAndActionId = ( - element: ElementFromLayoutBlock, -): element is Extract & { initialValue: unknown } => - 'initialValue' in element && 'actionId' in element && typeof element.actionId === 'string' && !!element?.initialValue; - -const extractValue = (element: ElementFromLayoutBlock, obj: Record, blockId?: string) => { - if (hasInitialValueAndActionId(element)) { - obj[element.actionId] = { value: element.initialValue, blockId }; - } -}; - -const reduceBlocks = (obj: Record, block: LayoutBlock) => { - if (hasElementInBlock(block)) { - extractValue(block.element, obj, block.blockId); - } - if (hasElementsInBlock(block)) { - for (const element of block.elements) { - extractValue(element, obj, block.blockId); - } - } - - return obj; -}; - -export const useValues = (blocks: LayoutBlock[]) => { - const reducer = useMutableCallback((values, { actionId, payload }) => ({ - ...values, - [actionId]: payload, - })); - - const initializer = useMutableCallback((blocks: LayoutBlock[]) => { - const obj: Record = {}; - - return blocks.reduce(reduceBlocks, obj); - }); - - return useReducer(reducer, blocks, initializer); -}; diff --git a/apps/meteor/client/views/omnichannel/contactHistory/MessageList/ContactHistoryMessage.tsx b/apps/meteor/client/views/omnichannel/contactHistory/MessageList/ContactHistoryMessage.tsx index 12342a6258a3..dab42e58e300 100644 --- a/apps/meteor/client/views/omnichannel/contactHistory/MessageList/ContactHistoryMessage.tsx +++ b/apps/meteor/client/views/omnichannel/contactHistory/MessageList/ContactHistoryMessage.tsx @@ -105,7 +105,7 @@ const ContactHistoryMessage: FC<{ )} - {message.blocks && } + {message.blocks && } {message.attachments && } diff --git a/apps/meteor/client/views/room/Room.tsx b/apps/meteor/client/views/room/Room.tsx index d53254647483..d8cf86dbbb48 100644 --- a/apps/meteor/client/views/room/Room.tsx +++ b/apps/meteor/client/views/room/Room.tsx @@ -23,7 +23,7 @@ const Room = (): ReactElement => { const toolbox = useRoomToolbox(); - const appsContextualBarContext = useAppsContextualBar(); + const contextualBarView = useAppsContextualBar(); return ( @@ -41,16 +41,11 @@ const Room = (): ReactElement => { )) || - (appsContextualBarContext && ( + (contextualBarView && ( }> - + diff --git a/apps/meteor/client/views/room/contextualBar/uikit/UiKitContextualBar.tsx b/apps/meteor/client/views/room/contextualBar/uikit/UiKitContextualBar.tsx index 6f3b803ebf84..543a36443185 100644 --- a/apps/meteor/client/views/room/contextualBar/uikit/UiKitContextualBar.tsx +++ b/apps/meteor/client/views/room/contextualBar/uikit/UiKitContextualBar.tsx @@ -1,15 +1,4 @@ -import type { - IUIKitContextualBarInteraction, - IUIKitErrorInteraction, - IUIKitSurface, - IInputElement, - IInputBlock, - IBlock, - IBlockElement, - IActionsBlock, -} from '@rocket.chat/apps-engine/definition/uikit'; -import { InputElementDispatchAction } from '@rocket.chat/apps-engine/definition/uikit'; -import { UIKitIncomingInteractionContainerType } from '@rocket.chat/apps-engine/definition/uikit/UIKitIncomingInteractionContainer'; +import type { UiKit } from '@rocket.chat/core-typings'; import { Avatar, Box, Button, ButtonGroup, ContextualbarFooter, ContextualbarHeader, ContextualbarTitle } from '@rocket.chat/fuselage'; import { useDebouncedCallback, useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { @@ -18,237 +7,139 @@ import { contextualBarParser, UiKitContext, } from '@rocket.chat/fuselage-ui-kit'; -import type { LayoutBlock } from '@rocket.chat/ui-kit'; -import { BlockContext, type Block } from '@rocket.chat/ui-kit'; -import type { Dispatch, SyntheticEvent, ContextType } from 'react'; -import React, { memo, useState, useEffect, useReducer } from 'react'; +import { BlockContext } from '@rocket.chat/ui-kit'; +import type { ContextType, FormEvent, UIEvent } from 'react'; +import React, { memo, useMemo } from 'react'; import { getURL } from '../../../../../app/utils/client'; +import { useUiKitActionManager } from '../../../../UIKit/hooks/useUiKitActionManager'; +import { useUiKitView } from '../../../../UIKit/hooks/useUiKitView'; import { ContextualbarClose, ContextualbarScrollableContent } from '../../../../components/Contextualbar'; -import { useUiKitActionManager } from '../../../../hooks/useUiKitActionManager'; +import { preventSyntheticEvent } from '../../../../lib/utils/preventSyntheticEvent'; import { getButtonStyle } from '../../../modal/uikit/getButtonStyle'; import { useRoomToolbox } from '../../contexts/RoomToolboxContext'; -type FieldStateValue = string | Array | undefined; -type FieldState = { value: FieldStateValue; blockId: string }; -type InputFieldStateTuple = [string, FieldState]; -type InputFieldStateObject = { [key: string]: FieldState }; -type InputFieldStateByBlockId = { [blockId: string]: { [actionId: string]: FieldStateValue } }; -type ActionParams = { - blockId: string; - appId: string; - actionId: string; - value: unknown; - viewId?: string; - dispatchActionConfig?: InputElementDispatchAction[]; +type UiKitContextualBarProps = { + key: UiKit.ContextualBarView['id']; // force re-mount when viewId changes + initialView: UiKit.ContextualBarView; }; -type ViewState = IUIKitContextualBarInteraction & { - errors?: { [field: string]: string }; -}; - -const isInputBlock = (block: any): block is IInputBlock => block?.element?.initialValue; - -const useValues = (view: IUIKitSurface): [any, Dispatch] => { - const reducer = useMutableCallback((values, { actionId, payload }) => ({ - ...values, - [actionId]: payload, - })); - - const initializer = useMutableCallback(() => { - const filterInputFields = (block: IBlock | Block): boolean => { - if (isInputBlock(block)) { - return true; - } - - if ( - ((block as IActionsBlock).elements as IInputElement[])?.filter((element) => filterInputFields({ element } as IInputBlock)).length - ) { - return true; - } - - return false; - }; - - const mapElementToState = (block: IBlock | Block): InputFieldStateTuple | InputFieldStateTuple[] => { - if (isInputBlock(block)) { - const { element, blockId } = block; - return [element.actionId, { value: element.initialValue, blockId } as FieldState]; - } - - const { elements, blockId }: { elements: IBlockElement[]; blockId?: string } = block as IActionsBlock; - - return elements - .filter((element) => filterInputFields({ element } as IInputBlock)) - .map((element) => mapElementToState({ element, blockId } as IInputBlock)) as InputFieldStateTuple[]; - }; - - return view.blocks - .filter(filterInputFields) - .map(mapElementToState) - .reduce((obj: InputFieldStateObject, el: InputFieldStateTuple | InputFieldStateTuple[]) => { - if (Array.isArray(el[0])) { - return { ...obj, ...Object.fromEntries(el as InputFieldStateTuple[]) }; - } - - const [key, value] = el as InputFieldStateTuple; - return { ...obj, [key]: value }; - }, {} as InputFieldStateObject); - }); - - return useReducer(reducer, null, initializer); -}; - -const UiKitContextualBar = ({ - viewId, - roomId, - payload, - appId, -}: { - viewId: string; - roomId: string; - payload: IUIKitContextualBarInteraction; - appId: string; -}): JSX.Element => { - const actionManager = useUiKitActionManager(); +const UiKitContextualBar = ({ initialView }: UiKitContextualBarProps): JSX.Element => { const { closeTab } = useRoomToolbox(); + const actionManager = useUiKitActionManager(); - const [state, setState] = useState(payload); - const { view } = state; - const [values, updateValues] = useValues(view); - - useEffect(() => { - const handleUpdate = ({ type, ...data }: IUIKitContextualBarInteraction | IUIKitErrorInteraction): void => { - if (type === 'errors') { - const { errors } = data as Omit; - setState((state: ViewState) => ({ ...state, errors })); - return; - } - - setState(data as IUIKitContextualBarInteraction); - }; - - actionManager.on(viewId, handleUpdate); - - return (): void => { - actionManager.off(viewId, handleUpdate); - }; - }, [actionManager, state, viewId]); + const { view, values, updateValues, state } = useUiKitView(initialView); - const groupStateByBlockId = (obj: InputFieldStateObject): InputFieldStateByBlockId => - Object.entries(obj).reduce((obj: InputFieldStateByBlockId, [key, { blockId, value }]: InputFieldStateTuple) => { - obj[blockId] = obj[blockId] || {}; - obj[blockId][key] = value; - return obj; - }, {} as InputFieldStateByBlockId); + const emitInteraction = useMemo(() => actionManager.emitInteraction.bind(actionManager), [actionManager]); + const debouncedEmitInteraction = useDebouncedCallback(emitInteraction, 700); - const prevent = (e: SyntheticEvent): void => { - if (e) { - (e.nativeEvent || e).stopImmediatePropagation(); - e.stopPropagation(); - e.preventDefault(); - } - }; + const contextValue = useMemo( + (): ContextType => ({ + action: async ({ appId, viewId, actionId, dispatchActionConfig, blockId, value }): Promise => { + if (!appId || !viewId) { + return; + } - const debouncedBlockAction = useDebouncedCallback(({ actionId, appId, value, blockId }: ActionParams) => { - actionManager.triggerBlockAction({ - container: { - type: UIKitIncomingInteractionContainerType.VIEW, - id: viewId, - }, - actionId, - appId, - value, - blockId, - }); - }, 700); + const emit = dispatchActionConfig?.includes('on_character_entered') ? debouncedEmitInteraction : emitInteraction; - const context: ContextType = { - action: async ({ actionId, appId, value, blockId, dispatchActionConfig }: ActionParams): Promise => { - if (Array.isArray(dispatchActionConfig) && dispatchActionConfig.includes(InputElementDispatchAction.ON_CHARACTER_ENTERED)) { - await debouncedBlockAction({ actionId, appId, value, blockId }); - } else { - await actionManager.triggerBlockAction({ + await emit(appId, { + type: 'blockAction', + actionId, container: { - type: UIKitIncomingInteractionContainerType.VIEW, + type: 'view', id: viewId, }, + payload: { + blockId, + value, + }, + }); + }, + state: ({ actionId, value, blockId = 'default' }) => { + updateValues({ actionId, - appId, - rid: roomId, - value, - blockId, + payload: { + blockId, + value, + }, }); - } - }, - state: ({ actionId, value, blockId = 'default' }: ActionParams): void => { - updateValues({ - actionId, - payload: { - blockId, - value, - }, - }); - }, - ...state, - values, - } as ContextType; + }, + ...view, + values, + viewId: view.id, + }), + [debouncedEmitInteraction, emitInteraction, updateValues, values, view], + ); - const handleSubmit = useMutableCallback((e) => { - prevent(e); + const handleSubmit = useMutableCallback((e: FormEvent) => { + preventSyntheticEvent(e); closeTab(); - actionManager.triggerSubmitView({ - viewId, - appId, - payload: { - view: { - ...view, - id: viewId, - state: groupStateByBlockId(values), + void actionManager + .emitInteraction(view.appId, { + type: 'viewSubmit', + payload: { + view: { + ...view, + state, + }, }, - }, - }); + viewId: view.id, + }) + .finally(() => { + actionManager.disposeView(view.id); + }); }); - const handleCancel = useMutableCallback((e) => { - prevent(e); + const handleCancel = useMutableCallback((e: UIEvent) => { + preventSyntheticEvent(e); closeTab(); - return actionManager.triggerCancel({ - appId, - viewId, - view: { - ...view, - id: viewId, - state: groupStateByBlockId(values), - }, - }); + void actionManager + .emitInteraction(view.appId, { + type: 'viewClosed', + payload: { + viewId: view.id, + view: { + ...view, + state, + }, + isCleared: false, + }, + }) + .finally(() => { + actionManager.disposeView(view.id); + }); }); - const handleClose = useMutableCallback((e) => { - prevent(e); + const handleClose = useMutableCallback((e: UIEvent) => { + preventSyntheticEvent(e); closeTab(); - return actionManager.triggerCancel({ - appId, - viewId, - view: { - ...view, - id: viewId, - state: groupStateByBlockId(values), - }, - isCleared: true, - }); + void actionManager + .emitInteraction(view.appId, { + type: 'viewClosed', + payload: { + viewId: view.id, + view: { + ...view, + state, + }, + isCleared: true, + }, + }) + .finally(() => { + actionManager.disposeView(view.id); + }); }); return ( - + - + {contextualBarParser.text(view.title, BlockContext.NONE, 0)} {handleClose && } - + @@ -258,8 +149,9 @@ const UiKitContextualBar = ({ {contextualBarParser.text(view.close.text, BlockContext.NONE, 0)} )} + {view.submit && ( - )} diff --git a/apps/meteor/client/views/room/hooks/useAppsContextualBar.ts b/apps/meteor/client/views/room/hooks/useAppsContextualBar.ts index 6afa6c3a6f84..c039c434a48f 100644 --- a/apps/meteor/client/views/room/hooks/useAppsContextualBar.ts +++ b/apps/meteor/client/views/room/hooks/useAppsContextualBar.ts @@ -1,49 +1,35 @@ -import type { IUIKitContextualBarInteraction } from '@rocket.chat/apps-engine/definition/uikit'; import { useRouteParameter } from '@rocket.chat/ui-contexts'; -import { useEffect, useState } from 'react'; +import { useCallback } from 'react'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; -import { useUiKitActionManager } from '../../../hooks/useUiKitActionManager'; -import { useRoom } from '../contexts/RoomContext'; +import { useUiKitActionManager } from '../../../UIKit/hooks/useUiKitActionManager'; -type AppsContextualBarData = { - viewId: string; - roomId: string; - payload: IUIKitContextualBarInteraction; - appId: string; -}; - -export const useAppsContextualBar = (): AppsContextualBarData | undefined => { - const [payload, setPayload] = useState(); +export const useAppsContextualBar = () => { + const viewId = useRouteParameter('context'); const actionManager = useUiKitActionManager(); - const [appId, setAppId] = useState(); - const { _id: roomId } = useRoom(); + const getSnapshot = useCallback(() => { + if (!viewId) { + return undefined; + } - const viewId = useRouteParameter('context'); + return actionManager.getInteractionPayloadByViewId(viewId)?.view; + }, [actionManager, viewId]); - useEffect(() => { - if (viewId) { - setPayload(actionManager.getUserInteractionPayloadByViewId(viewId) as IUIKitContextualBarInteraction); - } + const subscribe = useCallback( + (handler: () => void) => { + if (!viewId) { + return () => undefined; + } - if (payload?.appId) { - setAppId(payload.appId); - } + actionManager.on(viewId, handler); + + return () => actionManager.off(viewId, handler); + }, + [actionManager, viewId], + ); + + const view = useSyncExternalStore(subscribe, getSnapshot); - return (): void => { - setPayload(undefined); - setAppId(undefined); - }; - }, [viewId, payload?.appId, actionManager]); - - if (viewId && payload && appId) { - return { - viewId, - roomId, - payload, - appId, - }; - } - - return undefined; + return view; }; diff --git a/apps/meteor/client/views/room/providers/hooks/useAppsRoomActions.ts b/apps/meteor/client/views/room/providers/hooks/useAppsRoomActions.ts index 935da2a23c46..f402304a936b 100644 --- a/apps/meteor/client/views/room/providers/hooks/useAppsRoomActions.ts +++ b/apps/meteor/client/views/room/providers/hooks/useAppsRoomActions.ts @@ -1,9 +1,12 @@ +import { useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { UiKitTriggerTimeoutError } from '../../../../../app/ui-message/client/UiKitTriggerTimeoutError'; import { Utilities } from '../../../../../ee/lib/misc/Utilities'; +import { useUiKitActionManager } from '../../../../UIKit/hooks/useUiKitActionManager'; import { useAppActionButtons } from '../../../../hooks/useAppActionButtons'; import { useApplyButtonFilters } from '../../../../hooks/useApplyButtonFilters'; -import { useUiKitActionManager } from '../../../../hooks/useUiKitActionManager'; import { useRoom } from '../../contexts/RoomContext'; import type { RoomToolboxActionConfig } from '../../contexts/RoomToolboxContext'; @@ -12,6 +15,8 @@ export const useAppsRoomActions = () => { const actionManager = useUiKitActionManager(); const applyButtonFilters = useApplyButtonFilters(); const room = useRoom(); + const { t } = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); return useMemo( () => @@ -25,16 +30,29 @@ export const useAppsRoomActions = () => { groups: ['group', 'channel', 'live', 'team', 'direct', 'direct_multiple'], // Filters were applied in the applyButtonFilters function // if the code made it this far, the button should be shown - action: () => - void actionManager.triggerActionButtonAction({ - rid: room._id, - actionId: action.actionId, - appId: action.appId, - payload: { context: action.context }, - }), + action: () => { + void actionManager + .emitInteraction(action.appId, { + type: 'actionButton', + actionId: action.actionId, + rid: room._id, + payload: { context: action.context }, + }) + .catch(async (reason) => { + if (reason instanceof UiKitTriggerTimeoutError) { + dispatchToastMessage({ + type: 'error', + message: t('UIKit_Interaction_Timeout'), + }); + return; + } + + return reason; + }); + }, type: 'apps', }), ) ?? [], - [actionManager, applyButtonFilters, result.data, room._id], + [actionManager, applyButtonFilters, dispatchToastMessage, result.data, room._id, t], ); }; diff --git a/apps/meteor/ee/app/license/server/maxSeatsBanners.ts b/apps/meteor/ee/app/license/server/maxSeatsBanners.ts index 1aefb7848a42..b5aba719f29d 100644 --- a/apps/meteor/ee/app/license/server/maxSeatsBanners.ts +++ b/apps/meteor/ee/app/license/server/maxSeatsBanners.ts @@ -1,5 +1,3 @@ -import { BlockType } from '@rocket.chat/apps-engine/definition/uikit/blocks/Blocks'; -import { TextObjectType } from '@rocket.chat/apps-engine/definition/uikit/blocks/Objects'; import { Banner } from '@rocket.chat/core-services'; import type { IBanner } from '@rocket.chat/core-typings'; import { BannerPlatform } from '@rocket.chat/core-typings'; @@ -21,15 +19,14 @@ const makeWarningBanner = (seats: number): IBanner => ({ appId: 'banner-core', blocks: [ { - type: BlockType.SECTION, + type: 'section', blockId: 'attention', text: { - type: TextObjectType.MARKDOWN, + type: 'mrkdwn', text: i18n.t('Close_to_seat_limit_banner_warning', { seats, url: Meteor.absoluteUrl('/requestSeats'), }), - emoji: false, }, }, ], @@ -56,14 +53,13 @@ const makeDangerBanner = (): IBanner => ({ appId: 'banner-core', blocks: [ { - type: BlockType.SECTION, + type: 'section', blockId: 'attention', text: { - type: TextObjectType.MARKDOWN, + type: 'mrkdwn', text: i18n.t('Reached_seat_limit_banner_warning', { url: Meteor.absoluteUrl('/requestSeats'), }), - emoji: false, }, }, ], diff --git a/apps/meteor/ee/client/apps/gameCenter/GameCenter.tsx b/apps/meteor/ee/client/apps/gameCenter/GameCenter.tsx index c5377fdc30a0..75f4882ce747 100644 --- a/apps/meteor/ee/client/apps/gameCenter/GameCenter.tsx +++ b/apps/meteor/ee/client/apps/gameCenter/GameCenter.tsx @@ -1,8 +1,9 @@ import type { IExternalComponent } from '@rocket.chat/apps-engine/definition/externalComponent'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import React, { useState } from 'react'; -import type { ReactElement, SyntheticEvent } from 'react'; +import type { ReactElement } from 'react'; +import { preventSyntheticEvent } from '../../../../client/lib/utils/preventSyntheticEvent'; import { useRoomToolbox } from '../../../../client/views/room/contexts/RoomToolboxContext'; import GameCenterContainer from './GameCenterContainer'; import GameCenterList from './GameCenterList'; @@ -10,14 +11,6 @@ import { useExternalComponentsQuery } from './hooks/useExternalComponentsQuery'; export type IGame = IExternalComponent; -const prevent = (e: SyntheticEvent): void => { - if (e) { - (e.nativeEvent || e).stopImmediatePropagation(); - e.stopPropagation(); - e.preventDefault(); - } -}; - const GameCenter = (): ReactElement => { const [openedGame, setOpenedGame] = useState(); @@ -26,13 +19,13 @@ const GameCenter = (): ReactElement => { const result = useExternalComponentsQuery(); const handleClose = useMutableCallback((e) => { - prevent(e); + preventSyntheticEvent(e); closeTab(); }); const handleBack = useMutableCallback((e) => { setOpenedGame(undefined); - prevent(e); + preventSyntheticEvent(e); }); return ( diff --git a/apps/meteor/ee/server/apps/communication/uikit.ts b/apps/meteor/ee/server/apps/communication/uikit.ts index a7f84eab619b..61dee0a1857f 100644 --- a/apps/meteor/ee/server/apps/communication/uikit.ts +++ b/apps/meteor/ee/server/apps/communication/uikit.ts @@ -1,6 +1,6 @@ -import { AppInterface } from '@rocket.chat/apps-engine/definition/metadata'; -import { UIKitIncomingInteractionType } from '@rocket.chat/apps-engine/definition/uikit'; +import type { UiKitCoreAppPayload } from '@rocket.chat/core-services'; import { UiKitCoreApp } from '@rocket.chat/core-services'; +import type { OperationParams, UrlParams } from '@rocket.chat/rest-typings'; import cors from 'cors'; import type { Request, Response } from 'express'; import express from 'express'; @@ -91,41 +91,58 @@ const corsOptions: cors.CorsOptions = { apiServer.use('/api/apps/ui.interaction/', cors(corsOptions), router); // didn't have the rateLimiter option -const getPayloadForType = (type: UIKitIncomingInteractionType, req: Request) => { - if (type === UIKitIncomingInteractionType.BLOCK) { - const { type, actionId, triggerId, mid, rid, payload, container } = req.body; +type UiKitUserInteractionRequest = Request< + UrlParams<'/apps/ui.interaction/:id'>, + any, + OperationParams<'POST', '/apps/ui.interaction/:id'> & { + visitor?: { + id: string; + username: string; + name?: string; + department?: string; + updatedAt?: Date; + token: string; + phone?: { phoneNumber: string }[] | null; + visitorEmails?: { address: string }[]; + livechatData?: Record; + status?: 'online' | 'away' | 'offline' | 'busy' | 'disabled'; + }; + } +>; - const { visitor } = req.body; - const { user } = req; +const getCoreAppPayload = (req: UiKitUserInteractionRequest): UiKitCoreAppPayload => { + const { id: appId } = req.params; - const room = rid; // orch.getConverters().get('rooms').convertById(rid); - const message = mid; + if (req.body.type === 'blockAction') { + const { user } = req; + const { type, actionId, triggerId, payload, container, visitor } = req.body; + const message = 'mid' in req.body ? req.body.mid : undefined; + const room = 'rid' in req.body ? req.body.rid : undefined; return { + appId, type, - container, actionId, - message, triggerId, + container, + message, payload, user, visitor, room, - } as const; + }; } - if (type === UIKitIncomingInteractionType.VIEW_CLOSED) { + if (req.body.type === 'viewClosed') { + const { user } = req; const { type, - actionId, payload: { view, isCleared }, } = req.body; - const { user } = req; - return { + appId, type, - actionId, user, payload: { view, @@ -134,12 +151,12 @@ const getPayloadForType = (type: UIKitIncomingInteractionType, req: Request) => }; } - if (type === UIKitIncomingInteractionType.VIEW_SUBMIT) { - const { type, actionId, triggerId, payload } = req.body; - + if (req.body.type === 'viewSubmit') { const { user } = req; + const { type, actionId, triggerId, payload } = req.body; return { + appId, type, actionId, triggerId, @@ -151,24 +168,18 @@ const getPayloadForType = (type: UIKitIncomingInteractionType, req: Request) => throw new Error('Type not supported'); }; -router.post('/:appId', async (req, res, next) => { - const { appId } = req.params; +router.post('/:id', async (req: UiKitUserInteractionRequest, res, next) => { + const { id: appId } = req.params; - const isCore = await UiKitCoreApp.isRegistered(appId); - if (!isCore) { + const isCoreApp = await UiKitCoreApp.isRegistered(appId); + if (!isCoreApp) { return next(); } - // eslint-disable-next-line prefer-destructuring - const type: UIKitIncomingInteractionType = req.body.type; - try { - const payload = { - ...getPayloadForType(type, req), - appId, - }; + const payload = getCoreAppPayload(req); - const result = await (UiKitCoreApp as any)[type](payload); // TO-DO: fix type + const result = await UiKitCoreApp[payload.type](payload); // Using ?? to always send something in the response, even if the app had no result. res.send(result ?? {}); @@ -178,16 +189,24 @@ router.post('/:appId', async (req, res, next) => { } }); -const appsRoutes = - (orch: AppServerOrchestrator) => - async (req: Request, res: Response): Promise => { - const { appId } = req.params; +export class AppUIKitInteractionApi { + orch: AppServerOrchestrator; + + constructor(orch: AppServerOrchestrator) { + this.orch = orch; + + router.post('/:id', this.routeHandler); + } - const { type } = req.body; + private routeHandler = async (req: UiKitUserInteractionRequest, res: Response): Promise => { + const { orch } = this; + const { id: appId } = req.params; - switch (type) { - case UIKitIncomingInteractionType.BLOCK: { - const { type, actionId, triggerId, mid, rid, payload, container } = req.body; + switch (req.body.type) { + case 'blockAction': { + const { type, actionId, triggerId, payload, container } = req.body; + const mid = 'mid' in req.body ? req.body.mid : undefined; + const rid = 'rid' in req.body ? req.body.rid : undefined; const { visitor } = req.body; const room = await orch.getConverters()?.get('rooms').convertById(rid); @@ -208,7 +227,7 @@ const appsRoutes = }; try { - const eventInterface = !visitor ? AppInterface.IUIKitInteractionHandler : AppInterface.IUIKitLivechatInteractionHandler; + const eventInterface = !visitor ? 'IUIKitInteractionHandler' : 'IUIKitLivechatInteractionHandler'; const result = await orch.triggerEvent(eventInterface, action); @@ -220,10 +239,9 @@ const appsRoutes = break; } - case UIKitIncomingInteractionType.VIEW_CLOSED: { + case 'viewClosed': { const { type, - actionId, payload: { view, isCleared }, } = req.body; @@ -232,7 +250,6 @@ const appsRoutes = const action = { type, appId, - actionId, user, payload: { view, @@ -251,7 +268,7 @@ const appsRoutes = break; } - case UIKitIncomingInteractionType.VIEW_SUBMIT: { + case 'viewSubmit': { const { type, actionId, triggerId, payload } = req.body; const user = orch.getConverters()?.get('users').convertToApp(req.user); @@ -276,7 +293,7 @@ const appsRoutes = break; } - case UIKitIncomingInteractionType.ACTION_BUTTON: { + case 'actionButton': { const { type, actionId, @@ -302,7 +319,7 @@ const appsRoutes = tmid, payload: { context, - ...(msgText && { message: msgText }), + ...(msgText ? { message: msgText } : {}), }, }; @@ -324,13 +341,4 @@ const appsRoutes = // TODO: validate payloads per type }; - -export class AppUIKitInteractionApi { - orch: AppServerOrchestrator; - - constructor(orch: AppServerOrchestrator) { - this.orch = orch; - - router.post('/:appId', appsRoutes(orch)); - } } diff --git a/apps/meteor/server/modules/core-apps/banner.module.ts b/apps/meteor/server/modules/core-apps/banner.module.ts index bc850fea2078..fac891e5ea73 100644 --- a/apps/meteor/server/modules/core-apps/banner.module.ts +++ b/apps/meteor/server/modules/core-apps/banner.module.ts @@ -1,18 +1,24 @@ import { Banner } from '@rocket.chat/core-services'; -import type { IUiKitCoreApp } from '@rocket.chat/core-services'; +import type { IUiKitCoreApp, UiKitCoreAppPayload } from '@rocket.chat/core-services'; export class BannerModule implements IUiKitCoreApp { appId = 'banner-core'; // when banner view is closed we need to dissmiss that banner for that user - async viewClosed(payload: any): Promise { + async viewClosed(payload: UiKitCoreAppPayload) { const { - payload: { - view: { viewId: bannerId }, - }, - user: { _id: userId }, + payload: { view: { viewId: bannerId } = {} }, + user: { _id: userId } = {}, } = payload; + if (!userId) { + throw new Error('invalid user'); + } + + if (!bannerId) { + throw new Error('invalid banner'); + } + return Banner.dismiss(userId, bannerId); } } diff --git a/apps/meteor/server/modules/core-apps/nps.module.ts b/apps/meteor/server/modules/core-apps/nps.module.ts index 68ebeffd97c2..6e8965122df3 100644 --- a/apps/meteor/server/modules/core-apps/nps.module.ts +++ b/apps/meteor/server/modules/core-apps/nps.module.ts @@ -1,4 +1,4 @@ -import type { IUiKitCoreApp } from '@rocket.chat/core-services'; +import type { IUiKitCoreApp, UiKitCoreAppPayload } from '@rocket.chat/core-services'; import { Banner, NPS } from '@rocket.chat/core-services'; import { createModal } from './nps/createModal'; @@ -6,15 +6,19 @@ import { createModal } from './nps/createModal'; export class Nps implements IUiKitCoreApp { appId = 'nps-core'; - async blockAction(payload: any): Promise { + async blockAction(payload: UiKitCoreAppPayload) { const { triggerId, actionId, - container: { id: viewId }, + container: { id: viewId } = {}, payload: { value: score, blockId: npsId }, user, } = payload; + if (!viewId || !triggerId || !user || !npsId) { + throw new Error('Invalid payload'); + } + const bannerId = viewId.replace(`${npsId}-`, ''); return createModal({ @@ -23,13 +27,13 @@ export class Nps implements IUiKitCoreApp { appId: this.appId, npsId, triggerId, - score, + score: String(score), user, }); } - async viewSubmit(payload: any): Promise { - if (!payload.payload?.view?.state) { + async viewSubmit(payload: UiKitCoreAppPayload) { + if (!payload.payload?.view?.state || !payload.payload?.view?.id) { throw new Error('Invalid payload'); } @@ -37,7 +41,7 @@ export class Nps implements IUiKitCoreApp { payload: { view: { state, id: viewId }, }, - user: { _id: userId, roles }, + user: { _id: userId, roles } = {}, } = payload; const [npsId] = Object.keys(state); @@ -51,11 +55,15 @@ export class Nps implements IUiKitCoreApp { await NPS.vote({ npsId, userId, - comment, + comment: String(comment), roles, - score, + score: Number(score), }); + if (!userId) { + throw new Error('invalid user'); + } + await Banner.dismiss(userId, bannerId); return true; diff --git a/apps/meteor/server/modules/core-apps/videoconf.module.ts b/apps/meteor/server/modules/core-apps/videoconf.module.ts index b0425f6ffd55..694a0fac9b8e 100644 --- a/apps/meteor/server/modules/core-apps/videoconf.module.ts +++ b/apps/meteor/server/modules/core-apps/videoconf.module.ts @@ -1,4 +1,4 @@ -import type { IUiKitCoreApp } from '@rocket.chat/core-services'; +import type { IUiKitCoreApp, UiKitCoreAppPayload } from '@rocket.chat/core-services'; import { VideoConf } from '@rocket.chat/core-services'; import { i18n } from '../../lib/i18n'; @@ -6,14 +6,18 @@ import { i18n } from '../../lib/i18n'; export class VideoConfModule implements IUiKitCoreApp { appId = 'videoconf-core'; - async blockAction(payload: any): Promise { + async blockAction(payload: UiKitCoreAppPayload) { const { triggerId, actionId, payload: { blockId: callId }, - user: { _id: userId }, + user: { _id: userId } = {}, } = payload; + if (!callId) { + throw new Error('invalid call'); + } + if (actionId === 'join') { await VideoConf.join(userId, callId, {}); } diff --git a/apps/meteor/server/services/nps/getAndCreateNpsSurvey.ts b/apps/meteor/server/services/nps/getAndCreateNpsSurvey.ts index 02a3c29eedf3..8e4c06941c81 100644 --- a/apps/meteor/server/services/nps/getAndCreateNpsSurvey.ts +++ b/apps/meteor/server/services/nps/getAndCreateNpsSurvey.ts @@ -1,5 +1,5 @@ import { Banner } from '@rocket.chat/core-services'; -import type { UiKitBannerPayload, IBanner, BannerPlatform } from '@rocket.chat/core-typings'; +import type { UiKit, IBanner, BannerPlatform } from '@rocket.chat/core-typings'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { getWorkspaceAccessToken } from '../../../app/cloud/server'; @@ -10,7 +10,7 @@ type NpsSurveyData = { id: string; platform: BannerPlatform[]; roles: string[]; - survey: UiKitBannerPayload; + survey: UiKit.BannerView; createdAt: Date; startAt: Date; expireAt: Date; diff --git a/apps/meteor/server/services/nps/notification.ts b/apps/meteor/server/services/nps/notification.ts index 91ed3c7d2671..692b9bc6291f 100644 --- a/apps/meteor/server/services/nps/notification.ts +++ b/apps/meteor/server/services/nps/notification.ts @@ -1,5 +1,3 @@ -import { BlockType } from '@rocket.chat/apps-engine/definition/uikit/blocks/Blocks'; -import { TextObjectType } from '@rocket.chat/apps-engine/definition/uikit/blocks/Objects'; import type { IBanner } from '@rocket.chat/core-typings'; import { BannerPlatform } from '@rocket.chat/core-typings'; import moment from 'moment'; @@ -27,10 +25,10 @@ export const getBannerForAdmins = (expireAt: Date): Omit => { appId: '', blocks: [ { - type: BlockType.SECTION, + type: 'section', blockId: 'attention', text: { - type: TextObjectType.PLAINTEXT, + type: 'plain_text', text: i18n.t('NPS_survey_is_scheduled_to-run-at__date__for_all_users', { date: moment(expireAt).format('YYYY-MM-DD'), lng, diff --git a/apps/meteor/server/services/startup.ts b/apps/meteor/server/services/startup.ts index 968415620558..28ab5a35b553 100644 --- a/apps/meteor/server/services/startup.ts +++ b/apps/meteor/server/services/startup.ts @@ -25,7 +25,7 @@ import { SAUMonitorService } from './sauMonitor/service'; import { SettingsService } from './settings/service'; import { TeamService } from './team/service'; import { TranslationService } from './translation/service'; -import { UiKitCoreApp } from './uikit-core-app/service'; +import { UiKitCoreAppService } from './uikit-core-app/service'; import { UploadService } from './upload/service'; import { VideoConfService } from './video-conference/service'; import { VoipService } from './voip/service'; @@ -47,7 +47,7 @@ api.registerService(new VoipService(db)); api.registerService(new OmnichannelService()); api.registerService(new OmnichannelVoipService()); api.registerService(new TeamService()); -api.registerService(new UiKitCoreApp()); +api.registerService(new UiKitCoreAppService()); api.registerService(new PushService()); api.registerService(new DeviceManagementService()); api.registerService(new VideoConfService()); diff --git a/apps/meteor/server/services/uikit-core-app/service.ts b/apps/meteor/server/services/uikit-core-app/service.ts index a9eddf69ce81..a842a4854c6a 100644 --- a/apps/meteor/server/services/uikit-core-app/service.ts +++ b/apps/meteor/server/services/uikit-core-app/service.ts @@ -1,9 +1,9 @@ import { ServiceClassInternal } from '@rocket.chat/core-services'; -import type { IUiKitCoreApp, IUiKitCoreAppService } from '@rocket.chat/core-services'; +import type { IUiKitCoreApp, IUiKitCoreAppService, UiKitCoreAppPayload } from '@rocket.chat/core-services'; -const registeredApps = new Map(); +const registeredApps = new Map(); -const getAppModule = (appId: string): any => { +const getAppModule = (appId: string) => { const module = registeredApps.get(appId); if (typeof module === 'undefined') { @@ -17,14 +17,14 @@ export const registerCoreApp = (module: IUiKitCoreApp): void => { registeredApps.set(module.appId, module); }; -export class UiKitCoreApp extends ServiceClassInternal implements IUiKitCoreAppService { +export class UiKitCoreAppService extends ServiceClassInternal implements IUiKitCoreAppService { protected name = 'uikit-core-app'; async isRegistered(appId: string): Promise { return registeredApps.has(appId); } - async blockAction(payload: any): Promise { + async blockAction(payload: UiKitCoreAppPayload) { const { appId } = payload; const service = getAppModule(appId); @@ -35,7 +35,7 @@ export class UiKitCoreApp extends ServiceClassInternal implements IUiKitCoreAppS return service.blockAction?.(payload); } - async viewClosed(payload: any): Promise { + async viewClosed(payload: UiKitCoreAppPayload) { const { appId } = payload; const service = getAppModule(appId); @@ -46,7 +46,7 @@ export class UiKitCoreApp extends ServiceClassInternal implements IUiKitCoreAppS return service.viewClosed?.(payload); } - async viewSubmit(payload: any): Promise { + async viewSubmit(payload: UiKitCoreAppPayload) { const { appId } = payload; const service = getAppModule(appId); diff --git a/apps/meteor/server/services/video-conference/service.ts b/apps/meteor/server/services/video-conference/service.ts index c9079b0a2bfb..818280fd4d31 100644 --- a/apps/meteor/server/services/video-conference/service.ts +++ b/apps/meteor/server/services/video-conference/service.ts @@ -1,4 +1,3 @@ -import type { IBlock } from '@rocket.chat/apps-engine/definition/uikit'; import type { AppVideoConfProviderManager } from '@rocket.chat/apps-engine/server/managers'; import type { IVideoConfService, VideoConferenceJoinOptions } from '@rocket.chat/core-services'; import { api, ServiceClassInternal } from '@rocket.chat/core-services'; @@ -20,6 +19,7 @@ import type { VideoConferenceCapabilities, VideoConferenceCreateData, Optional, + UiKit, } from '@rocket.chat/core-typings'; import { VideoConferenceStatus, @@ -136,7 +136,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf return this.joinCall(call, user || undefined, options); } - public async getInfo(callId: VideoConference['_id'], uid: IUser['_id'] | undefined): Promise { + public async getInfo(callId: VideoConference['_id'], uid: IUser['_id'] | undefined): Promise { const call = await VideoConferenceModel.findOneById(callId); if (!call) { throw new Error('invalid-call'); @@ -162,7 +162,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf }); if (blocks?.length) { - return blocks; + return blocks as UiKit.LayoutBlock[]; } return [ @@ -173,7 +173,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf type: 'mrkdwn', text: `**${i18n.t('Video_Conference_Url')}**: ${call.url}`, }, - } as IBlock, + }, ]; } diff --git a/ee/packages/ddp-client/src/types/streams.ts b/ee/packages/ddp-client/src/types/streams.ts index a32dec470564..9010517faf7a 100644 --- a/ee/packages/ddp-client/src/types/streams.ts +++ b/ee/packages/ddp-client/src/types/streams.ts @@ -1,6 +1,5 @@ import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; import type { ISetting as AppsSetting } from '@rocket.chat/apps-engine/definition/settings'; -import type { IUIKitInteraction } from '@rocket.chat/apps-engine/definition/uikit'; import type { IMessage, IRoom, @@ -24,6 +23,7 @@ import type { ILivechatAgent, IImportProgress, IBanner, + UiKit, } from '@rocket.chat/core-typings'; type ClientAction = 'inserted' | 'updated' | 'removed' | 'changed'; @@ -148,7 +148,7 @@ export interface StreamerEvents { { key: `${string}/notification`; args: [INotificationDesktop] }, { key: `${string}/voip.events`; args: [VoipEventDataSignature] }, { key: `${string}/call.hangup`; args: [{ roomId: string }] }, - { key: `${string}/uiInteraction`; args: [IUIKitInteraction] }, + { key: `${string}/uiInteraction`; args: [UiKit.ServerInteraction] }, { key: `${string}/video-conference`; args: [{ action: string; params: { callId: VideoConference['_id']; uid: IUser['_id']; rid: IRoom['_id'] } }]; diff --git a/package.json b/package.json index 962e42d48c6e..236a551c5e5e 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "@types/chart.js": "^2.9.37", "@types/js-yaml": "^4.0.5", "husky": "^7.0.4", - "turbo": "~1.10.14" + "turbo": "~1.10.15" }, "workspaces": [ "apps/*", diff --git a/packages/core-services/src/Events.ts b/packages/core-services/src/Events.ts index e2a7f624d8df..2aa39588d2f0 100644 --- a/packages/core-services/src/Events.ts +++ b/packages/core-services/src/Events.ts @@ -1,6 +1,5 @@ import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; import type { ISetting as AppsSetting } from '@rocket.chat/apps-engine/definition/settings'; -import type { IUIKitInteraction } from '@rocket.chat/apps-engine/definition/uikit'; import type { IEmailInbox, IEmoji, @@ -33,6 +32,7 @@ import type { ILivechatAgent, IBanner, ILivechatVisitor, + UiKit, } from '@rocket.chat/core-typings'; import type { AutoUpdateRecord } from './types/IMeteor'; @@ -59,7 +59,7 @@ export type EventSignatures = { 'message'(data: { action: string; message: IMessage }): void; 'meteor.clientVersionUpdated'(data: AutoUpdateRecord): void; 'notify.desktop'(uid: string, data: INotificationDesktop): void; - 'notify.uiInteraction'(uid: string, data: IUIKitInteraction): void; + 'notify.uiInteraction'(uid: string, data: UiKit.ServerInteraction): void; 'notify.updateInvites'(uid: string, data: { invite: Omit }): void; 'notify.ephemeralMessage'(uid: string, rid: string, message: AtLeast): void; 'notify.webdav'( diff --git a/packages/core-services/src/index.ts b/packages/core-services/src/index.ts index def7622c9881..d3cc778e5a22 100644 --- a/packages/core-services/src/index.ts +++ b/packages/core-services/src/index.ts @@ -41,7 +41,7 @@ import type { } from './types/ITeamService'; import type { ITelemetryEvent, TelemetryMap, TelemetryEvents } from './types/ITelemetryEvent'; import type { ITranslationService } from './types/ITranslationService'; -import type { IUiKitCoreApp, IUiKitCoreAppService } from './types/IUiKitCoreApp'; +import type { UiKitCoreAppPayload, IUiKitCoreApp, IUiKitCoreAppService } from './types/IUiKitCoreApp'; import type { ISendFileLivechatMessageParams, ISendFileMessageParams, IUploadFileParams, IUploadService } from './types/IUploadService'; import type { IVideoConfService, VideoConferenceJoinOptions } from './types/IVideoConfService'; import type { IVoipService } from './types/IVoipService'; @@ -94,6 +94,7 @@ export { ITeamService, ITeamUpdateData, ITelemetryEvent, + UiKitCoreAppPayload, IUiKitCoreApp, IUiKitCoreAppService, IVideoConfService, diff --git a/packages/core-services/src/types/INPSService.ts b/packages/core-services/src/types/INPSService.ts index 4590a2910e8c..eaf54f6c6133 100644 --- a/packages/core-services/src/types/INPSService.ts +++ b/packages/core-services/src/types/INPSService.ts @@ -1,9 +1,9 @@ import type { IUser, IRole } from '@rocket.chat/core-typings'; export type NPSVotePayload = { - userId: string; + userId: string | undefined; npsId: string; - roles: IRole['_id'][]; + roles?: IRole['_id'][]; score: number; comment: string; }; diff --git a/packages/core-services/src/types/IUiKitCoreApp.ts b/packages/core-services/src/types/IUiKitCoreApp.ts index 92c7b7bd738e..98799918e594 100644 --- a/packages/core-services/src/types/IUiKitCoreApp.ts +++ b/packages/core-services/src/types/IUiKitCoreApp.ts @@ -1,16 +1,55 @@ +import type { IUser } from '@rocket.chat/core-typings'; + import type { IServiceClass } from './ServiceClass'; +export type UiKitCoreAppPayload = { + appId: string; + type: 'blockAction' | 'viewClosed' | 'viewSubmit'; + actionId?: string; + triggerId?: string; + container?: { + id: string; + [key: string]: unknown; + }; + message?: unknown; + payload: { + blockId?: string; + value?: unknown; + view?: { + viewId?: string; + id?: string; + state?: { [blockId: string]: { [key: string]: unknown } }; + [key: string]: unknown; + }; + isCleared?: unknown; + }; + user?: IUser; + visitor?: { + id: string; + username: string; + name?: string; + department?: string; + updatedAt?: Date; + token: string; + phone?: { phoneNumber: string }[] | null; + visitorEmails?: { address: string }[]; + livechatData?: Record; + status?: 'online' | 'away' | 'offline' | 'busy' | 'disabled'; + }; + room?: unknown; +}; + export interface IUiKitCoreApp { appId: string; - blockAction?(payload: any): Promise; - viewClosed?(payload: any): Promise; - viewSubmit?(payload: any): Promise; + blockAction?(payload: UiKitCoreAppPayload): Promise; + viewClosed?(payload: UiKitCoreAppPayload): Promise; + viewSubmit?(payload: UiKitCoreAppPayload): Promise; } export interface IUiKitCoreAppService extends IServiceClass { isRegistered(appId: string): Promise; - blockAction(payload: any): Promise; - viewClosed(payload: any): Promise; - viewSubmit(payload: any): Promise; + blockAction(payload: UiKitCoreAppPayload): Promise; + viewClosed(payload: UiKitCoreAppPayload): Promise; + viewSubmit(payload: UiKitCoreAppPayload): Promise; } diff --git a/packages/core-services/src/types/IVideoConfService.ts b/packages/core-services/src/types/IVideoConfService.ts index d545365b452a..09e336a51623 100644 --- a/packages/core-services/src/types/IVideoConfService.ts +++ b/packages/core-services/src/types/IVideoConfService.ts @@ -1,8 +1,8 @@ -import type { IBlock } from '@rocket.chat/apps-engine/definition/uikit'; import type { IRoom, IStats, IUser, + UiKit, VideoConference, VideoConferenceCapabilities, VideoConferenceCreateData, @@ -19,7 +19,7 @@ export interface IVideoConfService { create(data: VideoConferenceCreateData, useAppUser?: boolean): Promise; start(caller: IUser['_id'], rid: string, options: { title?: string; allowRinging?: boolean }): Promise; join(uid: IUser['_id'] | undefined, callId: VideoConference['_id'], options: VideoConferenceJoinOptions): Promise; - getInfo(callId: VideoConference['_id'], uid: IUser['_id'] | undefined): Promise; + getInfo(callId: VideoConference['_id'], uid: IUser['_id'] | undefined): Promise; cancel(uid: IUser['_id'], callId: VideoConference['_id']): Promise; get(callId: VideoConference['_id']): Promise | null>; getUnfiltered(callId: VideoConference['_id']): Promise; diff --git a/packages/core-typings/src/IBanner.ts b/packages/core-typings/src/IBanner.ts index 29867cdfb6c8..275c3353aa1f 100644 --- a/packages/core-typings/src/IBanner.ts +++ b/packages/core-typings/src/IBanner.ts @@ -1,6 +1,6 @@ import type { IRocketChatRecord } from './IRocketChatRecord'; import type { IUser } from './IUser'; -import type { UiKitBannerPayload } from './UIKit'; +import type * as UiKit from './uikit'; export enum BannerPlatform { Web = 'web', @@ -13,7 +13,7 @@ export interface IBanner extends IRocketChatRecord { roles?: string[]; // only show the banner to this roles createdBy: Pick; createdAt: Date; - view: UiKitBannerPayload; + view: UiKit.BannerView; active?: boolean; inactivedAt?: Date; snapshot?: string; diff --git a/packages/core-typings/src/INps.ts b/packages/core-typings/src/INps.ts index e89796d9d9a4..12b3a1a15d89 100644 --- a/packages/core-typings/src/INps.ts +++ b/packages/core-typings/src/INps.ts @@ -27,7 +27,7 @@ export interface INpsVote extends IRocketChatRecord { npsId: INps['_id']; ts: Date; identifier: string; // voter identifier - roles: IUser['roles']; // voter roles + roles?: IUser['roles']; // voter roles score: number; comment: string; status: INpsVoteStatus; diff --git a/packages/core-typings/src/Serialized.ts b/packages/core-typings/src/Serialized.ts index c84077610ee8..94f79cb64d06 100644 --- a/packages/core-typings/src/Serialized.ts +++ b/packages/core-typings/src/Serialized.ts @@ -1,9 +1,26 @@ -export type Serialized = T extends Date - ? Exclude | string - : T extends boolean | number | string | null | undefined +/* eslint-disable @typescript-eslint/ban-types */ + +type SerializablePrimitive = boolean | number | string | null; + +type UnserializablePrimitive = Function | bigint | symbol | undefined; + +type CustomSerializable = { + toJSON(key: string): T; +}; + +/** + * The type of a value that was serialized via `JSON.stringify` and then deserialized via `JSON.parse`. + */ +export type Serialized = T extends CustomSerializable + ? Serialized + : T extends [any, ...any] // is T a tuple? + ? { [K in keyof T]: T extends UnserializablePrimitive ? null : Serialized } + : T extends any[] + ? Serialized[] + : T extends object + ? { [K in keyof T]: Serialized } + : T extends SerializablePrimitive ? T - : T extends {} - ? { - [K in keyof T]: Serialized; - } + : T extends UnserializablePrimitive + ? undefined : null; diff --git a/packages/core-typings/src/UIKit.ts b/packages/core-typings/src/UIKit.ts deleted file mode 100644 index 19cf46f82b92..000000000000 --- a/packages/core-typings/src/UIKit.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { UIKitInteractionType as UIKitInteractionTypeApi } from '@rocket.chat/apps-engine/definition/uikit'; -import type { - IDividerBlock, - ISectionBlock, - IActionsBlock, - IContextBlock, - IInputBlock, -} from '@rocket.chat/apps-engine/definition/uikit/blocks/Blocks'; - -enum UIKitInteractionTypeExtended { - BANNER_OPEN = 'banner.open', - BANNER_UPDATE = 'banner.update', - BANNER_CLOSE = 'banner.close', -} - -export type UIKitInteractionType = UIKitInteractionTypeApi | UIKitInteractionTypeExtended; - -export const UIKitInteractionTypes = { - ...UIKitInteractionTypeApi, - ...UIKitInteractionTypeExtended, -}; - -export type UiKitPayload = { - viewId: string; - appId: string; - blocks: (IDividerBlock | ISectionBlock | IActionsBlock | IContextBlock | IInputBlock)[]; -}; - -export type UiKitBannerPayload = UiKitPayload & { - inline?: boolean; - variant?: 'neutral' | 'info' | 'success' | 'warning' | 'danger'; - icon?: string; - title?: string; -}; - -export type UIKitUserInteraction = { - type: UIKitInteractionType; -} & UiKitPayload; - -export type UiKitBannerProps = { - payload: UiKitBannerPayload; -}; - -export type UIKitUserInteractionResult = UIKitUserInteractionResultError | UIKitUserInteraction; - -type UIKitUserInteractionResultError = UIKitUserInteraction & { - type: UIKitInteractionTypeApi.ERRORS; - errors?: Array<{ [key: string]: string }>; -}; - -export const isErrorType = (result: UIKitUserInteractionResult): result is UIKitUserInteractionResultError => - result.type === UIKitInteractionTypeApi.ERRORS; - -export type UIKitActionEvent = { - blockId: string; - value?: unknown; - appId: string; - actionId: string; - viewId: string; -}; diff --git a/packages/core-typings/src/cloud/Announcement.ts b/packages/core-typings/src/cloud/Announcement.ts index 3d891daf132f..7c9541efe75a 100644 --- a/packages/core-typings/src/cloud/Announcement.ts +++ b/packages/core-typings/src/cloud/Announcement.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/naming-convention */ import type { IRocketChatRecord } from '../IRocketChatRecord'; -import { type UiKitPayload } from '../UIKit'; +import type * as UiKit from '../uikit'; type TargetPlatform = 'web' | 'mobile'; @@ -23,6 +23,6 @@ export interface Announcement extends IRocketChatRecord { createdBy: Creator; createdAt: Date; dictionary?: Dictionary; - view: UiKitPayload; + view: UiKit.View; surface: 'banner' | 'modal'; } diff --git a/packages/core-typings/src/index.ts b/packages/core-typings/src/index.ts index de36606e7f90..6411390f0fe9 100644 --- a/packages/core-typings/src/index.ts +++ b/packages/core-typings/src/index.ts @@ -4,7 +4,6 @@ export * from './FeaturedApps'; export * from './AppRequests'; export * from './MarketplaceRest'; export * from './IRoom'; -export * from './UIKit'; export * from './IMessage'; export * from './federation'; export * from './Serialized'; @@ -136,3 +135,5 @@ export * from './IModerationReport'; export * from './CustomFieldMetadata'; export * as Cloud from './cloud'; + +export * as UiKit from './uikit'; diff --git a/packages/core-typings/src/uikit/BannerView.ts b/packages/core-typings/src/uikit/BannerView.ts new file mode 100644 index 000000000000..f6914f75a6af --- /dev/null +++ b/packages/core-typings/src/uikit/BannerView.ts @@ -0,0 +1,16 @@ +import type { Keys as IconName } from '@rocket.chat/icons'; +import type { BannerSurfaceLayout } from '@rocket.chat/ui-kit'; + +import type { View } from './View'; + +/** + * A view that is displayed as a banner. + */ +export type BannerView = View & { + viewId: string; + inline?: boolean; + variant?: 'neutral' | 'info' | 'success' | 'warning' | 'danger'; + icon?: IconName; + title?: string; // TODO: change to plain_text block in the future + blocks: BannerSurfaceLayout; +}; diff --git a/packages/core-typings/src/uikit/ContextualBarView.ts b/packages/core-typings/src/uikit/ContextualBarView.ts new file mode 100644 index 000000000000..ab480be19b77 --- /dev/null +++ b/packages/core-typings/src/uikit/ContextualBarView.ts @@ -0,0 +1,14 @@ +import type { ButtonElement, ContextualBarSurfaceLayout, TextObject } from '@rocket.chat/ui-kit'; + +import type { View } from './View'; + +/** + * A view that is displayed as a contextual bar. + */ +export type ContextualBarView = View & { + id: string; + title: TextObject; + close?: ButtonElement; + submit?: ButtonElement; + blocks: ContextualBarSurfaceLayout; +}; diff --git a/packages/core-typings/src/uikit/ModalView.ts b/packages/core-typings/src/uikit/ModalView.ts new file mode 100644 index 000000000000..2e2fc12befe8 --- /dev/null +++ b/packages/core-typings/src/uikit/ModalView.ts @@ -0,0 +1,15 @@ +import type { ButtonElement, ModalSurfaceLayout, TextObject } from '@rocket.chat/ui-kit'; + +import type { View } from './View'; + +/** + * A view that is displayed as a modal dialog. + */ +export type ModalView = View & { + id: string; + showIcon?: boolean; + title: TextObject; + close?: ButtonElement; + submit?: ButtonElement; + blocks: ModalSurfaceLayout; +}; diff --git a/packages/core-typings/src/uikit/ServerInteraction.ts b/packages/core-typings/src/uikit/ServerInteraction.ts new file mode 100644 index 000000000000..a5b8aabca26e --- /dev/null +++ b/packages/core-typings/src/uikit/ServerInteraction.ts @@ -0,0 +1,84 @@ +import type { BannerView } from './BannerView'; +import type { ContextualBarView } from './ContextualBarView'; +import type { ModalView } from './ModalView'; + +type OpenModalServerInteraction = { + type: 'modal.open'; + triggerId: string; + appId: string; + view: ModalView; +}; + +type UpdateModalServerInteraction = { + type: 'modal.update'; + triggerId: string; + appId: string; + view: ModalView; +}; + +type CloseModalServerInteraction = { + type: 'modal.close'; + triggerId: string; + appId: string; +}; + +type OpenBannerServerInteraction = { + type: 'banner.open'; + triggerId: string; + appId: string; +} & BannerView; + +type UpdateBannerServerInteraction = { + type: 'banner.update'; + triggerId: string; + appId: string; + view: BannerView; +}; + +type CloseBannerServerInteraction = { + type: 'banner.close'; + triggerId: string; + appId: string; + viewId: BannerView['viewId']; +}; + +type OpenContextualBarServerInteraction = { + type: 'contextual_bar.open'; + triggerId: string; + appId: string; + view: ContextualBarView; +}; + +type UpdateContextualBarServerInteraction = { + type: 'contextual_bar.update'; + triggerId: string; + appId: string; + view: ContextualBarView; +}; + +type CloseContextualBarServerInteraction = { + type: 'contextual_bar.close'; + triggerId: string; + appId: string; + view: ContextualBarView; +}; + +type ReportErrorsServerInteraction = { + type: 'errors'; + triggerId: string; + appId: string; + viewId: ModalView['id'] | BannerView['viewId'] | ContextualBarView['id']; + errors: { [field: string]: string }[]; +}; + +export type ServerInteraction = + | OpenModalServerInteraction + | UpdateModalServerInteraction + | CloseModalServerInteraction + | OpenBannerServerInteraction + | UpdateBannerServerInteraction + | CloseBannerServerInteraction + | OpenContextualBarServerInteraction + | UpdateContextualBarServerInteraction + | CloseContextualBarServerInteraction + | ReportErrorsServerInteraction; diff --git a/packages/core-typings/src/uikit/UserInteraction.ts b/packages/core-typings/src/uikit/UserInteraction.ts new file mode 100644 index 000000000000..3b65acb839f8 --- /dev/null +++ b/packages/core-typings/src/uikit/UserInteraction.ts @@ -0,0 +1,122 @@ +import type { IMessage } from '../IMessage'; +import type { IRoom } from '../IRoom'; +import type { View } from './View'; + +export type MessageBlockActionUserInteraction = { + type: 'blockAction'; + actionId: string; + payload: { + blockId: string; + value: unknown; + }; + container: { + type: 'message'; + id: IMessage['_id']; + }; + mid: IMessage['_id']; + tmid?: IMessage['_id']; + rid: IRoom['_id']; + triggerId: string; +}; + +export type ViewBlockActionUserInteraction = { + type: 'blockAction'; + actionId: string; + payload: { + blockId: string; + value: unknown; + }; + container: { + type: 'view'; + id: string; + }; + triggerId: string; +}; + +export type ViewClosedUserInteraction = { + type: 'viewClosed'; + payload: { + viewId: string; + view: View & { + id: string; + state: { [blockId: string]: { [key: string]: unknown } }; + }; + isCleared?: boolean; + }; + triggerId: string; +}; + +export type ViewSubmitUserInteraction = { + type: 'viewSubmit'; + actionId?: undefined; + payload: { + view: View & { + id: string; + state: { [blockId: string]: { [key: string]: unknown } }; + }; + }; + triggerId: string; + viewId: string; +}; + +export type MessageBoxActionButtonUserInteraction = { + type: 'actionButton'; + actionId: string; + payload: { + context: 'messageBoxAction'; + message: string; + }; + mid?: undefined; + tmid?: IMessage['_id']; + rid: IRoom['_id']; + triggerId: string; +}; + +export type UserDropdownActionButtonUserInteraction = { + type: 'actionButton'; + actionId: string; + payload: { + context: 'userDropdownAction'; + message?: undefined; + }; + mid?: undefined; + tmid?: undefined; + rid?: undefined; + triggerId: string; +}; + +export type MesssageActionButtonUserInteraction = { + type: 'actionButton'; + actionId: string; + payload: { + context: 'messageAction'; + message?: undefined; + }; + mid: IMessage['_id']; + tmid?: IMessage['_id']; + rid: IRoom['_id']; + triggerId: string; +}; + +export type RoomActionButtonUserInteraction = { + type: 'actionButton'; + actionId: string; + payload: { + context: 'roomAction'; + message?: undefined; + }; + mid?: undefined; + tmid?: undefined; + rid: IRoom['_id']; + triggerId: string; +}; + +export type UserInteraction = + | MessageBlockActionUserInteraction + | ViewBlockActionUserInteraction + | ViewClosedUserInteraction + | ViewSubmitUserInteraction + | MessageBoxActionButtonUserInteraction + | UserDropdownActionButtonUserInteraction + | MesssageActionButtonUserInteraction + | RoomActionButtonUserInteraction; diff --git a/packages/core-typings/src/uikit/View.ts b/packages/core-typings/src/uikit/View.ts new file mode 100644 index 000000000000..fe3b3a366635 --- /dev/null +++ b/packages/core-typings/src/uikit/View.ts @@ -0,0 +1,9 @@ +import type { LayoutBlock } from '@rocket.chat/ui-kit'; + +/** + * An instance of a UiKit surface and its metadata. + */ +export type View = { + appId: string; + blocks: LayoutBlock[]; +}; diff --git a/packages/core-typings/src/uikit/index.ts b/packages/core-typings/src/uikit/index.ts new file mode 100644 index 000000000000..61ab79621d1a --- /dev/null +++ b/packages/core-typings/src/uikit/index.ts @@ -0,0 +1,17 @@ +export * from '@rocket.chat/ui-kit'; +export type { + UserInteraction, + MessageBlockActionUserInteraction, + ViewBlockActionUserInteraction, + ViewClosedUserInteraction, + ViewSubmitUserInteraction, + MessageBoxActionButtonUserInteraction, + UserDropdownActionButtonUserInteraction, + MesssageActionButtonUserInteraction, + RoomActionButtonUserInteraction, +} from './UserInteraction'; +export type { View } from './View'; +export type { BannerView } from './BannerView'; +export type { ContextualBarView } from './ContextualBarView'; +export type { ModalView } from './ModalView'; +export type { ServerInteraction } from './ServerInteraction'; diff --git a/packages/core-typings/src/utils.ts b/packages/core-typings/src/utils.ts index f3f0db9da1c2..e739257c070b 100644 --- a/packages/core-typings/src/utils.ts +++ b/packages/core-typings/src/utils.ts @@ -32,3 +32,5 @@ export type DeepWritable = T extends (...args: any) => any : { -readonly [P in keyof T]: DeepWritable; }; + +export type DistributiveOmit = T extends any ? Omit : never; diff --git a/packages/fuselage-ui-kit/src/contexts/UiKitContext.ts b/packages/fuselage-ui-kit/src/contexts/UiKitContext.ts index 2c8aa02e0fa6..9e5ca1a04e5f 100644 --- a/packages/fuselage-ui-kit/src/contexts/UiKitContext.ts +++ b/packages/fuselage-ui-kit/src/contexts/UiKitContext.ts @@ -1,10 +1,15 @@ -import type { InputElementDispatchAction } from '@rocket.chat/ui-kit'; +import type { + ActionableElement, + InputElementDispatchAction, +} from '@rocket.chat/ui-kit'; import { createContext } from 'react'; +type ActionId = ActionableElement['actionId']; + type ActionParams = { blockId: string; appId: string; - actionId: string; + actionId: ActionId; value: unknown; viewId?: string; dispatchActionConfig?: InputElementDispatchAction[]; @@ -21,7 +26,7 @@ type UiKitContextValue = { ) => Promise | void; appId: string; errors?: Record; - values: Record; + values: Record; viewId?: string; rid?: string; }; diff --git a/packages/fuselage-ui-kit/src/elements/MarkdownTextElement.tsx b/packages/fuselage-ui-kit/src/elements/MarkdownTextElement.tsx index da66e4f299fb..0ff2631d7a3a 100644 --- a/packages/fuselage-ui-kit/src/elements/MarkdownTextElement.tsx +++ b/packages/fuselage-ui-kit/src/elements/MarkdownTextElement.tsx @@ -2,15 +2,16 @@ import { Markup } from '@rocket.chat/gazzodown'; import { parse } from '@rocket.chat/message-parser'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { TextObject } from '@rocket.chat/ui-kit'; +import { useContext } from 'react'; -import { useUiKitContext } from '../hooks/useUiKitContext'; +import { UiKitContext } from '../contexts/UiKitContext'; const MarkdownTextElement = ({ textObject }: { textObject: TextObject }) => { const t = useTranslation() as ( key: string, args: { [key: string]: string | number } ) => string; - const { appId } = useUiKitContext(); + const { appId } = useContext(UiKitContext); const { i18n } = textObject; diff --git a/packages/fuselage-ui-kit/src/elements/PlainTextElement.tsx b/packages/fuselage-ui-kit/src/elements/PlainTextElement.tsx index bdb59e523dee..4e692caa0993 100644 --- a/packages/fuselage-ui-kit/src/elements/PlainTextElement.tsx +++ b/packages/fuselage-ui-kit/src/elements/PlainTextElement.tsx @@ -1,14 +1,15 @@ import { useTranslation } from '@rocket.chat/ui-contexts'; import type { TextObject } from '@rocket.chat/ui-kit'; +import { useContext } from 'react'; -import { useUiKitContext } from '../hooks/useUiKitContext'; +import { UiKitContext } from '../contexts/UiKitContext'; const PlainTextElement = ({ textObject }: { textObject: TextObject }) => { const t = useTranslation() as ( key: string, args: { [key: string]: string | number } ) => string; - const { appId } = useUiKitContext(); + const { appId } = useContext(UiKitContext); const { i18n } = textObject; diff --git a/packages/fuselage-ui-kit/src/extractInitialStateFromLayout.ts b/packages/fuselage-ui-kit/src/extractInitialStateFromLayout.ts new file mode 100644 index 000000000000..10b6790d976a --- /dev/null +++ b/packages/fuselage-ui-kit/src/extractInitialStateFromLayout.ts @@ -0,0 +1,90 @@ +import type * as UiKit from '@rocket.chat/ui-kit'; + +type Value = { value: unknown; blockId?: string }; + +type LayoutBlockWithElement = Extract< + UiKit.LayoutBlock, + { element: UiKit.BlockElement | UiKit.TextObject } +>; +type LayoutBlockWithElements = Extract< + UiKit.LayoutBlock, + { elements: readonly (UiKit.BlockElement | UiKit.TextObject)[] } +>; + +const hasElement = ( + block: UiKit.LayoutBlock +): block is LayoutBlockWithElement => 'element' in block; + +const hasElements = ( + block: UiKit.LayoutBlock +): block is LayoutBlockWithElements => + 'elements' in block && Array.isArray(block.elements); + +const isActionableElement = ( + element: UiKit.BlockElement | UiKit.TextObject +): element is UiKit.ActionableElement => + 'actionId' in element && typeof element.actionId === 'string'; + +const hasInitialValue = ( + element: UiKit.ActionableElement +): element is UiKit.ActionableElement & { initialValue: number | string } => + 'initialValue' in element; + +const hasInitialTime = ( + element: UiKit.ActionableElement +): element is UiKit.ActionableElement & { initialTime: string } => + 'initialTime' in element; + +const hasInitialDate = ( + element: UiKit.ActionableElement +): element is UiKit.ActionableElement & { initialDate: string } => + 'initialDate' in element; + +const hasInitialOption = ( + element: UiKit.ActionableElement +): element is UiKit.ActionableElement & { initialOption: UiKit.Option } => + 'initialOption' in element; + +const hasInitialOptions = ( + element: UiKit.ActionableElement +): element is UiKit.ActionableElement & { initialOptions: UiKit.Option[] } => + 'initialOptions' in element; + +const getInitialValue = (element: UiKit.ActionableElement) => + (hasInitialValue(element) && element.initialValue) || + (hasInitialTime(element) && element.initialTime) || + (hasInitialDate(element) && element.initialDate) || + (hasInitialOption(element) && element.initialOption.value) || + (hasInitialOptions(element) && + element.initialOptions.map((option) => option.value)) || + undefined; + +const reduceInitialValuesFromLayoutBlock = ( + state: { [actionId: string]: Value }, + block: UiKit.LayoutBlock +) => { + if (hasElement(block)) { + if (isActionableElement(block.element)) { + state[block.element.actionId] = { + value: getInitialValue(block.element), + blockId: block.blockId, + }; + } + } + + if (hasElements(block)) { + for (const element of block.elements) { + if (isActionableElement(element)) { + state[element.actionId] = { + value: getInitialValue(element), + blockId: block.blockId, + }; + } + } + } + + return state; +}; + +export const extractInitialStateFromLayout = (blocks: UiKit.LayoutBlock[]) => + blocks.reduce(reduceInitialValuesFromLayoutBlock, {}); diff --git a/packages/fuselage-ui-kit/src/hooks/useUiKitContext.ts b/packages/fuselage-ui-kit/src/hooks/useUiKitContext.ts deleted file mode 100644 index 1924b96d507e..000000000000 --- a/packages/fuselage-ui-kit/src/hooks/useUiKitContext.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { useContext } from 'react'; - -import { UiKitContext } from '../contexts/UiKitContext'; - -export const useUiKitContext = () => useContext(UiKitContext); diff --git a/packages/fuselage-ui-kit/src/hooks/useUiKitState.ts b/packages/fuselage-ui-kit/src/hooks/useUiKitState.ts index 5cbae5db2b5d..56fc553b1996 100644 --- a/packages/fuselage-ui-kit/src/hooks/useUiKitState.ts +++ b/packages/fuselage-ui-kit/src/hooks/useUiKitState.ts @@ -3,16 +3,6 @@ import * as UiKit from '@rocket.chat/ui-kit'; import { useContext, useMemo, useState } from 'react'; import { UiKitContext } from '../contexts/UiKitContext'; -import { useUiKitStateValue } from './useUiKitStateValue'; - -type UiKitState< - TElement extends UiKit.ActionableElement = UiKit.ActionableElement -> = { - loading: boolean; - setLoading: (loading: boolean) => void; - error?: string; - value: UiKit.ActionOf; -}; const hasInitialValue = ( element: TElement @@ -37,10 +27,48 @@ const hasInitialOptions = ( ): element is TElement & { initialOptions: UiKit.Option[] } => 'initialOptions' in element; -export const useUiKitState: ( +const getInitialValue = ( + element: TElement +) => + (hasInitialValue(element) && element.initialValue) || + (hasInitialTime(element) && element.initialTime) || + (hasInitialDate(element) && element.initialDate) || + (hasInitialOption(element) && element.initialOption.value) || + (hasInitialOptions(element) && + element.initialOptions.map((option) => option.value)) || + undefined; + +const getElementValueFromState = ( + actionId: string, + values: Record< + string, + | { + value: unknown; + } + | undefined + >, + initialValue: string | number | string[] | undefined +) => { + return ( + (values && + (values[actionId]?.value as string | number | string[] | undefined)) ?? + initialValue + ); +}; + +type UiKitState< + TElement extends UiKit.ActionableElement = UiKit.ActionableElement +> = { + loading: boolean; + setLoading: (loading: boolean) => void; + error?: string; + value: UiKit.ActionOf; +}; + +export const useUiKitState = ( element: TElement, context: UiKit.BlockContext -) => [ +): [ state: UiKitState, action: ( pseudoEvent?: @@ -48,8 +76,8 @@ export const useUiKitState: ( | { target: EventTarget } | { target: { value: UiKit.ActionOf } } ) => void -] = (rest, context) => { - const { blockId, actionId, appId, dispatchActionConfig } = rest; +] => { + const { blockId, actionId, appId, dispatchActionConfig } = element; const { action, appId: appIdFromContext, @@ -57,16 +85,13 @@ export const useUiKitState: ( state, } = useContext(UiKitContext); - const initialValue = - (hasInitialValue(rest) && rest.initialValue) || - (hasInitialTime(rest) && rest.initialTime) || - (hasInitialDate(rest) && rest.initialDate) || - (hasInitialOption(rest) && rest.initialOption.value) || - (hasInitialOptions(rest) && - rest.initialOptions.map((option) => option.value)) || - undefined; + const initialValue = getInitialValue(element); + + const { values, errors } = useContext(UiKitContext); + + const _value = getElementValueFromState(actionId, values, initialValue); + const error = errors?.[actionId]; - const { value: _value, error } = useUiKitStateValue(actionId, initialValue); const [value, setValue] = useSafely(useState(_value)); const [loading, setLoading] = useSafely(useState(false)); @@ -147,9 +172,9 @@ export const useUiKitState: ( ); if ( - rest.type === 'plain_text_input' && - Array.isArray(rest?.dispatchActionConfig) && - rest.dispatchActionConfig.includes('on_character_entered') + element.type === 'plain_text_input' && + Array.isArray(element?.dispatchActionConfig) && + element.dispatchActionConfig.includes('on_character_entered') ) { return [result, noLoadStateActionFunction]; } @@ -159,8 +184,8 @@ export const useUiKitState: ( [UiKit.BlockContext.SECTION, UiKit.BlockContext.ACTION].includes( context )) || - (Array.isArray(rest?.dispatchActionConfig) && - rest.dispatchActionConfig.includes('on_item_selected')) + (Array.isArray(element?.dispatchActionConfig) && + element.dispatchActionConfig.includes('on_item_selected')) ) { return [result, actionFunction]; } diff --git a/packages/fuselage-ui-kit/src/hooks/useUiKitStateValue.ts b/packages/fuselage-ui-kit/src/hooks/useUiKitStateValue.ts deleted file mode 100644 index 8d7e81aa69c5..000000000000 --- a/packages/fuselage-ui-kit/src/hooks/useUiKitStateValue.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { useUiKitContext } from './useUiKitContext'; - -export const useUiKitStateValue = < - T extends string | string[] | number | undefined ->( - actionId: string, - initialValue: T -): { - value: T; - error: string | undefined; -} => { - const { values, errors } = useUiKitContext(); - - return { - value: (values && (values[actionId]?.value as T)) ?? initialValue, - error: errors?.[actionId], - }; -}; diff --git a/packages/fuselage-ui-kit/src/index.ts b/packages/fuselage-ui-kit/src/index.ts index 95a713de071a..9db1f2097835 100644 --- a/packages/fuselage-ui-kit/src/index.ts +++ b/packages/fuselage-ui-kit/src/index.ts @@ -2,3 +2,4 @@ export * from './hooks/useUiKitState'; export * from './contexts/UiKitContext'; export * from './surfaces'; export { UiKitComponent } from './utils/UiKitComponent'; +export { extractInitialStateFromLayout } from './extractInitialStateFromLayout'; diff --git a/packages/mock-providers/src/MockedAppRootBuilder.tsx b/packages/mock-providers/src/MockedAppRootBuilder.tsx index 9e114be87d15..ae3eeea4cf1d 100644 --- a/packages/mock-providers/src/MockedAppRootBuilder.tsx +++ b/packages/mock-providers/src/MockedAppRootBuilder.tsx @@ -430,16 +430,13 @@ export class MockedAppRootBuilder { */} Promise.reject(new Error('not implemented')), generateTriggerId: () => '', - getUserInteractionPayloadByViewId: () => undefined, - handlePayloadUserInteraction: () => undefined, + emitInteraction: () => Promise.reject(new Error('not implemented')), + getInteractionPayloadByViewId: () => undefined, + handleServerInteraction: () => undefined, off: () => undefined, on: () => undefined, - triggerActionButtonAction: () => Promise.reject(new Error('not implemented')), - triggerBlockAction: () => Promise.reject(new Error('not implemented')), - triggerCancel: () => Promise.reject(new Error('not implemented')), - triggerSubmitView: () => Promise.reject(new Error('not implemented')), + disposeView: () => undefined, }} > {/* diff --git a/packages/rest-typings/src/apps/index.ts b/packages/rest-typings/src/apps/index.ts index 06ce6d98169e..31427afb3fee 100644 --- a/packages/rest-typings/src/apps/index.ts +++ b/packages/rest-typings/src/apps/index.ts @@ -12,6 +12,7 @@ import type { AppRequestFilter, AppRequestsStats, PaginatedAppRequests, + UiKit, } from '@rocket.chat/core-typings'; export type AppsEndpoints = { @@ -258,15 +259,6 @@ export type AppsEndpoints = { }; '/apps/ui.interaction/:id': { - POST: (params: { - type: string; - actionId: string; - rid: string; - mid: string; - viewId: string; - container: string; - triggerId: string; - payload: any; - }) => any; + POST: (params: UiKit.UserInteraction) => any; }; }; diff --git a/packages/ui-contexts/src/ActionManagerContext.ts b/packages/ui-contexts/src/ActionManagerContext.ts index d4dcdb61bfb9..76ca45cb6080 100644 --- a/packages/ui-contexts/src/ActionManagerContext.ts +++ b/packages/ui-contexts/src/ActionManagerContext.ts @@ -1,45 +1,20 @@ +import type { DistributiveOmit, UiKit } from '@rocket.chat/core-typings'; import { createContext } from 'react'; -type ActionManagerContextValue = { - on: (...args: any[]) => void; - off: (...args: any[]) => void; - generateTriggerId: (appId: any) => string; - handlePayloadUserInteraction: ( - type: any, - { - triggerId, - ...data - }: { - [x: string]: any; - triggerId: any; - }, - ) => any; - triggerAction: ({ - type, - actionId, - appId, - rid, - mid, - viewId, - container, - tmid, - ...rest - }: { - [x: string]: any; - type: any; - actionId: any; - appId: any; - rid: any; - mid: any; - viewId: any; - container: any; - tmid: any; - }) => Promise; - triggerBlockAction: (options: any) => Promise; - triggerActionButtonAction: (options: any) => Promise; - triggerSubmitView: ({ viewId, ...options }: { [x: string]: any; viewId: any }) => Promise; - triggerCancel: ({ view, ...options }: { [x: string]: any; view: any }) => Promise; - getUserInteractionPayloadByViewId: (viewId: any) => any; +type ActionManager = { + on(viewId: string, listener: (data: any) => void): void; + on(eventName: 'busy', listener: ({ busy }: { busy: boolean }) => void): void; + off(viewId: string, listener: (data: any) => any): void; + off(eventName: 'busy', listener: ({ busy }: { busy: boolean }) => void): void; + generateTriggerId(appId: string | undefined): string; + emitInteraction(appId: string, userInteraction: DistributiveOmit): Promise; + handleServerInteraction(interaction: UiKit.ServerInteraction): UiKit.ServerInteraction['type'] | undefined; + getInteractionPayloadByViewId(viewId: UiKit.ContextualBarView['id']): + | { + view: UiKit.ContextualBarView; + } + | undefined; + disposeView(viewId: UiKit.ModalView['id'] | UiKit.BannerView['viewId'] | UiKit.ContextualBarView['id']): void; }; -export const ActionManagerContext = createContext(undefined); +export const ActionManagerContext = createContext(undefined); diff --git a/yarn.lock b/yarn.lock index ce6dc859fc3d..4b4fd7f27bc9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -34263,7 +34263,7 @@ __metadata: "@types/chart.js": ^2.9.37 "@types/js-yaml": ^4.0.5 husky: ^7.0.4 - turbo: ~1.10.14 + turbo: ~1.10.15 languageName: unknown linkType: soft @@ -37678,58 +37678,58 @@ __metadata: languageName: node linkType: hard -"turbo-darwin-64@npm:1.10.14": - version: 1.10.14 - resolution: "turbo-darwin-64@npm:1.10.14" +"turbo-darwin-64@npm:1.10.15": + version: 1.10.15 + resolution: "turbo-darwin-64@npm:1.10.15" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"turbo-darwin-arm64@npm:1.10.14": - version: 1.10.14 - resolution: "turbo-darwin-arm64@npm:1.10.14" +"turbo-darwin-arm64@npm:1.10.15": + version: 1.10.15 + resolution: "turbo-darwin-arm64@npm:1.10.15" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"turbo-linux-64@npm:1.10.14": - version: 1.10.14 - resolution: "turbo-linux-64@npm:1.10.14" +"turbo-linux-64@npm:1.10.15": + version: 1.10.15 + resolution: "turbo-linux-64@npm:1.10.15" conditions: os=linux & cpu=x64 languageName: node linkType: hard -"turbo-linux-arm64@npm:1.10.14": - version: 1.10.14 - resolution: "turbo-linux-arm64@npm:1.10.14" +"turbo-linux-arm64@npm:1.10.15": + version: 1.10.15 + resolution: "turbo-linux-arm64@npm:1.10.15" conditions: os=linux & cpu=arm64 languageName: node linkType: hard -"turbo-windows-64@npm:1.10.14": - version: 1.10.14 - resolution: "turbo-windows-64@npm:1.10.14" +"turbo-windows-64@npm:1.10.15": + version: 1.10.15 + resolution: "turbo-windows-64@npm:1.10.15" conditions: os=win32 & cpu=x64 languageName: node linkType: hard -"turbo-windows-arm64@npm:1.10.14": - version: 1.10.14 - resolution: "turbo-windows-arm64@npm:1.10.14" +"turbo-windows-arm64@npm:1.10.15": + version: 1.10.15 + resolution: "turbo-windows-arm64@npm:1.10.15" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"turbo@npm:~1.10.14": - version: 1.10.14 - resolution: "turbo@npm:1.10.14" +"turbo@npm:~1.10.15": + version: 1.10.15 + resolution: "turbo@npm:1.10.15" dependencies: - turbo-darwin-64: 1.10.14 - turbo-darwin-arm64: 1.10.14 - turbo-linux-64: 1.10.14 - turbo-linux-arm64: 1.10.14 - turbo-windows-64: 1.10.14 - turbo-windows-arm64: 1.10.14 + turbo-darwin-64: 1.10.15 + turbo-darwin-arm64: 1.10.15 + turbo-linux-64: 1.10.15 + turbo-linux-arm64: 1.10.15 + turbo-windows-64: 1.10.15 + turbo-windows-arm64: 1.10.15 dependenciesMeta: turbo-darwin-64: optional: true @@ -37745,7 +37745,7 @@ __metadata: optional: true bin: turbo: bin/turbo - checksum: 219d245bb5cc32a9f76b136b81e86e179228d93a44cab4df3e3d487a55dd2688b5b85f4d585b66568ac53166145352399dd2d7ed0cd47f1aae63d08beb814ebb + checksum: b494c8bf79355874919e76ee0e4a0a53616e0ae5c7126eb1add50e67d4cd1e445ed9aecf99cb6d81c592b7a43ba91cd7dbf30df70410a44cecedba8b5126095d languageName: node linkType: hard