[NEW] UiKit - Interactive UI elements for Rocket.Chat Apps (#16048)

pull/16466/head^2
Douglas Gubert 5 years ago committed by GitHub
parent e6a8821a8d
commit 1932254338
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 35
      app/api/server/v1/commands.js
  2. 6
      app/apps/server/bridges/bridges.js
  3. 9
      app/apps/server/bridges/commands.js
  4. 11
      app/apps/server/bridges/listeners.js
  5. 13
      app/apps/server/bridges/uiInteraction.js
  6. 11
      app/apps/server/communication/index.js
  7. 159
      app/apps/server/communication/uikit.js
  8. 2
      app/apps/server/converters/messages.js
  9. 16
      app/apps/server/orchestrator.js
  10. 2
      app/lib/server/methods/executeSlashCommandPreview.js
  11. 8
      app/theme/client/imports/components/messages.css
  12. 10
      app/theme/client/imports/components/modal.css
  13. 154
      app/ui-message/client/ActionManager.js
  14. 3
      app/ui-message/client/blocks/Blocks.html
  15. 35
      app/ui-message/client/blocks/Blocks.js
  16. 3
      app/ui-message/client/blocks/ButtonElement.html
  17. 164
      app/ui-message/client/blocks/MessageBlock.js
  18. 3
      app/ui-message/client/blocks/ModalBlock.html
  19. 103
      app/ui-message/client/blocks/ModalBlock.js
  20. 12
      app/ui-message/client/blocks/TextBlock.html
  21. 5
      app/ui-message/client/blocks/TextBlock.js
  22. 5
      app/ui-message/client/blocks/index.js
  23. 18
      app/ui-message/client/blocks/styles.css
  24. 1
      app/ui-message/client/index.js
  25. 5
      app/ui-message/client/message.html
  26. 9
      app/ui-utils/client/lib/modal.html
  27. 244
      app/ui-utils/client/lib/modal.js
  28. 4
      app/ui/client/lib/chatMessages.js
  29. 9
      app/ui/client/views/app/room.js
  30. 11
      app/utils/lib/slashCommand.js
  31. 15
      client/components/admin/settings/inputs/SelectSettingInput.js
  32. 17864
      package-lock.json
  33. 10
      package.json
  34. 1
      packages/rocketchat-i18n/i18n/pt-BR.i18n.json

@ -51,7 +51,7 @@ API.v1.addRoute('commands.list', { authRequired: true }, {
},
});
// Expects a body of: { command: 'gimme', params: 'any string value', roomId: 'value' }
// Expects a body of: { command: 'gimme', params: 'any string value', roomId: 'value', triggerId: 'value' }
API.v1.addRoute('commands.run', { authRequired: true }, {
post() {
const body = this.bodyParams;
@ -74,7 +74,7 @@ API.v1.addRoute('commands.run', { authRequired: true }, {
}
const cmd = body.command.toLowerCase();
if (!slashCommands.commands[body.command.toLowerCase()]) {
if (!slashCommands.commands[cmd]) {
return API.v1.failure('The command provided does not exist (or is disabled).');
}
@ -96,7 +96,9 @@ API.v1.addRoute('commands.run', { authRequired: true }, {
message.tmid = body.tmid;
}
const result = Meteor.runAsUser(user._id, () => slashCommands.run(cmd, params, message));
const { triggerId } = body;
const result = Meteor.runAsUser(user._id, () => slashCommands.run(cmd, params, message, triggerId));
return API.v1.success({ result });
},
@ -137,7 +139,7 @@ API.v1.addRoute('commands.preview', { authRequired: true }, {
return API.v1.success({ preview });
},
// Expects a body format of: { command: 'giphy', params: 'mine', roomId: 'value', previewItem: { id: 'sadf8' type: 'image', value: 'https://dev.null/gif } }
// Expects a body format of: { command: 'giphy', params: 'mine', roomId: 'value', tmid: 'value', triggerId: 'value', previewItem: { id: 'sadf8' type: 'image', value: 'https://dev.null/gif' } }
post() {
const body = this.bodyParams;
const user = this.getLoggedInUser();
@ -162,6 +164,14 @@ API.v1.addRoute('commands.preview', { authRequired: true }, {
return API.v1.failure('The preview item being executed is in the wrong format.');
}
if (body.tmid && typeof body.tmid !== 'string') {
return API.v1.failure('The tmid parameter when provided must be a string.');
}
if (body.triggerId && typeof body.triggerId !== 'string') {
return API.v1.failure('The triggerId parameter when provided must be a string.');
}
const cmd = body.command.toLowerCase();
if (!slashCommands.commands[cmd]) {
return API.v1.failure('The command provided does not exist (or is disabled).');
@ -171,9 +181,24 @@ API.v1.addRoute('commands.preview', { authRequired: true }, {
Meteor.call('canAccessRoom', body.roomId, user._id);
const params = body.params ? body.params : '';
const message = {
rid: body.roomId,
};
if (body.tmid) {
const thread = Messages.findOneById(body.tmid);
if (!thread || thread.rid !== body.roomId) {
return API.v1.failure('Invalid thread.');
}
message.tmid = body.tmid;
}
Meteor.runAsUser(user._id, () => {
Meteor.call('executeSlashCommandPreview', { cmd, params, msg: { rid: body.roomId } }, body.previewItem);
Meteor.call('executeSlashCommandPreview', {
cmd,
params,
msg: { rid: body.roomId, tmid: body.tmid },
}, body.previewItem, body.triggerId);
});
return API.v1.success();

@ -15,6 +15,7 @@ import { AppSettingBridge } from './settings';
import { AppUserBridge } from './users';
import { AppLivechatBridge } from './livechat';
import { AppUploadBridge } from './uploads';
import { UiInteractionBridge } from './uiInteraction';
export class RealAppBridges extends AppBridges {
constructor(orch) {
@ -35,6 +36,7 @@ export class RealAppBridges extends AppBridges {
this._userBridge = new AppUserBridge(orch);
this._livechatBridge = new AppLivechatBridge(orch);
this._uploadBridge = new AppUploadBridge(orch);
this._uiInteractionBridge = new UiInteractionBridge(orch);
}
getCommandBridge() {
@ -96,4 +98,8 @@ export class RealAppBridges extends AppBridges {
getUploadBridge() {
return this._uploadBridge;
}
getUiInteractionBridge() {
return this._uiInteractionBridge;
}
}

@ -91,6 +91,7 @@ export class AppCommandsBridge {
this._verifyCommand(command);
const item = {
appId,
command: command.command.toLowerCase(),
params: Utilities.getI18nKeyForApp(command.i18nParamsExample, appId),
description: Utilities.getI18nKeyForApp(command.i18nDescription, appId),
@ -145,7 +146,7 @@ export class AppCommandsBridge {
}
}
_appCommandExecutor(command, parameters, message) {
_appCommandExecutor(command, parameters, message, triggerId) {
const user = this.orch.getConverters().get('users').convertById(Meteor.userId());
const room = this.orch.getConverters().get('rooms').convertById(message.rid);
const threadId = message.tmid;
@ -156,7 +157,9 @@ export class AppCommandsBridge {
Object.freeze(room),
Object.freeze(params),
threadId,
triggerId,
);
Promise.await(this.orch.getManager().getCommandManager().executeCommand(command, context));
}
@ -175,7 +178,7 @@ export class AppCommandsBridge {
return Promise.await(this.orch.getManager().getCommandManager().getPreviews(command, context));
}
_appCommandPreviewExecutor(command, parameters, message, preview) {
_appCommandPreviewExecutor(command, parameters, message, preview, triggerId) {
const user = this.orch.getConverters().get('users').convertById(Meteor.userId());
const room = this.orch.getConverters().get('rooms').convertById(message.rid);
const threadId = message.tmid;
@ -186,7 +189,9 @@ export class AppCommandsBridge {
Object.freeze(room),
Object.freeze(params),
threadId,
triggerId,
);
Promise.await(this.orch.getManager().getCommandManager().executePreview(command, preview, context));
}
}

@ -37,6 +37,17 @@ export class AppListenerBridge {
// }
}
async uiKitInteractionEvent(inte, action) {
return this.orch.getManager().getListenerManager().executeListener(inte, action);
// try {
// } catch (e) {
// this.orch.debugLog(`${ e.name }: ${ e.message }`);
// this.orch.debugLog(e.stack);
// }
}
async livechatEvent(inte, room) {
const rm = this.orch.getConverters().get('rooms').convertRoom(room);
const result = await this.orch.getManager().getListenerManager().executeListener(inte, rm);

@ -0,0 +1,13 @@
import { Notifications } from '../../../notifications/server';
export class UiInteractionBridge {
constructor(orch) {
this.orch = orch;
}
async notifyUser(user, interaction, appId) {
this.orch.debugLog(`The App ${ appId } is sending an interaction to user.`);
Notifications.notifyUser(user.id, 'uiInteraction', interaction);
}
}

@ -1,11 +1,6 @@
import { AppMethods } from './methods';
import { AppsRestApi } from './rest';
import { AppEvents, AppServerNotifier, AppServerListener } from './websockets';
import { AppUIKitInteractionApi } from './uikit';
import { AppEvents, AppServerListener, AppServerNotifier } from './websockets';
export {
AppMethods,
AppsRestApi,
AppEvents,
AppServerNotifier,
AppServerListener,
};
export { AppUIKitInteractionApi, AppMethods, AppsRestApi, AppEvents, AppServerNotifier, AppServerListener };

@ -0,0 +1,159 @@
import express from 'express';
import { WebApp } from 'meteor/webapp';
import { UIKitIncomingInteractionType } from '@rocket.chat/apps-engine/definition/uikit';
import { Users } from '../../../models/server';
const apiServer = express();
apiServer.disable('x-powered-by');
WebApp.connectHandlers.use(apiServer);
// eslint-disable-next-line new-cap
const router = express.Router();
const unauthorized = (res) =>
res.status(401).send({
status: 'error',
message: 'You must be logged in to do this.',
});
router.use((req, res, next) => {
const {
'x-user-id': userId,
'x-auth-token': authToken,
} = req.headers;
if (!userId || !authToken) {
return unauthorized(res);
}
const user = Users.findOneByIdAndLoginToken(userId, authToken);
if (!user) {
return unauthorized(res);
}
req.user = user;
req.userId = user._id;
next();
});
apiServer.use('/api/apps/ui.interaction/', router);
export class AppUIKitInteractionApi {
constructor(orch) {
this.orch = orch;
router.post('/:appId', (req, res) => {
const {
appId,
} = req.params;
const {
type,
} = req.body;
switch (type) {
case UIKitIncomingInteractionType.BLOCK: {
const {
type,
actionId,
triggerId,
mid,
rid,
payload,
} = req.body;
const room = this.orch.getConverters().get('rooms').convertById(rid);
const user = this.orch.getConverters().get('users').convertToApp(req.user);
const message = mid && this.orch.getConverters().get('messages').convertById(mid);
const action = {
type,
appId,
actionId,
message,
triggerId,
payload,
user,
room,
};
try {
const result = Promise.await(this.orch.getBridges().getListenerBridge().uiKitInteractionEvent('IUIKitInteractionHandler', action));
res.send(result);
} catch (e) {
res.status(500).send(e.message);
}
break;
}
case UIKitIncomingInteractionType.VIEW_CLOSED: {
const {
type,
actionId,
view,
isCleared,
} = req.body;
const user = this.orch.getConverters().get('users').convertToApp(req.user);
const action = {
type,
appId,
actionId,
user,
payload: {
view,
isCleared,
},
};
try {
Promise.await(this.orch.getBridges().getListenerBridge().uiKitInteractionEvent('IUIKitInteractionHandler', action));
res.send(200);
} catch (e) {
console.log(e);
res.status(500).send(e.message);
}
break;
}
case UIKitIncomingInteractionType.VIEW_SUBMIT: {
const {
type,
actionId,
triggerId,
payload,
} = req.body;
const user = this.orch.getConverters().get('users').convertToApp(req.user);
const action = {
type,
appId,
actionId,
triggerId,
payload,
user,
};
try {
const result = Promise.await(this.orch.getBridges().getListenerBridge().uiKitInteractionEvent('IUIKitInteractionHandler', action));
res.send(result);
} catch (e) {
res.status(500).send(e.message);
}
break;
}
}
// TODO: validate payloads per type
});
}
}

@ -35,6 +35,7 @@ export class AppMessagesConverter {
customFields: 'customFields',
groupable: 'groupable',
token: 'token',
blocks: 'blocks',
room: (message) => {
const result = this.orch.getConverters().get('rooms').convertById(message.rid);
delete message.rid;
@ -135,6 +136,7 @@ export class AppMessagesConverter {
attachments,
reactions: message.reactions,
parseUrls: message.parseUrls,
blocks: message.blocks,
token: message.token,
};

@ -1,16 +1,17 @@
import { Meteor } from 'meteor/meteor';
import { AppManager } from '@rocket.chat/apps-engine/server/AppManager';
import { Logger } from '../../logger';
import { AppsLogsModel, AppsModel, AppsPersistenceModel, Permissions } from '../../models';
import { settings } from '../../settings';
import { RealAppBridges } from './bridges';
import { AppMethods, AppsRestApi, AppServerNotifier } from './communication';
import { AppMethods, AppServerNotifier, AppsRestApi, AppUIKitInteractionApi } from './communication';
import { AppMessagesConverter, AppRoomsConverter, AppSettingsConverter, AppUsersConverter } from './converters';
import { AppRealStorage, AppRealLogsStorage } from './storage';
import { settings } from '../../settings';
import { Permissions, AppsLogsModel, AppsModel, AppsPersistenceModel } from '../../models';
import { Logger } from '../../logger';
import { AppVisitorsConverter } from './converters/visitors';
import { AppUploadsConverter } from './converters/uploads';
import { AppDepartmentsConverter } from './converters/departments';
import { AppUploadsConverter } from './converters/uploads';
import { AppVisitorsConverter } from './converters/visitors';
import { AppRealLogsStorage, AppRealStorage } from './storage';
class AppServerOrchestrator {
constructor() {
@ -46,6 +47,7 @@ class AppServerOrchestrator {
this._communicators.set('methods', new AppMethods(this));
this._communicators.set('notifier', new AppServerNotifier(this));
this._communicators.set('restapi', new AppsRestApi(this, this._manager));
this._communicators.set('uikit', new AppUIKitInteractionApi(this));
this._isInitialized = true;
}

@ -29,6 +29,6 @@ Meteor.methods({
});
}
return slashCommands.executePreview(command.cmd, command.params, command.msg, preview);
return slashCommands.executePreview(command.cmd, command.params, command.msg, preview, command.triggerId);
},
});

@ -44,6 +44,14 @@
}
}
.rc-ui-kit {
display: inline-block;
width: 100%;
max-width: 400px;
}
.message {
& .toggle-hidden {
display: none;

@ -7,7 +7,7 @@
height: auto;
max-height: 90%;
padding: 1.5rem;
padding: 1rem;
animation: dropdown-show 0.3s cubic-bezier(0.45, 0.05, 0.55, 0.95);
@ -143,19 +143,13 @@
flex: 0 0 auto;
padding: 16px;
padding: 1rem;
justify-content: space-between;
& > .rc-button {
margin: 0;
}
&--empty {
padding: 0;
background: transparent;
}
}
}

@ -0,0 +1,154 @@
import { UIKitInteractionType, UIKitIncomingInteractionType } from '@rocket.chat/apps-engine/definition/uikit';
import { Meteor } from 'meteor/meteor';
import { Random } from 'meteor/random';
import EventEmitter from 'wolfy87-eventemitter';
import Notifications from '../../notifications/client/lib/Notifications';
import { CachedCollectionManager } from '../../ui-cached-collection';
import { modal } from '../../ui-utils/client/lib/modal';
import { APIClient } from '../../utils';
const events = new EventEmitter();
export const on = (...args) => {
events.on(...args);
};
export const off = (...args) => {
events.off(...args);
};
const TRIGGER_TIMEOUT = 5000;
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;
};
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 ([UIKitInteractionType.ERRORS].includes(type)) {
events.emit(viewId, {
type,
triggerId,
viewId,
appId,
...data,
});
return UIKitInteractionType.ERRORS;
}
if ([UIKitInteractionType.MODAL_UPDATE].includes(type)) {
events.emit(viewId, {
type,
triggerId,
viewId,
appId,
...data,
});
return UIKitInteractionType.MODAL_UPDATE;
}
if ([UIKitInteractionType.MODAL_OPEN].includes(type)) {
const instance = modal.push({
template: 'ModalBlock',
modifier: 'uikit',
closeOnEscape: false,
data: {
triggerId,
viewId,
appId,
...data,
},
});
instances.set(viewId, instance);
return UIKitInteractionType.MODAL_OPEN;
}
return UIKitInteractionType.MODAL_ClOSE;
};
export const triggerAction = async ({ type, actionId, appId, rid, mid, viewId, ...rest }) => new Promise(async (resolve, reject) => {
const triggerId = generateTriggerId(appId);
const payload = rest.payload || rest;
setTimeout(reject, TRIGGER_TIMEOUT, triggerId);
const { type: interactionType, ...data } = await APIClient.post(
`apps/ui.interaction/${ appId }`,
{ type, actionId, payload, mid, rid, triggerId, viewId },
);
return resolve(handlePayloadUserInteraction(interactionType, data));
});
export const triggerBlockAction = (options) => triggerAction({ type: UIKitIncomingInteractionType.BLOCK, ...options });
export const triggerSubmitView = async ({ viewId, ...options }) => {
const close = () => {
const instance = instances.get(viewId);
if (instance) {
instance.close();
instances.delete(viewId);
}
};
try {
const result = await triggerAction({ type: UIKitIncomingInteractionType.VIEW_SUBMIT, viewId, ...options });
if (!result || UIKitInteractionType.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();
instances.delete(view.id);
}
}
};
Meteor.startup(() =>
CachedCollectionManager.onLogin(() =>
Notifications.onUser('uiInteraction', ({ type, ...data }) => {
handlePayloadUserInteraction(type, data);
}),
),
);

@ -0,0 +1,3 @@
<template name="Blocks">
<div class="js-block-wrapper"></div>
</template>

@ -0,0 +1,35 @@
import { Template } from 'meteor/templating';
import { ReactiveVar } from 'meteor/reactive-var';
import { messageBlockWithContext } from './MessageBlock';
import './Blocks.html';
import * as ActionManager from '../ActionManager';
Template.Blocks.onRendered(async function() {
const React = await import('react');
const ReactDOM = await import('react-dom');
const state = new ReactiveVar();
this.autorun(() => {
state.set(Template.currentData());
});
ReactDOM.render(
React.createElement(messageBlockWithContext({
action: (options) => {
const { actionId, value, blockId, mid = this.data.mid } = options;
ActionManager.triggerBlockAction({ actionId, appId: this.data.blocks[1].appId, value, blockId, rid: this.data.rid, mid });
},
// state: alert,
appId: this.data.appId,
rid: this.data.rid,
}), { data: () => state.get() }),
this.firstNode,
);
const event = new Event('rendered');
this.firstNode.dispatchEvent(event);
});
Template.Blocks.onDestroyed(async function() {
const ReactDOM = await import('react-dom');
this.firstNode && ReactDOM.unmountComponentAtNode(this.firstNode);
});

@ -0,0 +1,3 @@
<template name="ButtonElement">
<button form="{{form}}" value="{{value}}" id="{{id}}" type="{{type}}" class="rc-button rc-button--primary"> {{>TextBlock text}} </button>
</template>

@ -0,0 +1,164 @@
import React, { useRef, useEffect, useCallback, useMemo } from 'react';
import { UiKitMessage as uiKitMessage, kitContext, UiKitModal as uiKitModal, messageParser, modalParser, UiKitComponent } from '@rocket.chat/fuselage-ui-kit';
import { uiKitText } from '@rocket.chat/ui-kit';
import { Modal, AnimatedVisibility, ButtonGroup, Button, Box } from '@rocket.chat/fuselage';
import { useUniqueId } from '@rocket.chat/fuselage-hooks';
import { renderMessageBody } from '../../../ui-utils/client';
import { useReactiveValue } from '../../../../client/hooks/useReactiveValue';
const focusableElementsString = 'a[href]:not([tabindex="-1"]), area[href]:not([tabindex="-1"]), input:not([disabled]):not([tabindex="-1"]), select:not([disabled]):not([tabindex="-1"]), textarea:not([disabled]):not([tabindex="-1"]), button:not([disabled]):not([tabindex="-1"]), iframe, object, embed, [tabindex]:not([tabindex="-1"]), [contenteditable]';
messageParser.text = ({ text, type } = {}) => {
if (type !== 'mrkdwn') {
return text;
}
return <span dangerouslySetInnerHTML={{ __html: renderMessageBody({ msg: text }) }} />;
};
modalParser.text = messageParser.text;
const contextDefault = {
action: console.log,
state: (data) => {
console.log('state', data);
},
};
export const messageBlockWithContext = (context) => (props) => {
const data = useReactiveValue(props.data);
return (
<kitContext.Provider value={context}>
{uiKitMessage(data.blocks)}
</kitContext.Provider>
);
};
const textParser = uiKitText(new class {
plain_text({ text }) {
return text;
}
text({ text }) {
return text;
}
}());
const thumb = 'data:image/gif;base64,R0lGODlhAQABAIAAAMLCwgAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==';
// https://www.w3.org/TR/wai-aria-practices/examples/dialog-modal/dialog.html
export const modalBlockWithContext = ({
view: {
title,
close,
submit,
},
onSubmit,
onClose,
onCancel,
...context
}) => (props) => {
const id = `modal_id_${ useUniqueId() }`;
const { view, ...data } = useReactiveValue(props.data);
const ref = useRef();
// Auto focus
useEffect(() => ref.current && ref.current.querySelector(focusableElementsString).focus(), [ref.current]);
// save fovus to restore after close
const previousFocus = useMemo(() => document.activeElement, []);
// restore the focus after the component unmount
useEffect(() => () => previousFocus && previousFocus.focus(), []);
// Handle Tab, Shift + Tab, Enter and Escape
const handleKeyUp = useCallback((event) => {
if (event.keyCode === 13) { // ENTER
return onSubmit();
}
if (event.keyCode === 27) { // ESC
event.stopPropagation();
event.preventDefault();
onClose();
return false;
}
if (event.keyCode === 9) { // TAB
const elements = Array.from(ref.current.querySelectorAll(focusableElementsString));
const [first] = elements;
const last = elements.pop();
if (!ref.current.contains(document.activeElement)) {
return first.focus();
}
if (event.shiftKey) {
if (!first || first === document.activeElement) {
last.focus();
event.stopPropagation();
event.preventDefault();
}
return;
}
if (!last || last === document.activeElement) {
first.focus();
event.stopPropagation();
event.preventDefault();
}
}
}, [onSubmit]);
// Clean the events
useEffect(() => {
const close = (e) => {
e.preventDefault();
e.stopPropagation();
onClose();
return false;
};
const element = document.querySelector('.rc-modal-wrapper');
document.addEventListener('keydown', handleKeyUp);
element.addEventListener('click', close);
return () => {
document.removeEventListener('keydown', handleKeyUp);
element.removeEventListener('click', close);
};
}, handleKeyUp);
return (
<kitContext.Provider value={{ ...context, ...data }}>
<AnimatedVisibility visibility={AnimatedVisibility.UNHIDING}>
<Modal open id={id} ref={ref}>
<Modal.Header>
{/* <Modal.Thumb url={`api/apps/${ context.appId }/icon`} /> */}
<Modal.Thumb url={thumb} />
<Modal.Title>{textParser([title])}</Modal.Title>
<Modal.Close tabIndex={-1} onClick={onClose} />
</Modal.Header>
<Modal.Content>
<Box
is='form'
method='post'
action='#'
onSubmit={onSubmit}
>
<UiKitComponent render={uiKitModal} blocks={view.blocks} />
</Box>
</Modal.Content>
<Modal.Footer>
<ButtonGroup align='end'>
<Button onClick={onCancel}>{textParser([close.text])}</Button>
<Button primary onClick={onSubmit}>{textParser([submit.text])}</Button>
</ButtonGroup>
</Modal.Footer>
</Modal>
</AnimatedVisibility>
</kitContext.Provider>
);
};
export const MessageBlock = ({ blocks }, context = contextDefault) => (
<kitContext.Provider value={context}>
{uiKitMessage(blocks)}
</kitContext.Provider>
);

@ -0,0 +1,3 @@
<template name="ModalBlock">
<div class="js-modal-block"></div>
</template>

@ -0,0 +1,103 @@
import { Template } from 'meteor/templating';
import { ReactiveDict } from 'meteor/reactive-dict';
import { ReactiveVar } from 'meteor/reactive-var';
import { modalBlockWithContext } from './MessageBlock';
import './ModalBlock.html';
import * as ActionManager from '../ActionManager';
Template.ModalBlock.onRendered(async function() {
const React = await import('react');
const ReactDOM = await import('react-dom');
const state = new ReactiveVar();
const { viewId, appId } = this.data;
this.autorun(() => {
state.set(Template.currentData());
});
const handleUpdate = ({ type, ...data }) => {
if (type === 'errors') {
return state.set({ ...state.get(), errors: data.errors });
}
return state.set(data);
};
this.cancel = () => {
ActionManager.off(viewId, handleUpdate);
};
this.node = this.find('.js-modal-block').parentElement;
ActionManager.on(viewId, handleUpdate);
const filterInputFields = ({ type, element }) => type === 'input' && element.initialValue;
const mapElementToState = ({ element, blockId }) => [element.actionId, { value: element.initialValue, blockId }];
const groupStateByBlockIdMap = (obj, [key, { blockId, value }]) => {
obj[blockId] = obj[blockId] || {};
obj[blockId][key] = value;
return obj;
};
const groupStateByBlockId = (obj) => Object.entries(obj).reduce(groupStateByBlockIdMap, {});
this.state = new ReactiveDict(Object.fromEntries(this.data.view.blocks.filter(filterInputFields).map(mapElementToState)));
ReactDOM.render(
React.createElement(
modalBlockWithContext({
onCancel: () => ActionManager.triggerCancel({
appId,
viewId,
view: {
...this.data.view,
id: viewId,
state: groupStateByBlockId(this.state.all()),
},
}),
onClose: () => ActionManager.triggerCancel({
appId,
viewId,
view: {
...this.data.view,
id: viewId,
state: groupStateByBlockId(this.state.all()),
},
isCleared: true,
}),
onSubmit: () => ActionManager.triggerSubmitView({
viewId,
appId,
payload: {
view: {
...this.data.view,
id: viewId,
state: groupStateByBlockId(this.state.all()),
},
},
}),
action: ({ actionId, appId, value, blockId, mid = this.data.mid }) => {
ActionManager.triggerBlockAction({
actionId,
appId,
value,
blockId,
mid,
});
},
state: ({ actionId, value, /* ,appId, */ blockId = 'default' }) => {
this.state.set(actionId, {
blockId,
value,
});
},
...this.data,
}),
{ data: () => state.get() },
),
this.node,
);
});
Template.ModalBlock.onDestroyed(async function() {
const ReactDOM = await import('react-dom');
this.node && ReactDOM.unmountComponentAtNode(this.node);
});

@ -0,0 +1,12 @@
<!-- {
"type": "button",
"text": {
"type": "plain_text",
"text": "Button",
"emoji": true
}
},
-->
<template name="TextBlock" arguments="type text emoji">
{{text.text}}
</template>

@ -0,0 +1,5 @@
// import { Template } from 'meteor/templating';
import './TextBlock.html';

@ -0,0 +1,5 @@
import './styles.css';
import './Blocks.js';
import './ModalBlock';
import './TextBlock';
import './ButtonElement.html';

@ -0,0 +1,18 @@
.block-kit-debug.debug {
padding: 1rem;
border: 1px solid;
}
.block-kit-debug legend {
display: none;
}
.block-kit-debug.debug legend {
display: initial;
}
.rc-modal--uikit {
width: 680px;
max-width: 100%;
}

@ -6,3 +6,4 @@ import './popup/messagePopupChannel';
import './popup/messagePopupConfig';
import './popup/messagePopupEmoji';
import './popup/messagePopupSlashCommandPreview';
import './blocks';

@ -80,6 +80,11 @@
{{else}}
{{{body}}}
{{/if}}
{{#if msg.blocks}}
<div class='rc-ui-kit'>
{{> Blocks blocks=msg.blocks rid=msg.rid mid=msg._id}}
</div>
{{/if}}
</div>
</div>

@ -1,8 +1,12 @@
<template name="rc_modal">
<div class="rc-modal-wrapper">
{{#if $eq template 'ModalBlock'}}
{{> ModalBlock data}}
{{else}}
<dialog class="rc-modal rc-modal--{{modifier}}" data-modal="modal">
{{#if template}}
{{> Template.dynamic template=template data=data}}
{{# if template}}
{{> Template.dynamic template=template data=data}} -->
{{else}}
<header class="rc-modal__header">
<h1 class="rc-modal__title">
@ -59,5 +63,6 @@
{{/if}}
{{/if}}
</dialog>
{{/if}}
</div>
</template>

@ -1,97 +1,185 @@
import './modal.html';
import { Meteor } from 'meteor/meteor';
import { Blaze } from 'meteor/blaze';
import { Template } from 'meteor/templating';
import { t, getUserPreference, handleError } from '../../../utils';
import './modal.html';
export const modal = {
renderedModal: null,
open(config = {}, fn, onCancel) {
config.confirmButtonText = config.confirmButtonText || (config.type === 'error' ? t('Ok') : t('Send'));
config.cancelButtonText = config.cancelButtonText || t('Cancel');
config.closeOnConfirm = config.closeOnConfirm == null ? true : config.closeOnConfirm;
config.showConfirmButton = config.showConfirmButton == null ? true : config.showConfirmButton;
config.showFooter = config.showConfirmButton === true || config.showCancelButton === true;
config.confirmOnEnter = config.confirmOnEnter == null ? true : config.confirmOnEnter;
if (config.type === 'input') {
config.input = true;
config.type = false;
if (!config.inputType) {
config.inputType = 'text';
}
let modalStack = [];
const createModal = (config = {}, fn, onCancel) => {
config.confirmButtonText = config.confirmButtonText || (config.type === 'error' ? t('Ok') : t('Send'));
config.cancelButtonText = config.cancelButtonText || t('Cancel');
config.closeOnConfirm = config.closeOnConfirm == null ? true : config.closeOnConfirm;
config.showConfirmButton = config.showConfirmButton == null ? true : config.showConfirmButton;
config.showFooter = config.showConfirmButton === true || config.showCancelButton === true;
config.confirmOnEnter = config.confirmOnEnter == null ? true : config.confirmOnEnter;
config.closeOnEscape = config.closeOnEscape == null ? true : config.closeOnEscape;
if (config.type === 'input') {
config.input = true;
config.type = false;
if (!config.inputType) {
config.inputType = 'text';
}
}
this.close();
this.fn = fn;
this.onCancel = onCancel;
this.config = config;
let renderedModal;
let timer;
const instance = {
...config,
render: () => {
if (renderedModal) {
renderedModal.firstNode().style.display = '';
return;
}
renderedModal = Blaze.renderWithData(Template.rc_modal, instance, document.body);
if (config.timer) {
timer = setTimeout(() => {
instance.close();
}, config.timer);
}
},
hide: () => {
if (renderedModal) {
renderedModal.firstNode().style.display = 'none';
}
if (timer) {
clearTimeout(timer);
timer = undefined;
}
},
destroy: () => {
if (renderedModal) {
Blaze.remove(renderedModal);
renderedModal = undefined;
}
if (timer) {
clearTimeout(timer);
timer = undefined;
}
},
close: () => {
instance.destroy();
modalStack = modalStack.filter((modal) => modal !== instance);
if (modalStack.length) {
modalStack[modalStack.length - 1].render();
}
},
confirm: (value) => {
config.closeOnConfirm && instance.close();
if (fn) {
fn.call(instance, value);
return;
}
instance.close();
},
cancel: () => {
if (onCancel) {
onCancel.call(instance);
}
instance.close();
},
showInputError: (text) => {
const errorEl = document.querySelector('.rc-modal__content-error');
errorEl.innerHTML = text;
errorEl.style.display = 'block';
},
};
return instance;
};
export const modal = {
open: (config = {}, fn, onCancel) => {
modalStack.forEach((instance) => {
instance.destroy();
});
modalStack = [];
const instance = createModal(config, fn, onCancel);
if (config.dontAskAgain) {
const dontAskAgainList = getUserPreference(Meteor.userId(), 'dontAskAgainList');
if (dontAskAgainList && dontAskAgainList.some((dontAsk) => dontAsk.action === config.dontAskAgain.action)) {
this.confirm(true);
instance.confirm(true);
return;
}
}
this.renderedModal = Blaze.renderWithData(Template.rc_modal, config, document.body);
this.timer = null;
if (config.timer) {
this.timer = setTimeout(() => this.close(), config.timer);
instance.render();
modalStack.push(instance);
},
push: (config = {}, fn, onCancel) => {
const instance = createModal(config, fn, onCancel);
modalStack.forEach((instance) => {
instance.hide();
});
instance.render();
modalStack.push(instance);
return instance;
},
cancel: () => {
if (modalStack.length) {
modalStack[modalStack.length - 1].cancel();
}
},
cancel() {
if (this.onCancel) {
this.onCancel();
close: () => {
if (modalStack.length) {
modalStack[modalStack.length - 1].close();
}
this.close();
},
close() {
if (this.renderedModal) {
Blaze.remove(this.renderedModal);
confirm: (value) => {
if (modalStack.length) {
modalStack[modalStack.length - 1].confirm(value);
}
this.fn = null;
this.onCancel = null;
if (this.timer) {
clearTimeout(this.timer);
},
showInputError: (text) => {
if (modalStack.length) {
modalStack[modalStack.length - 1].showInputError(text);
}
},
confirm(value) {
const { fn } = this;
this.config.closeOnConfirm && this.close();
if (fn) {
fn.call(this, value);
onKeyDown: (event) => {
if (!modalStack.length) {
return;
}
this.close();
},
showInputError(text) {
const errorEl = document.querySelector('.rc-modal__content-error');
errorEl.innerHTML = text;
errorEl.style.display = 'block';
},
onKeydown(e) {
if (modal.config.confirmOnEnter && e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
const instance = modalStack[modalStack.length - 1];
if (modal.config.input) {
return modal.confirm($('.js-modal-input').val());
if (instance && instance.config && instance.config.confirmOnEnter && event.key === 'Enter') {
event.preventDefault();
event.stopPropagation();
if (instance.config.input) {
return instance.confirm($('.js-modal-input').val());
}
modal.confirm(true);
} else if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
instance.confirm(true);
return;
}
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
modal.close();
instance.close();
}
},
};
@ -131,25 +219,25 @@ Template.rc_modal.onRendered(function() {
$('.js-modal-input').focus();
}
document.addEventListener('keydown', modal.onKeydown);
this.data.closeOnEscape && document.addEventListener('keydown', modal.onKeyDown);
});
Template.rc_modal.onDestroyed(function() {
document.removeEventListener('keydown', modal.onKeydown);
document.removeEventListener('keydown', modal.onKeyDown);
});
Template.rc_modal.events({
'click .js-action'(e, instance) {
!this.action || this.action.call(instance.data.data, e, instance);
e.stopPropagation();
modal.close();
'click .js-action'(event, instance) {
!this.action || this.action.call(instance.data.data, event, instance);
event.stopPropagation();
this.close();
},
'click .js-close'(e) {
e.stopPropagation();
modal.cancel();
this.cancel();
},
'click .js-confirm'(e, instance) {
e.stopPropagation();
'click .js-confirm'(event, instance) {
event.stopPropagation();
const { dontAskAgain } = instance.data;
if (dontAskAgain && document.getElementById('dont-ask-me-again').checked) {
const dontAskAgainObject = {
@ -172,20 +260,20 @@ Template.rc_modal.events({
}
if (instance.data.input) {
modal.confirm(document.getElementsByClassName('js-modal-input')[0].value);
this.confirm(document.getElementsByClassName('js-modal-input')[0].value);
return;
}
modal.confirm(true);
this.confirm(true);
},
'click .rc-modal-wrapper'(e, instance) {
'click .rc-modal-wrapper'(event, instance) {
if (instance.data.allowOutsideClick === false) {
return false;
}
if (e.currentTarget === e.target) {
e.stopPropagation();
modal.close();
if (event.currentTarget === event.target) {
event.stopPropagation();
this.close();
}
},
});

@ -27,6 +27,7 @@ import { promises } from '../../../promises/client';
import { hasAtLeastOnePermission } from '../../../authorization/client';
import { Messages, Rooms, ChatMessage, ChatSubscription } from '../../../models/client';
import { emoji } from '../../../emoji/client';
import { generateTriggerId } from '../../../ui-message/client/ActionManager';
const messageBoxState = {
@ -406,7 +407,8 @@ export class ChatMessages {
if (commandOptions.clientOnly) {
commandOptions.callback(command, param, msgObject);
} else {
Meteor.call('slashCommand', { cmd: command, params: param, msg: msgObject }, (err, result) => {
const triggerId = generateTriggerId(slashCommands.commands[command].appId);
Meteor.call('slashCommand', { cmd: command, params: param, msg: msgObject, triggerId }, (err, result) => {
typeof commandOptions.result === 'function' && commandOptions.result(err, result, { cmd: command, params: param, msg: msgObject });
});
}

@ -284,16 +284,13 @@ Template.room.helpers({
embeddedVersion() {
return Layout.isEmbedded();
},
showTopNavbar() {
return !Layout.isEmbedded() || settings.get('UI_Show_top_navbar_embedded_layout');
},
subscribed() {
const { state } = Template.instance();
return state.get('subscribed');
},
messagesHistory() {
const { rid } = Template.instance();
const room = Rooms.findOne(rid, { fields: { sysMes: 1 } });
@ -930,7 +927,11 @@ Template.room.events({
}
},
'load .gallery-item'(e, template) {
return template.sendToBottomIfNecessaryDebounced();
template.sendToBottomIfNecessaryDebounced();
},
'rendered .js-block-wrapper'(e, i) {
i.sendToBottomIfNecessaryDebounced();
},
'click .jump-recent button'(e, template) {

@ -19,13 +19,12 @@ slashCommands.add = function _addingSlashCommand(command, callback, options = {}
};
};
slashCommands.run = function _runningSlashCommand(command, params, message) {
slashCommands.run = function _runningSlashCommand(command, params, message, triggerId) {
if (slashCommands.commands[command] && typeof slashCommands.commands[command].callback === 'function') {
if (!message || !message.rid) {
throw new Meteor.Error('invalid-command-usage', 'Executing a command requires at least a message with a room id.');
}
return slashCommands.commands[command].callback(command, params, message);
return slashCommands.commands[command].callback(command, params, message, triggerId);
}
};
@ -51,7 +50,7 @@ slashCommands.getPreviews = function _gettingSlashCommandPreviews(command, param
}
};
slashCommands.executePreview = function _executeSlashCommandPreview(command, params, message, preview) {
slashCommands.executePreview = function _executeSlashCommandPreview(command, params, message, preview, triggerId) {
if (slashCommands.commands[command] && typeof slashCommands.commands[command].previewCallback === 'function') {
if (!message || !message.rid) {
throw new Meteor.Error('invalid-command-usage', 'Executing a command requires at least a message with a room id.');
@ -62,7 +61,7 @@ slashCommands.executePreview = function _executeSlashCommandPreview(command, par
throw new Meteor.Error('error-invalid-preview', 'Preview Item must have an id, type, and value.');
}
return slashCommands.commands[command].previewCallback(command, params, message, preview);
return slashCommands.commands[command].previewCallback(command, params, message, preview, triggerId);
}
};
@ -80,6 +79,6 @@ Meteor.methods({
});
}
return slashCommands.run(command.cmd, command.params, command.msg);
return slashCommands.run(command.cmd, command.params, command.msg, command.triggerId);
},
});

@ -24,8 +24,8 @@ export function SelectSettingInput({
}) {
const t = useTranslation();
const handleChange = (event) => {
onChangeValue && onChangeValue(event.currentTarget.value);
const handleChange = ([value]) => {
onChangeValue && onChangeValue(value);
};
return <>
@ -51,5 +51,16 @@ export function SelectSettingInput({
)}
</SelectInput>
</Field.Row>
{/* <Select
data-qa-setting-id={_id}
id={_id}
value={value}
placeholder={placeholder}
disabled={disabled}
readOnly={readonly}
onChange={handleChange}
option={values.map(({ key, i18nLabel }) => [key, t(i18nLabel)],
)}
/> */}
</>;
}

17864
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -122,10 +122,12 @@
"@google-cloud/language": "^3.7.0",
"@google-cloud/storage": "^2.3.1",
"@google-cloud/vision": "^1.8.0",
"@rocket.chat/apps-engine": "1.12.0-beta.2496",
"@rocket.chat/fuselage": "^0.2.0-alpha.19",
"@rocket.chat/fuselage-hooks": "^0.2.0-dev.50",
"@rocket.chat/icons": "^0.2.0-dev.49",
"@rocket.chat/apps-engine": "^1.12.0-beta.2575",
"@rocket.chat/fuselage": "^0.2.0-alpha.20",
"@rocket.chat/fuselage-hooks": "^0.2.0-alpha.20",
"@rocket.chat/fuselage-ui-kit": "^0.2.0-alpha.20",
"@rocket.chat/icons": "^0.2.0-alpha.20",
"@rocket.chat/ui-kit": "^0.2.0-alpha.20",
"@slack/client": "^4.8.0",
"adm-zip": "RocketChat/adm-zip",
"archiver": "^3.0.0",

@ -1153,6 +1153,7 @@
"Email_subject": "Assunto",
"email_style_label": "Estilo do Email",
"email_style_description": "Evite seletores aninhados",
"email_plain_text_only": "Enviar emails apenas em texto puro",
"Email_verified": "Email verificado",
"email_plain_text_only": "Enviar emails apenas em texto puro",
"Emoji": "Emoji",

Loading…
Cancel
Save