refactor(uikit): uikit interactions (#30534)

pull/30683/head
Tasso Evangelista 3 years ago committed by GitHub
parent fff548fe8a
commit 7b02ca3b4d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 7
      apps/meteor/app/apps/server/bridges/uiInteraction.ts
  2. 261
      apps/meteor/app/ui-message/client/ActionManager.js
  3. 237
      apps/meteor/app/ui-message/client/ActionManager.ts
  4. 7
      apps/meteor/app/ui-message/client/UiKitTriggerTimeoutError.ts
  5. 4
      apps/meteor/app/ui/client/lib/ChatMessages.ts
  6. 26
      apps/meteor/client/UIKit/hooks/useUIKitHandleAction.tsx
  7. 34
      apps/meteor/client/UIKit/hooks/useUIKitHandleClose.tsx
  8. 36
      apps/meteor/client/UIKit/hooks/useUIKitStateManager.tsx
  9. 0
      apps/meteor/client/UIKit/hooks/useUiKitActionManager.ts
  10. 93
      apps/meteor/client/UIKit/hooks/useUiKitView.ts
  11. 8
      apps/meteor/client/components/ActionManagerBusyState.tsx
  12. 86
      apps/meteor/client/components/message/uikit/UiKitMessageBlock.tsx
  13. 2
      apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx
  14. 2
      apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx
  15. 105
      apps/meteor/client/hooks/useAppActionButtons.ts
  16. 19
      apps/meteor/client/hooks/useAppUiKitInteraction.ts
  17. 6
      apps/meteor/client/lib/banners.ts
  18. 4
      apps/meteor/client/lib/chats/flows/processSlashCommand.ts
  19. 9
      apps/meteor/client/lib/utils/preventSyntheticEvent.ts
  20. 1
      apps/meteor/client/polyfills/index.ts
  21. 16
      apps/meteor/client/polyfills/promiseFinally.ts
  22. 6
      apps/meteor/client/providers/ActionManagerProvider.tsx
  23. 2
      apps/meteor/client/views/admin/moderation/helpers/ContextMessage.tsx
  24. 2
      apps/meteor/client/views/banners/BannerRegion.tsx
  25. 90
      apps/meteor/client/views/banners/UiKitBanner.tsx
  26. 4
      apps/meteor/client/views/banners/hooks/useRemoteBanners.ts
  27. 10
      apps/meteor/client/views/modal/uikit/ModalBlock.tsx
  28. 195
      apps/meteor/client/views/modal/uikit/UiKitModal.tsx
  29. 6
      apps/meteor/client/views/modal/uikit/getButtonStyle.ts
  30. 39
      apps/meteor/client/views/modal/uikit/hooks/useActionManagerState.ts
  31. 48
      apps/meteor/client/views/modal/uikit/hooks/useValues.ts
  32. 2
      apps/meteor/client/views/omnichannel/contactHistory/MessageList/ContactHistoryMessage.tsx
  33. 11
      apps/meteor/client/views/room/Room.tsx
  34. 306
      apps/meteor/client/views/room/contextualBar/uikit/UiKitContextualBar.tsx
  35. 64
      apps/meteor/client/views/room/hooks/useAppsContextualBar.ts
  36. 36
      apps/meteor/client/views/room/providers/hooks/useAppsRoomActions.ts
  37. 12
      apps/meteor/ee/app/license/server/maxSeatsBanners.ts
  38. 15
      apps/meteor/ee/client/apps/gameCenter/GameCenter.tsx
  39. 120
      apps/meteor/ee/server/apps/communication/uikit.ts
  40. 18
      apps/meteor/server/modules/core-apps/banner.module.ts
  41. 26
      apps/meteor/server/modules/core-apps/nps.module.ts
  42. 10
      apps/meteor/server/modules/core-apps/videoconf.module.ts
  43. 4
      apps/meteor/server/services/nps/getAndCreateNpsSurvey.ts
  44. 6
      apps/meteor/server/services/nps/notification.ts
  45. 4
      apps/meteor/server/services/startup.ts
  46. 14
      apps/meteor/server/services/uikit-core-app/service.ts
  47. 8
      apps/meteor/server/services/video-conference/service.ts
  48. 4
      ee/packages/ddp-client/src/types/streams.ts
  49. 2
      package.json
  50. 4
      packages/core-services/src/Events.ts
  51. 3
      packages/core-services/src/index.ts
  52. 4
      packages/core-services/src/types/INPSService.ts
  53. 51
      packages/core-services/src/types/IUiKitCoreApp.ts
  54. 4
      packages/core-services/src/types/IVideoConfService.ts
  55. 4
      packages/core-typings/src/IBanner.ts
  56. 2
      packages/core-typings/src/INps.ts
  57. 31
      packages/core-typings/src/Serialized.ts
  58. 60
      packages/core-typings/src/UIKit.ts
  59. 4
      packages/core-typings/src/cloud/Announcement.ts
  60. 3
      packages/core-typings/src/index.ts
  61. 16
      packages/core-typings/src/uikit/BannerView.ts
  62. 14
      packages/core-typings/src/uikit/ContextualBarView.ts
  63. 15
      packages/core-typings/src/uikit/ModalView.ts
  64. 84
      packages/core-typings/src/uikit/ServerInteraction.ts
  65. 122
      packages/core-typings/src/uikit/UserInteraction.ts
  66. 9
      packages/core-typings/src/uikit/View.ts
  67. 17
      packages/core-typings/src/uikit/index.ts
  68. 2
      packages/core-typings/src/utils.ts
  69. 11
      packages/fuselage-ui-kit/src/contexts/UiKitContext.ts
  70. 5
      packages/fuselage-ui-kit/src/elements/MarkdownTextElement.tsx
  71. 5
      packages/fuselage-ui-kit/src/elements/PlainTextElement.tsx
  72. 90
      packages/fuselage-ui-kit/src/extractInitialStateFromLayout.ts
  73. 5
      packages/fuselage-ui-kit/src/hooks/useUiKitContext.ts
  74. 81
      packages/fuselage-ui-kit/src/hooks/useUiKitState.ts
  75. 18
      packages/fuselage-ui-kit/src/hooks/useUiKitStateValue.ts
  76. 1
      packages/fuselage-ui-kit/src/index.ts
  77. 11
      packages/mock-providers/src/MockedAppRootBuilder.tsx
  78. 12
      packages/rest-typings/src/apps/index.ts
  79. 57
      packages/ui-contexts/src/ActionManagerContext.ts
  80. 58
      yarn.lock

@ -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);
}
}

@ -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;
};

@ -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<ContextType<typeof ActionManagerContext>, 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<string, string | undefined>();
protected viewInstances = new Map<
string,
{
payload?: {
view: UiKit.ContextualBarView;
};
close: () => void;
}
>();
public constructor(protected router: ContextType<typeof RouterContext>) {}
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<UiKit.UserInteraction, 'triggerId'>) {
this.events.emit('busy', { busy: true });
const triggerId = this.generateTriggerId(appId);
let timeout: ReturnType<typeof setTimeout> | 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);

@ -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);
}
}

@ -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> = 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');

@ -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 = <S extends UiKitPayload>(state: S): ((event: UIKitActionEvent) => Promise<void>) => {
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 };

@ -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 = <S extends UiKitPayload>(state: S, fn = emptyFn): (() => Promise<void | UIKitInteractionType>) => {
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 };

@ -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 = <S extends UiKitPayload>(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 };

@ -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<TView extends UiKit.View> = {
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<S extends UiKit.View>(initialView: S): UseUiKitViewReturnType<S> {
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 };
}

@ -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]);

@ -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<typeof UiKitContext> = {
// @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<typeof UiKitContext> => ({
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 (
<MessageBlock fixedWidth>
<UiKitContext.Provider value={context}>
<UiKitContext.Provider value={contextValue}>
<GazzodownText>
<UiKitComponent render={UiKitMessageSurfaceRender} blocks={blocks} />
</GazzodownText>

@ -61,7 +61,7 @@ const RoomMessageContent = ({ message, unread, all, mention, searchText }: RoomM
)}
{normalizedMessage.blocks && (
<UiKitMessageBlock mid={normalizedMessage._id} blocks={normalizedMessage.blocks} appId rid={normalizedMessage.rid} />
<UiKitMessageBlock rid={normalizedMessage.rid} mid={normalizedMessage._id} blocks={normalizedMessage.blocks} />
)}
{!!normalizedMessage?.attachments?.length && <Attachments attachments={normalizedMessage.attachments} />}

@ -49,7 +49,7 @@ const ThreadMessageContent = ({ message }: ThreadMessageContentProps): ReactElem
)}
{normalizedMessage.blocks && (
<UiKitMessageBlock mid={normalizedMessage._id} blocks={normalizedMessage.blocks} appId rid={normalizedMessage.rid} />
<UiKitMessageBlock rid={normalizedMessage.rid} mid={normalizedMessage._id} blocks={normalizedMessage.blocks} />
)}
{normalizedMessage.attachments && <Attachments attachments={normalizedMessage.attachments} />}

@ -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 = <TContext extends `${UIActionButtonContext}`>(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,

@ -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]);
};

@ -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> | 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) {

@ -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,

@ -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();
}
};

@ -4,3 +4,4 @@ import './childNodeRemove';
import './cssVars';
import './customEventPolyfill';
import './hoverTouchClick';
import './promiseFinally';

@ -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;
}),
);
};
}

@ -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 <ActionManagerContext.Provider value={ActionManager}>{children}</ActionManagerContext.Provider>;
return <ActionManagerContext.Provider value={actionManager}>{children}</ActionManagerContext.Provider>;
};
export default ActionManagerProvider;

@ -75,7 +75,7 @@ const ContextMessage = ({
) : (
message.msg
)}
{message.blocks && <UiKitMessageBlock mid={message._id} blocks={message.blocks} appId rid={message.rid} />}
{message.blocks && <UiKitMessageBlock rid={message.rid} mid={message._id} blocks={message.blocks} />}
{message.attachments && <Attachments attachments={message.attachments} />}
</Message.Body>
<ReportReasonCollapsible>

@ -22,7 +22,7 @@ const BannerRegion = (): ReactElement | null => {
return <LegacyBanner config={payload} />;
}
return <UiKitBanner payload={payload} />;
return <UiKitBanner key={payload.viewId} initialView={payload} />;
};
export default BannerRegion;

@ -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 => <MarkdownText variant='inline' content={text} />;
const UiKitBanner: FC<UiKitBannerProps> = ({ 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 <Icon name={state.icon as IconName} size='x20' />;
if (view.icon) {
return <Icon name={view.icon} size='x20' />;
}
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<ContextType<typeof UiKitContext>>(
() => ({
action: async (event): Promise<void> => {
if (!event.viewId) {
const contextValue = useMemo(
(): ContextType<typeof UiKitContext> => ({
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 (
<Banner closeable icon={icon} inline={state.inline} title={state.title} variant={state.variant} onClose={handleClose}>
<Banner icon={icon} inline={view.inline} title={view.title} variant={view.variant} closeable onClose={handleClose}>
<UiKitContext.Provider value={contextValue}>
<UiKitComponent render={UiKitBannerSurfaceRender} blocks={state.blocks as LayoutBlock[]} />
<UiKitComponent render={UiKitBannerSurfaceRender} blocks={view.blocks} />
</UiKitContext.Provider>
</Banner>
);

@ -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<IBanner>): UiKitBannerPayload => ({
const mapBanner = (banner: Serialized<IBanner>): UiKit.BannerView => ({
...banner.view,
viewId: banner.view.viewId || banner._id,
});

@ -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<HTMLElement>;
@ -55,7 +55,7 @@ const KeyboardCode = new Map<string, number>([
['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<HTMLElement>(null);
@ -165,7 +165,7 @@ const ModalBlock = ({ view, errors, appId, onSubmit, onClose, onCancel }: ModalB
<FocusScope contain restoreFocus autoFocus>
<Modal open id={id} ref={ref}>
<Modal.Header>
{view.showIcon ? <Modal.Thumb url={getURL(`/api/apps/${appId}/icon`)} /> : null}
{view.showIcon ? <Modal.Thumb url={getURL(`/api/apps/${view.appId}/icon`)} /> : null}
<Modal.Title>{modalParser.text(view.title, BlockContext.NONE, 0)}</Modal.Title>
<Modal.Close tabIndex={-1} onClick={onClose} />
</Modal.Header>
@ -182,7 +182,7 @@ const ModalBlock = ({ view, errors, appId, onSubmit, onClose, onCancel }: ModalB
</Button>
)}
{view.submit && (
<Button {...getButtonStyle(view)} onClick={onSubmit}>
<Button {...getButtonStyle(view.submit)} onClick={onSubmit}>
{modalParser.text(view.submit.text, BlockContext.NONE, 1)}
</Button>
)}

@ -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<any>((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<typeof UiKitContext> => ({
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<typeof UiKitContext> = {
// @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 (
<UiKitContext.Provider value={context}>
<UiKitContext.Provider value={contextValue}>
<MarkupInteractionContext.Provider
value={{
detectEmoji,
}}
>
<ModalBlock view={view} errors={errors} appId={appId} onSubmit={handleSubmit} onCancel={handleCancel} onClose={handleClose} />
<ModalBlock view={view} errors={errors} appId={view.appId} onSubmit={handleSubmit} onCancel={handleCancel} onClose={handleClose} />
</MarkupInteractionContext.Provider>
</UiKitContext.Provider>
);

@ -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 };
};

@ -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<string, string>;
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;
};

@ -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<LayoutBlock, { element: unknown }>;
type LayoutBlockWithElements = Extract<LayoutBlock, { elements: readonly unknown[] }>;
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<ElementFromLayoutBlock, { actionId: string }> & { initialValue: unknown } =>
'initialValue' in element && 'actionId' in element && typeof element.actionId === 'string' && !!element?.initialValue;
const extractValue = (element: ElementFromLayoutBlock, obj: Record<string, { value: unknown; blockId?: string }>, blockId?: string) => {
if (hasInitialValueAndActionId(element)) {
obj[element.actionId] = { value: element.initialValue, blockId };
}
};
const reduceBlocks = (obj: Record<string, { value: unknown; blockId?: string }>, 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<string, { value: unknown; blockId?: string }> = {};
return blocks.reduce(reduceBlocks, obj);
});
return useReducer(reducer, blocks, initializer);
};

@ -105,7 +105,7 @@ const ContactHistoryMessage: FC<{
<MessageContentBody md={message.md} mentions={message.mentions} channels={message.channels} />
</MessageBody>
)}
{message.blocks && <UiKitMessageBlock mid={message._id} blocks={message.blocks} appId rid={message.rid} />}
{message.blocks && <UiKitMessageBlock rid={message.rid} mid={message._id} blocks={message.blocks} />}
{message.attachments && <Attachments attachments={message.attachments} />}
</MessageContainer>
</MessageTemplate>

@ -23,7 +23,7 @@ const Room = (): ReactElement => {
const toolbox = useRoomToolbox();
const appsContextualBarContext = useAppsContextualBar();
const contextualBarView = useAppsContextualBar();
return (
<ChatProvider>
@ -41,16 +41,11 @@ const Room = (): ReactElement => {
</SelectedMessagesProvider>
</ErrorBoundary>
)) ||
(appsContextualBarContext && (
(contextualBarView && (
<ErrorBoundary fallback={null}>
<SelectedMessagesProvider>
<Suspense fallback={<ContextualbarSkeleton />}>
<UiKitContextualBar
viewId={appsContextualBarContext.viewId}
roomId={appsContextualBarContext.roomId}
payload={appsContextualBarContext.payload}
appId={appsContextualBarContext.appId}
/>
<UiKitContextualBar key={contextualBarView.id} initialView={contextualBarView} />
</Suspense>
</SelectedMessagesProvider>
</ErrorBoundary>

@ -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<string> | 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<any>] => {
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<ViewState>(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<IUIKitErrorInteraction, 'type'>;
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<typeof UiKitContext> => ({
action: async ({ appId, viewId, actionId, dispatchActionConfig, blockId, value }): Promise<void> => {
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<typeof UiKitContext> = {
action: async ({ actionId, appId, value, blockId, dispatchActionConfig }: ActionParams): Promise<void> => {
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<typeof UiKitContext>;
},
...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 (
<UiKitContext.Provider value={context}>
<UiKitContext.Provider value={contextValue}>
<ContextualbarHeader>
<Avatar url={getURL(`/api/apps/${appId}/icon`)} />
<Avatar url={getURL(`/api/apps/${view.appId}/icon`)} />
<ContextualbarTitle>{contextualBarParser.text(view.title, BlockContext.NONE, 0)}</ContextualbarTitle>
{handleClose && <ContextualbarClose onClick={handleClose} />}
</ContextualbarHeader>
<ContextualbarScrollableContent>
<Box is='form' method='post' action='#' onSubmit={handleSubmit}>
<UiKitComponent render={UiKitContextualBarSurfaceRender} blocks={view.blocks as LayoutBlock[]} />
<UiKitComponent render={UiKitContextualBarSurfaceRender} blocks={view.blocks} />
</Box>
</ContextualbarScrollableContent>
<ContextualbarFooter>
@ -258,8 +149,9 @@ const UiKitContextualBar = ({
{contextualBarParser.text(view.close.text, BlockContext.NONE, 0)}
</Button>
)}
{view.submit && (
<Button {...getButtonStyle(view)} onClick={handleSubmit}>
<Button {...getButtonStyle(view.submit)} onClick={handleSubmit}>
{contextualBarParser.text(view.submit.text, BlockContext.NONE, 1)}
</Button>
)}

@ -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<IUIKitContextualBarInteraction>();
export const useAppsContextualBar = () => {
const viewId = useRouteParameter('context');
const actionManager = useUiKitActionManager();
const [appId, setAppId] = useState<string>();
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;
};

@ -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],
);
};

@ -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,
},
},
],

@ -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<IGame>();
@ -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 (

@ -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<string, unknown>;
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<void> => {
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<void> => {
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));
}
}

@ -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<any> {
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);
}
}

@ -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<any> {
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<any> {
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;

@ -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<any> {
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, {});
}

@ -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;

@ -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<IBanner, '_id'> => {
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,

@ -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());

@ -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<string, IUiKitCoreApp>();
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<boolean> {
return registeredApps.has(appId);
}
async blockAction(payload: any): Promise<any> {
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<any> {
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<any> {
async viewSubmit(payload: UiKitCoreAppPayload) {
const { appId } = payload;
const service = getAppModule(appId);

@ -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<IBlock[]> {
public async getInfo(callId: VideoConference['_id'], uid: IUser['_id'] | undefined): Promise<UiKit.LayoutBlock[]> {
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,
},
];
}

@ -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'] } }];

@ -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/*",

@ -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<IInvite, '_updatedAt'> }): void;
'notify.ephemeralMessage'(uid: string, rid: string, message: AtLeast<IMessage, 'msg'>): void;
'notify.webdav'(

@ -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,

@ -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;
};

@ -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<string, unknown>;
status?: 'online' | 'away' | 'offline' | 'busy' | 'disabled';
};
room?: unknown;
};
export interface IUiKitCoreApp {
appId: string;
blockAction?(payload: any): Promise<any>;
viewClosed?(payload: any): Promise<any>;
viewSubmit?(payload: any): Promise<any>;
blockAction?(payload: UiKitCoreAppPayload): Promise<unknown>;
viewClosed?(payload: UiKitCoreAppPayload): Promise<unknown>;
viewSubmit?(payload: UiKitCoreAppPayload): Promise<unknown>;
}
export interface IUiKitCoreAppService extends IServiceClass {
isRegistered(appId: string): Promise<boolean>;
blockAction(payload: any): Promise<any>;
viewClosed(payload: any): Promise<any>;
viewSubmit(payload: any): Promise<any>;
blockAction(payload: UiKitCoreAppPayload): Promise<unknown>;
viewClosed(payload: UiKitCoreAppPayload): Promise<unknown>;
viewSubmit(payload: UiKitCoreAppPayload): Promise<unknown>;
}

@ -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<VideoConferenceInstructions>;
start(caller: IUser['_id'], rid: string, options: { title?: string; allowRinging?: boolean }): Promise<VideoConferenceInstructions>;
join(uid: IUser['_id'] | undefined, callId: VideoConference['_id'], options: VideoConferenceJoinOptions): Promise<string>;
getInfo(callId: VideoConference['_id'], uid: IUser['_id'] | undefined): Promise<IBlock[]>;
getInfo(callId: VideoConference['_id'], uid: IUser['_id'] | undefined): Promise<UiKit.LayoutBlock[]>;
cancel(uid: IUser['_id'], callId: VideoConference['_id']): Promise<void>;
get(callId: VideoConference['_id']): Promise<Omit<VideoConference, 'providerData'> | null>;
getUnfiltered(callId: VideoConference['_id']): Promise<VideoConference | null>;

@ -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<IUser, '_id' | 'username'>;
createdAt: Date;
view: UiKitBannerPayload;
view: UiKit.BannerView;
active?: boolean;
inactivedAt?: Date;
snapshot?: string;

@ -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;

@ -1,9 +1,26 @@
export type Serialized<T> = T extends Date
? Exclude<T, Date> | 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<T> = {
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> = T extends CustomSerializable<infer U>
? Serialized<U>
: T extends [any, ...any] // is T a tuple?
? { [K in keyof T]: T extends UnserializablePrimitive ? null : Serialized<T[K]> }
: T extends any[]
? Serialized<T[number]>[]
: T extends object
? { [K in keyof T]: Serialized<T[K]> }
: T extends SerializablePrimitive
? T
: T extends {}
? {
[K in keyof T]: Serialized<T[K]>;
}
: T extends UnserializablePrimitive
? undefined
: 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;
};

@ -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';
}

@ -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';

@ -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;
};

@ -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;
};

@ -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;
};

@ -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;

@ -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;

@ -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[];
};

@ -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';

@ -32,3 +32,5 @@ export type DeepWritable<T> = T extends (...args: any) => any
: {
-readonly [P in keyof T]: DeepWritable<T[P]>;
};
export type DistributiveOmit<T, K extends keyof any> = T extends any ? Omit<T, K> : never;

@ -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> | void;
appId: string;
errors?: Record<string, string>;
values: Record<string, { value: string } | undefined>;
values: Record<ActionId, { value: unknown } | undefined>;
viewId?: string;
rid?: string;
};

@ -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;

@ -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;

@ -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, {});

@ -1,5 +0,0 @@
import { useContext } from 'react';
import { UiKitContext } from '../contexts/UiKitContext';
export const useUiKitContext = () => useContext(UiKitContext);

@ -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<TElement>;
};
const hasInitialValue = <TElement extends UiKit.ActionableElement>(
element: TElement
@ -37,10 +27,48 @@ const hasInitialOptions = <TElement extends UiKit.ActionableElement>(
): element is TElement & { initialOptions: UiKit.Option[] } =>
'initialOptions' in element;
export const useUiKitState: <TElement extends UiKit.ActionableElement>(
const getInitialValue = <TElement extends UiKit.ActionableElement>(
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<TElement>;
};
export const useUiKitState = <TElement extends UiKit.ActionableElement>(
element: TElement,
context: UiKit.BlockContext
) => [
): [
state: UiKitState<TElement>,
action: (
pseudoEvent?:
@ -48,8 +76,8 @@ export const useUiKitState: <TElement extends UiKit.ActionableElement>(
| { target: EventTarget }
| { target: { value: UiKit.ActionOf<TElement> } }
) => 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: <TElement extends UiKit.ActionableElement>(
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: <TElement extends UiKit.ActionableElement>(
);
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: <TElement extends UiKit.ActionableElement>(
[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];
}

@ -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],
};
};

@ -2,3 +2,4 @@ export * from './hooks/useUiKitState';
export * from './contexts/UiKitContext';
export * from './surfaces';
export { UiKitComponent } from './utils/UiKitComponent';
export { extractInitialStateFromLayout } from './extractInitialStateFromLayout';

@ -430,16 +430,13 @@ export class MockedAppRootBuilder {
<UserPresenceProvider>*/}
<ActionManagerContext.Provider
value={{
triggerAction: () => 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,
}}
>
{/* <VideoConfProvider>

@ -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;
};
};

@ -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<any>;
triggerBlockAction: (options: any) => Promise<any>;
triggerActionButtonAction: (options: any) => Promise<any>;
triggerSubmitView: ({ viewId, ...options }: { [x: string]: any; viewId: any }) => Promise<void>;
triggerCancel: ({ view, ...options }: { [x: string]: any; view: any }) => Promise<void>;
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<UiKit.UserInteraction, 'triggerId'>): Promise<unknown>;
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<ActionManagerContextValue | undefined>(undefined);
export const ActionManagerContext = createContext<ActionManager | undefined>(undefined);

@ -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

Loading…
Cancel
Save