From ff2263a3c11d59f9e964c4f1f6b6926521f9283c Mon Sep 17 00:00:00 2001 From: Matheus Barbosa Silva <36537004+matheusbsilva137@users.noreply.github.com> Date: Tue, 17 Oct 2023 10:25:51 -0300 Subject: [PATCH 01/26] fix: Read receipts are not created on the first time a user reads a room (#30610) Co-authored-by: Heitor Tanoue <68477006+heitortanoue@users.noreply.github.com> --- .changeset/weak-cameras-pay.md | 5 +++++ apps/meteor/server/lib/readMessages.ts | 5 +++-- 2 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 .changeset/weak-cameras-pay.md diff --git a/.changeset/weak-cameras-pay.md b/.changeset/weak-cameras-pay.md new file mode 100644 index 000000000000..724f3af69a29 --- /dev/null +++ b/.changeset/weak-cameras-pay.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed issue with message read receipts not being created when accessing a room the first time diff --git a/apps/meteor/server/lib/readMessages.ts b/apps/meteor/server/lib/readMessages.ts index 00bf04bd3449..d7c8cf559288 100644 --- a/apps/meteor/server/lib/readMessages.ts +++ b/apps/meteor/server/lib/readMessages.ts @@ -6,7 +6,7 @@ import { callbacks } from '../../lib/callbacks'; export async function readMessages(rid: IRoom['_id'], uid: IUser['_id'], readThreads: boolean): Promise { await callbacks.run('beforeReadMessages', rid, uid); - const projection = { ls: 1, tunread: 1, alert: 1 }; + const projection = { ls: 1, tunread: 1, alert: 1, ts: 1 }; const sub = await Subscriptions.findOneByRoomIdAndUserId(rid, uid, { projection }); if (!sub) { throw new Error('error-invalid-subscription'); @@ -19,5 +19,6 @@ export async function readMessages(rid: IRoom['_id'], uid: IUser['_id'], readThr await NotificationQueue.clearQueueByUserId(uid); - callbacks.runAsync('afterReadMessages', rid, { uid, lastSeen: sub.ls }); + const lastSeen = sub.ls || sub.ts; + callbacks.runAsync('afterReadMessages', rid, { uid, lastSeen }); } From dd5b236895f754bbec857fe9c0d4f17ecaa28465 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com> Date: Tue, 17 Oct 2023 12:07:28 -0300 Subject: [PATCH 02/26] chore: remove license v3 public key envvar (#30646) --- ee/packages/license/babel.config.json | 11 ----- ee/packages/license/package.json | 9 +--- ee/packages/license/src/token.ts | 2 +- yarn.lock | 65 +++------------------------ 4 files changed, 7 insertions(+), 80 deletions(-) delete mode 100644 ee/packages/license/babel.config.json diff --git a/ee/packages/license/babel.config.json b/ee/packages/license/babel.config.json deleted file mode 100644 index e154c0813530..000000000000 --- a/ee/packages/license/babel.config.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "presets": ["@babel/preset-typescript"], - "plugins": [ - [ - "transform-inline-environment-variables", - { - "include": ["LICENSE_PUBLIC_KEY_V3"] - } - ] - ] -} diff --git a/ee/packages/license/package.json b/ee/packages/license/package.json index 6810f53e40dd..ec79532a9680 100644 --- a/ee/packages/license/package.json +++ b/ee/packages/license/package.json @@ -3,18 +3,11 @@ "version": "0.0.1", "private": true, "devDependencies": { - "@babel/cli": "^7.23.0", - "@babel/core": "^7.23.0", - "@babel/preset-env": "^7.22.20", - "@babel/preset-typescript": "^7.23.0", "@swc/core": "^1.3.66", "@swc/jest": "^0.2.26", - "@types/babel__core": "^7", - "@types/babel__preset-env": "^7", "@types/bcrypt": "^5.0.0", "@types/jest": "~29.5.3", "@types/ws": "^8.5.5", - "babel-plugin-transform-inline-environment-variables": "^0.4.4", "eslint": "~8.45.0", "jest": "~29.6.1", "jest-environment-jsdom": "~29.6.1", @@ -29,7 +22,7 @@ "testunit": "jest", "build": "npm run build:types && npm run build:js", "build:types": "tsc --emitDeclarationOnly", - "build:js": "babel src --out-dir dist --extensions \".ts,.tsx\" --source-maps inline", + "build:js": "rm -rf dist && tsc -p tsconfig.json", "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput" }, "main": "./dist/index.js", diff --git a/ee/packages/license/src/token.ts b/ee/packages/license/src/token.ts index 2a9836a48303..46daaef83974 100644 --- a/ee/packages/license/src/token.ts +++ b/ee/packages/license/src/token.ts @@ -7,7 +7,7 @@ import type { ILicenseV3 } from './definition/ILicenseV3'; const PUBLIC_LICENSE_KEY_V2 = 'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQ0lqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FnOEFNSUlDQ2dLQ0FnRUFxV1Nza2Q5LzZ6Ung4a3lQY2ljcwpiMzJ3Mnd4VnV3N3lCVDk2clEvOEQreU1lQ01POXdTU3BIYS85bkZ5d293RXRpZ3B0L3dyb1BOK1ZHU3didHdQCkZYQmVxRWxCbmRHRkFsODZlNStFbGlIOEt6L2hHbkNtSk5tWHB4RUsyUkUwM1g0SXhzWVg3RERCN010eC9pcXMKY2pCL091dlNCa2ppU2xlUzdibE5JVC9kQTdLNC9DSjNvaXUwMmJMNEV4Y2xDSGVwenFOTWVQM3dVWmdweE9uZgpOT3VkOElYWUs3M3pTY3VFOEUxNTdZd3B6Q0twVmFIWDdaSmY4UXVOc09PNVcvYUlqS2wzTDYyNjkrZUlPRXJHCndPTm1hSG56Zmc5RkxwSmh6Z3BPMzhhVm43NnZENUtLakJhaldza1krNGEyZ1NRbUtOZUZxYXFPb3p5RUZNMGUKY0ZXWlZWWjNMZWg0dkVNb1lWUHlJeng5Nng4ZjIveW1QbmhJdXZRdjV3TjRmeWVwYTdFWTVVQ2NwNzF6OGtmUAo0RmNVelBBMElEV3lNaWhYUi9HNlhnUVFaNEdiL3FCQmh2cnZpSkNGemZZRGNKZ0w3RmVnRllIUDNQR0wwN1FnCnZMZXZNSytpUVpQcnhyYnh5U3FkUE9rZ3VyS2pWclhUVXI0QTlUZ2lMeUlYNVVsSnEzRS9SVjdtZk9xWm5MVGEKU0NWWEhCaHVQbG5DR1pSMDFUb1RDZktoTUcxdTBDRm5MMisxNWhDOWZxT21XdjlRa2U0M3FsSjBQZ0YzVkovWAp1eC9tVHBuazlnbmJHOUpIK21mSDM5Um9GdlROaW5Zd1NNdll6dXRWT242OXNPemR3aERsYTkwbDNBQ2g0eENWCks3Sk9YK3VIa29OdTNnMmlWeGlaVU0wQ0F3RUFBUT09Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo='; -const PUBLIC_LICENSE_KEY_V3 = process.env.PUBLIC_LICENSE_KEY_V3 || PUBLIC_LICENSE_KEY_V2; +const PUBLIC_LICENSE_KEY_V3 = PUBLIC_LICENSE_KEY_V2; let TEST_KEYS: [string, string] | undefined = undefined; diff --git a/yarn.lock b/yarn.lock index b4e4af200f30..ce6dc859fc3d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -966,33 +966,6 @@ __metadata: languageName: node linkType: hard -"@babel/cli@npm:^7.23.0": - version: 7.23.0 - resolution: "@babel/cli@npm:7.23.0" - dependencies: - "@jridgewell/trace-mapping": ^0.3.17 - "@nicolo-ribaudo/chokidar-2": 2.1.8-no-fsevents.3 - chokidar: ^3.4.0 - commander: ^4.0.1 - convert-source-map: ^2.0.0 - fs-readdir-recursive: ^1.1.0 - glob: ^7.2.0 - make-dir: ^2.1.0 - slash: ^2.0.0 - peerDependencies: - "@babel/core": ^7.0.0-0 - dependenciesMeta: - "@nicolo-ribaudo/chokidar-2": - optional: true - chokidar: - optional: true - bin: - babel: ./bin/babel.js - babel-external-helpers: ./bin/babel-external-helpers.js - checksum: beeb189560bf9c4ea951ef637eefa5214654678fb09c4aaa6695921037059c1e1553c610fe95fbd19a9cdfd9f5598a812fc13df40a6b9a9ea899e43fc6c42052 - languageName: node - linkType: hard - "@babel/code-frame@npm:7.12.11": version: 7.12.11 resolution: "@babel/code-frame@npm:7.12.11" @@ -1043,7 +1016,7 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:^7.1.0, @babel/core@npm:^7.11.6, @babel/core@npm:^7.12.10, @babel/core@npm:^7.12.3, @babel/core@npm:^7.20.7, @babel/core@npm:^7.21.4, @babel/core@npm:^7.23.0, @babel/core@npm:^7.7.5": +"@babel/core@npm:^7.1.0, @babel/core@npm:^7.11.6, @babel/core@npm:^7.12.10, @babel/core@npm:^7.12.3, @babel/core@npm:^7.20.7, @babel/core@npm:^7.21.4, @babel/core@npm:^7.7.5": version: 7.23.0 resolution: "@babel/core@npm:7.23.0" dependencies: @@ -2513,7 +2486,7 @@ __metadata: languageName: node linkType: hard -"@babel/preset-env@npm:^7.12.11, @babel/preset-env@npm:^7.22.20, @babel/preset-env@npm:~7.22.10, @babel/preset-env@npm:~7.22.9": +"@babel/preset-env@npm:^7.12.11, @babel/preset-env@npm:~7.22.10, @babel/preset-env@npm:~7.22.9": version: 7.22.20 resolution: "@babel/preset-env@npm:7.22.20" dependencies: @@ -2645,7 +2618,7 @@ __metadata: languageName: node linkType: hard -"@babel/preset-typescript@npm:^7.12.7, @babel/preset-typescript@npm:^7.23.0": +"@babel/preset-typescript@npm:^7.12.7": version: 7.23.0 resolution: "@babel/preset-typescript@npm:7.23.0" dependencies: @@ -4588,13 +4561,6 @@ __metadata: languageName: node linkType: hard -"@nicolo-ribaudo/chokidar-2@npm:2.1.8-no-fsevents.3": - version: 2.1.8-no-fsevents.3 - resolution: "@nicolo-ribaudo/chokidar-2@npm:2.1.8-no-fsevents.3" - checksum: ee55cc9241aeea7eb94b8a8551bfa4246c56c53bc71ecda0a2104018fcc328ba5723b33686bdf9cc65d4df4ae65e8016b89e0bbdeb94e0309fe91bb9ced42344 - languageName: node - linkType: hard - "@nicolo-ribaudo/eslint-scope-5-internals@npm:5.1.1-v1": version: 5.1.1-v1 resolution: "@nicolo-ribaudo/eslint-scope-5-internals@npm:5.1.1-v1" @@ -8474,21 +8440,14 @@ __metadata: version: 0.0.0-use.local resolution: "@rocket.chat/license@workspace:ee/packages/license" dependencies: - "@babel/cli": ^7.23.0 - "@babel/core": ^7.23.0 - "@babel/preset-env": ^7.22.20 - "@babel/preset-typescript": ^7.23.0 "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/jwt": "workspace:^" "@rocket.chat/logger": "workspace:^" "@swc/core": ^1.3.66 "@swc/jest": ^0.2.26 - "@types/babel__core": ^7 - "@types/babel__preset-env": ^7 "@types/bcrypt": ^5.0.0 "@types/jest": ~29.5.3 "@types/ws": ^8.5.5 - babel-plugin-transform-inline-environment-variables: ^0.4.4 bcrypt: ^5.0.1 eslint: ~8.45.0 jest: ~29.6.1 @@ -15433,13 +15392,6 @@ __metadata: languageName: node linkType: hard -"babel-plugin-transform-inline-environment-variables@npm:^0.4.4": - version: 0.4.4 - resolution: "babel-plugin-transform-inline-environment-variables@npm:0.4.4" - checksum: fa361287411301237fd8ce332aff4f8e8ccb8db30e87a2ddc7224c8bf7cd792eda47aca24dc2e09e70bce4c027bc8cbe22f4999056be37a25d2472945df21ef5 - languageName: node - linkType: hard - "babel-polyfill@npm:^6.2.0": version: 6.26.0 resolution: "babel-polyfill@npm:6.26.0" @@ -16964,7 +16916,7 @@ __metadata: languageName: node linkType: hard -"chokidar@npm:3.5.3, chokidar@npm:>=3.0.0 <4.0.0, chokidar@npm:^3.4.0, chokidar@npm:^3.4.1, chokidar@npm:^3.4.2, chokidar@npm:^3.5.1, chokidar@npm:^3.5.3": +"chokidar@npm:3.5.3, chokidar@npm:>=3.0.0 <4.0.0, chokidar@npm:^3.4.1, chokidar@npm:^3.4.2, chokidar@npm:^3.5.1, chokidar@npm:^3.5.3": version: 3.5.3 resolution: "chokidar@npm:3.5.3" dependencies: @@ -17513,7 +17465,7 @@ __metadata: languageName: node linkType: hard -"commander@npm:^4.0.0, commander@npm:^4.0.1, commander@npm:^4.1.1": +"commander@npm:^4.0.0, commander@npm:^4.1.1": version: 4.1.1 resolution: "commander@npm:4.1.1" checksum: d7b9913ff92cae20cb577a4ac6fcc121bd6223319e54a40f51a14740a681ad5c574fd29a57da478a5f234a6fa6c52cbf0b7c641353e03c648b1ae85ba670b977 @@ -22120,13 +22072,6 @@ __metadata: languageName: node linkType: hard -"fs-readdir-recursive@npm:^1.1.0": - version: 1.1.0 - resolution: "fs-readdir-recursive@npm:1.1.0" - checksum: 29d50f3d2128391c7fc9fd051c8b7ea45bcc8aa84daf31ef52b17218e20bfd2bd34d02382742801954cc8d1905832b68227f6b680a666ce525d8b6b75068ad1e - languageName: node - linkType: hard - "fs-write-stream-atomic@npm:^1.0.8": version: 1.0.10 resolution: "fs-write-stream-atomic@npm:1.0.10" From d6fa895e84007a898e410691292f4647dcc3acb6 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 17 Oct 2023 13:55:56 -0600 Subject: [PATCH 03/26] refactor: Move functions out of `Livechat.js` (#30650) --- .../app/apps/server/bridges/livechat.ts | 5 +- .../app/livechat/server/api/lib/livechat.ts | 3 +- .../app/livechat/server/api/v1/message.ts | 4 +- .../livechat/server/api/v1/offlineMessage.ts | 2 +- .../meteor/app/livechat/server/api/v1/room.ts | 14 +- apps/meteor/app/livechat/server/lib/Helper.ts | 3 + .../app/livechat/server/lib/Livechat.js | 213 ------------------ .../app/livechat/server/lib/LivechatTyped.ts | 208 ++++++++++++++++- .../livechat/server/lib/stream/agentStatus.ts | 2 +- .../server/methods/sendOfflineMessage.ts | 2 +- .../server/methods/setDepartmentForVisitor.ts | 2 +- .../app/livechat/server/methods/transfer.ts | 6 +- .../core-typings/src/omnichannel/routing.ts | 4 +- 13 files changed, 236 insertions(+), 232 deletions(-) diff --git a/apps/meteor/app/apps/server/bridges/livechat.ts b/apps/meteor/app/apps/server/bridges/livechat.ts index 0ace08bb8446..5b6c76257667 100644 --- a/apps/meteor/app/apps/server/bridges/livechat.ts +++ b/apps/meteor/app/apps/server/bridges/livechat.ts @@ -74,7 +74,8 @@ export class AppLivechatBridge extends LivechatBridge { message: await this.orch.getConverters()?.get('messages').convertAppMessage(message), }; - await Livechat.updateMessage(data); + // @ts-expect-error IVisitor vs ILivechatVisitor :( + await LivechatTyped.updateMessage(data); } protected async createRoom(visitor: IVisitor, agent: IUser, appId: string, extraParams?: IExtraRoomParams): Promise { @@ -208,7 +209,7 @@ export class AppLivechatBridge extends LivechatBridge { userId = transferredTo._id; } - return Livechat.transfer( + return LivechatTyped.transfer( await this.orch.getConverters()?.get('rooms').convertAppRoom(currentRoom), this.orch.getConverters()?.get('visitors').convertAppVisitor(visitor), { userId, departmentId, transferredBy, transferredTo }, diff --git a/apps/meteor/app/livechat/server/api/lib/livechat.ts b/apps/meteor/app/livechat/server/api/lib/livechat.ts index 7bb608090557..2b72065345d6 100644 --- a/apps/meteor/app/livechat/server/api/lib/livechat.ts +++ b/apps/meteor/app/livechat/server/api/lib/livechat.ts @@ -13,7 +13,6 @@ import { Meteor } from 'meteor/meteor'; import { callbacks } from '../../../../../lib/callbacks'; import { i18n } from '../../../../../server/lib/i18n'; import { normalizeAgent } from '../../lib/Helper'; -import { Livechat } from '../../lib/Livechat'; import { Livechat as LivechatTyped } from '../../lib/LivechatTyped'; export function online(department: string, skipSettingCheck = false, skipFallbackCheck = false): Promise { @@ -139,7 +138,7 @@ export function normalizeHttpHeaderData(headers: Record> { // Putting this ugly conversion while we type the livechat service - const initSettings = (await Livechat.getInitSettings()) as unknown as Record; + const initSettings = await LivechatTyped.getInitSettings(); const triggers = await findTriggers(); const departments = await findDepartments(businessUnit); const sound = `${Meteor.absoluteUrl()}sounds/chime.mp3`; diff --git a/apps/meteor/app/livechat/server/api/v1/message.ts b/apps/meteor/app/livechat/server/api/v1/message.ts index 104e2ece94d5..0d5a22b90d89 100644 --- a/apps/meteor/app/livechat/server/api/v1/message.ts +++ b/apps/meteor/app/livechat/server/api/v1/message.ts @@ -134,9 +134,9 @@ API.v1.addRoute( throw new Error('invalid-message'); } - const result = await Livechat.updateMessage({ + const result = await LivechatTyped.updateMessage({ guest, - message: { _id: msg._id, msg: this.bodyParams.msg }, + message: { _id: msg._id, msg: this.bodyParams.msg, rid: msg.rid }, }); if (!result) { return API.v1.failure(); diff --git a/apps/meteor/app/livechat/server/api/v1/offlineMessage.ts b/apps/meteor/app/livechat/server/api/v1/offlineMessage.ts index b01e60d2265f..6acd6ab98ea1 100644 --- a/apps/meteor/app/livechat/server/api/v1/offlineMessage.ts +++ b/apps/meteor/app/livechat/server/api/v1/offlineMessage.ts @@ -2,7 +2,7 @@ import { isPOSTLivechatOfflineMessageParams } from '@rocket.chat/rest-typings'; import { i18n } from '../../../../../server/lib/i18n'; import { API } from '../../../../api/server'; -import { Livechat } from '../../lib/Livechat'; +import { Livechat } from '../../lib/LivechatTyped'; API.v1.addRoute( 'livechat/offline.message', diff --git a/apps/meteor/app/livechat/server/api/v1/room.ts b/apps/meteor/app/livechat/server/api/v1/room.ts index 8f6151797463..4f3b4eb6234d 100644 --- a/apps/meteor/app/livechat/server/api/v1/room.ts +++ b/apps/meteor/app/livechat/server/api/v1/room.ts @@ -251,7 +251,7 @@ API.v1.addRoute( const { _id, username, name } = guest; const transferredBy = normalizeTransferredByData({ _id, username, name, userType: 'visitor' }, room); - if (!(await Livechat.transfer(room, guest, { roomId: rid, departmentId: department, transferredBy }))) { + if (!(await LivechatTyped.transfer(room, guest, { departmentId: department, transferredBy }))) { return API.v1.failure(); } @@ -312,10 +312,10 @@ API.v1.addRoute( { authRequired: true, permissionsRequired: ['view-l-room', 'transfer-livechat-guest'], validateParams: isLiveChatRoomForwardProps }, { async post() { - const transferData: typeof this.bodyParams & { - transferredBy?: unknown; + const transferData = this.bodyParams as typeof this.bodyParams & { + transferredBy: TransferByData; transferredTo?: { _id: string; username?: string; name?: string }; - } = this.bodyParams; + }; const room = await LivechatRooms.findOneById(this.bodyParams.roomId); if (!room || room.t !== 'l') { @@ -327,6 +327,10 @@ API.v1.addRoute( } const guest = await LivechatVisitors.findOneEnabledById(room.v?._id); + if (!guest) { + throw new Error('error-invalid-visitor'); + } + const transferedBy = this.user satisfies TransferByData; transferData.transferredBy = normalizeTransferredByData(transferedBy, room); if (transferData.userId) { @@ -340,7 +344,7 @@ API.v1.addRoute( } } - const chatForwardedResult = await Livechat.transfer(room, guest, transferData); + const chatForwardedResult = await LivechatTyped.transfer(room, guest, transferData); if (!chatForwardedResult) { throw new Error('error-forwarding-chat'); } diff --git a/apps/meteor/app/livechat/server/lib/Helper.ts b/apps/meteor/app/livechat/server/lib/Helper.ts index 75722e709b17..63cbbd6998ef 100644 --- a/apps/meteor/app/livechat/server/lib/Helper.ts +++ b/apps/meteor/app/livechat/server/lib/Helper.ts @@ -402,6 +402,9 @@ export const forwardRoomToAgent = async (room: IOmnichannelRoom, transferData: T logger.debug(`Forwarding room ${room._id} to agent ${transferData.userId}`); const { userId: agentId, clientAction } = transferData; + if (!agentId) { + throw new Error('error-invalid-agent'); + } const user = await Users.findOneOnlineAgentById(agentId); if (!user) { logger.debug(`Agent ${agentId} is offline. Cannot forward`); diff --git a/apps/meteor/app/livechat/server/lib/Livechat.js b/apps/meteor/app/livechat/server/lib/Livechat.js index e1d6626c7ddb..837a8eb7309b 100644 --- a/apps/meteor/app/livechat/server/lib/Livechat.js +++ b/apps/meteor/app/livechat/server/lib/Livechat.js @@ -1,21 +1,15 @@ // Note: Please don't add any new methods to this file, since its still in js and we are migrating to ts // Please add new methods to LivechatTyped.ts - -import dns from 'dns'; -import util from 'util'; - import { Message } from '@rocket.chat/core-services'; import { Logger } from '@rocket.chat/logger'; import { LivechatVisitors, LivechatCustomField, - Settings, LivechatRooms, LivechatInquiry, Subscriptions, Messages, LivechatDepartment as LivechatDepartmentRaw, - LivechatDepartmentAgents, Rooms, Users, ReadReceipts, @@ -34,7 +28,6 @@ import { hasPermissionAsync } from '../../../authorization/server/functions/hasP import { FileUpload } from '../../../file-upload/server'; import { deleteMessage } from '../../../lib/server/functions/deleteMessage'; import { sendMessage } from '../../../lib/server/functions/sendMessage'; -import { updateMessage } from '../../../lib/server/functions/updateMessage'; import * as Mailer from '../../../mailer/server/api'; import { settings } from '../../../settings/server'; import { businessHourManager } from '../business-hour'; @@ -45,8 +38,6 @@ import { RoutingManager } from './RoutingManager'; const logger = new Logger('Livechat'); -const dnsResolveMx = util.promisify(dns.resolveMx); - export const Livechat = { Analytics, @@ -63,28 +54,6 @@ export const Livechat = { }); }, - async updateMessage({ guest, message }) { - check(message, Match.ObjectIncluding({ _id: String })); - - const originalMessage = await Messages.findOneById(message._id); - if (!originalMessage || !originalMessage._id) { - return; - } - - const editAllowed = settings.get('Message_AllowEditing'); - const editOwn = originalMessage.u && originalMessage.u._id === guest._id; - - if (!editAllowed || !editOwn) { - throw new Meteor.Error('error-action-not-allowed', 'Message editing not allowed', { - method: 'livechatUpdateMessage', - }); - } - - await updateMessage(message, guest); - - return true; - }, - async deleteMessage({ guest, message }) { Livechat.logger.debug(`Attempting to delete a message by visitor ${guest._id}`); check(message, Match.ObjectIncluding({ _id: String })); @@ -188,50 +157,6 @@ export const Livechat = { return 0; }, - async getInitSettings() { - const rcSettings = {}; - - await Settings.findNotHiddenPublic([ - 'Livechat_title', - 'Livechat_title_color', - 'Livechat_enable_message_character_limit', - 'Livechat_message_character_limit', - 'Message_MaxAllowedSize', - 'Livechat_enabled', - 'Livechat_registration_form', - 'Livechat_allow_switching_departments', - 'Livechat_offline_title', - 'Livechat_offline_title_color', - 'Livechat_offline_message', - 'Livechat_offline_success_message', - 'Livechat_offline_form_unavailable', - 'Livechat_display_offline_form', - 'Omnichannel_call_provider', - 'Language', - 'Livechat_enable_transcript', - 'Livechat_transcript_message', - 'Livechat_fileupload_enabled', - 'FileUpload_Enabled', - 'Livechat_conversation_finished_message', - 'Livechat_conversation_finished_text', - 'Livechat_name_field_registration_form', - 'Livechat_email_field_registration_form', - 'Livechat_registration_form_message', - 'Livechat_force_accept_data_processing_consent', - 'Livechat_data_processing_consent_text', - 'Livechat_show_agent_info', - 'Livechat_clear_local_storage_when_chat_ended', - ]).forEach((setting) => { - rcSettings[setting._id] = setting.value; - }); - - rcSettings.Livechat_history_monitor_type = settings.get('Livechat_history_monitor_type'); - - rcSettings.Livechat_Show_Connecting = this.showConnecting(); - - return rcSettings; - }, - async saveRoomInfo(roomData, guestData, userId) { Livechat.logger.debug(`Saving room information on room ${roomData._id}`); const { livechatData = {} } = roomData; @@ -280,35 +205,6 @@ export const Livechat = { } }, - async closeOpenChats(userId, comment) { - Livechat.logger.debug(`Closing open chats for user ${userId}`); - const user = await Users.findOneById(userId); - - const extraQuery = await callbacks.run('livechat.applyDepartmentRestrictions', {}, { userId }); - const openChats = LivechatRooms.findOpenByAgent(userId, extraQuery); - const promises = []; - await openChats.forEach((room) => { - promises.push(LivechatTyped.closeRoom({ user, room, comment })); - }); - - await Promise.all(promises); - }, - - async forwardOpenChats(userId) { - Livechat.logger.debug(`Transferring open chats for user ${userId}`); - for await (const room of LivechatRooms.findOpenByAgent(userId)) { - const guest = await LivechatVisitors.findOneEnabledById(room.v._id); - const user = await Users.findOneById(userId); - const { _id, username, name } = user; - const transferredBy = normalizeTransferredByData({ _id, username, name }, room); - await this.transfer(room, guest, { - roomId: room._id, - transferredBy, - departmentId: guest.department, - }); - } - }, - async savePageHistory(token, roomId, pageInfo) { Livechat.logger.debug(`Saving page movement history for visitor with token ${token}`); if (pageInfo.change !== settings.get('Livechat_history_monitor_type')) { @@ -387,23 +283,6 @@ export const Livechat = { await sendMessage(transferredBy, transferMessage, room); }, - async transfer(room, guest, transferData) { - Livechat.logger.debug(`Transfering room ${room._id} [Transfered by: ${transferData?.transferredBy?._id}]`); - if (room.onHold) { - Livechat.logger.debug('Cannot transfer. Room is on hold'); - throw new Error('error-room-onHold'); - } - - if (transferData.departmentId) { - transferData.department = await LivechatDepartmentRaw.findOneById(transferData.departmentId, { - projection: { name: 1 }, - }); - Livechat.logger.debug(`Transfering room ${room._id} to department ${transferData.department?._id}`); - } - - return RoutingManager.transferRoom(room, guest, transferData); - }, - async returnRoomAsInquiry(rid, departmentId, overrideTransferData = {}) { Livechat.logger.debug(`Transfering room ${rid} to ${departmentId ? 'department' : ''} queue`); const room = await LivechatRooms.findOneById(rid); @@ -682,41 +561,6 @@ export const Livechat = { return updateDepartmentAgents(_id, departmentAgents, department.enabled); }, - /* - * @deprecated - Use the equivalent from DepartmentHelpers class - */ - async removeDepartment(_id) { - check(_id, String); - - const departmentRemovalEnabled = settings.get('Omnichannel_enable_department_removal'); - - if (!departmentRemovalEnabled) { - throw new Meteor.Error('department-removal-disabled', 'Department removal is disabled', { - method: 'livechat:removeDepartment', - }); - } - - const department = await LivechatDepartmentRaw.findOneById(_id, { projection: { _id: 1 } }); - - if (!department) { - throw new Meteor.Error('department-not-found', 'Department not found', { - method: 'livechat:removeDepartment', - }); - } - const ret = (await LivechatDepartmentRaw.removeById(_id)).deletedCount; - const agentsIds = (await LivechatDepartmentAgents.findByDepartmentId(_id, { projection: { agentId: 1 } }).toArray()).map( - (agent) => agent.agentId, - ); - await LivechatDepartmentAgents.removeByDepartmentId(_id); - await LivechatDepartmentRaw.unsetFallbackDepartmentByDepartmentId(_id); - if (ret) { - setImmediate(() => { - callbacks.run('livechat.afterRemoveDepartment', { department, agentsIds }); - }); - } - return ret; - }, - showConnecting() { const { showConnecting } = RoutingManager.getConfig(); return showConnecting; @@ -778,63 +622,6 @@ export const Livechat = { await LivechatRooms.updateVisitorStatus(token, status); }, - async sendOfflineMessage(data = {}) { - if (!settings.get('Livechat_display_offline_form')) { - throw new Error('error-offline-form-disabled'); - } - - const { message, name, email, department, host } = data; - const emailMessage = `${message}`.replace(/([^>\r\n]?)(\r\n|\n\r|\r|\n)/g, '$1
$2'); - - let html = '

New livechat message

'; - if (host && host !== '') { - html = html.concat(`

Sent from: ${host}

`); - } - html = html.concat(` -

Visitor name: ${name}

-

Visitor email: ${email}

-

Message:
${emailMessage}

`); - - let fromEmail = settings.get('From_Email').match(/\b[A-Z0-9._%+-]+@(?:[A-Z0-9-]+\.)+[A-Z]{2,4}\b/i); - - if (fromEmail) { - fromEmail = fromEmail[0]; - } else { - fromEmail = settings.get('From_Email'); - } - - if (settings.get('Livechat_validate_offline_email')) { - const emailDomain = email.substr(email.lastIndexOf('@') + 1); - - try { - await dnsResolveMx(emailDomain); - } catch (e) { - throw new Meteor.Error('error-invalid-email-address', 'Invalid email address', { - method: 'livechat:sendOfflineMessage', - }); - } - } - - // TODO Block offline form if Livechat_offline_email is undefined - // (it does not make sense to have an offline form that does nothing) - // `this.sendEmail` will throw an error if the email is invalid - // thus this breaks livechat, since the "to" email is invalid, and that returns an [invalid email] error to the livechat client - let emailTo = settings.get('Livechat_offline_email'); - if (department && department !== '') { - const dep = await LivechatDepartmentRaw.findOneByIdOrName(department); - emailTo = dep.email || emailTo; - } - - const from = `${name} - ${email} <${fromEmail}>`; - const replyTo = `${name} <${email}>`; - const subject = `Livechat offline message from ${name}: ${`${emailMessage}`.substring(0, 20)}`; - await this.sendEmail(from, emailTo, replyTo, subject, html); - - setImmediate(() => { - callbacks.run('livechat.offlineMessage', data); - }); - }, - async allowAgentChangeServiceStatus(statusLivechat, agentId) { if (statusLivechat !== 'available') { return true; diff --git a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts index afb649488300..293b15e8d63c 100644 --- a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts +++ b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts @@ -1,3 +1,6 @@ +import dns from 'dns'; +import * as util from 'util'; + import { Message, VideoConf, api } from '@rocket.chat/core-services'; import type { IOmnichannelRoom, @@ -10,6 +13,8 @@ import type { ILivechatAgent, IMessage, ILivechatDepartment, + AtLeast, + TransferData, } from '@rocket.chat/core-typings'; import { UserStatus, isOmnichannelRoom } from '@rocket.chat/core-typings'; import { Logger, type MainLogger } from '@rocket.chat/logger'; @@ -24,6 +29,7 @@ import { LivechatDepartmentAgents, ReadReceipts, Rooms, + Settings, } from '@rocket.chat/models'; import { Random } from '@rocket.chat/random'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; @@ -41,7 +47,7 @@ import * as Mailer from '../../../mailer/server/api'; import { metrics } from '../../../metrics/server'; import { settings } from '../../../settings/server'; import { getTimezone } from '../../../utils/server/lib/getTimezone'; -import { updateDepartmentAgents, validateEmail } from './Helper'; +import { updateDepartmentAgents, validateEmail, normalizeTransferredByData } from './Helper'; import { QueueManager } from './QueueManager'; import { RoutingManager } from './RoutingManager'; @@ -75,6 +81,16 @@ export type CloseRoomParamsByVisitor = { export type CloseRoomParams = CloseRoomParamsByUser | CloseRoomParamsByVisitor; +type OfflineMessageData = { + message: string; + name: string; + email: string; + department?: string; + host?: string; +}; + +const dnsResolveMx = util.promisify(dns.resolveMx); + class LivechatClass { logger: Logger; @@ -917,6 +933,196 @@ class LivechatClass { await Promise.all([LivechatDepartmentAgents.enableAgentsByDepartmentId(_id), LivechatDepartment.unarchiveDepartment(_id)]); return true; } + + async updateMessage({ guest, message }: { guest: ILivechatVisitor; message: AtLeast }) { + check(message, Match.ObjectIncluding({ _id: String })); + + const originalMessage = await Messages.findOneById>(message._id, { projection: { u: 1 } }); + if (!originalMessage?._id) { + return; + } + + const editAllowed = settings.get('Message_AllowEditing'); + const editOwn = originalMessage.u && originalMessage.u._id === guest._id; + + if (!editAllowed || !editOwn) { + throw new Error('error-action-not-allowed'); + } + + // TODO: Apps sends an `any` object and apparently we just check for _id being present + // while updateMessage expects AtLeast + await updateMessage(message, guest as unknown as IUser); + + return true; + } + + async closeOpenChats(userId: string, comment?: string) { + this.logger.debug(`Closing open chats for user ${userId}`); + const user = await Users.findOneById(userId); + + const extraQuery = await callbacks.run('livechat.applyDepartmentRestrictions', {}, { userId }); + const openChats = LivechatRooms.findOpenByAgent(userId, extraQuery); + const promises: Promise[] = []; + await openChats.forEach((room) => { + promises.push(this.closeRoom({ user, room, comment })); + }); + + await Promise.all(promises); + } + + async transfer(room: IOmnichannelRoom, guest: ILivechatVisitor, transferData: TransferData) { + this.logger.debug(`Transfering room ${room._id} [Transfered by: ${transferData?.transferredBy?._id}]`); + if (room.onHold) { + throw new Error('error-room-onHold'); + } + + if (transferData.departmentId) { + const department = await LivechatDepartment.findOneById(transferData.departmentId, { + projection: { name: 1 }, + }); + if (!department) { + throw new Error('error-invalid-department'); + } + + transferData.department = department; + this.logger.debug(`Transfering room ${room._id} to department ${transferData.department?._id}`); + } + + return RoutingManager.transferRoom(room, guest, transferData); + } + + async forwardOpenChats(userId: string) { + this.logger.debug(`Transferring open chats for user ${userId}`); + const user = await Users.findOneById(userId); + if (!user) { + throw new Error('error-invalid-user'); + } + + const { _id, username, name } = user; + for await (const room of LivechatRooms.findOpenByAgent(userId)) { + const guest = await LivechatVisitors.findOneEnabledById(room.v._id); + if (!guest) { + continue; + } + + const transferredBy = normalizeTransferredByData({ _id, username, name }, room); + await this.transfer(room, guest, { + transferredBy, + departmentId: guest.department, + }); + } + } + + showConnecting() { + return RoutingManager.getConfig()?.showConnecting || false; + } + + async getInitSettings() { + const rcSettings: Record = {}; + + await Settings.findNotHiddenPublic([ + 'Livechat_title', + 'Livechat_title_color', + 'Livechat_enable_message_character_limit', + 'Livechat_message_character_limit', + 'Message_MaxAllowedSize', + 'Livechat_enabled', + 'Livechat_registration_form', + 'Livechat_allow_switching_departments', + 'Livechat_offline_title', + 'Livechat_offline_title_color', + 'Livechat_offline_message', + 'Livechat_offline_success_message', + 'Livechat_offline_form_unavailable', + 'Livechat_display_offline_form', + 'Omnichannel_call_provider', + 'Language', + 'Livechat_enable_transcript', + 'Livechat_transcript_message', + 'Livechat_fileupload_enabled', + 'FileUpload_Enabled', + 'Livechat_conversation_finished_message', + 'Livechat_conversation_finished_text', + 'Livechat_name_field_registration_form', + 'Livechat_email_field_registration_form', + 'Livechat_registration_form_message', + 'Livechat_force_accept_data_processing_consent', + 'Livechat_data_processing_consent_text', + 'Livechat_show_agent_info', + 'Livechat_clear_local_storage_when_chat_ended', + ]).forEach((setting) => { + rcSettings[setting._id] = setting.value; + }); + + rcSettings.Livechat_history_monitor_type = settings.get('Livechat_history_monitor_type'); + + rcSettings.Livechat_Show_Connecting = this.showConnecting(); + + return rcSettings; + } + + async sendOfflineMessage(data: OfflineMessageData) { + if (!settings.get('Livechat_display_offline_form')) { + throw new Error('error-offline-form-disabled'); + } + + const { message, name, email, department, host } = data; + + if (!email) { + throw new Error('error-invalid-email'); + } + + const emailMessage = `${message}`.replace(/([^>\r\n]?)(\r\n|\n\r|\r|\n)/g, '$1
$2'); + + let html = '

New livechat message

'; + if (host && host !== '') { + html = html.concat(`

Sent from: ${host}

`); + } + html = html.concat(` +

Visitor name: ${name}

+

Visitor email: ${email}

+

Message:
${emailMessage}

`); + + const fromEmail = settings.get('From_Email').match(/\b[A-Z0-9._%+-]+@(?:[A-Z0-9-]+\.)+[A-Z]{2,4}\b/i); + + let from: string; + if (fromEmail) { + from = fromEmail[0]; + } else { + from = settings.get('From_Email'); + } + + if (settings.get('Livechat_validate_offline_email')) { + const emailDomain = email.substr(email.lastIndexOf('@') + 1); + + try { + await dnsResolveMx(emailDomain); + } catch (e) { + throw new Meteor.Error('error-invalid-email-address'); + } + } + + // TODO Block offline form if Livechat_offline_email is undefined + // (it does not make sense to have an offline form that does nothing) + // `this.sendEmail` will throw an error if the email is invalid + // thus this breaks livechat, since the "to" email is invalid, and that returns an [invalid email] error to the livechat client + let emailTo = settings.get('Livechat_offline_email'); + if (department && department !== '') { + const dep = await LivechatDepartment.findOneByIdOrName(department, { projection: { email: 1 } }); + if (dep) { + emailTo = dep.email || emailTo; + } + } + + const fromText = `${name} - ${email} <${from}>`; + const replyTo = `${name} <${email}>`; + const subject = `Livechat offline message from ${name}: ${`${emailMessage}`.substring(0, 20)}`; + await this.sendEmail(fromText, emailTo, replyTo, subject, html); + + setImmediate(() => { + void callbacks.run('livechat.offlineMessage', data); + }); + } } export const Livechat = new LivechatClass(); diff --git a/apps/meteor/app/livechat/server/lib/stream/agentStatus.ts b/apps/meteor/app/livechat/server/lib/stream/agentStatus.ts index bbce5d16efb4..5ddd25e90bd2 100644 --- a/apps/meteor/app/livechat/server/lib/stream/agentStatus.ts +++ b/apps/meteor/app/livechat/server/lib/stream/agentStatus.ts @@ -1,7 +1,7 @@ import { Logger } from '@rocket.chat/logger'; import { settings } from '../../../../settings/server'; -import { Livechat } from '../Livechat'; +import { Livechat } from '../LivechatTyped'; const logger = new Logger('AgentStatusWatcher'); diff --git a/apps/meteor/app/livechat/server/methods/sendOfflineMessage.ts b/apps/meteor/app/livechat/server/methods/sendOfflineMessage.ts index 9a475de5e32d..c3b5537f31be 100644 --- a/apps/meteor/app/livechat/server/methods/sendOfflineMessage.ts +++ b/apps/meteor/app/livechat/server/methods/sendOfflineMessage.ts @@ -4,7 +4,7 @@ import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; import { Meteor } from 'meteor/meteor'; import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; -import { Livechat } from '../lib/Livechat'; +import { Livechat } from '../lib/LivechatTyped'; declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention diff --git a/apps/meteor/app/livechat/server/methods/setDepartmentForVisitor.ts b/apps/meteor/app/livechat/server/methods/setDepartmentForVisitor.ts index 61e6b21267da..a14933ed8d47 100644 --- a/apps/meteor/app/livechat/server/methods/setDepartmentForVisitor.ts +++ b/apps/meteor/app/livechat/server/methods/setDepartmentForVisitor.ts @@ -5,7 +5,7 @@ import { Meteor } from 'meteor/meteor'; import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; import { normalizeTransferredByData } from '../lib/Helper'; -import { Livechat } from '../lib/Livechat'; +import { Livechat } from '../lib/LivechatTyped'; declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention diff --git a/apps/meteor/app/livechat/server/methods/transfer.ts b/apps/meteor/app/livechat/server/methods/transfer.ts index 3817b10bf42b..16ee1abc6191 100644 --- a/apps/meteor/app/livechat/server/methods/transfer.ts +++ b/apps/meteor/app/livechat/server/methods/transfer.ts @@ -7,7 +7,7 @@ import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; import { normalizeTransferredByData } from '../lib/Helper'; -import { Livechat } from '../lib/Livechat'; +import { Livechat } from '../lib/LivechatTyped'; declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -60,6 +60,10 @@ Meteor.methods({ const guest = await LivechatVisitors.findOneEnabledById(room.v?._id); + if (!guest) { + throw new Meteor.Error('error-invalid-visitor', 'Invalid visitor', { method: 'livechat:transfer' }); + } + const user = await Meteor.userAsync(); if (!user) { diff --git a/packages/core-typings/src/omnichannel/routing.ts b/packages/core-typings/src/omnichannel/routing.ts index eed6dd6f1a19..43ca0c08f5d2 100644 --- a/packages/core-typings/src/omnichannel/routing.ts +++ b/packages/core-typings/src/omnichannel/routing.ts @@ -24,7 +24,7 @@ export interface IRoutingMethod { } export type TransferData = { - userId: string; + userId?: string; departmentId?: string; department?: Pick; transferredBy: { @@ -36,7 +36,7 @@ export type TransferData = { name?: string; }; clientAction?: boolean; - scope: 'agent' | 'department' | 'queue' | 'autoTransferUnansweredChatsToAgent' | 'autoTransferUnansweredChatsToQueue'; + scope?: 'agent' | 'department' | 'queue' | 'autoTransferUnansweredChatsToAgent' | 'autoTransferUnansweredChatsToQueue'; comment?: string; }; From 3b5310cf2350b93ab5f171d5d547434e6a9f46c5 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Wed, 18 Oct 2023 05:48:04 -0700 Subject: [PATCH 04/26] regression: Restore default limits to community apps (#30611) Co-authored-by: Rodrigo Nascimento --- ee/packages/license/src/deprecated.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ee/packages/license/src/deprecated.ts b/ee/packages/license/src/deprecated.ts index 65851a79c7eb..0a4a6b0f1bb3 100644 --- a/ee/packages/license/src/deprecated.ts +++ b/ee/packages/license/src/deprecated.ts @@ -23,8 +23,8 @@ export function getMaxActiveUsers(this: LicenseManager) { export function getAppsConfig(this: LicenseManager) { return { - maxPrivateApps: getLicenseLimit(this.getLicense(), 'privateApps') ?? -1, - maxMarketplaceApps: getLicenseLimit(this.getLicense(), 'marketplaceApps') ?? -1, + maxPrivateApps: getLicenseLimit(this.getLicense(), 'privateApps') ?? 3, + maxMarketplaceApps: getLicenseLimit(this.getLicense(), 'marketplaceApps') ?? 5, }; } From 343ba56f44c35ea7044c73f3f9e9af8debd19a79 Mon Sep 17 00:00:00 2001 From: Rafael Tapia Date: Wed, 18 Oct 2023 10:34:02 -0300 Subject: [PATCH 05/26] test: wait for the name update finish (#30663) --- apps/meteor/tests/end-to-end/api/09-rooms.js | 47 ++++++++++---------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/apps/meteor/tests/end-to-end/api/09-rooms.js b/apps/meteor/tests/end-to-end/api/09-rooms.js index ed3c7eefb15b..10d576c316a2 100644 --- a/apps/meteor/tests/end-to-end/api/09-rooms.js +++ b/apps/meteor/tests/end-to-end/api/09-rooms.js @@ -1570,29 +1570,30 @@ describe('[Rooms]', function () { }); }); - it('should update group name if user changes name', (done) => { - updateSetting('UI_Use_Real_Name', true).then(() => { - request - .post(api('users.update')) - .set(credentials) - .send({ - userId: testUser._id, - data: { - name: `changed.name.${testUser.username}`, - }, - }) - .end(() => { - request - .get(api('subscriptions.getOne')) - .set(credentials) - .query({ roomId }) - .end((err, res) => { - const { subscription } = res.body; - expect(subscription.fname).to.equal(`changed.name.${testUser.username}, Rocket.Cat`); - done(); - }); - }); - }); + it('should update group name if user changes name', async () => { + await updateSetting('UI_Use_Real_Name', true); + await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: testUser._id, + data: { + name: `changed.name.${testUser.username}`, + }, + }); + + // need to wait for the name update finish + await sleep(300); + + await request + .get(api('subscriptions.getOne')) + .set(credentials) + .query({ roomId }) + .send() + .expect((res) => { + const { subscription } = res.body; + expect(subscription.fname).to.equal(`changed.name.${testUser.username}, Rocket.Cat`); + }); }); }); From 049b921bc337d1ec802d60f1493414ccac5b4972 Mon Sep 17 00:00:00 2001 From: Noach Magedman Date: Wed, 18 Oct 2023 17:10:01 +0300 Subject: [PATCH 06/26] fix: Handle AWS S3 Re-Authentication via s3.getSignedUrlPromise (#30642) --- apps/meteor/app/file-upload/ufs/AmazonS3/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/app/file-upload/ufs/AmazonS3/server.ts b/apps/meteor/app/file-upload/ufs/AmazonS3/server.ts index b9f0807b6112..d6b69faf75fa 100644 --- a/apps/meteor/app/file-upload/ufs/AmazonS3/server.ts +++ b/apps/meteor/app/file-upload/ufs/AmazonS3/server.ts @@ -80,7 +80,7 @@ class AmazonS3Store extends UploadFS.Store { ResponseContentDisposition: `${forceDownload ? 'attachment' : 'inline'}; filename="${encodeURI(file.name || '')}"`, }; - return s3.getSignedUrl('getObject', params); + return s3.getSignedUrlPromise('getObject', params); }; /** From e24d071675c720d8dc947193b180ee2c81cde95b Mon Sep 17 00:00:00 2001 From: Aleksander Nicacio da Silva Date: Wed, 18 Oct 2023 13:17:28 -0300 Subject: [PATCH 07/26] fix: inconsistent behavior when removing subscriptions and inquiries (#30572) --- .changeset/long-cars-dream.md | 5 +++ .../client/lib/stream/queueManager.ts | 26 ++++++++++--- .../client/views/room/hooks/useOpenRoom.ts | 10 +++++ .../views/room/providers/RoomProvider.tsx | 39 +------------------ 4 files changed, 38 insertions(+), 42 deletions(-) create mode 100644 .changeset/long-cars-dream.md diff --git a/.changeset/long-cars-dream.md b/.changeset/long-cars-dream.md new file mode 100644 index 000000000000..95f226d6dfb4 --- /dev/null +++ b/.changeset/long-cars-dream.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixed intermittent errors caused by the removal of subscriptions and inquiries when lacking permissions. diff --git a/apps/meteor/app/livechat/client/lib/stream/queueManager.ts b/apps/meteor/app/livechat/client/lib/stream/queueManager.ts index 28d09958535a..906ace402bb9 100644 --- a/apps/meteor/app/livechat/client/lib/stream/queueManager.ts +++ b/apps/meteor/app/livechat/client/lib/stream/queueManager.ts @@ -8,18 +8,34 @@ import { LivechatInquiry } from '../../collections/LivechatInquiry'; const departments = new Set(); const events = { - added: (inquiry: ILivechatInquiryRecord) => { - departments.has(inquiry.department) && LivechatInquiry.insert({ ...inquiry, alert: true, _updatedAt: new Date(inquiry._updatedAt) }); + added: async (inquiry: ILivechatInquiryRecord) => { + if (!departments.has(inquiry.department)) { + return; + } + + LivechatInquiry.insert({ ...inquiry, alert: true, _updatedAt: new Date(inquiry._updatedAt) }); + await invalidateRoomQueries(inquiry.rid); }, changed: async (inquiry: ILivechatInquiryRecord) => { if (inquiry.status !== 'queued' || (inquiry.department && !departments.has(inquiry.department))) { - return LivechatInquiry.remove(inquiry._id); + return removeInquiry(inquiry); } LivechatInquiry.upsert({ _id: inquiry._id }, { ...inquiry, alert: true, _updatedAt: new Date(inquiry._updatedAt) }); - await queryClient.invalidateQueries(['/v1/rooms.info', inquiry.rid]); + await invalidateRoomQueries(inquiry.rid); }, - removed: (inquiry: ILivechatInquiryRecord) => LivechatInquiry.remove(inquiry._id), + removed: (inquiry: ILivechatInquiryRecord) => removeInquiry(inquiry), +}; + +const invalidateRoomQueries = async (rid: string) => { + await queryClient.invalidateQueries(['rooms', { reference: rid, type: 'l' }]); + await queryClient.removeQueries(['rooms', rid]); + await queryClient.removeQueries(['/v1/rooms.info', rid]); +}; + +const removeInquiry = async (inquiry: ILivechatInquiryRecord) => { + await LivechatInquiry.remove(inquiry._id); + return queryClient.invalidateQueries(['rooms', { reference: inquiry.rid, type: 'l' }]); }; const getInquiriesFromAPI = async () => { diff --git a/apps/meteor/client/views/room/hooks/useOpenRoom.ts b/apps/meteor/client/views/room/hooks/useOpenRoom.ts index c2b694414002..d529145aaf17 100644 --- a/apps/meteor/client/views/room/hooks/useOpenRoom.ts +++ b/apps/meteor/client/views/room/hooks/useOpenRoom.ts @@ -8,6 +8,7 @@ import { omit } from '../../../../lib/utils/omit'; import { NotAuthorizedError } from '../../../lib/errors/NotAuthorizedError'; import { OldUrlRoomError } from '../../../lib/errors/OldUrlRoomError'; import { RoomNotFoundError } from '../../../lib/errors/RoomNotFoundError'; +import { queryClient } from '../../../lib/queryClient'; export function useOpenRoom({ type, reference }: { type: RoomType; reference: string }) { const user = useUser(); @@ -102,6 +103,15 @@ export function useOpenRoom({ type, reference }: { type: RoomType; reference: st }, { retry: 0, + onError: async (error) => { + if (['l', 'v'].includes(type) && error instanceof RoomNotFoundError) { + const { ChatRoom } = await import('../../../../app/models/client'); + + ChatRoom.remove(reference); + queryClient.removeQueries(['rooms', reference]); + queryClient.removeQueries(['/v1/rooms.info', reference]); + } + }, }, ); } diff --git a/apps/meteor/client/views/room/providers/RoomProvider.tsx b/apps/meteor/client/views/room/providers/RoomProvider.tsx index e19fa8136f59..82c66c6f5d8d 100644 --- a/apps/meteor/client/views/room/providers/RoomProvider.tsx +++ b/apps/meteor/client/views/room/providers/RoomProvider.tsx @@ -1,10 +1,9 @@ import type { IRoom } from '@rocket.chat/core-typings'; -import { usePermission, useStream, useUserId, useRouter } from '@rocket.chat/ui-contexts'; -import { useQueryClient } from '@tanstack/react-query'; +import { useRouter } from '@rocket.chat/ui-contexts'; import type { ReactNode, ContextType, ReactElement } from 'react'; import React, { useMemo, memo, useEffect, useCallback } from 'react'; -import { ChatRoom, ChatSubscription } from '../../../../app/models/client'; +import { ChatSubscription } from '../../../../app/models/client'; import { RoomHistoryManager } from '../../../../app/ui-utils/client'; import { UserAction } from '../../../../app/ui/client/lib/UserAction'; import { useReactiveQuery } from '../../../hooks/useReactiveQuery'; @@ -29,24 +28,6 @@ const RoomProvider = ({ rid, children }: RoomProviderProps): ReactElement => { const { data: room, isSuccess } = useRoomQuery(rid); - const subscribeToRoom = useStream('room-data'); - - const queryClient = useQueryClient(); - const userId = useUserId(); - const isLivechatAdmin = usePermission('view-livechat-rooms'); - const { t: roomType } = room ?? {}; - - // TODO: move this to omnichannel context only - useEffect(() => { - if (roomType !== 'l') { - return; - } - - return subscribeToRoom(rid, (room) => { - queryClient.setQueryData(['rooms', rid], room); - }); - }, [subscribeToRoom, rid, queryClient, roomType]); - // TODO: the following effect is a workaround while we don't have a general and definitive solution for it const router = useRouter(); useEffect(() => { @@ -55,22 +36,6 @@ const RoomProvider = ({ rid, children }: RoomProviderProps): ReactElement => { } }, [isSuccess, room, router]); - const { _id: servedById } = room?.servedBy ?? {}; - - // TODO: Review the necessity of this effect when we move away from cached collections - useEffect(() => { - if (roomType !== 'l' || !servedById) { - return; - } - - if (!isLivechatAdmin && servedById !== userId) { - ChatRoom.remove(rid); - queryClient.removeQueries(['rooms', rid]); - queryClient.removeQueries(['rooms', { reference: rid, type: 'l' }]); - queryClient.removeQueries(['/v1/rooms.info', rid]); - } - }, [isLivechatAdmin, queryClient, userId, rid, roomType, servedById]); - const subscriptionQuery = useReactiveQuery(['subscriptions', { rid }], () => ChatSubscription.findOne({ rid }) ?? null); const pseudoRoom = useMemo(() => { From f3dd1277e6ff7010bb6251b9b6554146a8c40a7c Mon Sep 17 00:00:00 2001 From: Aleksander Nicacio da Silva Date: Wed, 18 Oct 2023 15:05:02 -0300 Subject: [PATCH 08/26] feat: Added new setting 'Hide conversation after closing' (#30591) --- .changeset/thick-spoons-compete.md | 5 +++ .../omnichannel/useOmnichannelCloseRoute.ts | 23 ++++++++++++ .../OmnichannelPreferencesPage.tsx | 5 ++- .../omnichannel/PreferencesGeneral.tsx | 26 +++++++++++++ .../room/body/hooks/useGoToHomeOnRemoved.ts | 37 ++++++++++++++----- .../rocketchat-i18n/i18n/en.i18n.json | 2 + .../rocketchat-i18n/i18n/pt-BR.i18n.json | 2 + .../server/methods/saveUserPreferences.ts | 1 + .../v1/users/UsersSetPreferenceParamsPOST.ts | 5 +++ 9 files changed, 95 insertions(+), 11 deletions(-) create mode 100644 .changeset/thick-spoons-compete.md create mode 100644 apps/meteor/client/hooks/omnichannel/useOmnichannelCloseRoute.ts create mode 100644 apps/meteor/client/views/account/omnichannel/PreferencesGeneral.tsx diff --git a/.changeset/thick-spoons-compete.md b/.changeset/thick-spoons-compete.md new file mode 100644 index 000000000000..cf6e9eb2697d --- /dev/null +++ b/.changeset/thick-spoons-compete.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +Added new Omnichannel setting 'Hide conversation after closing' diff --git a/apps/meteor/client/hooks/omnichannel/useOmnichannelCloseRoute.ts b/apps/meteor/client/hooks/omnichannel/useOmnichannelCloseRoute.ts new file mode 100644 index 000000000000..746a62bd87e6 --- /dev/null +++ b/apps/meteor/client/hooks/omnichannel/useOmnichannelCloseRoute.ts @@ -0,0 +1,23 @@ +import { useRouter, useUserPreference } from '@rocket.chat/ui-contexts'; +import { useCallback } from 'react'; + +export const useOmnichannelCloseRoute = () => { + const hideConversationAfterClosing = useUserPreference('omnichannelHideConversationAfterClosing') ?? true; + const router = useRouter(); + + const navigateHome = useCallback(() => { + if (!hideConversationAfterClosing) { + return; + } + + const routeName = router.getRouteName(); + + if (routeName === 'omnichannel-current-chats') { + router.navigate({ name: 'omnichannel-current-chats' }); + } else { + router.navigate({ name: 'home' }); + } + }, [hideConversationAfterClosing, router]); + + return { navigateHome }; +}; diff --git a/apps/meteor/client/views/account/omnichannel/OmnichannelPreferencesPage.tsx b/apps/meteor/client/views/account/omnichannel/OmnichannelPreferencesPage.tsx index 515446a154f6..d448a180f834 100644 --- a/apps/meteor/client/views/account/omnichannel/OmnichannelPreferencesPage.tsx +++ b/apps/meteor/client/views/account/omnichannel/OmnichannelPreferencesPage.tsx @@ -6,6 +6,7 @@ import { useForm, FormProvider } from 'react-hook-form'; import Page from '../../../components/Page'; import PreferencesConversationTranscript from './PreferencesConversationTranscript'; +import { PreferencesGeneral } from './PreferencesGeneral'; type FormData = { omnichannelTranscriptPDF: boolean; @@ -18,9 +19,10 @@ const OmnichannelPreferencesPage = (): ReactElement => { const omnichannelTranscriptPDF = useUserPreference('omnichannelTranscriptPDF') ?? false; const omnichannelTranscriptEmail = useUserPreference('omnichannelTranscriptEmail') ?? false; + const omnichannelHideConversationAfterClosing = useUserPreference('omnichannelHideConversationAfterClosing') ?? true; const methods = useForm({ - defaultValues: { omnichannelTranscriptPDF, omnichannelTranscriptEmail }, + defaultValues: { omnichannelTranscriptPDF, omnichannelTranscriptEmail, omnichannelHideConversationAfterClosing }, }); const { @@ -48,6 +50,7 @@ const OmnichannelPreferencesPage = (): ReactElement => { + diff --git a/apps/meteor/client/views/account/omnichannel/PreferencesGeneral.tsx b/apps/meteor/client/views/account/omnichannel/PreferencesGeneral.tsx new file mode 100644 index 000000000000..67c06bd2c5b2 --- /dev/null +++ b/apps/meteor/client/views/account/omnichannel/PreferencesGeneral.tsx @@ -0,0 +1,26 @@ +import { Box, Field, FieldGroup, FieldHint, FieldLabel, FieldRow, ToggleSwitch } from '@rocket.chat/fuselage'; +import { useUniqueId } from '@rocket.chat/fuselage-hooks'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; +import React from 'react'; +import { useFormContext } from 'react-hook-form'; + +export const PreferencesGeneral = (): ReactElement => { + const t = useTranslation(); + const { register } = useFormContext(); + const omnichannelHideAfterClosing = useUniqueId(); + + return ( + + + + {t('Omnichannel_hide_conversation_after_closing')} + + + + + {t('Omnichannel_hide_conversation_after_closing_description')} + + + ); +}; diff --git a/apps/meteor/client/views/room/body/hooks/useGoToHomeOnRemoved.ts b/apps/meteor/client/views/room/body/hooks/useGoToHomeOnRemoved.ts index a087d288d0d7..068c97a2de4b 100644 --- a/apps/meteor/client/views/room/body/hooks/useGoToHomeOnRemoved.ts +++ b/apps/meteor/client/views/room/body/hooks/useGoToHomeOnRemoved.ts @@ -1,9 +1,9 @@ -import type { IRoom } from '@rocket.chat/core-typings'; +import { isOmnichannelRoom, type IRoom } from '@rocket.chat/core-typings'; import { useRoute, useStream, useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; import { useQueryClient } from '@tanstack/react-query'; import { useEffect } from 'react'; -const IGNORED_ROOMS = ['l', 'v']; +import { useOmnichannelCloseRoute } from '../../../../hooks/omnichannel/useOmnichannelCloseRoute'; export function useGoToHomeOnRemoved(room: IRoom, userId: string | undefined): void { const homeRouter = useRoute('home'); @@ -11,6 +11,7 @@ export function useGoToHomeOnRemoved(room: IRoom, userId: string | undefined): v const dispatchToastMessage = useToastMessageDispatch(); const subscribeToNotifyUser = useStream('notify-user'); const t = useTranslation(); + const { navigateHome } = useOmnichannelCloseRoute(); useEffect(() => { if (!userId) { @@ -21,19 +22,35 @@ export function useGoToHomeOnRemoved(room: IRoom, userId: string | undefined): v if (event === 'removed' && subscription.rid === room._id) { queryClient.invalidateQueries(['rooms', room._id]); - if (!IGNORED_ROOMS.includes(room.t)) { - dispatchToastMessage({ - type: 'info', - message: t('You_have_been_removed_from__roomName_', { - roomName: room?.fname || room?.name || '', - }), - }); + if (isOmnichannelRoom(room)) { + navigateHome(); + return; } + dispatchToastMessage({ + type: 'info', + message: t('You_have_been_removed_from__roomName_', { + roomName: room?.fname || room?.name || '', + }), + }); + homeRouter.push({}); } }); return unSubscribeFromNotifyUser; - }, [userId, homeRouter, subscribeToNotifyUser, room._id, room?.fname, room?.name, t, dispatchToastMessage, queryClient, room.t]); + }, [ + userId, + homeRouter, + subscribeToNotifyUser, + room._id, + room?.fname, + room?.name, + t, + dispatchToastMessage, + queryClient, + room.t, + room, + navigateHome, + ]); } diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json index 001cdf080f7b..7ef10e0988b0 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json @@ -3121,6 +3121,8 @@ "Omnichannel_sorting_disclaimer": "Omnichannel conversations are sorted by {{sortingMechanism}}, edit a room to apply.", "Livechat_online": "Omnichannel on-line", "Omnichannel_placed_chat_on_hold": "Chat On Hold: {{comment}}", + "Omnichannel_hide_conversation_after_closing": "Hide conversation after closing", + "Omnichannel_hide_conversation_after_closing_description": "After closing the conversation you will be redirected to Home.", "Livechat_Queue": "Omnichannel Queue", "Livechat_registration_form": "Registration Form", "Livechat_registration_form_message": "Registration Form Message", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/pt-BR.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/pt-BR.i18n.json index 7dd1b47f335e..8da04e7c4e67 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/pt-BR.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/pt-BR.i18n.json @@ -2679,6 +2679,8 @@ "Omnichannel_sorting_disclaimer": "Conversar do Omnichannel são ordenadas por {{sortingMechanism}}, edite a sala para alterar.", "Livechat_online": "Omnichannel online", "Omnichannel_placed_chat_on_hold": "Conversa em espera: {{comment}}", + "Omnichannel_hide_conversation_after_closing": "Ocultar conversa após fechar", + "Omnichannel_hide_conversation_after_closing_description": "Após encerrar a conversa, você será redirecionado para a página inicial.", "Livechat_Queue": "Fila omnichannel", "Livechat_registration_form": "Formulário de registro", "Livechat_registration_form_message": "Mensagem do formulário de registro", diff --git a/apps/meteor/server/methods/saveUserPreferences.ts b/apps/meteor/server/methods/saveUserPreferences.ts index 71abe7bea3b1..814627a745bc 100644 --- a/apps/meteor/server/methods/saveUserPreferences.ts +++ b/apps/meteor/server/methods/saveUserPreferences.ts @@ -86,6 +86,7 @@ export const saveUserPreferences = async (settings: Partial, us fontSize: Match.Optional(String), omnichannelTranscriptEmail: Match.Optional(Boolean), omnichannelTranscriptPDF: Match.Optional(Boolean), + omnichannelHideConversationAfterClosing: Match.Optional(Boolean), notifyCalendarEvents: Match.Optional(Boolean), enableMobileRinging: Match.Optional(Boolean), mentionsWithSymbol: Match.Optional(Boolean), diff --git a/packages/rest-typings/src/v1/users/UsersSetPreferenceParamsPOST.ts b/packages/rest-typings/src/v1/users/UsersSetPreferenceParamsPOST.ts index bb32dc27fb04..1c89fdc04d5d 100644 --- a/packages/rest-typings/src/v1/users/UsersSetPreferenceParamsPOST.ts +++ b/packages/rest-typings/src/v1/users/UsersSetPreferenceParamsPOST.ts @@ -49,6 +49,7 @@ export type UsersSetPreferencesParamsPOST = { idleTimeLimit?: number; omnichannelTranscriptEmail?: boolean; omnichannelTranscriptPDF?: boolean; + omnichannelHideConversationAfterClosing?: boolean; enableMobileRinging?: boolean; mentionsWithSymbol?: boolean; }; @@ -242,6 +243,10 @@ const UsersSetPreferencesParamsPostSchema = { type: 'boolean', nullable: true, }, + omnichannelHideConversationAfterClosing: { + type: 'boolean', + nullable: true, + }, enableMobileRinging: { type: 'boolean', nullable: true, From 083840662c1cf79633a12889663f4414517c6e9a Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 18 Oct 2023 13:07:08 -0600 Subject: [PATCH 09/26] refactor: Small files to typescript (#30665) --- .../federation/server/lib/getFederationDiscoveryMethod.js | 3 --- .../federation/server/lib/getFederationDiscoveryMethod.ts | 3 +++ .../meteor/app/federation/server/lib/getFederationDomain.js | 3 --- .../meteor/app/federation/server/lib/getFederationDomain.ts | 3 +++ .../meteor/app/federation/server/lib/isFederationEnabled.js | 3 --- .../meteor/app/federation/server/lib/isFederationEnabled.ts | 3 +++ .../app/federation/server/lib/{logger.js => logger.ts} | 0 apps/meteor/definition/externals/meteor/meteor.d.ts | 6 ++++++ .../lib/{addRoleRestrictions.js => addRoleRestrictions.ts} | 0 .../lib/{guestPermissions.js => guestPermissions.ts} | 0 apps/meteor/ee/app/settings/server/{index.js => index.ts} | 0 apps/meteor/server/startup/{appcache.js => appcache.ts} | 0 apps/meteor/server/startup/migrations/{xrun.js => xrun.ts} | 0 apps/meteor/tests/data/api-data.js | 4 ++-- apps/meteor/tests/data/{channel.js => channel.ts} | 0 apps/meteor/tests/data/{interactions.js => interactions.ts} | 0 apps/meteor/tests/data/{role.js => role.ts} | 0 apps/meteor/tests/data/uploads.helper.ts | 2 +- apps/meteor/tests/end-to-end/api/01-users.js | 2 +- apps/meteor/tests/end-to-end/api/09-rooms.js | 2 +- apps/meteor/tests/end-to-end/api/14-assets.js | 2 +- 21 files changed, 21 insertions(+), 15 deletions(-) delete mode 100644 apps/meteor/app/federation/server/lib/getFederationDiscoveryMethod.js create mode 100644 apps/meteor/app/federation/server/lib/getFederationDiscoveryMethod.ts delete mode 100644 apps/meteor/app/federation/server/lib/getFederationDomain.js create mode 100644 apps/meteor/app/federation/server/lib/getFederationDomain.ts delete mode 100644 apps/meteor/app/federation/server/lib/isFederationEnabled.js create mode 100644 apps/meteor/app/federation/server/lib/isFederationEnabled.ts rename apps/meteor/app/federation/server/lib/{logger.js => logger.ts} (100%) rename apps/meteor/ee/app/authorization/lib/{addRoleRestrictions.js => addRoleRestrictions.ts} (100%) rename apps/meteor/ee/app/authorization/lib/{guestPermissions.js => guestPermissions.ts} (100%) rename apps/meteor/ee/app/settings/server/{index.js => index.ts} (100%) rename apps/meteor/server/startup/{appcache.js => appcache.ts} (100%) rename apps/meteor/server/startup/migrations/{xrun.js => xrun.ts} (100%) rename apps/meteor/tests/data/{channel.js => channel.ts} (100%) rename apps/meteor/tests/data/{interactions.js => interactions.ts} (100%) rename apps/meteor/tests/data/{role.js => role.ts} (100%) diff --git a/apps/meteor/app/federation/server/lib/getFederationDiscoveryMethod.js b/apps/meteor/app/federation/server/lib/getFederationDiscoveryMethod.js deleted file mode 100644 index 2da490942fdc..000000000000 --- a/apps/meteor/app/federation/server/lib/getFederationDiscoveryMethod.js +++ /dev/null @@ -1,3 +0,0 @@ -import { settings } from '../../../settings/server'; - -export const getFederationDiscoveryMethod = () => settings.get('FEDERATION_Discovery_Method'); diff --git a/apps/meteor/app/federation/server/lib/getFederationDiscoveryMethod.ts b/apps/meteor/app/federation/server/lib/getFederationDiscoveryMethod.ts new file mode 100644 index 000000000000..b8ea8c4f6ce6 --- /dev/null +++ b/apps/meteor/app/federation/server/lib/getFederationDiscoveryMethod.ts @@ -0,0 +1,3 @@ +import { settings } from '../../../settings/server'; + +export const getFederationDiscoveryMethod = () => settings.get('FEDERATION_Discovery_Method'); diff --git a/apps/meteor/app/federation/server/lib/getFederationDomain.js b/apps/meteor/app/federation/server/lib/getFederationDomain.js deleted file mode 100644 index c5e67629db75..000000000000 --- a/apps/meteor/app/federation/server/lib/getFederationDomain.js +++ /dev/null @@ -1,3 +0,0 @@ -import { settings } from '../../../settings/server'; - -export const getFederationDomain = () => settings.get('FEDERATION_Domain').replace('@', ''); diff --git a/apps/meteor/app/federation/server/lib/getFederationDomain.ts b/apps/meteor/app/federation/server/lib/getFederationDomain.ts new file mode 100644 index 000000000000..80f683743f2d --- /dev/null +++ b/apps/meteor/app/federation/server/lib/getFederationDomain.ts @@ -0,0 +1,3 @@ +import { settings } from '../../../settings/server'; + +export const getFederationDomain = () => settings.get('FEDERATION_Domain').replace('@', ''); diff --git a/apps/meteor/app/federation/server/lib/isFederationEnabled.js b/apps/meteor/app/federation/server/lib/isFederationEnabled.js deleted file mode 100644 index 9e46d3004ace..000000000000 --- a/apps/meteor/app/federation/server/lib/isFederationEnabled.js +++ /dev/null @@ -1,3 +0,0 @@ -import { settings } from '../../../settings/server'; - -export const isFederationEnabled = () => settings.get('FEDERATION_Enabled'); diff --git a/apps/meteor/app/federation/server/lib/isFederationEnabled.ts b/apps/meteor/app/federation/server/lib/isFederationEnabled.ts new file mode 100644 index 000000000000..e3edb818e602 --- /dev/null +++ b/apps/meteor/app/federation/server/lib/isFederationEnabled.ts @@ -0,0 +1,3 @@ +import { settings } from '../../../settings/server'; + +export const isFederationEnabled = () => settings.get('FEDERATION_Enabled'); diff --git a/apps/meteor/app/federation/server/lib/logger.js b/apps/meteor/app/federation/server/lib/logger.ts similarity index 100% rename from apps/meteor/app/federation/server/lib/logger.js rename to apps/meteor/app/federation/server/lib/logger.ts diff --git a/apps/meteor/definition/externals/meteor/meteor.d.ts b/apps/meteor/definition/externals/meteor/meteor.d.ts index 622f3032b484..4854d24a37ba 100644 --- a/apps/meteor/definition/externals/meteor/meteor.d.ts +++ b/apps/meteor/definition/externals/meteor/meteor.d.ts @@ -130,5 +130,11 @@ declare module 'meteor/meteor' { ...args: StringifyBuffers> ) => ReturnType | Promise>; }): void; + + const AppCache: + | { + config: (config: { onlineOnly: string[] }) => void; + } + | undefined; } } diff --git a/apps/meteor/ee/app/authorization/lib/addRoleRestrictions.js b/apps/meteor/ee/app/authorization/lib/addRoleRestrictions.ts similarity index 100% rename from apps/meteor/ee/app/authorization/lib/addRoleRestrictions.js rename to apps/meteor/ee/app/authorization/lib/addRoleRestrictions.ts diff --git a/apps/meteor/ee/app/authorization/lib/guestPermissions.js b/apps/meteor/ee/app/authorization/lib/guestPermissions.ts similarity index 100% rename from apps/meteor/ee/app/authorization/lib/guestPermissions.js rename to apps/meteor/ee/app/authorization/lib/guestPermissions.ts diff --git a/apps/meteor/ee/app/settings/server/index.js b/apps/meteor/ee/app/settings/server/index.ts similarity index 100% rename from apps/meteor/ee/app/settings/server/index.js rename to apps/meteor/ee/app/settings/server/index.ts diff --git a/apps/meteor/server/startup/appcache.js b/apps/meteor/server/startup/appcache.ts similarity index 100% rename from apps/meteor/server/startup/appcache.js rename to apps/meteor/server/startup/appcache.ts diff --git a/apps/meteor/server/startup/migrations/xrun.js b/apps/meteor/server/startup/migrations/xrun.ts similarity index 100% rename from apps/meteor/server/startup/migrations/xrun.js rename to apps/meteor/server/startup/migrations/xrun.ts diff --git a/apps/meteor/tests/data/api-data.js b/apps/meteor/tests/data/api-data.js index 25e89c2ef99a..d08e4cc50c54 100644 --- a/apps/meteor/tests/data/api-data.js +++ b/apps/meteor/tests/data/api-data.js @@ -1,7 +1,7 @@ import supertest from 'supertest'; -import { publicChannelName, privateChannelName } from './channel.js'; -import { roleNameUsers, roleNameSubscriptions, roleScopeUsers, roleScopeSubscriptions, roleDescription } from './role.js'; +import { publicChannelName, privateChannelName } from './channel'; +import { roleNameUsers, roleNameSubscriptions, roleScopeUsers, roleScopeSubscriptions, roleDescription } from './role'; import { username, email, adminUsername, adminPassword } from './user'; const apiUrl = process.env.TEST_API_URL || 'http://localhost:3000'; diff --git a/apps/meteor/tests/data/channel.js b/apps/meteor/tests/data/channel.ts similarity index 100% rename from apps/meteor/tests/data/channel.js rename to apps/meteor/tests/data/channel.ts diff --git a/apps/meteor/tests/data/interactions.js b/apps/meteor/tests/data/interactions.ts similarity index 100% rename from apps/meteor/tests/data/interactions.js rename to apps/meteor/tests/data/interactions.ts diff --git a/apps/meteor/tests/data/role.js b/apps/meteor/tests/data/role.ts similarity index 100% rename from apps/meteor/tests/data/role.js rename to apps/meteor/tests/data/role.ts diff --git a/apps/meteor/tests/data/uploads.helper.ts b/apps/meteor/tests/data/uploads.helper.ts index 194b19df34c8..29c7a143484c 100644 --- a/apps/meteor/tests/data/uploads.helper.ts +++ b/apps/meteor/tests/data/uploads.helper.ts @@ -5,7 +5,7 @@ import { after, before, it } from 'mocha'; import { api, request, credentials } from './api-data.js'; import { password } from './user'; import { createUser, login } from './users.helper'; -import { imgURL } from './interactions.js'; +import { imgURL } from './interactions'; import { updateSetting } from './permissions.helper'; import { createRoom } from './rooms.helper'; import { createVisitor } from './livechat/rooms'; diff --git a/apps/meteor/tests/end-to-end/api/01-users.js b/apps/meteor/tests/end-to-end/api/01-users.js index d99fa68a036f..b8343dc015da 100644 --- a/apps/meteor/tests/end-to-end/api/01-users.js +++ b/apps/meteor/tests/end-to-end/api/01-users.js @@ -19,7 +19,7 @@ import { } from '../../data/api-data.js'; import { MAX_BIO_LENGTH, MAX_NICKNAME_LENGTH } from '../../data/constants.ts'; import { customFieldText, clearCustomFields, setCustomFields } from '../../data/custom-fields.js'; -import { imgURL } from '../../data/interactions.js'; +import { imgURL } from '../../data/interactions'; import { updatePermission, updateSetting } from '../../data/permissions.helper'; import { createRoom } from '../../data/rooms.helper'; import { adminEmail, preferences, password, adminUsername } from '../../data/user'; diff --git a/apps/meteor/tests/end-to-end/api/09-rooms.js b/apps/meteor/tests/end-to-end/api/09-rooms.js index 10d576c316a2..533c0b63da44 100644 --- a/apps/meteor/tests/end-to-end/api/09-rooms.js +++ b/apps/meteor/tests/end-to-end/api/09-rooms.js @@ -7,7 +7,7 @@ import { after, afterEach, before, beforeEach, describe, it } from 'mocha'; import { sleep } from '../../../lib/utils/sleep'; import { getCredentials, api, request, credentials } from '../../data/api-data.js'; import { sendSimpleMessage, deleteMessage } from '../../data/chat.helper'; -import { imgURL } from '../../data/interactions.js'; +import { imgURL } from '../../data/interactions'; import { updateEEPermission, updatePermission, updateSetting } from '../../data/permissions.helper'; import { closeRoom, createRoom } from '../../data/rooms.helper'; import { password } from '../../data/user'; diff --git a/apps/meteor/tests/end-to-end/api/14-assets.js b/apps/meteor/tests/end-to-end/api/14-assets.js index 4e9c61b53301..8248e8c04f09 100644 --- a/apps/meteor/tests/end-to-end/api/14-assets.js +++ b/apps/meteor/tests/end-to-end/api/14-assets.js @@ -2,7 +2,7 @@ import { expect } from 'chai'; import { before, describe, it } from 'mocha'; import { getCredentials, api, request, credentials } from '../../data/api-data.js'; -import { imgURL } from '../../data/interactions.js'; +import { imgURL } from '../../data/interactions'; describe('[Assets]', function () { this.retries(0); From fff548fe8aa5d438f90596c778f0cc5906dc7981 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com> Date: Wed, 18 Oct 2023 17:03:20 -0300 Subject: [PATCH 10/26] feat: add trial flag to licenses.info endpoint (#30662) --- ee/packages/license/src/definition/LicenseInfo.ts | 1 + ee/packages/license/src/license.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/ee/packages/license/src/definition/LicenseInfo.ts b/ee/packages/license/src/definition/LicenseInfo.ts index 7de3c0cfbdd6..4c4e34d30528 100644 --- a/ee/packages/license/src/definition/LicenseInfo.ts +++ b/ee/packages/license/src/definition/LicenseInfo.ts @@ -7,4 +7,5 @@ export type LicenseInfo = { activeModules: LicenseModule[]; limits: Record; tags: ILicenseTag[]; + trial: boolean; }; diff --git a/ee/packages/license/src/license.ts b/ee/packages/license/src/license.ts index d24d91287d1e..14dceedd735a 100644 --- a/ee/packages/license/src/license.ts +++ b/ee/packages/license/src/license.ts @@ -332,6 +332,7 @@ export class LicenseManager extends Emitter { activeModules, limits: limits as Record, tags: license?.information.tags || [], + trial: Boolean(license?.information.trial), }; } } From 7b02ca3b4dc537d8abeed0f3d633a3e14fd0d2d5 Mon Sep 17 00:00:00 2001 From: Tasso Evangelista Date: Thu, 19 Oct 2023 00:09:45 -0300 Subject: [PATCH 11/26] refactor(uikit): uikit interactions (#30534) --- .../app/apps/server/bridges/uiInteraction.ts | 7 +- .../app/ui-message/client/ActionManager.js | 261 --------------- .../app/ui-message/client/ActionManager.ts | 237 ++++++++++++++ .../client/UiKitTriggerTimeoutError.ts | 7 + apps/meteor/app/ui/client/lib/ChatMessages.ts | 4 +- .../UIKit/hooks/useUIKitHandleAction.tsx | 26 -- .../UIKit/hooks/useUIKitHandleClose.tsx | 34 -- .../UIKit/hooks/useUIKitStateManager.tsx | 36 --- .../hooks/useUiKitActionManager.ts | 0 .../meteor/client/UIKit/hooks/useUiKitView.ts | 93 ++++++ .../components/ActionManagerBusyState.tsx | 8 +- .../message/uikit/UiKitMessageBlock.tsx | 86 +++-- .../variants/room/RoomMessageContent.tsx | 2 +- .../variants/thread/ThreadMessageContent.tsx | 2 +- .../client/hooks/useAppActionButtons.ts | 105 ++++-- .../client/hooks/useAppUiKitInteraction.ts | 19 +- apps/meteor/client/lib/banners.ts | 6 +- .../lib/chats/flows/processSlashCommand.ts | 4 +- .../client/lib/utils/preventSyntheticEvent.ts | 9 + apps/meteor/client/polyfills/index.ts | 1 + .../meteor/client/polyfills/promiseFinally.ts | 16 + .../providers/ActionManagerProvider.tsx | 6 +- .../moderation/helpers/ContextMessage.tsx | 2 +- .../client/views/banners/BannerRegion.tsx | 2 +- .../client/views/banners/UiKitBanner.tsx | 90 ++++-- .../views/banners/hooks/useRemoteBanners.ts | 4 +- .../client/views/modal/uikit/ModalBlock.tsx | 10 +- .../client/views/modal/uikit/UiKitModal.tsx | 195 ++++++----- .../views/modal/uikit/getButtonStyle.ts | 6 +- .../uikit/hooks/useActionManagerState.ts | 39 --- .../views/modal/uikit/hooks/useValues.ts | 48 --- .../MessageList/ContactHistoryMessage.tsx | 2 +- apps/meteor/client/views/room/Room.tsx | 11 +- .../uikit/UiKitContextualBar.tsx | 306 ++++++------------ .../views/room/hooks/useAppsContextualBar.ts | 64 ++-- .../providers/hooks/useAppsRoomActions.ts | 36 ++- .../ee/app/license/server/maxSeatsBanners.ts | 12 +- .../ee/client/apps/gameCenter/GameCenter.tsx | 15 +- .../ee/server/apps/communication/uikit.ts | 120 +++---- .../server/modules/core-apps/banner.module.ts | 18 +- .../server/modules/core-apps/nps.module.ts | 26 +- .../modules/core-apps/videoconf.module.ts | 10 +- .../services/nps/getAndCreateNpsSurvey.ts | 4 +- .../server/services/nps/notification.ts | 6 +- apps/meteor/server/services/startup.ts | 4 +- .../server/services/uikit-core-app/service.ts | 14 +- .../services/video-conference/service.ts | 8 +- ee/packages/ddp-client/src/types/streams.ts | 4 +- package.json | 2 +- packages/core-services/src/Events.ts | 4 +- packages/core-services/src/index.ts | 3 +- .../core-services/src/types/INPSService.ts | 4 +- .../core-services/src/types/IUiKitCoreApp.ts | 51 ++- .../src/types/IVideoConfService.ts | 4 +- packages/core-typings/src/IBanner.ts | 4 +- packages/core-typings/src/INps.ts | 2 +- packages/core-typings/src/Serialized.ts | 31 +- packages/core-typings/src/UIKit.ts | 60 ---- .../core-typings/src/cloud/Announcement.ts | 4 +- packages/core-typings/src/index.ts | 3 +- packages/core-typings/src/uikit/BannerView.ts | 16 + .../src/uikit/ContextualBarView.ts | 14 + packages/core-typings/src/uikit/ModalView.ts | 15 + .../src/uikit/ServerInteraction.ts | 84 +++++ .../core-typings/src/uikit/UserInteraction.ts | 122 +++++++ packages/core-typings/src/uikit/View.ts | 9 + packages/core-typings/src/uikit/index.ts | 17 + packages/core-typings/src/utils.ts | 2 + .../src/contexts/UiKitContext.ts | 11 +- .../src/elements/MarkdownTextElement.tsx | 5 +- .../src/elements/PlainTextElement.tsx | 5 +- .../src/extractInitialStateFromLayout.ts | 90 ++++++ .../src/hooks/useUiKitContext.ts | 5 - .../src/hooks/useUiKitState.ts | 81 +++-- .../src/hooks/useUiKitStateValue.ts | 18 -- packages/fuselage-ui-kit/src/index.ts | 1 + .../src/MockedAppRootBuilder.tsx | 11 +- packages/rest-typings/src/apps/index.ts | 12 +- .../ui-contexts/src/ActionManagerContext.ts | 57 +--- yarn.lock | 58 ++-- 80 files changed, 1531 insertions(+), 1299 deletions(-) delete mode 100644 apps/meteor/app/ui-message/client/ActionManager.js create mode 100644 apps/meteor/app/ui-message/client/ActionManager.ts create mode 100644 apps/meteor/app/ui-message/client/UiKitTriggerTimeoutError.ts delete mode 100644 apps/meteor/client/UIKit/hooks/useUIKitHandleAction.tsx delete mode 100644 apps/meteor/client/UIKit/hooks/useUIKitHandleClose.tsx delete mode 100644 apps/meteor/client/UIKit/hooks/useUIKitStateManager.tsx rename apps/meteor/client/{ => UIKit}/hooks/useUiKitActionManager.ts (100%) create mode 100644 apps/meteor/client/UIKit/hooks/useUiKitView.ts create mode 100644 apps/meteor/client/lib/utils/preventSyntheticEvent.ts create mode 100644 apps/meteor/client/polyfills/promiseFinally.ts delete mode 100644 apps/meteor/client/views/modal/uikit/hooks/useActionManagerState.ts delete mode 100644 apps/meteor/client/views/modal/uikit/hooks/useValues.ts delete mode 100644 packages/core-typings/src/UIKit.ts create mode 100644 packages/core-typings/src/uikit/BannerView.ts create mode 100644 packages/core-typings/src/uikit/ContextualBarView.ts create mode 100644 packages/core-typings/src/uikit/ModalView.ts create mode 100644 packages/core-typings/src/uikit/ServerInteraction.ts create mode 100644 packages/core-typings/src/uikit/UserInteraction.ts create mode 100644 packages/core-typings/src/uikit/View.ts create mode 100644 packages/core-typings/src/uikit/index.ts create mode 100644 packages/fuselage-ui-kit/src/extractInitialStateFromLayout.ts delete mode 100644 packages/fuselage-ui-kit/src/hooks/useUiKitContext.ts delete mode 100644 packages/fuselage-ui-kit/src/hooks/useUiKitStateValue.ts diff --git a/apps/meteor/app/apps/server/bridges/uiInteraction.ts b/apps/meteor/app/apps/server/bridges/uiInteraction.ts index b51c3be8ae3b..8e94f66e9617 100644 --- a/apps/meteor/app/apps/server/bridges/uiInteraction.ts +++ b/apps/meteor/app/apps/server/bridges/uiInteraction.ts @@ -1,11 +1,12 @@ import type { IUIKitInteraction } from '@rocket.chat/apps-engine/definition/uikit'; import type { IUser } from '@rocket.chat/apps-engine/definition/users'; -import { UiInteractionBridge as UiIntBridge } from '@rocket.chat/apps-engine/server/bridges/UiInteractionBridge'; +import { UiInteractionBridge as AppsEngineUiInteractionBridge } from '@rocket.chat/apps-engine/server/bridges/UiInteractionBridge'; import { api } from '@rocket.chat/core-services'; +import type { UiKit } from '@rocket.chat/core-typings'; import type { AppServerOrchestrator } from '../../../../ee/server/apps/orchestrator'; -export class UiInteractionBridge extends UiIntBridge { +export class UiInteractionBridge extends AppsEngineUiInteractionBridge { constructor(private readonly orch: AppServerOrchestrator) { super(); } @@ -19,6 +20,6 @@ export class UiInteractionBridge extends UiIntBridge { throw new Error('Invalid app provided'); } - void api.broadcast('notify.uiInteraction', user.id, interaction); + void api.broadcast('notify.uiInteraction', user.id, interaction as UiKit.ServerInteraction); } } diff --git a/apps/meteor/app/ui-message/client/ActionManager.js b/apps/meteor/app/ui-message/client/ActionManager.js deleted file mode 100644 index ebe9d1aed093..000000000000 --- a/apps/meteor/app/ui-message/client/ActionManager.js +++ /dev/null @@ -1,261 +0,0 @@ -import { UIKitIncomingInteractionType } from '@rocket.chat/apps-engine/definition/uikit'; -import { UIKitInteractionTypes } from '@rocket.chat/core-typings'; -import { Emitter } from '@rocket.chat/emitter'; -import { Random } from '@rocket.chat/random'; -import { lazy } from 'react'; - -import * as banners from '../../../client/lib/banners'; -import { imperativeModal } from '../../../client/lib/imperativeModal'; -import { dispatchToastMessage } from '../../../client/lib/toast'; -import { router } from '../../../client/providers/RouterProvider'; -import { sdk } from '../../utils/client/lib/SDKClient'; -import { t } from '../../utils/lib/i18n'; - -const UiKitModal = lazy(() => import('../../../client/views/modal/uikit/UiKitModal')); - -export const events = new Emitter(); - -export const on = (...args) => { - events.on(...args); -}; - -export const off = (...args) => { - events.off(...args); -}; - -const TRIGGER_TIMEOUT = 5000; - -const TRIGGER_TIMEOUT_ERROR = 'TRIGGER_TIMEOUT_ERROR'; - -const triggersId = new Map(); - -const instances = new Map(); - -const invalidateTriggerId = (id) => { - const appId = triggersId.get(id); - triggersId.delete(id); - return appId; -}; - -export const generateTriggerId = (appId) => { - const triggerId = Random.id(); - triggersId.set(triggerId, appId); - setTimeout(invalidateTriggerId, TRIGGER_TIMEOUT, triggerId); - return triggerId; -}; - -export const handlePayloadUserInteraction = (type, { /* appId,*/ triggerId, ...data }) => { - if (!triggersId.has(triggerId)) { - return; - } - const appId = invalidateTriggerId(triggerId); - if (!appId) { - return; - } - - const { view } = data; - let { viewId } = data; - - if (view && view.id) { - viewId = view.id; - } - - if (!viewId) { - return; - } - - if ([UIKitInteractionTypes.ERRORS].includes(type)) { - events.emit(viewId, { - type, - triggerId, - viewId, - appId, - ...data, - }); - return UIKitInteractionTypes.ERRORS; - } - - if ( - [UIKitInteractionTypes.BANNER_UPDATE, UIKitInteractionTypes.MODAL_UPDATE, UIKitInteractionTypes.CONTEXTUAL_BAR_UPDATE].includes(type) - ) { - events.emit(viewId, { - type, - triggerId, - viewId, - appId, - ...data, - }); - return type; - } - - if ([UIKitInteractionTypes.MODAL_OPEN].includes(type)) { - const instance = imperativeModal.open({ - component: UiKitModal, - props: { - triggerId, - viewId, - appId, - ...data, - }, - }); - - instances.set(viewId, { - close() { - instance.close(); - instances.delete(viewId); - }, - }); - - return UIKitInteractionTypes.MODAL_OPEN; - } - - if ([UIKitInteractionTypes.CONTEXTUAL_BAR_OPEN].includes(type)) { - instances.set(viewId, { - payload: { - type, - triggerId, - appId, - viewId, - ...data, - }, - close() { - instances.delete(viewId); - }, - }); - - router.navigate({ - name: router.getRouteName(), - params: { - ...router.getRouteParameters(), - tab: 'app', - context: viewId, - }, - }); - - return UIKitInteractionTypes.CONTEXTUAL_BAR_OPEN; - } - - if ([UIKitInteractionTypes.BANNER_OPEN].includes(type)) { - banners.open(data); - instances.set(viewId, { - close() { - banners.closeById(viewId); - }, - }); - return UIKitInteractionTypes.BANNER_OPEN; - } - - if ([UIKitIncomingInteractionType.BANNER_CLOSE].includes(type)) { - const instance = instances.get(viewId); - - if (instance) { - instance.close(); - } - return UIKitIncomingInteractionType.BANNER_CLOSE; - } - - if ([UIKitIncomingInteractionType.CONTEXTUAL_BAR_CLOSE].includes(type)) { - const instance = instances.get(viewId); - - if (instance) { - instance.close(); - } - return UIKitIncomingInteractionType.CONTEXTUAL_BAR_CLOSE; - } - - return UIKitInteractionTypes.MODAL_ClOSE; -}; - -export const triggerAction = async ({ type, actionId, appId, rid, mid, viewId, container, tmid, ...rest }) => - new Promise(async (resolve, reject) => { - events.emit('busy', { busy: true }); - - const triggerId = generateTriggerId(appId); - - const payload = rest.payload || rest; - - setTimeout(reject, TRIGGER_TIMEOUT, [TRIGGER_TIMEOUT_ERROR, { triggerId, appId }]); - - const { type: interactionType, ...data } = await (async () => { - try { - return await sdk.rest.post(`/apps/ui.interaction/${appId}`, { - type, - actionId, - payload, - container, - mid, - rid, - tmid, - triggerId, - viewId, - }); - } catch (e) { - reject(e); - return {}; - } finally { - events.emit('busy', { busy: false }); - } - })(); - - return resolve(handlePayloadUserInteraction(interactionType, data)); - }); - -export const triggerBlockAction = (options) => triggerAction({ type: UIKitIncomingInteractionType.BLOCK, ...options }); - -export const triggerActionButtonAction = (options) => - triggerAction({ type: UIKitIncomingInteractionType.ACTION_BUTTON, ...options }).catch(async (reason) => { - if (Array.isArray(reason) && reason[0] === TRIGGER_TIMEOUT_ERROR) { - dispatchToastMessage({ - type: 'error', - message: t('UIKit_Interaction_Timeout'), - }); - } - }); - -export const triggerSubmitView = async ({ viewId, ...options }) => { - const close = () => { - const instance = instances.get(viewId); - - if (instance) { - instance.close(); - } - }; - - try { - const result = await triggerAction({ - type: UIKitIncomingInteractionType.VIEW_SUBMIT, - viewId, - ...options, - }); - if (!result || UIKitInteractionTypes.MODAL_CLOSE === result) { - close(); - } - } catch { - close(); - } -}; - -export const triggerCancel = async ({ view, ...options }) => { - const instance = instances.get(view.id); - try { - await triggerAction({ type: UIKitIncomingInteractionType.VIEW_CLOSED, view, ...options }); - } finally { - if (instance) { - instance.close(); - } - } -}; - -export const getUserInteractionPayloadByViewId = (viewId) => { - if (!viewId) { - throw new Error('No viewId provided when checking for `user interaction payload`'); - } - - const instance = instances.get(viewId); - - if (!instance) { - return {}; - } - - return instance.payload; -}; diff --git a/apps/meteor/app/ui-message/client/ActionManager.ts b/apps/meteor/app/ui-message/client/ActionManager.ts new file mode 100644 index 000000000000..14650c3e12a0 --- /dev/null +++ b/apps/meteor/app/ui-message/client/ActionManager.ts @@ -0,0 +1,237 @@ +import type { DistributiveOmit, UiKit } from '@rocket.chat/core-typings'; +import { Emitter } from '@rocket.chat/emitter'; +import { Random } from '@rocket.chat/random'; +import type { ActionManagerContext, RouterContext } from '@rocket.chat/ui-contexts'; +import type { ContextType } from 'react'; +import { lazy } from 'react'; + +import * as banners from '../../../client/lib/banners'; +import { imperativeModal } from '../../../client/lib/imperativeModal'; +import { router } from '../../../client/providers/RouterProvider'; +import { sdk } from '../../utils/client/lib/SDKClient'; +import { UiKitTriggerTimeoutError } from './UiKitTriggerTimeoutError'; + +const UiKitModal = lazy(() => import('../../../client/views/modal/uikit/UiKitModal')); + +type ActionManagerType = Exclude, undefined>; + +export class ActionManager implements ActionManagerType { + protected static TRIGGER_TIMEOUT = 5000; + + protected static TRIGGER_TIMEOUT_ERROR = 'TRIGGER_TIMEOUT_ERROR'; + + protected events = new Emitter<{ busy: { busy: boolean }; [viewId: string]: any }>(); + + protected triggersId = new Map(); + + protected viewInstances = new Map< + string, + { + payload?: { + view: UiKit.ContextualBarView; + }; + close: () => void; + } + >(); + + public constructor(protected router: ContextType) {} + + protected invalidateTriggerId(id: string) { + const appId = this.triggersId.get(id); + this.triggersId.delete(id); + return appId; + } + + public on(viewId: string, listener: (data: any) => void): void; + + public on(eventName: 'busy', listener: ({ busy }: { busy: boolean }) => void): void; + + public on(eventName: string, listener: (data: any) => void) { + return this.events.on(eventName, listener); + } + + public off(viewId: string, listener: (data: any) => any): void; + + public off(eventName: 'busy', listener: ({ busy }: { busy: boolean }) => void): void; + + public off(eventName: string, listener: (data: any) => void) { + return this.events.off(eventName, listener); + } + + public generateTriggerId(appId: string | undefined) { + const triggerId = Random.id(); + this.triggersId.set(triggerId, appId); + setTimeout(() => this.invalidateTriggerId(triggerId), ActionManager.TRIGGER_TIMEOUT); + return triggerId; + } + + public async emitInteraction(appId: string, userInteraction: DistributiveOmit) { + this.events.emit('busy', { busy: true }); + + const triggerId = this.generateTriggerId(appId); + + let timeout: ReturnType | undefined; + + await Promise.race([ + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new UiKitTriggerTimeoutError('Timeout', { triggerId, appId })), ActionManager.TRIGGER_TIMEOUT); + }), + sdk.rest + .post(`/apps/ui.interaction/${appId}`, { + ...userInteraction, + triggerId, + }) + .then((interaction) => this.handleServerInteraction(interaction)), + ]).finally(() => { + if (timeout) clearTimeout(timeout); + this.events.emit('busy', { busy: false }); + }); + } + + public handleServerInteraction(interaction: UiKit.ServerInteraction) { + const { triggerId } = interaction; + + if (!this.triggersId.has(triggerId)) { + return; + } + + const appId = this.invalidateTriggerId(triggerId); + if (!appId) { + return; + } + + switch (interaction.type) { + case 'errors': { + const { type, triggerId, viewId, appId, errors } = interaction; + this.events.emit(interaction.viewId, { + type, + triggerId, + viewId, + appId, + errors, + }); + break; + } + + case 'modal.open': { + const { view } = interaction; + const instance = imperativeModal.open({ + component: UiKitModal, + props: { + key: view.id, + initialView: interaction.view, + }, + }); + + this.viewInstances.set(view.id, { + close: () => { + instance.close(); + this.viewInstances.delete(view.id); + }, + }); + break; + } + + case 'modal.update': + case 'contextual_bar.update': { + const { type, triggerId, appId, view } = interaction; + this.events.emit(view.id, { + type, + triggerId, + viewId: view.id, + appId, + view, + }); + break; + } + + case 'modal.close': { + break; + } + + case 'banner.open': { + const { type, triggerId, ...view } = interaction; + banners.open(view); + this.viewInstances.set(view.viewId, { + close: () => { + banners.closeById(view.viewId); + }, + }); + break; + } + + case 'banner.update': { + const { type, triggerId, appId, view } = interaction; + this.events.emit(view.viewId, { + type, + triggerId, + viewId: view.viewId, + appId, + view, + }); + break; + } + + case 'banner.close': { + const { viewId } = interaction; + this.viewInstances.get(viewId)?.close(); + + break; + } + + case 'contextual_bar.open': { + const { view } = interaction; + this.viewInstances.set(view.id, { + payload: { + view, + }, + close: () => { + this.viewInstances.delete(view.id); + }, + }); + + const routeName = this.router.getRouteName(); + const routeParams = this.router.getRouteParameters(); + + if (!routeName) { + break; + } + + this.router.navigate({ + name: routeName, + params: { + ...routeParams, + tab: 'app', + context: view.id, + }, + }); + break; + } + + case 'contextual_bar.close': { + const { view } = interaction; + this.viewInstances.get(view.id)?.close(); + break; + } + } + + return interaction.type; + } + + public getInteractionPayloadByViewId(viewId: UiKit.ContextualBarView['id']) { + if (!viewId) { + throw new Error('No viewId provided when checking for `user interaction payload`'); + } + + return this.viewInstances.get(viewId)?.payload; + } + + public disposeView(viewId: UiKit.ModalView['id'] | UiKit.BannerView['viewId'] | UiKit.ContextualBarView['id']) { + const instance = this.viewInstances.get(viewId); + instance?.close?.(); + this.viewInstances.delete(viewId); + } +} + +/** @deprecated consumer should use the context instead */ +export const actionManager = new ActionManager(router); diff --git a/apps/meteor/app/ui-message/client/UiKitTriggerTimeoutError.ts b/apps/meteor/app/ui-message/client/UiKitTriggerTimeoutError.ts new file mode 100644 index 000000000000..75b035d822a1 --- /dev/null +++ b/apps/meteor/app/ui-message/client/UiKitTriggerTimeoutError.ts @@ -0,0 +1,7 @@ +import { RocketChatError } from '../../../client/lib/errors/RocketChatError'; + +export class UiKitTriggerTimeoutError extends RocketChatError<'trigger-timeout'> { + constructor(message = 'Timeout', details: { triggerId: string; appId: string }) { + super('trigger-timeout', message, details); + } +} diff --git a/apps/meteor/app/ui/client/lib/ChatMessages.ts b/apps/meteor/app/ui/client/lib/ChatMessages.ts index 4a4b04f11833..4563bae81d52 100644 --- a/apps/meteor/app/ui/client/lib/ChatMessages.ts +++ b/apps/meteor/app/ui/client/lib/ChatMessages.ts @@ -18,7 +18,7 @@ import { setHighlightMessage, clearHighlightMessage, } from '../../../../client/views/room/MessageList/providers/messageHighlightSubscription'; -import * as ActionManager from '../../../ui-message/client/ActionManager'; +import { actionManager } from '../../../ui-message/client/ActionManager'; import { UserAction } from './UserAction'; type DeepWritable = T extends (...args: any) => any @@ -150,7 +150,7 @@ export class ChatMessages implements ChatAPI { this.uid = params.uid; this.data = createDataAPI({ rid, tmid }); this.uploads = createUploadsAPI({ rid, tmid }); - this.ActionManager = ActionManager; + this.ActionManager = actionManager; const unimplemented = () => { throw new Error('Flow is not implemented'); diff --git a/apps/meteor/client/UIKit/hooks/useUIKitHandleAction.tsx b/apps/meteor/client/UIKit/hooks/useUIKitHandleAction.tsx deleted file mode 100644 index 6a97f18a7936..000000000000 --- a/apps/meteor/client/UIKit/hooks/useUIKitHandleAction.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { UIKitIncomingInteractionContainerType } from '@rocket.chat/apps-engine/definition/uikit/UIKitIncomingInteractionContainer'; -import type { UiKitPayload, UIKitActionEvent } from '@rocket.chat/core-typings'; -import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; - -import { useUiKitActionManager } from '../../hooks/useUiKitActionManager'; - -const useUIKitHandleAction = (state: S): ((event: UIKitActionEvent) => Promise) => { - const actionManager = useUiKitActionManager(); - return useMutableCallback(async ({ blockId, value, appId, actionId }) => { - if (!appId) { - throw new Error('useUIKitHandleAction - invalid appId'); - } - return actionManager.triggerBlockAction({ - container: { - type: UIKitIncomingInteractionContainerType.VIEW, - id: state.viewId || state.appId, - }, - actionId, - appId, - value, - blockId, - }); - }); -}; - -export { useUIKitHandleAction }; diff --git a/apps/meteor/client/UIKit/hooks/useUIKitHandleClose.tsx b/apps/meteor/client/UIKit/hooks/useUIKitHandleClose.tsx deleted file mode 100644 index 672e1b311b5d..000000000000 --- a/apps/meteor/client/UIKit/hooks/useUIKitHandleClose.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import type { UIKitInteractionType } from '@rocket.chat/apps-engine/definition/uikit'; -import type { UiKitPayload } from '@rocket.chat/core-typings'; -import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { useToastMessageDispatch } from '@rocket.chat/ui-contexts'; - -import { useUiKitActionManager } from '../../hooks/useUiKitActionManager'; - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const emptyFn = (_error: any, _result: UIKitInteractionType | void): void => undefined; - -const useUIKitHandleClose = (state: S, fn = emptyFn): (() => Promise) => { - const actionManager = useUiKitActionManager(); - const dispatchToastMessage = useToastMessageDispatch(); - return useMutableCallback(() => - actionManager - .triggerCancel({ - appId: state.appId, - viewId: state.viewId, - view: { - ...state, - id: state.viewId, - }, - isCleared: true, - }) - .then((result) => fn(undefined, result)) - .catch((error) => { - dispatchToastMessage({ type: 'error', message: error }); - fn(error, undefined); - return Promise.reject(error); - }), - ); -}; - -export { useUIKitHandleClose }; diff --git a/apps/meteor/client/UIKit/hooks/useUIKitStateManager.tsx b/apps/meteor/client/UIKit/hooks/useUIKitStateManager.tsx deleted file mode 100644 index 26b329f2ea60..000000000000 --- a/apps/meteor/client/UIKit/hooks/useUIKitStateManager.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import type { UIKitUserInteractionResult, UiKitPayload } from '@rocket.chat/core-typings'; -import { isErrorType } from '@rocket.chat/core-typings'; -import { useSafely } from '@rocket.chat/fuselage-hooks'; -import { useEffect, useState } from 'react'; - -import { useUiKitActionManager } from '../../hooks/useUiKitActionManager'; - -const useUIKitStateManager = (initialState: S): S => { - const actionManager = useUiKitActionManager(); - const [state, setState] = useSafely(useState(initialState)); - - const { viewId } = state; - - useEffect(() => { - const handleUpdate = ({ ...data }: UIKitUserInteractionResult): void => { - if (isErrorType(data)) { - const { errors } = data; - setState((state) => ({ ...state, errors })); - return; - } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { type, ...rest } = data; - setState(rest as any); - }; - - actionManager.on(viewId, handleUpdate); - - return (): void => { - actionManager.off(viewId, handleUpdate); - }; - }, [setState, viewId]); - - return state; -}; - -export { useUIKitStateManager }; diff --git a/apps/meteor/client/hooks/useUiKitActionManager.ts b/apps/meteor/client/UIKit/hooks/useUiKitActionManager.ts similarity index 100% rename from apps/meteor/client/hooks/useUiKitActionManager.ts rename to apps/meteor/client/UIKit/hooks/useUiKitActionManager.ts diff --git a/apps/meteor/client/UIKit/hooks/useUiKitView.ts b/apps/meteor/client/UIKit/hooks/useUiKitView.ts new file mode 100644 index 000000000000..2d0d1512bc17 --- /dev/null +++ b/apps/meteor/client/UIKit/hooks/useUiKitView.ts @@ -0,0 +1,93 @@ +import type { UiKit } from '@rocket.chat/core-typings'; +import { useSafely } from '@rocket.chat/fuselage-hooks'; +import { extractInitialStateFromLayout } from '@rocket.chat/fuselage-ui-kit'; +import type { Dispatch } from 'react'; +import { useEffect, useMemo, useReducer, useState } from 'react'; + +import { useUiKitActionManager } from './useUiKitActionManager'; + +const reduceValues = ( + values: { [actionId: string]: { value: unknown; blockId?: string } }, + { actionId, payload }: { actionId: string; payload: { value: unknown; blockId?: string } }, +): { [actionId: string]: { value: unknown; blockId?: string } } => ({ + ...values, + [actionId]: payload, +}); + +const getViewId = (view: UiKit.View): string => { + if ('id' in view && typeof view.id === 'string') { + return view.id; + } + + if ('viewId' in view && typeof view.viewId === 'string') { + return view.viewId; + } + + throw new Error('Invalid view'); +}; + +const getViewFromInteraction = (interaction: UiKit.ServerInteraction): UiKit.View | undefined => { + if ('view' in interaction && typeof interaction.view === 'object') { + return interaction.view; + } + + if (interaction.type === 'banner.open') { + return interaction; + } + + return undefined; +}; + +type UseUiKitViewReturnType = { + view: TView; + errors?: { [field: string]: string }[]; + values: { [actionId: string]: { value: unknown; blockId?: string } }; + updateValues: Dispatch<{ actionId: string; payload: { value: unknown; blockId?: string } }>; + state: { + [blockId: string]: { + [key: string]: unknown; + }; + }; +}; + +export function useUiKitView(initialView: S): UseUiKitViewReturnType { + const [errors, setErrors] = useSafely(useState<{ [field: string]: string }[] | undefined>()); + const [values, updateValues] = useSafely(useReducer(reduceValues, initialView.blocks, extractInitialStateFromLayout)); + const [view, updateView] = useSafely(useState(initialView)); + const actionManager = useUiKitActionManager(); + + const state = useMemo(() => { + return Object.entries(values).reduce<{ [blockId: string]: { [actionId: string]: unknown } }>((obj, [key, payload]) => { + if (!payload?.blockId) { + return obj; + } + + const { blockId, value } = payload; + obj[blockId] = obj[blockId] || {}; + obj[blockId][key] = value; + + return obj; + }, {}); + }, [values]); + + const viewId = getViewId(view); + + useEffect(() => { + const handleUpdate = (interaction: UiKit.ServerInteraction): void => { + if (interaction.type === 'errors') { + setErrors(interaction.errors); + return; + } + + updateView((view) => ({ ...view, ...getViewFromInteraction(interaction) })); + }; + + actionManager.on(viewId, handleUpdate); + + return (): void => { + actionManager.off(viewId, handleUpdate); + }; + }, [actionManager, setErrors, updateView, viewId]); + + return { view, errors, values, updateValues, state }; +} diff --git a/apps/meteor/client/components/ActionManagerBusyState.tsx b/apps/meteor/client/components/ActionManagerBusyState.tsx index 0374254a7de9..033b200a2aa7 100644 --- a/apps/meteor/client/components/ActionManagerBusyState.tsx +++ b/apps/meteor/client/components/ActionManagerBusyState.tsx @@ -3,7 +3,7 @@ import { Box } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; import React, { useEffect, useState } from 'react'; -import { useUiKitActionManager } from '../hooks/useUiKitActionManager'; +import { useUiKitActionManager } from '../UIKit/hooks/useUiKitActionManager'; const ActionManagerBusyState = () => { const t = useTranslation(); @@ -15,10 +15,12 @@ const ActionManagerBusyState = () => { return; } - actionManager.on('busy', ({ busy }: { busy: boolean }) => setBusy(busy)); + const handleBusyStateChange = ({ busy }: { busy: boolean }) => setBusy(busy); + + actionManager.on('busy', handleBusyStateChange); return () => { - actionManager.off('busy'); + actionManager.off('busy', handleBusyStateChange); }; }, [actionManager]); diff --git a/apps/meteor/client/components/message/uikit/UiKitMessageBlock.tsx b/apps/meteor/client/components/message/uikit/UiKitMessageBlock.tsx index d59314ae8198..6d86e724b95f 100644 --- a/apps/meteor/client/components/message/uikit/UiKitMessageBlock.tsx +++ b/apps/meteor/client/components/message/uikit/UiKitMessageBlock.tsx @@ -1,12 +1,12 @@ -import { UIKitIncomingInteractionContainerType } from '@rocket.chat/apps-engine/definition/uikit/UIKitIncomingInteractionContainer'; import type { IMessage, IRoom } from '@rocket.chat/core-typings'; import { MessageBlock } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { UiKitComponent, UiKitMessage as UiKitMessageSurfaceRender, UiKitContext } from '@rocket.chat/fuselage-ui-kit'; import type { MessageSurfaceLayout } from '@rocket.chat/ui-kit'; import type { ContextType, ReactElement } from 'react'; -import React from 'react'; +import React, { useMemo } from 'react'; +import { useUiKitActionManager } from '../../../UIKit/hooks/useUiKitActionManager'; import { useVideoConfDispatchOutgoing, useVideoConfIsCalling, @@ -15,27 +15,16 @@ import { useVideoConfManager, useVideoConfSetPreferences, } from '../../../contexts/VideoConfContext'; -import { useUiKitActionManager } from '../../../hooks/useUiKitActionManager'; import { useVideoConfWarning } from '../../../views/room/contextualBar/VideoConference/hooks/useVideoConfWarning'; import GazzodownText from '../../GazzodownText'; -let patched = false; -const patchMessageParser = () => { - if (patched) { - return; - } - - patched = true; -}; - type UiKitMessageBlockProps = { + rid: IRoom['_id']; mid: IMessage['_id']; blocks: MessageSurfaceLayout; - rid: IRoom['_id']; - appId?: string | boolean; // TODO: this is a hack while the context value is not properly typed }; -const UiKitMessageBlock = ({ mid: _mid, blocks, rid, appId }: UiKitMessageBlockProps): ReactElement => { +const UiKitMessageBlock = ({ rid, mid, blocks }: UiKitMessageBlockProps): ReactElement => { const joinCall = useVideoConfJoinCall(); const setPreferences = useVideoConfSetPreferences(); const isCalling = useVideoConfIsCalling(); @@ -61,44 +50,47 @@ const UiKitMessageBlock = ({ mid: _mid, blocks, rid, appId }: UiKitMessageBlockP const actionManager = useUiKitActionManager(); // TODO: this structure is attrociously wrong; we should revisit this - const context: ContextType = { - // @ts-ignore Property 'mid' does not exist on type 'ActionParams'. - action: ({ actionId, value, blockId, mid = _mid, appId }, event) => { - if (appId === 'videoconf-core') { - event.preventDefault(); - setPreferences({ mic: true, cam: false }); - if (actionId === 'join') { - return joinCall(blockId); - } + const contextValue = useMemo( + (): ContextType => ({ + action: ({ appId, actionId, blockId, value }, event) => { + if (appId === 'videoconf-core') { + event.preventDefault(); + setPreferences({ mic: true, cam: false }); + if (actionId === 'join') { + return joinCall(blockId); + } - if (actionId === 'callBack') { - return handleOpenVideoConf(blockId); + if (actionId === 'callBack') { + return handleOpenVideoConf(blockId); + } } - } - actionManager?.triggerBlockAction({ - blockId, - actionId, - value, - mid, - rid, - appId, - container: { - type: UIKitIncomingInteractionContainerType.MESSAGE, - id: mid, - }, - }); - }, - // @ts-ignore Type 'string | boolean | undefined' is not assignable to type 'string'. - appId, - rid, - }; - - patchMessageParser(); // TODO: this is a hack + actionManager.emitInteraction(appId, { + type: 'blockAction', + actionId, + payload: { + blockId, + value, + }, + container: { + type: 'message', + id: mid, + }, + rid, + mid, + }); + }, + appId: '', // TODO: this is a hack + rid, + state: () => undefined, // TODO: this is a hack + values: {}, // TODO: this is a hack + }), + [actionManager, handleOpenVideoConf, joinCall, mid, rid, setPreferences], + ); return ( - + diff --git a/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx b/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx index 2b54588c6263..b22627bea8d2 100644 --- a/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx +++ b/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx @@ -61,7 +61,7 @@ const RoomMessageContent = ({ message, unread, all, mention, searchText }: RoomM )} {normalizedMessage.blocks && ( - + )} {!!normalizedMessage?.attachments?.length && } diff --git a/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx b/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx index 655f96639929..57835ec75e0c 100644 --- a/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx +++ b/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx @@ -49,7 +49,7 @@ const ThreadMessageContent = ({ message }: ThreadMessageContentProps): ReactElem )} {normalizedMessage.blocks && ( - + )} {normalizedMessage.attachments && } diff --git a/apps/meteor/client/hooks/useAppActionButtons.ts b/apps/meteor/client/hooks/useAppActionButtons.ts index 28d62ef1b75a..d039b2bd7c71 100644 --- a/apps/meteor/client/hooks/useAppActionButtons.ts +++ b/apps/meteor/client/hooks/useAppActionButtons.ts @@ -1,20 +1,22 @@ import type { IUIActionButton, UIActionButtonContext } from '@rocket.chat/apps-engine/definition/ui'; import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks'; -import { useEndpoint, useSingleStream, useUserId } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useSingleStream, useToastMessageDispatch, useUserId } from '@rocket.chat/ui-contexts'; import type { UseQueryResult } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { UiKitTriggerTimeoutError } from '../../app/ui-message/client/UiKitTriggerTimeoutError'; import type { MessageActionConfig, MessageActionContext } from '../../app/ui-utils/client/lib/MessageAction'; import type { MessageBoxAction } from '../../app/ui-utils/client/lib/messageBox'; import { Utilities } from '../../ee/lib/misc/Utilities'; +import { useUiKitActionManager } from '../UIKit/hooks/useUiKitActionManager'; import type { GenericMenuItemProps } from '../components/GenericMenu/GenericMenuItem'; import { useApplyButtonFilters, useApplyButtonAuthFilter } from './useApplyButtonFilters'; -import { useUiKitActionManager } from './useUiKitActionManager'; const getIdForActionButton = ({ appId, actionId }: IUIActionButton): string => `${appId}/${actionId}`; -export const useAppActionButtons = (context?: `${UIActionButtonContext}`) => { +export const useAppActionButtons = (context?: TContext) => { const queryClient = useQueryClient(); const apps = useSingleStream('apps'); @@ -24,7 +26,14 @@ export const useAppActionButtons = (context?: `${UIActionButtonContext}`) => { const result = useQuery(['apps', 'actionButtons'], () => getActionButtons(), { ...(context && { - select: (data) => data.filter((button) => button.context === context), + select: (data) => + data.filter( + ( + button, + ): button is IUIActionButton & { + context: UIActionButtonContext extends infer X ? (X extends TContext ? X : never) : never; + } => button.context === context, + ), }), staleTime: Infinity, }); @@ -55,6 +64,8 @@ export const useAppActionButtons = (context?: `${UIActionButtonContext}`) => { export const useMessageboxAppsActionButtons = () => { const result = useAppActionButtons('messageBoxAction'); const actionManager = useUiKitActionManager(); + const dispatchToastMessage = useToastMessageDispatch(); + const { t } = useTranslation(); const applyButtonFilters = useApplyButtonFilters(); @@ -69,19 +80,31 @@ export const useMessageboxAppsActionButtons = () => { id: getIdForActionButton(action), label: Utilities.getI18nKeyForApp(action.labelI18n, action.appId), action: (params) => { - void actionManager.triggerActionButtonAction({ - rid: params.rid, - tmid: params.tmid, - actionId: action.actionId, - appId: action.appId, - payload: { context: action.context, message: params.chat.composer?.text }, - }); + void actionManager + .emitInteraction(action.appId, { + type: 'actionButton', + rid: params.rid, + tmid: params.tmid, + actionId: action.actionId, + payload: { context: action.context, message: params.chat.composer?.text ?? '' }, + }) + .catch(async (reason) => { + if (reason instanceof UiKitTriggerTimeoutError) { + dispatchToastMessage({ + type: 'error', + message: t('UIKit_Interaction_Timeout'), + }); + return; + } + + return reason; + }); }, }; return item; }), - [actionManager, applyButtonFilters, result.data], + [actionManager, applyButtonFilters, dispatchToastMessage, result.data, t], ); return { ...result, @@ -92,6 +115,8 @@ export const useMessageboxAppsActionButtons = () => { export const useUserDropdownAppsActionButtons = () => { const result = useAppActionButtons('userDropdownAction'); const actionManager = useUiKitActionManager(); + const dispatchToastMessage = useToastMessageDispatch(); + const { t } = useTranslation(); const applyButtonFilters = useApplyButtonAuthFilter(); @@ -107,15 +132,27 @@ export const useUserDropdownAppsActionButtons = () => { // icon: action.icon as GenericMenuItemProps['icon'], content: action.labelI18n, onClick: () => { - actionManager.triggerActionButtonAction({ - actionId: action.actionId, - appId: action.appId, - payload: { context: action.context }, - }); + void actionManager + .emitInteraction(action.appId, { + type: 'actionButton', + actionId: action.actionId, + payload: { context: action.context }, + }) + .catch(async (reason) => { + if (reason instanceof UiKitTriggerTimeoutError) { + dispatchToastMessage({ + type: 'error', + message: t('UIKit_Interaction_Timeout'), + }); + return; + } + + return reason; + }); }, }; }), - [actionManager, applyButtonFilters, result.data], + [actionManager, applyButtonFilters, dispatchToastMessage, result.data, t], ); return { ...result, @@ -127,6 +164,8 @@ export const useMessageActionAppsActionButtons = (context?: MessageActionContext const result = useAppActionButtons('messageAction'); const actionManager = useUiKitActionManager(); const applyButtonFilters = useApplyButtonFilters(); + const dispatchToastMessage = useToastMessageDispatch(); + const { t } = useTranslation(); const data = useMemo( () => result.data @@ -148,20 +187,32 @@ export const useMessageActionAppsActionButtons = (context?: MessageActionContext type: 'apps', variant: action.variant, action: (_, params) => { - void actionManager.triggerActionButtonAction({ - rid: params.message.rid, - tmid: params.message.tmid, - mid: params.message._id, - actionId: action.actionId, - appId: action.appId, - payload: { context: action.context }, - }); + void actionManager + .emitInteraction(action.appId, { + type: 'actionButton', + rid: params.message.rid, + tmid: params.message.tmid, + mid: params.message._id, + actionId: action.actionId, + payload: { context: action.context }, + }) + .catch(async (reason) => { + if (reason instanceof UiKitTriggerTimeoutError) { + dispatchToastMessage({ + type: 'error', + message: t('UIKit_Interaction_Timeout'), + }); + return; + } + + return reason; + }); }, }; return item; }), - [actionManager, applyButtonFilters, context, result.data], + [actionManager, applyButtonFilters, context, dispatchToastMessage, result.data, t], ); return { ...result, diff --git a/apps/meteor/client/hooks/useAppUiKitInteraction.ts b/apps/meteor/client/hooks/useAppUiKitInteraction.ts index 84849f592a48..e620d34a141d 100644 --- a/apps/meteor/client/hooks/useAppUiKitInteraction.ts +++ b/apps/meteor/client/hooks/useAppUiKitInteraction.ts @@ -1,16 +1,8 @@ -import type { UIKitInteractionType } from '@rocket.chat/apps-engine/definition/uikit'; +import type { UiKit } from '@rocket.chat/core-typings'; import { useStream, useUserId } from '@rocket.chat/ui-contexts'; import { useEffect } from 'react'; -export const useAppUiKitInteraction = ( - handlePayloadUserInteraction: ( - type: UIKitInteractionType, - data: { - triggerId: string; - appId: string; - }, - ) => void, -) => { +export const useAppUiKitInteraction = (handleServerInteraction: (interaction: UiKit.ServerInteraction) => void) => { const notifyUser = useStream('notify-user'); const uid = useUserId(); @@ -19,8 +11,9 @@ export const useAppUiKitInteraction = ( return; } - return notifyUser(`${uid}/uiInteraction`, ({ type, ...data }) => { - handlePayloadUserInteraction(type, data); + return notifyUser(`${uid}/uiInteraction`, (interaction) => { + // @ts-ignore + handleServerInteraction(interaction); }); - }, [notifyUser, uid, handlePayloadUserInteraction]); + }, [notifyUser, uid, handleServerInteraction]); }; diff --git a/apps/meteor/client/lib/banners.ts b/apps/meteor/client/lib/banners.ts index 89310da2e3c7..91185450a21a 100644 --- a/apps/meteor/client/lib/banners.ts +++ b/apps/meteor/client/lib/banners.ts @@ -1,4 +1,4 @@ -import type { UiKitBannerPayload } from '@rocket.chat/core-typings'; +import type { UiKit } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import type { Keys as IconName } from '@rocket.chat/icons'; @@ -15,7 +15,7 @@ export type LegacyBannerPayload = { onClose?: () => Promise | void; }; -type BannerPayload = LegacyBannerPayload | UiKitBannerPayload; +type BannerPayload = LegacyBannerPayload | UiKit.BannerView; export const isLegacyPayload = (payload: BannerPayload): payload is LegacyBannerPayload => !('blocks' in payload); @@ -35,7 +35,7 @@ export const open = (payload: BannerPayload): void => { if (isLegacyPayload(_payload)) { return _payload.id === (payload as LegacyBannerPayload).id; } - return (_payload as UiKitBannerPayload).viewId === (payload as UiKitBannerPayload).viewId; + return _payload.viewId === (payload as UiKit.BannerView).viewId; }); if (index === -1) { diff --git a/apps/meteor/client/lib/chats/flows/processSlashCommand.ts b/apps/meteor/client/lib/chats/flows/processSlashCommand.ts index 1551f8eb1f57..c9922162a67c 100644 --- a/apps/meteor/client/lib/chats/flows/processSlashCommand.ts +++ b/apps/meteor/client/lib/chats/flows/processSlashCommand.ts @@ -4,7 +4,7 @@ import { escapeHTML } from '@rocket.chat/string-helpers'; import { hasAtLeastOnePermission } from '../../../../app/authorization/client'; import { settings } from '../../../../app/settings/client'; -import { generateTriggerId } from '../../../../app/ui-message/client/ActionManager'; +import { actionManager } from '../../../../app/ui-message/client/ActionManager'; import { slashCommands } from '../../../../app/utils/client'; import { sdk } from '../../../../app/utils/client/lib/SDKClient'; import { t } from '../../../../app/utils/lib/i18n'; @@ -78,7 +78,7 @@ export const processSlashCommand = async (chat: ChatAPI, message: IMessage): Pro params: [{ eventName: 'slashCommandsStats', timestamp: Date.now(), command: commandName }], }); - const triggerId = generateTriggerId(appId); + const triggerId = actionManager.generateTriggerId(appId); const data = { cmd: commandName, diff --git a/apps/meteor/client/lib/utils/preventSyntheticEvent.ts b/apps/meteor/client/lib/utils/preventSyntheticEvent.ts new file mode 100644 index 000000000000..773b53a1a88c --- /dev/null +++ b/apps/meteor/client/lib/utils/preventSyntheticEvent.ts @@ -0,0 +1,9 @@ +import type { SyntheticEvent } from 'react'; + +export const preventSyntheticEvent = (e: SyntheticEvent): void => { + if (e) { + (e.nativeEvent || e).stopImmediatePropagation(); + e.stopPropagation(); + e.preventDefault(); + } +}; diff --git a/apps/meteor/client/polyfills/index.ts b/apps/meteor/client/polyfills/index.ts index f07d828a4602..bc91265b04ba 100644 --- a/apps/meteor/client/polyfills/index.ts +++ b/apps/meteor/client/polyfills/index.ts @@ -4,3 +4,4 @@ import './childNodeRemove'; import './cssVars'; import './customEventPolyfill'; import './hoverTouchClick'; +import './promiseFinally'; diff --git a/apps/meteor/client/polyfills/promiseFinally.ts b/apps/meteor/client/polyfills/promiseFinally.ts new file mode 100644 index 000000000000..ab826c2bd0ba --- /dev/null +++ b/apps/meteor/client/polyfills/promiseFinally.ts @@ -0,0 +1,16 @@ +if (!Promise.prototype.finally) { + // eslint-disable-next-line no-extend-native + Promise.prototype.finally = function (callback) { + if (typeof callback !== 'function') { + return this.then(callback, callback); + } + const P = (this.constructor as PromiseConstructor) || Promise; + return this.then( + (value) => P.resolve(callback()).then(() => value), + (err) => + P.resolve(callback()).then(() => { + throw err; + }), + ); + }; +} diff --git a/apps/meteor/client/providers/ActionManagerProvider.tsx b/apps/meteor/client/providers/ActionManagerProvider.tsx index 8faa55260f13..e8961ec357e9 100644 --- a/apps/meteor/client/providers/ActionManagerProvider.tsx +++ b/apps/meteor/client/providers/ActionManagerProvider.tsx @@ -2,7 +2,7 @@ import { ActionManagerContext } from '@rocket.chat/ui-contexts'; import type { ReactNode, ReactElement } from 'react'; import React from 'react'; -import * as ActionManager from '../../app/ui-message/client/ActionManager'; +import { actionManager } from '../../app/ui-message/client/ActionManager'; import { useAppActionButtons } from '../hooks/useAppActionButtons'; import { useAppSlashCommands } from '../hooks/useAppSlashCommands'; import { useAppTranslations } from '../hooks/useAppTranslations'; @@ -16,9 +16,9 @@ const ActionManagerProvider = ({ children }: ActionManagerProviderProps): ReactE useAppTranslations(); useAppActionButtons(); useAppSlashCommands(); - useAppUiKitInteraction(ActionManager.handlePayloadUserInteraction); + useAppUiKitInteraction(actionManager.handleServerInteraction.bind(actionManager)); - return {children}; + return {children}; }; export default ActionManagerProvider; diff --git a/apps/meteor/client/views/admin/moderation/helpers/ContextMessage.tsx b/apps/meteor/client/views/admin/moderation/helpers/ContextMessage.tsx index 114cde52c1d2..500ae78a6d26 100644 --- a/apps/meteor/client/views/admin/moderation/helpers/ContextMessage.tsx +++ b/apps/meteor/client/views/admin/moderation/helpers/ContextMessage.tsx @@ -75,7 +75,7 @@ const ContextMessage = ({ ) : ( message.msg )} - {message.blocks && } + {message.blocks && } {message.attachments && } diff --git a/apps/meteor/client/views/banners/BannerRegion.tsx b/apps/meteor/client/views/banners/BannerRegion.tsx index c5394f787229..b79c156db842 100644 --- a/apps/meteor/client/views/banners/BannerRegion.tsx +++ b/apps/meteor/client/views/banners/BannerRegion.tsx @@ -22,7 +22,7 @@ const BannerRegion = (): ReactElement | null => { return ; } - return ; + return ; }; export default BannerRegion; diff --git a/apps/meteor/client/views/banners/UiKitBanner.tsx b/apps/meteor/client/views/banners/UiKitBanner.tsx index 7cb52dd8d3c9..64a602d548dc 100644 --- a/apps/meteor/client/views/banners/UiKitBanner.tsx +++ b/apps/meteor/client/views/banners/UiKitBanner.tsx @@ -1,55 +1,93 @@ -import type { UIKitActionEvent, UiKitBannerProps } from '@rocket.chat/core-typings'; +import type { UiKit } from '@rocket.chat/core-typings'; import { Banner, Icon } from '@rocket.chat/fuselage'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { UiKitContext, bannerParser, UiKitBanner as UiKitBannerSurfaceRender, UiKitComponent } from '@rocket.chat/fuselage-ui-kit'; -import type { Keys as IconName } from '@rocket.chat/icons'; -import type { LayoutBlock } from '@rocket.chat/ui-kit'; -import type { FC, ReactElement, ContextType } from 'react'; +import { useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import type { ReactElement, ContextType } from 'react'; import React, { useMemo } from 'react'; -import { useUIKitHandleAction } from '../../UIKit/hooks/useUIKitHandleAction'; -import { useUIKitHandleClose } from '../../UIKit/hooks/useUIKitHandleClose'; -import { useUIKitStateManager } from '../../UIKit/hooks/useUIKitStateManager'; +import { useUiKitActionManager } from '../../UIKit/hooks/useUiKitActionManager'; +import { useUiKitView } from '../../UIKit/hooks/useUiKitView'; import MarkdownText from '../../components/MarkdownText'; -import * as banners from '../../lib/banners'; // TODO: move this to fuselage-ui-kit itself bannerParser.mrkdwn = ({ text }): ReactElement => ; -const UiKitBanner: FC = ({ payload }) => { - const state = useUIKitStateManager(payload); +type UiKitBannerProps = { + key: UiKit.BannerView['viewId']; // force re-mount when viewId changes + initialView: UiKit.BannerView; +}; + +const UiKitBanner = ({ initialView }: UiKitBannerProps) => { + const { view, values, state } = useUiKitView(initialView); const icon = useMemo(() => { - if (state.icon) { - return ; + if (view.icon) { + return ; } return null; - }, [state.icon]); + }, [view.icon]); - const handleClose = useUIKitHandleClose(state, () => banners.close()); + const dispatchToastMessage = useToastMessageDispatch(); + const handleClose = useMutableCallback(() => { + void actionManager + .emitInteraction(view.appId, { + type: 'viewClosed', + payload: { + viewId: view.viewId, + view: { + ...view, + id: view.viewId, + state, + }, + isCleared: true, + }, + }) + .catch((error) => { + dispatchToastMessage({ type: 'error', message: error }); + return Promise.reject(error); + }) + .finally(() => { + actionManager.disposeView(view.viewId); + }); + }); - const action = useUIKitHandleAction(state); + const actionManager = useUiKitActionManager(); - const contextValue = useMemo>( - () => ({ - action: async (event): Promise => { - if (!event.viewId) { + const contextValue = useMemo( + (): ContextType => ({ + action: async ({ appId, viewId, actionId, blockId, value }) => { + if (!appId || !viewId) { return; } - await action(event as UIKitActionEvent); - banners.closeById(state.viewId); + + await actionManager.emitInteraction(appId, { + type: 'blockAction', + actionId, + container: { + type: 'view', + id: viewId, + }, + payload: { + blockId, + value, + }, + }); + + actionManager.disposeView(view.viewId); }, state: (): void => undefined, - appId: state.appId, - values: {}, + appId: view.appId, + values: values as any, }), - [action, state.appId, state.viewId], + [view, values, actionManager], ); return ( - + - + ); diff --git a/apps/meteor/client/views/banners/hooks/useRemoteBanners.ts b/apps/meteor/client/views/banners/hooks/useRemoteBanners.ts index ff42d4ae9ace..ebed89e06037 100644 --- a/apps/meteor/client/views/banners/hooks/useRemoteBanners.ts +++ b/apps/meteor/client/views/banners/hooks/useRemoteBanners.ts @@ -1,5 +1,5 @@ import { BannerPlatform } from '@rocket.chat/core-typings'; -import type { IBanner, Serialized, UiKitBannerPayload } from '@rocket.chat/core-typings'; +import type { IBanner, Serialized, UiKit } from '@rocket.chat/core-typings'; import { useEndpoint, useStream, useUserId, ServerContext } from '@rocket.chat/ui-contexts'; import { useContext, useEffect } from 'react'; @@ -22,7 +22,7 @@ export const useRemoteBanners = () => { const { signal } = controller; - const mapBanner = (banner: Serialized): UiKitBannerPayload => ({ + const mapBanner = (banner: Serialized): UiKit.BannerView => ({ ...banner.view, viewId: banner.view.viewId || banner._id, }); diff --git a/apps/meteor/client/views/modal/uikit/ModalBlock.tsx b/apps/meteor/client/views/modal/uikit/ModalBlock.tsx index 7993355206e7..bd0876fc49ad 100644 --- a/apps/meteor/client/views/modal/uikit/ModalBlock.tsx +++ b/apps/meteor/client/views/modal/uikit/ModalBlock.tsx @@ -1,4 +1,4 @@ -import type { IUIKitSurface } from '@rocket.chat/apps-engine/definition/uikit'; +import type { UiKit } from '@rocket.chat/core-typings'; import { Modal, AnimatedVisibility, Button, Box } from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; import { UiKitComponent, UiKitModal, modalParser } from '@rocket.chat/fuselage-ui-kit'; @@ -38,7 +38,7 @@ const focusableElementsStringInvalid = ` [contenteditable]:invalid`; type ModalBlockParams = { - view: IUIKitSurface & { showIcon?: boolean }; + view: UiKit.ModalView; errors: any; appId: string; onSubmit: FormEventHandler; @@ -55,7 +55,7 @@ const KeyboardCode = new Map([ ['TAB', 9], ]); -const ModalBlock = ({ view, errors, appId, onSubmit, onClose, onCancel }: ModalBlockParams): ReactElement => { +const ModalBlock = ({ view, errors, onSubmit, onClose, onCancel }: ModalBlockParams): ReactElement => { const id = `modal_id_${useUniqueId()}`; const ref = useRef(null); @@ -165,7 +165,7 @@ const ModalBlock = ({ view, errors, appId, onSubmit, onClose, onCancel }: ModalB - {view.showIcon ? : null} + {view.showIcon ? : null} {modalParser.text(view.title, BlockContext.NONE, 0)} @@ -182,7 +182,7 @@ const ModalBlock = ({ view, errors, appId, onSubmit, onClose, onCancel }: ModalB )} {view.submit && ( - )} diff --git a/apps/meteor/client/views/modal/uikit/UiKitModal.tsx b/apps/meteor/client/views/modal/uikit/UiKitModal.tsx index b985f94b09b9..52aaa49ed009 100644 --- a/apps/meteor/client/views/modal/uikit/UiKitModal.tsx +++ b/apps/meteor/client/views/modal/uikit/UiKitModal.tsx @@ -1,139 +1,130 @@ -import { UIKitIncomingInteractionContainerType } from '@rocket.chat/apps-engine/definition/uikit/UIKitIncomingInteractionContainer'; +import type { UiKit } from '@rocket.chat/core-typings'; import { useDebouncedCallback, useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { UiKitContext } from '@rocket.chat/fuselage-ui-kit'; import { MarkupInteractionContext } from '@rocket.chat/gazzodown'; -import type { LayoutBlock } from '@rocket.chat/ui-kit'; -import type { ContextType, ReactElement, ReactEventHandler } from 'react'; -import React from 'react'; +import type { ContextType, FormEvent } from 'react'; +import React, { useMemo } from 'react'; -import { useUiKitActionManager } from '../../../hooks/useUiKitActionManager'; +import { useUiKitActionManager } from '../../../UIKit/hooks/useUiKitActionManager'; +import { useUiKitView } from '../../../UIKit/hooks/useUiKitView'; import { detectEmoji } from '../../../lib/utils/detectEmoji'; +import { preventSyntheticEvent } from '../../../lib/utils/preventSyntheticEvent'; import ModalBlock from './ModalBlock'; -import type { ActionManagerState } from './hooks/useActionManagerState'; -import { useActionManagerState } from './hooks/useActionManagerState'; -import { useValues } from './hooks/useValues'; -const UiKitModal = (props: ActionManagerState): ReactElement => { - const actionManager = useUiKitActionManager(); - const state = useActionManagerState(props); - - const { appId, viewId, mid: _mid, errors, view } = state; - - const [values, updateValues] = useValues(view.blocks as LayoutBlock[]); +type UiKitModalProps = { + key: UiKit.ModalView['id']; // force re-mount when viewId changes + initialView: UiKit.ModalView; +}; - const groupStateByBlockId = (values: { value: unknown; blockId: string }[]) => - Object.entries(values).reduce((obj, [key, { blockId, value }]) => { - obj[blockId] = obj[blockId] || {}; - obj[blockId][key] = value; +const UiKitModal = ({ initialView }: UiKitModalProps) => { + const actionManager = useUiKitActionManager(); + const { view, errors, values, updateValues, state } = useUiKitView(initialView); - return obj; - }, {}); + const emitInteraction = useMemo(() => actionManager.emitInteraction.bind(actionManager), [actionManager]); + const debouncedEmitInteraction = useDebouncedCallback(emitInteraction, 700); - const prevent: ReactEventHandler = (e) => { - if (e) { - (e.nativeEvent || e).stopImmediatePropagation(); - e.stopPropagation(); - e.preventDefault(); - } - }; + // TODO: this structure is atrociously wrong; we should revisit this + const contextValue = useMemo( + (): ContextType => ({ + action: async ({ actionId, viewId, appId, dispatchActionConfig, blockId, value }) => { + if (!appId || !viewId) { + return; + } - const debouncedBlockAction = useDebouncedCallback((actionId, appId, value, blockId, mid) => { - actionManager.triggerBlockAction({ - container: { - type: UIKitIncomingInteractionContainerType.VIEW, - id: viewId, - }, - actionId, - appId, - value, - blockId, - mid, - }); - }, 700); + const emit = dispatchActionConfig?.includes('on_character_entered') ? debouncedEmitInteraction : emitInteraction; - // TODO: this structure is atrociously wrong; we should revisit this - const context: ContextType = { - // @ts-expect-error Property 'mid' does not exist on type 'ActionParams'. - action: ({ actionId, appId, value, blockId, mid = _mid, dispatchActionConfig }) => { - if (Array.isArray(dispatchActionConfig) && dispatchActionConfig.includes('on_character_entered')) { - debouncedBlockAction(actionId, appId, value, blockId, mid); - } else { - actionManager.triggerBlockAction({ + await emit(appId, { + type: 'blockAction', + actionId, container: { - type: UIKitIncomingInteractionContainerType.VIEW, + type: 'view', id: viewId, }, + payload: { + blockId, + value, + }, + }); + }, + state: ({ actionId, value, /* ,appId, */ blockId = 'default' }) => { + updateValues({ actionId, - appId, - value, - blockId, - mid, + payload: { + blockId, + value, + }, }); - } - }, + }, + ...view, + values, + viewId: view.id, + }), + [debouncedEmitInteraction, emitInteraction, updateValues, values, view], + ); - state: ({ actionId, value, /* ,appId, */ blockId = 'default' }) => { - updateValues({ - actionId, + const handleSubmit = useMutableCallback((e: FormEvent) => { + preventSyntheticEvent(e); + void actionManager + .emitInteraction(view.appId, { + type: 'viewSubmit', payload: { - blockId, - value, + view: { + ...view, + state, + }, }, + viewId: view.id, + }) + .finally(() => { + actionManager.disposeView(view.id); }); - }, - ...state, - values, - }; - - const handleSubmit = useMutableCallback((e) => { - prevent(e); - actionManager.triggerSubmitView({ - viewId, - appId, - payload: { - view: { - ...view, - id: viewId, - state: groupStateByBlockId(values), - }, - }, - }); }); - const handleCancel = useMutableCallback((e) => { - prevent(e); - actionManager.triggerCancel({ - viewId, - appId, - view: { - ...view, - id: viewId, - state: groupStateByBlockId(values), - }, - }); + const handleCancel = useMutableCallback((e: FormEvent) => { + preventSyntheticEvent(e); + void actionManager + .emitInteraction(view.appId, { + type: 'viewClosed', + payload: { + viewId: view.id, + view: { + ...view, + state, + }, + isCleared: false, + }, + }) + .finally(() => { + actionManager.disposeView(view.id); + }); }); const handleClose = useMutableCallback(() => { - actionManager.triggerCancel({ - viewId, - appId, - view: { - ...view, - id: viewId, - state: groupStateByBlockId(values), - }, - isCleared: true, - }); + void actionManager + .emitInteraction(view.appId, { + type: 'viewClosed', + payload: { + viewId: view.id, + view: { + ...view, + state, + }, + isCleared: true, + }, + }) + .finally(() => { + actionManager.disposeView(view.id); + }); }); return ( - + - + ); diff --git a/apps/meteor/client/views/modal/uikit/getButtonStyle.ts b/apps/meteor/client/views/modal/uikit/getButtonStyle.ts index 4a78cb5e250a..89b489fd66fc 100644 --- a/apps/meteor/client/views/modal/uikit/getButtonStyle.ts +++ b/apps/meteor/client/views/modal/uikit/getButtonStyle.ts @@ -1,6 +1,6 @@ -import type { IUIKitSurface } from '@rocket.chat/apps-engine/definition/uikit'; +import type { ButtonElement } from '@rocket.chat/ui-kit'; // TODO: Move to fuselage-ui-kit -export const getButtonStyle = (view: IUIKitSurface): { danger: boolean } | { primary: boolean } => { - return view.submit?.style === 'danger' ? { danger: true } : { primary: true }; +export const getButtonStyle = (buttonElement: ButtonElement): { danger: boolean } | { primary: boolean } => { + return buttonElement?.style === 'danger' ? { danger: true } : { primary: true }; }; diff --git a/apps/meteor/client/views/modal/uikit/hooks/useActionManagerState.ts b/apps/meteor/client/views/modal/uikit/hooks/useActionManagerState.ts deleted file mode 100644 index fb1da19010e3..000000000000 --- a/apps/meteor/client/views/modal/uikit/hooks/useActionManagerState.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { IUIKitSurface } from '@rocket.chat/apps-engine/definition/uikit'; -import { useEffect, useState } from 'react'; - -import { useUiKitActionManager } from '../../../../hooks/useUiKitActionManager'; - -export type ActionManagerState = { - viewId: string; - type: 'errors' | string; - appId: string; - mid: string; - errors: Record; - view: IUIKitSurface; -}; - -export const useActionManagerState = (initialState: ActionManagerState) => { - const actionManager = useUiKitActionManager(); - const [state, setState] = useState(initialState); - - const { viewId } = state; - - useEffect(() => { - const handleUpdate = ({ type, errors, ...data }: ActionManagerState) => { - if (type === 'errors') { - setState((state) => ({ ...state, errors, type })); - return; - } - - setState({ ...data, type, errors }); - }; - - actionManager.on(viewId, handleUpdate); - - return () => { - actionManager.off(viewId, handleUpdate); - }; - }, [actionManager, viewId]); - - return state; -}; diff --git a/apps/meteor/client/views/modal/uikit/hooks/useValues.ts b/apps/meteor/client/views/modal/uikit/hooks/useValues.ts deleted file mode 100644 index 34a8eb0c5ae2..000000000000 --- a/apps/meteor/client/views/modal/uikit/hooks/useValues.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import type { LayoutBlock } from '@rocket.chat/ui-kit'; -import { useReducer } from 'react'; - -type LayoutBlockWithElement = Extract; -type LayoutBlockWithElements = Extract; -type ElementFromLayoutBlock = LayoutBlockWithElement['element'] | LayoutBlockWithElements['elements'][number]; - -const hasElementInBlock = (block: LayoutBlock): block is LayoutBlockWithElement => 'element' in block; -const hasElementsInBlock = (block: LayoutBlock): block is LayoutBlockWithElements => 'elements' in block; -const hasInitialValueAndActionId = ( - element: ElementFromLayoutBlock, -): element is Extract & { initialValue: unknown } => - 'initialValue' in element && 'actionId' in element && typeof element.actionId === 'string' && !!element?.initialValue; - -const extractValue = (element: ElementFromLayoutBlock, obj: Record, blockId?: string) => { - if (hasInitialValueAndActionId(element)) { - obj[element.actionId] = { value: element.initialValue, blockId }; - } -}; - -const reduceBlocks = (obj: Record, block: LayoutBlock) => { - if (hasElementInBlock(block)) { - extractValue(block.element, obj, block.blockId); - } - if (hasElementsInBlock(block)) { - for (const element of block.elements) { - extractValue(element, obj, block.blockId); - } - } - - return obj; -}; - -export const useValues = (blocks: LayoutBlock[]) => { - const reducer = useMutableCallback((values, { actionId, payload }) => ({ - ...values, - [actionId]: payload, - })); - - const initializer = useMutableCallback((blocks: LayoutBlock[]) => { - const obj: Record = {}; - - return blocks.reduce(reduceBlocks, obj); - }); - - return useReducer(reducer, blocks, initializer); -}; diff --git a/apps/meteor/client/views/omnichannel/contactHistory/MessageList/ContactHistoryMessage.tsx b/apps/meteor/client/views/omnichannel/contactHistory/MessageList/ContactHistoryMessage.tsx index 12342a6258a3..dab42e58e300 100644 --- a/apps/meteor/client/views/omnichannel/contactHistory/MessageList/ContactHistoryMessage.tsx +++ b/apps/meteor/client/views/omnichannel/contactHistory/MessageList/ContactHistoryMessage.tsx @@ -105,7 +105,7 @@ const ContactHistoryMessage: FC<{ )} - {message.blocks && } + {message.blocks && } {message.attachments && } diff --git a/apps/meteor/client/views/room/Room.tsx b/apps/meteor/client/views/room/Room.tsx index d53254647483..d8cf86dbbb48 100644 --- a/apps/meteor/client/views/room/Room.tsx +++ b/apps/meteor/client/views/room/Room.tsx @@ -23,7 +23,7 @@ const Room = (): ReactElement => { const toolbox = useRoomToolbox(); - const appsContextualBarContext = useAppsContextualBar(); + const contextualBarView = useAppsContextualBar(); return ( @@ -41,16 +41,11 @@ const Room = (): ReactElement => { )) || - (appsContextualBarContext && ( + (contextualBarView && ( }> - + diff --git a/apps/meteor/client/views/room/contextualBar/uikit/UiKitContextualBar.tsx b/apps/meteor/client/views/room/contextualBar/uikit/UiKitContextualBar.tsx index 6f3b803ebf84..543a36443185 100644 --- a/apps/meteor/client/views/room/contextualBar/uikit/UiKitContextualBar.tsx +++ b/apps/meteor/client/views/room/contextualBar/uikit/UiKitContextualBar.tsx @@ -1,15 +1,4 @@ -import type { - IUIKitContextualBarInteraction, - IUIKitErrorInteraction, - IUIKitSurface, - IInputElement, - IInputBlock, - IBlock, - IBlockElement, - IActionsBlock, -} from '@rocket.chat/apps-engine/definition/uikit'; -import { InputElementDispatchAction } from '@rocket.chat/apps-engine/definition/uikit'; -import { UIKitIncomingInteractionContainerType } from '@rocket.chat/apps-engine/definition/uikit/UIKitIncomingInteractionContainer'; +import type { UiKit } from '@rocket.chat/core-typings'; import { Avatar, Box, Button, ButtonGroup, ContextualbarFooter, ContextualbarHeader, ContextualbarTitle } from '@rocket.chat/fuselage'; import { useDebouncedCallback, useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { @@ -18,237 +7,139 @@ import { contextualBarParser, UiKitContext, } from '@rocket.chat/fuselage-ui-kit'; -import type { LayoutBlock } from '@rocket.chat/ui-kit'; -import { BlockContext, type Block } from '@rocket.chat/ui-kit'; -import type { Dispatch, SyntheticEvent, ContextType } from 'react'; -import React, { memo, useState, useEffect, useReducer } from 'react'; +import { BlockContext } from '@rocket.chat/ui-kit'; +import type { ContextType, FormEvent, UIEvent } from 'react'; +import React, { memo, useMemo } from 'react'; import { getURL } from '../../../../../app/utils/client'; +import { useUiKitActionManager } from '../../../../UIKit/hooks/useUiKitActionManager'; +import { useUiKitView } from '../../../../UIKit/hooks/useUiKitView'; import { ContextualbarClose, ContextualbarScrollableContent } from '../../../../components/Contextualbar'; -import { useUiKitActionManager } from '../../../../hooks/useUiKitActionManager'; +import { preventSyntheticEvent } from '../../../../lib/utils/preventSyntheticEvent'; import { getButtonStyle } from '../../../modal/uikit/getButtonStyle'; import { useRoomToolbox } from '../../contexts/RoomToolboxContext'; -type FieldStateValue = string | Array | undefined; -type FieldState = { value: FieldStateValue; blockId: string }; -type InputFieldStateTuple = [string, FieldState]; -type InputFieldStateObject = { [key: string]: FieldState }; -type InputFieldStateByBlockId = { [blockId: string]: { [actionId: string]: FieldStateValue } }; -type ActionParams = { - blockId: string; - appId: string; - actionId: string; - value: unknown; - viewId?: string; - dispatchActionConfig?: InputElementDispatchAction[]; +type UiKitContextualBarProps = { + key: UiKit.ContextualBarView['id']; // force re-mount when viewId changes + initialView: UiKit.ContextualBarView; }; -type ViewState = IUIKitContextualBarInteraction & { - errors?: { [field: string]: string }; -}; - -const isInputBlock = (block: any): block is IInputBlock => block?.element?.initialValue; - -const useValues = (view: IUIKitSurface): [any, Dispatch] => { - const reducer = useMutableCallback((values, { actionId, payload }) => ({ - ...values, - [actionId]: payload, - })); - - const initializer = useMutableCallback(() => { - const filterInputFields = (block: IBlock | Block): boolean => { - if (isInputBlock(block)) { - return true; - } - - if ( - ((block as IActionsBlock).elements as IInputElement[])?.filter((element) => filterInputFields({ element } as IInputBlock)).length - ) { - return true; - } - - return false; - }; - - const mapElementToState = (block: IBlock | Block): InputFieldStateTuple | InputFieldStateTuple[] => { - if (isInputBlock(block)) { - const { element, blockId } = block; - return [element.actionId, { value: element.initialValue, blockId } as FieldState]; - } - - const { elements, blockId }: { elements: IBlockElement[]; blockId?: string } = block as IActionsBlock; - - return elements - .filter((element) => filterInputFields({ element } as IInputBlock)) - .map((element) => mapElementToState({ element, blockId } as IInputBlock)) as InputFieldStateTuple[]; - }; - - return view.blocks - .filter(filterInputFields) - .map(mapElementToState) - .reduce((obj: InputFieldStateObject, el: InputFieldStateTuple | InputFieldStateTuple[]) => { - if (Array.isArray(el[0])) { - return { ...obj, ...Object.fromEntries(el as InputFieldStateTuple[]) }; - } - - const [key, value] = el as InputFieldStateTuple; - return { ...obj, [key]: value }; - }, {} as InputFieldStateObject); - }); - - return useReducer(reducer, null, initializer); -}; - -const UiKitContextualBar = ({ - viewId, - roomId, - payload, - appId, -}: { - viewId: string; - roomId: string; - payload: IUIKitContextualBarInteraction; - appId: string; -}): JSX.Element => { - const actionManager = useUiKitActionManager(); +const UiKitContextualBar = ({ initialView }: UiKitContextualBarProps): JSX.Element => { const { closeTab } = useRoomToolbox(); + const actionManager = useUiKitActionManager(); - const [state, setState] = useState(payload); - const { view } = state; - const [values, updateValues] = useValues(view); - - useEffect(() => { - const handleUpdate = ({ type, ...data }: IUIKitContextualBarInteraction | IUIKitErrorInteraction): void => { - if (type === 'errors') { - const { errors } = data as Omit; - setState((state: ViewState) => ({ ...state, errors })); - return; - } - - setState(data as IUIKitContextualBarInteraction); - }; - - actionManager.on(viewId, handleUpdate); - - return (): void => { - actionManager.off(viewId, handleUpdate); - }; - }, [actionManager, state, viewId]); + const { view, values, updateValues, state } = useUiKitView(initialView); - const groupStateByBlockId = (obj: InputFieldStateObject): InputFieldStateByBlockId => - Object.entries(obj).reduce((obj: InputFieldStateByBlockId, [key, { blockId, value }]: InputFieldStateTuple) => { - obj[blockId] = obj[blockId] || {}; - obj[blockId][key] = value; - return obj; - }, {} as InputFieldStateByBlockId); + const emitInteraction = useMemo(() => actionManager.emitInteraction.bind(actionManager), [actionManager]); + const debouncedEmitInteraction = useDebouncedCallback(emitInteraction, 700); - const prevent = (e: SyntheticEvent): void => { - if (e) { - (e.nativeEvent || e).stopImmediatePropagation(); - e.stopPropagation(); - e.preventDefault(); - } - }; + const contextValue = useMemo( + (): ContextType => ({ + action: async ({ appId, viewId, actionId, dispatchActionConfig, blockId, value }): Promise => { + if (!appId || !viewId) { + return; + } - const debouncedBlockAction = useDebouncedCallback(({ actionId, appId, value, blockId }: ActionParams) => { - actionManager.triggerBlockAction({ - container: { - type: UIKitIncomingInteractionContainerType.VIEW, - id: viewId, - }, - actionId, - appId, - value, - blockId, - }); - }, 700); + const emit = dispatchActionConfig?.includes('on_character_entered') ? debouncedEmitInteraction : emitInteraction; - const context: ContextType = { - action: async ({ actionId, appId, value, blockId, dispatchActionConfig }: ActionParams): Promise => { - if (Array.isArray(dispatchActionConfig) && dispatchActionConfig.includes(InputElementDispatchAction.ON_CHARACTER_ENTERED)) { - await debouncedBlockAction({ actionId, appId, value, blockId }); - } else { - await actionManager.triggerBlockAction({ + await emit(appId, { + type: 'blockAction', + actionId, container: { - type: UIKitIncomingInteractionContainerType.VIEW, + type: 'view', id: viewId, }, + payload: { + blockId, + value, + }, + }); + }, + state: ({ actionId, value, blockId = 'default' }) => { + updateValues({ actionId, - appId, - rid: roomId, - value, - blockId, + payload: { + blockId, + value, + }, }); - } - }, - state: ({ actionId, value, blockId = 'default' }: ActionParams): void => { - updateValues({ - actionId, - payload: { - blockId, - value, - }, - }); - }, - ...state, - values, - } as ContextType; + }, + ...view, + values, + viewId: view.id, + }), + [debouncedEmitInteraction, emitInteraction, updateValues, values, view], + ); - const handleSubmit = useMutableCallback((e) => { - prevent(e); + const handleSubmit = useMutableCallback((e: FormEvent) => { + preventSyntheticEvent(e); closeTab(); - actionManager.triggerSubmitView({ - viewId, - appId, - payload: { - view: { - ...view, - id: viewId, - state: groupStateByBlockId(values), + void actionManager + .emitInteraction(view.appId, { + type: 'viewSubmit', + payload: { + view: { + ...view, + state, + }, }, - }, - }); + viewId: view.id, + }) + .finally(() => { + actionManager.disposeView(view.id); + }); }); - const handleCancel = useMutableCallback((e) => { - prevent(e); + const handleCancel = useMutableCallback((e: UIEvent) => { + preventSyntheticEvent(e); closeTab(); - return actionManager.triggerCancel({ - appId, - viewId, - view: { - ...view, - id: viewId, - state: groupStateByBlockId(values), - }, - }); + void actionManager + .emitInteraction(view.appId, { + type: 'viewClosed', + payload: { + viewId: view.id, + view: { + ...view, + state, + }, + isCleared: false, + }, + }) + .finally(() => { + actionManager.disposeView(view.id); + }); }); - const handleClose = useMutableCallback((e) => { - prevent(e); + const handleClose = useMutableCallback((e: UIEvent) => { + preventSyntheticEvent(e); closeTab(); - return actionManager.triggerCancel({ - appId, - viewId, - view: { - ...view, - id: viewId, - state: groupStateByBlockId(values), - }, - isCleared: true, - }); + void actionManager + .emitInteraction(view.appId, { + type: 'viewClosed', + payload: { + viewId: view.id, + view: { + ...view, + state, + }, + isCleared: true, + }, + }) + .finally(() => { + actionManager.disposeView(view.id); + }); }); return ( - + - + {contextualBarParser.text(view.title, BlockContext.NONE, 0)} {handleClose && } - + @@ -258,8 +149,9 @@ const UiKitContextualBar = ({ {contextualBarParser.text(view.close.text, BlockContext.NONE, 0)} )} + {view.submit && ( - )} diff --git a/apps/meteor/client/views/room/hooks/useAppsContextualBar.ts b/apps/meteor/client/views/room/hooks/useAppsContextualBar.ts index 6afa6c3a6f84..c039c434a48f 100644 --- a/apps/meteor/client/views/room/hooks/useAppsContextualBar.ts +++ b/apps/meteor/client/views/room/hooks/useAppsContextualBar.ts @@ -1,49 +1,35 @@ -import type { IUIKitContextualBarInteraction } from '@rocket.chat/apps-engine/definition/uikit'; import { useRouteParameter } from '@rocket.chat/ui-contexts'; -import { useEffect, useState } from 'react'; +import { useCallback } from 'react'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; -import { useUiKitActionManager } from '../../../hooks/useUiKitActionManager'; -import { useRoom } from '../contexts/RoomContext'; +import { useUiKitActionManager } from '../../../UIKit/hooks/useUiKitActionManager'; -type AppsContextualBarData = { - viewId: string; - roomId: string; - payload: IUIKitContextualBarInteraction; - appId: string; -}; - -export const useAppsContextualBar = (): AppsContextualBarData | undefined => { - const [payload, setPayload] = useState(); +export const useAppsContextualBar = () => { + const viewId = useRouteParameter('context'); const actionManager = useUiKitActionManager(); - const [appId, setAppId] = useState(); - const { _id: roomId } = useRoom(); + const getSnapshot = useCallback(() => { + if (!viewId) { + return undefined; + } - const viewId = useRouteParameter('context'); + return actionManager.getInteractionPayloadByViewId(viewId)?.view; + }, [actionManager, viewId]); - useEffect(() => { - if (viewId) { - setPayload(actionManager.getUserInteractionPayloadByViewId(viewId) as IUIKitContextualBarInteraction); - } + const subscribe = useCallback( + (handler: () => void) => { + if (!viewId) { + return () => undefined; + } - if (payload?.appId) { - setAppId(payload.appId); - } + actionManager.on(viewId, handler); + + return () => actionManager.off(viewId, handler); + }, + [actionManager, viewId], + ); + + const view = useSyncExternalStore(subscribe, getSnapshot); - return (): void => { - setPayload(undefined); - setAppId(undefined); - }; - }, [viewId, payload?.appId, actionManager]); - - if (viewId && payload && appId) { - return { - viewId, - roomId, - payload, - appId, - }; - } - - return undefined; + return view; }; diff --git a/apps/meteor/client/views/room/providers/hooks/useAppsRoomActions.ts b/apps/meteor/client/views/room/providers/hooks/useAppsRoomActions.ts index 935da2a23c46..f402304a936b 100644 --- a/apps/meteor/client/views/room/providers/hooks/useAppsRoomActions.ts +++ b/apps/meteor/client/views/room/providers/hooks/useAppsRoomActions.ts @@ -1,9 +1,12 @@ +import { useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { UiKitTriggerTimeoutError } from '../../../../../app/ui-message/client/UiKitTriggerTimeoutError'; import { Utilities } from '../../../../../ee/lib/misc/Utilities'; +import { useUiKitActionManager } from '../../../../UIKit/hooks/useUiKitActionManager'; import { useAppActionButtons } from '../../../../hooks/useAppActionButtons'; import { useApplyButtonFilters } from '../../../../hooks/useApplyButtonFilters'; -import { useUiKitActionManager } from '../../../../hooks/useUiKitActionManager'; import { useRoom } from '../../contexts/RoomContext'; import type { RoomToolboxActionConfig } from '../../contexts/RoomToolboxContext'; @@ -12,6 +15,8 @@ export const useAppsRoomActions = () => { const actionManager = useUiKitActionManager(); const applyButtonFilters = useApplyButtonFilters(); const room = useRoom(); + const { t } = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); return useMemo( () => @@ -25,16 +30,29 @@ export const useAppsRoomActions = () => { groups: ['group', 'channel', 'live', 'team', 'direct', 'direct_multiple'], // Filters were applied in the applyButtonFilters function // if the code made it this far, the button should be shown - action: () => - void actionManager.triggerActionButtonAction({ - rid: room._id, - actionId: action.actionId, - appId: action.appId, - payload: { context: action.context }, - }), + action: () => { + void actionManager + .emitInteraction(action.appId, { + type: 'actionButton', + actionId: action.actionId, + rid: room._id, + payload: { context: action.context }, + }) + .catch(async (reason) => { + if (reason instanceof UiKitTriggerTimeoutError) { + dispatchToastMessage({ + type: 'error', + message: t('UIKit_Interaction_Timeout'), + }); + return; + } + + return reason; + }); + }, type: 'apps', }), ) ?? [], - [actionManager, applyButtonFilters, result.data, room._id], + [actionManager, applyButtonFilters, dispatchToastMessage, result.data, room._id, t], ); }; diff --git a/apps/meteor/ee/app/license/server/maxSeatsBanners.ts b/apps/meteor/ee/app/license/server/maxSeatsBanners.ts index 1aefb7848a42..b5aba719f29d 100644 --- a/apps/meteor/ee/app/license/server/maxSeatsBanners.ts +++ b/apps/meteor/ee/app/license/server/maxSeatsBanners.ts @@ -1,5 +1,3 @@ -import { BlockType } from '@rocket.chat/apps-engine/definition/uikit/blocks/Blocks'; -import { TextObjectType } from '@rocket.chat/apps-engine/definition/uikit/blocks/Objects'; import { Banner } from '@rocket.chat/core-services'; import type { IBanner } from '@rocket.chat/core-typings'; import { BannerPlatform } from '@rocket.chat/core-typings'; @@ -21,15 +19,14 @@ const makeWarningBanner = (seats: number): IBanner => ({ appId: 'banner-core', blocks: [ { - type: BlockType.SECTION, + type: 'section', blockId: 'attention', text: { - type: TextObjectType.MARKDOWN, + type: 'mrkdwn', text: i18n.t('Close_to_seat_limit_banner_warning', { seats, url: Meteor.absoluteUrl('/requestSeats'), }), - emoji: false, }, }, ], @@ -56,14 +53,13 @@ const makeDangerBanner = (): IBanner => ({ appId: 'banner-core', blocks: [ { - type: BlockType.SECTION, + type: 'section', blockId: 'attention', text: { - type: TextObjectType.MARKDOWN, + type: 'mrkdwn', text: i18n.t('Reached_seat_limit_banner_warning', { url: Meteor.absoluteUrl('/requestSeats'), }), - emoji: false, }, }, ], diff --git a/apps/meteor/ee/client/apps/gameCenter/GameCenter.tsx b/apps/meteor/ee/client/apps/gameCenter/GameCenter.tsx index c5377fdc30a0..75f4882ce747 100644 --- a/apps/meteor/ee/client/apps/gameCenter/GameCenter.tsx +++ b/apps/meteor/ee/client/apps/gameCenter/GameCenter.tsx @@ -1,8 +1,9 @@ import type { IExternalComponent } from '@rocket.chat/apps-engine/definition/externalComponent'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import React, { useState } from 'react'; -import type { ReactElement, SyntheticEvent } from 'react'; +import type { ReactElement } from 'react'; +import { preventSyntheticEvent } from '../../../../client/lib/utils/preventSyntheticEvent'; import { useRoomToolbox } from '../../../../client/views/room/contexts/RoomToolboxContext'; import GameCenterContainer from './GameCenterContainer'; import GameCenterList from './GameCenterList'; @@ -10,14 +11,6 @@ import { useExternalComponentsQuery } from './hooks/useExternalComponentsQuery'; export type IGame = IExternalComponent; -const prevent = (e: SyntheticEvent): void => { - if (e) { - (e.nativeEvent || e).stopImmediatePropagation(); - e.stopPropagation(); - e.preventDefault(); - } -}; - const GameCenter = (): ReactElement => { const [openedGame, setOpenedGame] = useState(); @@ -26,13 +19,13 @@ const GameCenter = (): ReactElement => { const result = useExternalComponentsQuery(); const handleClose = useMutableCallback((e) => { - prevent(e); + preventSyntheticEvent(e); closeTab(); }); const handleBack = useMutableCallback((e) => { setOpenedGame(undefined); - prevent(e); + preventSyntheticEvent(e); }); return ( diff --git a/apps/meteor/ee/server/apps/communication/uikit.ts b/apps/meteor/ee/server/apps/communication/uikit.ts index a7f84eab619b..61dee0a1857f 100644 --- a/apps/meteor/ee/server/apps/communication/uikit.ts +++ b/apps/meteor/ee/server/apps/communication/uikit.ts @@ -1,6 +1,6 @@ -import { AppInterface } from '@rocket.chat/apps-engine/definition/metadata'; -import { UIKitIncomingInteractionType } from '@rocket.chat/apps-engine/definition/uikit'; +import type { UiKitCoreAppPayload } from '@rocket.chat/core-services'; import { UiKitCoreApp } from '@rocket.chat/core-services'; +import type { OperationParams, UrlParams } from '@rocket.chat/rest-typings'; import cors from 'cors'; import type { Request, Response } from 'express'; import express from 'express'; @@ -91,41 +91,58 @@ const corsOptions: cors.CorsOptions = { apiServer.use('/api/apps/ui.interaction/', cors(corsOptions), router); // didn't have the rateLimiter option -const getPayloadForType = (type: UIKitIncomingInteractionType, req: Request) => { - if (type === UIKitIncomingInteractionType.BLOCK) { - const { type, actionId, triggerId, mid, rid, payload, container } = req.body; +type UiKitUserInteractionRequest = Request< + UrlParams<'/apps/ui.interaction/:id'>, + any, + OperationParams<'POST', '/apps/ui.interaction/:id'> & { + visitor?: { + id: string; + username: string; + name?: string; + department?: string; + updatedAt?: Date; + token: string; + phone?: { phoneNumber: string }[] | null; + visitorEmails?: { address: string }[]; + livechatData?: Record; + status?: 'online' | 'away' | 'offline' | 'busy' | 'disabled'; + }; + } +>; - const { visitor } = req.body; - const { user } = req; +const getCoreAppPayload = (req: UiKitUserInteractionRequest): UiKitCoreAppPayload => { + const { id: appId } = req.params; - const room = rid; // orch.getConverters().get('rooms').convertById(rid); - const message = mid; + if (req.body.type === 'blockAction') { + const { user } = req; + const { type, actionId, triggerId, payload, container, visitor } = req.body; + const message = 'mid' in req.body ? req.body.mid : undefined; + const room = 'rid' in req.body ? req.body.rid : undefined; return { + appId, type, - container, actionId, - message, triggerId, + container, + message, payload, user, visitor, room, - } as const; + }; } - if (type === UIKitIncomingInteractionType.VIEW_CLOSED) { + if (req.body.type === 'viewClosed') { + const { user } = req; const { type, - actionId, payload: { view, isCleared }, } = req.body; - const { user } = req; - return { + appId, type, - actionId, user, payload: { view, @@ -134,12 +151,12 @@ const getPayloadForType = (type: UIKitIncomingInteractionType, req: Request) => }; } - if (type === UIKitIncomingInteractionType.VIEW_SUBMIT) { - const { type, actionId, triggerId, payload } = req.body; - + if (req.body.type === 'viewSubmit') { const { user } = req; + const { type, actionId, triggerId, payload } = req.body; return { + appId, type, actionId, triggerId, @@ -151,24 +168,18 @@ const getPayloadForType = (type: UIKitIncomingInteractionType, req: Request) => throw new Error('Type not supported'); }; -router.post('/:appId', async (req, res, next) => { - const { appId } = req.params; +router.post('/:id', async (req: UiKitUserInteractionRequest, res, next) => { + const { id: appId } = req.params; - const isCore = await UiKitCoreApp.isRegistered(appId); - if (!isCore) { + const isCoreApp = await UiKitCoreApp.isRegistered(appId); + if (!isCoreApp) { return next(); } - // eslint-disable-next-line prefer-destructuring - const type: UIKitIncomingInteractionType = req.body.type; - try { - const payload = { - ...getPayloadForType(type, req), - appId, - }; + const payload = getCoreAppPayload(req); - const result = await (UiKitCoreApp as any)[type](payload); // TO-DO: fix type + const result = await UiKitCoreApp[payload.type](payload); // Using ?? to always send something in the response, even if the app had no result. res.send(result ?? {}); @@ -178,16 +189,24 @@ router.post('/:appId', async (req, res, next) => { } }); -const appsRoutes = - (orch: AppServerOrchestrator) => - async (req: Request, res: Response): Promise => { - const { appId } = req.params; +export class AppUIKitInteractionApi { + orch: AppServerOrchestrator; + + constructor(orch: AppServerOrchestrator) { + this.orch = orch; + + router.post('/:id', this.routeHandler); + } - const { type } = req.body; + private routeHandler = async (req: UiKitUserInteractionRequest, res: Response): Promise => { + const { orch } = this; + const { id: appId } = req.params; - switch (type) { - case UIKitIncomingInteractionType.BLOCK: { - const { type, actionId, triggerId, mid, rid, payload, container } = req.body; + switch (req.body.type) { + case 'blockAction': { + const { type, actionId, triggerId, payload, container } = req.body; + const mid = 'mid' in req.body ? req.body.mid : undefined; + const rid = 'rid' in req.body ? req.body.rid : undefined; const { visitor } = req.body; const room = await orch.getConverters()?.get('rooms').convertById(rid); @@ -208,7 +227,7 @@ const appsRoutes = }; try { - const eventInterface = !visitor ? AppInterface.IUIKitInteractionHandler : AppInterface.IUIKitLivechatInteractionHandler; + const eventInterface = !visitor ? 'IUIKitInteractionHandler' : 'IUIKitLivechatInteractionHandler'; const result = await orch.triggerEvent(eventInterface, action); @@ -220,10 +239,9 @@ const appsRoutes = break; } - case UIKitIncomingInteractionType.VIEW_CLOSED: { + case 'viewClosed': { const { type, - actionId, payload: { view, isCleared }, } = req.body; @@ -232,7 +250,6 @@ const appsRoutes = const action = { type, appId, - actionId, user, payload: { view, @@ -251,7 +268,7 @@ const appsRoutes = break; } - case UIKitIncomingInteractionType.VIEW_SUBMIT: { + case 'viewSubmit': { const { type, actionId, triggerId, payload } = req.body; const user = orch.getConverters()?.get('users').convertToApp(req.user); @@ -276,7 +293,7 @@ const appsRoutes = break; } - case UIKitIncomingInteractionType.ACTION_BUTTON: { + case 'actionButton': { const { type, actionId, @@ -302,7 +319,7 @@ const appsRoutes = tmid, payload: { context, - ...(msgText && { message: msgText }), + ...(msgText ? { message: msgText } : {}), }, }; @@ -324,13 +341,4 @@ const appsRoutes = // TODO: validate payloads per type }; - -export class AppUIKitInteractionApi { - orch: AppServerOrchestrator; - - constructor(orch: AppServerOrchestrator) { - this.orch = orch; - - router.post('/:appId', appsRoutes(orch)); - } } diff --git a/apps/meteor/server/modules/core-apps/banner.module.ts b/apps/meteor/server/modules/core-apps/banner.module.ts index bc850fea2078..fac891e5ea73 100644 --- a/apps/meteor/server/modules/core-apps/banner.module.ts +++ b/apps/meteor/server/modules/core-apps/banner.module.ts @@ -1,18 +1,24 @@ import { Banner } from '@rocket.chat/core-services'; -import type { IUiKitCoreApp } from '@rocket.chat/core-services'; +import type { IUiKitCoreApp, UiKitCoreAppPayload } from '@rocket.chat/core-services'; export class BannerModule implements IUiKitCoreApp { appId = 'banner-core'; // when banner view is closed we need to dissmiss that banner for that user - async viewClosed(payload: any): Promise { + async viewClosed(payload: UiKitCoreAppPayload) { const { - payload: { - view: { viewId: bannerId }, - }, - user: { _id: userId }, + payload: { view: { viewId: bannerId } = {} }, + user: { _id: userId } = {}, } = payload; + if (!userId) { + throw new Error('invalid user'); + } + + if (!bannerId) { + throw new Error('invalid banner'); + } + return Banner.dismiss(userId, bannerId); } } diff --git a/apps/meteor/server/modules/core-apps/nps.module.ts b/apps/meteor/server/modules/core-apps/nps.module.ts index 68ebeffd97c2..6e8965122df3 100644 --- a/apps/meteor/server/modules/core-apps/nps.module.ts +++ b/apps/meteor/server/modules/core-apps/nps.module.ts @@ -1,4 +1,4 @@ -import type { IUiKitCoreApp } from '@rocket.chat/core-services'; +import type { IUiKitCoreApp, UiKitCoreAppPayload } from '@rocket.chat/core-services'; import { Banner, NPS } from '@rocket.chat/core-services'; import { createModal } from './nps/createModal'; @@ -6,15 +6,19 @@ import { createModal } from './nps/createModal'; export class Nps implements IUiKitCoreApp { appId = 'nps-core'; - async blockAction(payload: any): Promise { + async blockAction(payload: UiKitCoreAppPayload) { const { triggerId, actionId, - container: { id: viewId }, + container: { id: viewId } = {}, payload: { value: score, blockId: npsId }, user, } = payload; + if (!viewId || !triggerId || !user || !npsId) { + throw new Error('Invalid payload'); + } + const bannerId = viewId.replace(`${npsId}-`, ''); return createModal({ @@ -23,13 +27,13 @@ export class Nps implements IUiKitCoreApp { appId: this.appId, npsId, triggerId, - score, + score: String(score), user, }); } - async viewSubmit(payload: any): Promise { - if (!payload.payload?.view?.state) { + async viewSubmit(payload: UiKitCoreAppPayload) { + if (!payload.payload?.view?.state || !payload.payload?.view?.id) { throw new Error('Invalid payload'); } @@ -37,7 +41,7 @@ export class Nps implements IUiKitCoreApp { payload: { view: { state, id: viewId }, }, - user: { _id: userId, roles }, + user: { _id: userId, roles } = {}, } = payload; const [npsId] = Object.keys(state); @@ -51,11 +55,15 @@ export class Nps implements IUiKitCoreApp { await NPS.vote({ npsId, userId, - comment, + comment: String(comment), roles, - score, + score: Number(score), }); + if (!userId) { + throw new Error('invalid user'); + } + await Banner.dismiss(userId, bannerId); return true; diff --git a/apps/meteor/server/modules/core-apps/videoconf.module.ts b/apps/meteor/server/modules/core-apps/videoconf.module.ts index b0425f6ffd55..694a0fac9b8e 100644 --- a/apps/meteor/server/modules/core-apps/videoconf.module.ts +++ b/apps/meteor/server/modules/core-apps/videoconf.module.ts @@ -1,4 +1,4 @@ -import type { IUiKitCoreApp } from '@rocket.chat/core-services'; +import type { IUiKitCoreApp, UiKitCoreAppPayload } from '@rocket.chat/core-services'; import { VideoConf } from '@rocket.chat/core-services'; import { i18n } from '../../lib/i18n'; @@ -6,14 +6,18 @@ import { i18n } from '../../lib/i18n'; export class VideoConfModule implements IUiKitCoreApp { appId = 'videoconf-core'; - async blockAction(payload: any): Promise { + async blockAction(payload: UiKitCoreAppPayload) { const { triggerId, actionId, payload: { blockId: callId }, - user: { _id: userId }, + user: { _id: userId } = {}, } = payload; + if (!callId) { + throw new Error('invalid call'); + } + if (actionId === 'join') { await VideoConf.join(userId, callId, {}); } diff --git a/apps/meteor/server/services/nps/getAndCreateNpsSurvey.ts b/apps/meteor/server/services/nps/getAndCreateNpsSurvey.ts index 02a3c29eedf3..8e4c06941c81 100644 --- a/apps/meteor/server/services/nps/getAndCreateNpsSurvey.ts +++ b/apps/meteor/server/services/nps/getAndCreateNpsSurvey.ts @@ -1,5 +1,5 @@ import { Banner } from '@rocket.chat/core-services'; -import type { UiKitBannerPayload, IBanner, BannerPlatform } from '@rocket.chat/core-typings'; +import type { UiKit, IBanner, BannerPlatform } from '@rocket.chat/core-typings'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { getWorkspaceAccessToken } from '../../../app/cloud/server'; @@ -10,7 +10,7 @@ type NpsSurveyData = { id: string; platform: BannerPlatform[]; roles: string[]; - survey: UiKitBannerPayload; + survey: UiKit.BannerView; createdAt: Date; startAt: Date; expireAt: Date; diff --git a/apps/meteor/server/services/nps/notification.ts b/apps/meteor/server/services/nps/notification.ts index 91ed3c7d2671..692b9bc6291f 100644 --- a/apps/meteor/server/services/nps/notification.ts +++ b/apps/meteor/server/services/nps/notification.ts @@ -1,5 +1,3 @@ -import { BlockType } from '@rocket.chat/apps-engine/definition/uikit/blocks/Blocks'; -import { TextObjectType } from '@rocket.chat/apps-engine/definition/uikit/blocks/Objects'; import type { IBanner } from '@rocket.chat/core-typings'; import { BannerPlatform } from '@rocket.chat/core-typings'; import moment from 'moment'; @@ -27,10 +25,10 @@ export const getBannerForAdmins = (expireAt: Date): Omit => { appId: '', blocks: [ { - type: BlockType.SECTION, + type: 'section', blockId: 'attention', text: { - type: TextObjectType.PLAINTEXT, + type: 'plain_text', text: i18n.t('NPS_survey_is_scheduled_to-run-at__date__for_all_users', { date: moment(expireAt).format('YYYY-MM-DD'), lng, diff --git a/apps/meteor/server/services/startup.ts b/apps/meteor/server/services/startup.ts index 968415620558..28ab5a35b553 100644 --- a/apps/meteor/server/services/startup.ts +++ b/apps/meteor/server/services/startup.ts @@ -25,7 +25,7 @@ import { SAUMonitorService } from './sauMonitor/service'; import { SettingsService } from './settings/service'; import { TeamService } from './team/service'; import { TranslationService } from './translation/service'; -import { UiKitCoreApp } from './uikit-core-app/service'; +import { UiKitCoreAppService } from './uikit-core-app/service'; import { UploadService } from './upload/service'; import { VideoConfService } from './video-conference/service'; import { VoipService } from './voip/service'; @@ -47,7 +47,7 @@ api.registerService(new VoipService(db)); api.registerService(new OmnichannelService()); api.registerService(new OmnichannelVoipService()); api.registerService(new TeamService()); -api.registerService(new UiKitCoreApp()); +api.registerService(new UiKitCoreAppService()); api.registerService(new PushService()); api.registerService(new DeviceManagementService()); api.registerService(new VideoConfService()); diff --git a/apps/meteor/server/services/uikit-core-app/service.ts b/apps/meteor/server/services/uikit-core-app/service.ts index a9eddf69ce81..a842a4854c6a 100644 --- a/apps/meteor/server/services/uikit-core-app/service.ts +++ b/apps/meteor/server/services/uikit-core-app/service.ts @@ -1,9 +1,9 @@ import { ServiceClassInternal } from '@rocket.chat/core-services'; -import type { IUiKitCoreApp, IUiKitCoreAppService } from '@rocket.chat/core-services'; +import type { IUiKitCoreApp, IUiKitCoreAppService, UiKitCoreAppPayload } from '@rocket.chat/core-services'; -const registeredApps = new Map(); +const registeredApps = new Map(); -const getAppModule = (appId: string): any => { +const getAppModule = (appId: string) => { const module = registeredApps.get(appId); if (typeof module === 'undefined') { @@ -17,14 +17,14 @@ export const registerCoreApp = (module: IUiKitCoreApp): void => { registeredApps.set(module.appId, module); }; -export class UiKitCoreApp extends ServiceClassInternal implements IUiKitCoreAppService { +export class UiKitCoreAppService extends ServiceClassInternal implements IUiKitCoreAppService { protected name = 'uikit-core-app'; async isRegistered(appId: string): Promise { return registeredApps.has(appId); } - async blockAction(payload: any): Promise { + async blockAction(payload: UiKitCoreAppPayload) { const { appId } = payload; const service = getAppModule(appId); @@ -35,7 +35,7 @@ export class UiKitCoreApp extends ServiceClassInternal implements IUiKitCoreAppS return service.blockAction?.(payload); } - async viewClosed(payload: any): Promise { + async viewClosed(payload: UiKitCoreAppPayload) { const { appId } = payload; const service = getAppModule(appId); @@ -46,7 +46,7 @@ export class UiKitCoreApp extends ServiceClassInternal implements IUiKitCoreAppS return service.viewClosed?.(payload); } - async viewSubmit(payload: any): Promise { + async viewSubmit(payload: UiKitCoreAppPayload) { const { appId } = payload; const service = getAppModule(appId); diff --git a/apps/meteor/server/services/video-conference/service.ts b/apps/meteor/server/services/video-conference/service.ts index c9079b0a2bfb..818280fd4d31 100644 --- a/apps/meteor/server/services/video-conference/service.ts +++ b/apps/meteor/server/services/video-conference/service.ts @@ -1,4 +1,3 @@ -import type { IBlock } from '@rocket.chat/apps-engine/definition/uikit'; import type { AppVideoConfProviderManager } from '@rocket.chat/apps-engine/server/managers'; import type { IVideoConfService, VideoConferenceJoinOptions } from '@rocket.chat/core-services'; import { api, ServiceClassInternal } from '@rocket.chat/core-services'; @@ -20,6 +19,7 @@ import type { VideoConferenceCapabilities, VideoConferenceCreateData, Optional, + UiKit, } from '@rocket.chat/core-typings'; import { VideoConferenceStatus, @@ -136,7 +136,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf return this.joinCall(call, user || undefined, options); } - public async getInfo(callId: VideoConference['_id'], uid: IUser['_id'] | undefined): Promise { + public async getInfo(callId: VideoConference['_id'], uid: IUser['_id'] | undefined): Promise { const call = await VideoConferenceModel.findOneById(callId); if (!call) { throw new Error('invalid-call'); @@ -162,7 +162,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf }); if (blocks?.length) { - return blocks; + return blocks as UiKit.LayoutBlock[]; } return [ @@ -173,7 +173,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf type: 'mrkdwn', text: `**${i18n.t('Video_Conference_Url')}**: ${call.url}`, }, - } as IBlock, + }, ]; } diff --git a/ee/packages/ddp-client/src/types/streams.ts b/ee/packages/ddp-client/src/types/streams.ts index a32dec470564..9010517faf7a 100644 --- a/ee/packages/ddp-client/src/types/streams.ts +++ b/ee/packages/ddp-client/src/types/streams.ts @@ -1,6 +1,5 @@ import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; import type { ISetting as AppsSetting } from '@rocket.chat/apps-engine/definition/settings'; -import type { IUIKitInteraction } from '@rocket.chat/apps-engine/definition/uikit'; import type { IMessage, IRoom, @@ -24,6 +23,7 @@ import type { ILivechatAgent, IImportProgress, IBanner, + UiKit, } from '@rocket.chat/core-typings'; type ClientAction = 'inserted' | 'updated' | 'removed' | 'changed'; @@ -148,7 +148,7 @@ export interface StreamerEvents { { key: `${string}/notification`; args: [INotificationDesktop] }, { key: `${string}/voip.events`; args: [VoipEventDataSignature] }, { key: `${string}/call.hangup`; args: [{ roomId: string }] }, - { key: `${string}/uiInteraction`; args: [IUIKitInteraction] }, + { key: `${string}/uiInteraction`; args: [UiKit.ServerInteraction] }, { key: `${string}/video-conference`; args: [{ action: string; params: { callId: VideoConference['_id']; uid: IUser['_id']; rid: IRoom['_id'] } }]; diff --git a/package.json b/package.json index 962e42d48c6e..236a551c5e5e 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "@types/chart.js": "^2.9.37", "@types/js-yaml": "^4.0.5", "husky": "^7.0.4", - "turbo": "~1.10.14" + "turbo": "~1.10.15" }, "workspaces": [ "apps/*", diff --git a/packages/core-services/src/Events.ts b/packages/core-services/src/Events.ts index e2a7f624d8df..2aa39588d2f0 100644 --- a/packages/core-services/src/Events.ts +++ b/packages/core-services/src/Events.ts @@ -1,6 +1,5 @@ import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; import type { ISetting as AppsSetting } from '@rocket.chat/apps-engine/definition/settings'; -import type { IUIKitInteraction } from '@rocket.chat/apps-engine/definition/uikit'; import type { IEmailInbox, IEmoji, @@ -33,6 +32,7 @@ import type { ILivechatAgent, IBanner, ILivechatVisitor, + UiKit, } from '@rocket.chat/core-typings'; import type { AutoUpdateRecord } from './types/IMeteor'; @@ -59,7 +59,7 @@ export type EventSignatures = { 'message'(data: { action: string; message: IMessage }): void; 'meteor.clientVersionUpdated'(data: AutoUpdateRecord): void; 'notify.desktop'(uid: string, data: INotificationDesktop): void; - 'notify.uiInteraction'(uid: string, data: IUIKitInteraction): void; + 'notify.uiInteraction'(uid: string, data: UiKit.ServerInteraction): void; 'notify.updateInvites'(uid: string, data: { invite: Omit }): void; 'notify.ephemeralMessage'(uid: string, rid: string, message: AtLeast): void; 'notify.webdav'( diff --git a/packages/core-services/src/index.ts b/packages/core-services/src/index.ts index def7622c9881..d3cc778e5a22 100644 --- a/packages/core-services/src/index.ts +++ b/packages/core-services/src/index.ts @@ -41,7 +41,7 @@ import type { } from './types/ITeamService'; import type { ITelemetryEvent, TelemetryMap, TelemetryEvents } from './types/ITelemetryEvent'; import type { ITranslationService } from './types/ITranslationService'; -import type { IUiKitCoreApp, IUiKitCoreAppService } from './types/IUiKitCoreApp'; +import type { UiKitCoreAppPayload, IUiKitCoreApp, IUiKitCoreAppService } from './types/IUiKitCoreApp'; import type { ISendFileLivechatMessageParams, ISendFileMessageParams, IUploadFileParams, IUploadService } from './types/IUploadService'; import type { IVideoConfService, VideoConferenceJoinOptions } from './types/IVideoConfService'; import type { IVoipService } from './types/IVoipService'; @@ -94,6 +94,7 @@ export { ITeamService, ITeamUpdateData, ITelemetryEvent, + UiKitCoreAppPayload, IUiKitCoreApp, IUiKitCoreAppService, IVideoConfService, diff --git a/packages/core-services/src/types/INPSService.ts b/packages/core-services/src/types/INPSService.ts index 4590a2910e8c..eaf54f6c6133 100644 --- a/packages/core-services/src/types/INPSService.ts +++ b/packages/core-services/src/types/INPSService.ts @@ -1,9 +1,9 @@ import type { IUser, IRole } from '@rocket.chat/core-typings'; export type NPSVotePayload = { - userId: string; + userId: string | undefined; npsId: string; - roles: IRole['_id'][]; + roles?: IRole['_id'][]; score: number; comment: string; }; diff --git a/packages/core-services/src/types/IUiKitCoreApp.ts b/packages/core-services/src/types/IUiKitCoreApp.ts index 92c7b7bd738e..98799918e594 100644 --- a/packages/core-services/src/types/IUiKitCoreApp.ts +++ b/packages/core-services/src/types/IUiKitCoreApp.ts @@ -1,16 +1,55 @@ +import type { IUser } from '@rocket.chat/core-typings'; + import type { IServiceClass } from './ServiceClass'; +export type UiKitCoreAppPayload = { + appId: string; + type: 'blockAction' | 'viewClosed' | 'viewSubmit'; + actionId?: string; + triggerId?: string; + container?: { + id: string; + [key: string]: unknown; + }; + message?: unknown; + payload: { + blockId?: string; + value?: unknown; + view?: { + viewId?: string; + id?: string; + state?: { [blockId: string]: { [key: string]: unknown } }; + [key: string]: unknown; + }; + isCleared?: unknown; + }; + user?: IUser; + visitor?: { + id: string; + username: string; + name?: string; + department?: string; + updatedAt?: Date; + token: string; + phone?: { phoneNumber: string }[] | null; + visitorEmails?: { address: string }[]; + livechatData?: Record; + status?: 'online' | 'away' | 'offline' | 'busy' | 'disabled'; + }; + room?: unknown; +}; + export interface IUiKitCoreApp { appId: string; - blockAction?(payload: any): Promise; - viewClosed?(payload: any): Promise; - viewSubmit?(payload: any): Promise; + blockAction?(payload: UiKitCoreAppPayload): Promise; + viewClosed?(payload: UiKitCoreAppPayload): Promise; + viewSubmit?(payload: UiKitCoreAppPayload): Promise; } export interface IUiKitCoreAppService extends IServiceClass { isRegistered(appId: string): Promise; - blockAction(payload: any): Promise; - viewClosed(payload: any): Promise; - viewSubmit(payload: any): Promise; + blockAction(payload: UiKitCoreAppPayload): Promise; + viewClosed(payload: UiKitCoreAppPayload): Promise; + viewSubmit(payload: UiKitCoreAppPayload): Promise; } diff --git a/packages/core-services/src/types/IVideoConfService.ts b/packages/core-services/src/types/IVideoConfService.ts index d545365b452a..09e336a51623 100644 --- a/packages/core-services/src/types/IVideoConfService.ts +++ b/packages/core-services/src/types/IVideoConfService.ts @@ -1,8 +1,8 @@ -import type { IBlock } from '@rocket.chat/apps-engine/definition/uikit'; import type { IRoom, IStats, IUser, + UiKit, VideoConference, VideoConferenceCapabilities, VideoConferenceCreateData, @@ -19,7 +19,7 @@ export interface IVideoConfService { create(data: VideoConferenceCreateData, useAppUser?: boolean): Promise; start(caller: IUser['_id'], rid: string, options: { title?: string; allowRinging?: boolean }): Promise; join(uid: IUser['_id'] | undefined, callId: VideoConference['_id'], options: VideoConferenceJoinOptions): Promise; - getInfo(callId: VideoConference['_id'], uid: IUser['_id'] | undefined): Promise; + getInfo(callId: VideoConference['_id'], uid: IUser['_id'] | undefined): Promise; cancel(uid: IUser['_id'], callId: VideoConference['_id']): Promise; get(callId: VideoConference['_id']): Promise | null>; getUnfiltered(callId: VideoConference['_id']): Promise; diff --git a/packages/core-typings/src/IBanner.ts b/packages/core-typings/src/IBanner.ts index 29867cdfb6c8..275c3353aa1f 100644 --- a/packages/core-typings/src/IBanner.ts +++ b/packages/core-typings/src/IBanner.ts @@ -1,6 +1,6 @@ import type { IRocketChatRecord } from './IRocketChatRecord'; import type { IUser } from './IUser'; -import type { UiKitBannerPayload } from './UIKit'; +import type * as UiKit from './uikit'; export enum BannerPlatform { Web = 'web', @@ -13,7 +13,7 @@ export interface IBanner extends IRocketChatRecord { roles?: string[]; // only show the banner to this roles createdBy: Pick; createdAt: Date; - view: UiKitBannerPayload; + view: UiKit.BannerView; active?: boolean; inactivedAt?: Date; snapshot?: string; diff --git a/packages/core-typings/src/INps.ts b/packages/core-typings/src/INps.ts index e89796d9d9a4..12b3a1a15d89 100644 --- a/packages/core-typings/src/INps.ts +++ b/packages/core-typings/src/INps.ts @@ -27,7 +27,7 @@ export interface INpsVote extends IRocketChatRecord { npsId: INps['_id']; ts: Date; identifier: string; // voter identifier - roles: IUser['roles']; // voter roles + roles?: IUser['roles']; // voter roles score: number; comment: string; status: INpsVoteStatus; diff --git a/packages/core-typings/src/Serialized.ts b/packages/core-typings/src/Serialized.ts index c84077610ee8..94f79cb64d06 100644 --- a/packages/core-typings/src/Serialized.ts +++ b/packages/core-typings/src/Serialized.ts @@ -1,9 +1,26 @@ -export type Serialized = T extends Date - ? Exclude | string - : T extends boolean | number | string | null | undefined +/* eslint-disable @typescript-eslint/ban-types */ + +type SerializablePrimitive = boolean | number | string | null; + +type UnserializablePrimitive = Function | bigint | symbol | undefined; + +type CustomSerializable = { + toJSON(key: string): T; +}; + +/** + * The type of a value that was serialized via `JSON.stringify` and then deserialized via `JSON.parse`. + */ +export type Serialized = T extends CustomSerializable + ? Serialized + : T extends [any, ...any] // is T a tuple? + ? { [K in keyof T]: T extends UnserializablePrimitive ? null : Serialized } + : T extends any[] + ? Serialized[] + : T extends object + ? { [K in keyof T]: Serialized } + : T extends SerializablePrimitive ? T - : T extends {} - ? { - [K in keyof T]: Serialized; - } + : T extends UnserializablePrimitive + ? undefined : null; diff --git a/packages/core-typings/src/UIKit.ts b/packages/core-typings/src/UIKit.ts deleted file mode 100644 index 19cf46f82b92..000000000000 --- a/packages/core-typings/src/UIKit.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { UIKitInteractionType as UIKitInteractionTypeApi } from '@rocket.chat/apps-engine/definition/uikit'; -import type { - IDividerBlock, - ISectionBlock, - IActionsBlock, - IContextBlock, - IInputBlock, -} from '@rocket.chat/apps-engine/definition/uikit/blocks/Blocks'; - -enum UIKitInteractionTypeExtended { - BANNER_OPEN = 'banner.open', - BANNER_UPDATE = 'banner.update', - BANNER_CLOSE = 'banner.close', -} - -export type UIKitInteractionType = UIKitInteractionTypeApi | UIKitInteractionTypeExtended; - -export const UIKitInteractionTypes = { - ...UIKitInteractionTypeApi, - ...UIKitInteractionTypeExtended, -}; - -export type UiKitPayload = { - viewId: string; - appId: string; - blocks: (IDividerBlock | ISectionBlock | IActionsBlock | IContextBlock | IInputBlock)[]; -}; - -export type UiKitBannerPayload = UiKitPayload & { - inline?: boolean; - variant?: 'neutral' | 'info' | 'success' | 'warning' | 'danger'; - icon?: string; - title?: string; -}; - -export type UIKitUserInteraction = { - type: UIKitInteractionType; -} & UiKitPayload; - -export type UiKitBannerProps = { - payload: UiKitBannerPayload; -}; - -export type UIKitUserInteractionResult = UIKitUserInteractionResultError | UIKitUserInteraction; - -type UIKitUserInteractionResultError = UIKitUserInteraction & { - type: UIKitInteractionTypeApi.ERRORS; - errors?: Array<{ [key: string]: string }>; -}; - -export const isErrorType = (result: UIKitUserInteractionResult): result is UIKitUserInteractionResultError => - result.type === UIKitInteractionTypeApi.ERRORS; - -export type UIKitActionEvent = { - blockId: string; - value?: unknown; - appId: string; - actionId: string; - viewId: string; -}; diff --git a/packages/core-typings/src/cloud/Announcement.ts b/packages/core-typings/src/cloud/Announcement.ts index 3d891daf132f..7c9541efe75a 100644 --- a/packages/core-typings/src/cloud/Announcement.ts +++ b/packages/core-typings/src/cloud/Announcement.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/naming-convention */ import type { IRocketChatRecord } from '../IRocketChatRecord'; -import { type UiKitPayload } from '../UIKit'; +import type * as UiKit from '../uikit'; type TargetPlatform = 'web' | 'mobile'; @@ -23,6 +23,6 @@ export interface Announcement extends IRocketChatRecord { createdBy: Creator; createdAt: Date; dictionary?: Dictionary; - view: UiKitPayload; + view: UiKit.View; surface: 'banner' | 'modal'; } diff --git a/packages/core-typings/src/index.ts b/packages/core-typings/src/index.ts index de36606e7f90..6411390f0fe9 100644 --- a/packages/core-typings/src/index.ts +++ b/packages/core-typings/src/index.ts @@ -4,7 +4,6 @@ export * from './FeaturedApps'; export * from './AppRequests'; export * from './MarketplaceRest'; export * from './IRoom'; -export * from './UIKit'; export * from './IMessage'; export * from './federation'; export * from './Serialized'; @@ -136,3 +135,5 @@ export * from './IModerationReport'; export * from './CustomFieldMetadata'; export * as Cloud from './cloud'; + +export * as UiKit from './uikit'; diff --git a/packages/core-typings/src/uikit/BannerView.ts b/packages/core-typings/src/uikit/BannerView.ts new file mode 100644 index 000000000000..f6914f75a6af --- /dev/null +++ b/packages/core-typings/src/uikit/BannerView.ts @@ -0,0 +1,16 @@ +import type { Keys as IconName } from '@rocket.chat/icons'; +import type { BannerSurfaceLayout } from '@rocket.chat/ui-kit'; + +import type { View } from './View'; + +/** + * A view that is displayed as a banner. + */ +export type BannerView = View & { + viewId: string; + inline?: boolean; + variant?: 'neutral' | 'info' | 'success' | 'warning' | 'danger'; + icon?: IconName; + title?: string; // TODO: change to plain_text block in the future + blocks: BannerSurfaceLayout; +}; diff --git a/packages/core-typings/src/uikit/ContextualBarView.ts b/packages/core-typings/src/uikit/ContextualBarView.ts new file mode 100644 index 000000000000..ab480be19b77 --- /dev/null +++ b/packages/core-typings/src/uikit/ContextualBarView.ts @@ -0,0 +1,14 @@ +import type { ButtonElement, ContextualBarSurfaceLayout, TextObject } from '@rocket.chat/ui-kit'; + +import type { View } from './View'; + +/** + * A view that is displayed as a contextual bar. + */ +export type ContextualBarView = View & { + id: string; + title: TextObject; + close?: ButtonElement; + submit?: ButtonElement; + blocks: ContextualBarSurfaceLayout; +}; diff --git a/packages/core-typings/src/uikit/ModalView.ts b/packages/core-typings/src/uikit/ModalView.ts new file mode 100644 index 000000000000..2e2fc12befe8 --- /dev/null +++ b/packages/core-typings/src/uikit/ModalView.ts @@ -0,0 +1,15 @@ +import type { ButtonElement, ModalSurfaceLayout, TextObject } from '@rocket.chat/ui-kit'; + +import type { View } from './View'; + +/** + * A view that is displayed as a modal dialog. + */ +export type ModalView = View & { + id: string; + showIcon?: boolean; + title: TextObject; + close?: ButtonElement; + submit?: ButtonElement; + blocks: ModalSurfaceLayout; +}; diff --git a/packages/core-typings/src/uikit/ServerInteraction.ts b/packages/core-typings/src/uikit/ServerInteraction.ts new file mode 100644 index 000000000000..a5b8aabca26e --- /dev/null +++ b/packages/core-typings/src/uikit/ServerInteraction.ts @@ -0,0 +1,84 @@ +import type { BannerView } from './BannerView'; +import type { ContextualBarView } from './ContextualBarView'; +import type { ModalView } from './ModalView'; + +type OpenModalServerInteraction = { + type: 'modal.open'; + triggerId: string; + appId: string; + view: ModalView; +}; + +type UpdateModalServerInteraction = { + type: 'modal.update'; + triggerId: string; + appId: string; + view: ModalView; +}; + +type CloseModalServerInteraction = { + type: 'modal.close'; + triggerId: string; + appId: string; +}; + +type OpenBannerServerInteraction = { + type: 'banner.open'; + triggerId: string; + appId: string; +} & BannerView; + +type UpdateBannerServerInteraction = { + type: 'banner.update'; + triggerId: string; + appId: string; + view: BannerView; +}; + +type CloseBannerServerInteraction = { + type: 'banner.close'; + triggerId: string; + appId: string; + viewId: BannerView['viewId']; +}; + +type OpenContextualBarServerInteraction = { + type: 'contextual_bar.open'; + triggerId: string; + appId: string; + view: ContextualBarView; +}; + +type UpdateContextualBarServerInteraction = { + type: 'contextual_bar.update'; + triggerId: string; + appId: string; + view: ContextualBarView; +}; + +type CloseContextualBarServerInteraction = { + type: 'contextual_bar.close'; + triggerId: string; + appId: string; + view: ContextualBarView; +}; + +type ReportErrorsServerInteraction = { + type: 'errors'; + triggerId: string; + appId: string; + viewId: ModalView['id'] | BannerView['viewId'] | ContextualBarView['id']; + errors: { [field: string]: string }[]; +}; + +export type ServerInteraction = + | OpenModalServerInteraction + | UpdateModalServerInteraction + | CloseModalServerInteraction + | OpenBannerServerInteraction + | UpdateBannerServerInteraction + | CloseBannerServerInteraction + | OpenContextualBarServerInteraction + | UpdateContextualBarServerInteraction + | CloseContextualBarServerInteraction + | ReportErrorsServerInteraction; diff --git a/packages/core-typings/src/uikit/UserInteraction.ts b/packages/core-typings/src/uikit/UserInteraction.ts new file mode 100644 index 000000000000..3b65acb839f8 --- /dev/null +++ b/packages/core-typings/src/uikit/UserInteraction.ts @@ -0,0 +1,122 @@ +import type { IMessage } from '../IMessage'; +import type { IRoom } from '../IRoom'; +import type { View } from './View'; + +export type MessageBlockActionUserInteraction = { + type: 'blockAction'; + actionId: string; + payload: { + blockId: string; + value: unknown; + }; + container: { + type: 'message'; + id: IMessage['_id']; + }; + mid: IMessage['_id']; + tmid?: IMessage['_id']; + rid: IRoom['_id']; + triggerId: string; +}; + +export type ViewBlockActionUserInteraction = { + type: 'blockAction'; + actionId: string; + payload: { + blockId: string; + value: unknown; + }; + container: { + type: 'view'; + id: string; + }; + triggerId: string; +}; + +export type ViewClosedUserInteraction = { + type: 'viewClosed'; + payload: { + viewId: string; + view: View & { + id: string; + state: { [blockId: string]: { [key: string]: unknown } }; + }; + isCleared?: boolean; + }; + triggerId: string; +}; + +export type ViewSubmitUserInteraction = { + type: 'viewSubmit'; + actionId?: undefined; + payload: { + view: View & { + id: string; + state: { [blockId: string]: { [key: string]: unknown } }; + }; + }; + triggerId: string; + viewId: string; +}; + +export type MessageBoxActionButtonUserInteraction = { + type: 'actionButton'; + actionId: string; + payload: { + context: 'messageBoxAction'; + message: string; + }; + mid?: undefined; + tmid?: IMessage['_id']; + rid: IRoom['_id']; + triggerId: string; +}; + +export type UserDropdownActionButtonUserInteraction = { + type: 'actionButton'; + actionId: string; + payload: { + context: 'userDropdownAction'; + message?: undefined; + }; + mid?: undefined; + tmid?: undefined; + rid?: undefined; + triggerId: string; +}; + +export type MesssageActionButtonUserInteraction = { + type: 'actionButton'; + actionId: string; + payload: { + context: 'messageAction'; + message?: undefined; + }; + mid: IMessage['_id']; + tmid?: IMessage['_id']; + rid: IRoom['_id']; + triggerId: string; +}; + +export type RoomActionButtonUserInteraction = { + type: 'actionButton'; + actionId: string; + payload: { + context: 'roomAction'; + message?: undefined; + }; + mid?: undefined; + tmid?: undefined; + rid: IRoom['_id']; + triggerId: string; +}; + +export type UserInteraction = + | MessageBlockActionUserInteraction + | ViewBlockActionUserInteraction + | ViewClosedUserInteraction + | ViewSubmitUserInteraction + | MessageBoxActionButtonUserInteraction + | UserDropdownActionButtonUserInteraction + | MesssageActionButtonUserInteraction + | RoomActionButtonUserInteraction; diff --git a/packages/core-typings/src/uikit/View.ts b/packages/core-typings/src/uikit/View.ts new file mode 100644 index 000000000000..fe3b3a366635 --- /dev/null +++ b/packages/core-typings/src/uikit/View.ts @@ -0,0 +1,9 @@ +import type { LayoutBlock } from '@rocket.chat/ui-kit'; + +/** + * An instance of a UiKit surface and its metadata. + */ +export type View = { + appId: string; + blocks: LayoutBlock[]; +}; diff --git a/packages/core-typings/src/uikit/index.ts b/packages/core-typings/src/uikit/index.ts new file mode 100644 index 000000000000..61ab79621d1a --- /dev/null +++ b/packages/core-typings/src/uikit/index.ts @@ -0,0 +1,17 @@ +export * from '@rocket.chat/ui-kit'; +export type { + UserInteraction, + MessageBlockActionUserInteraction, + ViewBlockActionUserInteraction, + ViewClosedUserInteraction, + ViewSubmitUserInteraction, + MessageBoxActionButtonUserInteraction, + UserDropdownActionButtonUserInteraction, + MesssageActionButtonUserInteraction, + RoomActionButtonUserInteraction, +} from './UserInteraction'; +export type { View } from './View'; +export type { BannerView } from './BannerView'; +export type { ContextualBarView } from './ContextualBarView'; +export type { ModalView } from './ModalView'; +export type { ServerInteraction } from './ServerInteraction'; diff --git a/packages/core-typings/src/utils.ts b/packages/core-typings/src/utils.ts index f3f0db9da1c2..e739257c070b 100644 --- a/packages/core-typings/src/utils.ts +++ b/packages/core-typings/src/utils.ts @@ -32,3 +32,5 @@ export type DeepWritable = T extends (...args: any) => any : { -readonly [P in keyof T]: DeepWritable; }; + +export type DistributiveOmit = T extends any ? Omit : never; diff --git a/packages/fuselage-ui-kit/src/contexts/UiKitContext.ts b/packages/fuselage-ui-kit/src/contexts/UiKitContext.ts index 2c8aa02e0fa6..9e5ca1a04e5f 100644 --- a/packages/fuselage-ui-kit/src/contexts/UiKitContext.ts +++ b/packages/fuselage-ui-kit/src/contexts/UiKitContext.ts @@ -1,10 +1,15 @@ -import type { InputElementDispatchAction } from '@rocket.chat/ui-kit'; +import type { + ActionableElement, + InputElementDispatchAction, +} from '@rocket.chat/ui-kit'; import { createContext } from 'react'; +type ActionId = ActionableElement['actionId']; + type ActionParams = { blockId: string; appId: string; - actionId: string; + actionId: ActionId; value: unknown; viewId?: string; dispatchActionConfig?: InputElementDispatchAction[]; @@ -21,7 +26,7 @@ type UiKitContextValue = { ) => Promise | void; appId: string; errors?: Record; - values: Record; + values: Record; viewId?: string; rid?: string; }; diff --git a/packages/fuselage-ui-kit/src/elements/MarkdownTextElement.tsx b/packages/fuselage-ui-kit/src/elements/MarkdownTextElement.tsx index da66e4f299fb..0ff2631d7a3a 100644 --- a/packages/fuselage-ui-kit/src/elements/MarkdownTextElement.tsx +++ b/packages/fuselage-ui-kit/src/elements/MarkdownTextElement.tsx @@ -2,15 +2,16 @@ import { Markup } from '@rocket.chat/gazzodown'; import { parse } from '@rocket.chat/message-parser'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { TextObject } from '@rocket.chat/ui-kit'; +import { useContext } from 'react'; -import { useUiKitContext } from '../hooks/useUiKitContext'; +import { UiKitContext } from '../contexts/UiKitContext'; const MarkdownTextElement = ({ textObject }: { textObject: TextObject }) => { const t = useTranslation() as ( key: string, args: { [key: string]: string | number } ) => string; - const { appId } = useUiKitContext(); + const { appId } = useContext(UiKitContext); const { i18n } = textObject; diff --git a/packages/fuselage-ui-kit/src/elements/PlainTextElement.tsx b/packages/fuselage-ui-kit/src/elements/PlainTextElement.tsx index bdb59e523dee..4e692caa0993 100644 --- a/packages/fuselage-ui-kit/src/elements/PlainTextElement.tsx +++ b/packages/fuselage-ui-kit/src/elements/PlainTextElement.tsx @@ -1,14 +1,15 @@ import { useTranslation } from '@rocket.chat/ui-contexts'; import type { TextObject } from '@rocket.chat/ui-kit'; +import { useContext } from 'react'; -import { useUiKitContext } from '../hooks/useUiKitContext'; +import { UiKitContext } from '../contexts/UiKitContext'; const PlainTextElement = ({ textObject }: { textObject: TextObject }) => { const t = useTranslation() as ( key: string, args: { [key: string]: string | number } ) => string; - const { appId } = useUiKitContext(); + const { appId } = useContext(UiKitContext); const { i18n } = textObject; diff --git a/packages/fuselage-ui-kit/src/extractInitialStateFromLayout.ts b/packages/fuselage-ui-kit/src/extractInitialStateFromLayout.ts new file mode 100644 index 000000000000..10b6790d976a --- /dev/null +++ b/packages/fuselage-ui-kit/src/extractInitialStateFromLayout.ts @@ -0,0 +1,90 @@ +import type * as UiKit from '@rocket.chat/ui-kit'; + +type Value = { value: unknown; blockId?: string }; + +type LayoutBlockWithElement = Extract< + UiKit.LayoutBlock, + { element: UiKit.BlockElement | UiKit.TextObject } +>; +type LayoutBlockWithElements = Extract< + UiKit.LayoutBlock, + { elements: readonly (UiKit.BlockElement | UiKit.TextObject)[] } +>; + +const hasElement = ( + block: UiKit.LayoutBlock +): block is LayoutBlockWithElement => 'element' in block; + +const hasElements = ( + block: UiKit.LayoutBlock +): block is LayoutBlockWithElements => + 'elements' in block && Array.isArray(block.elements); + +const isActionableElement = ( + element: UiKit.BlockElement | UiKit.TextObject +): element is UiKit.ActionableElement => + 'actionId' in element && typeof element.actionId === 'string'; + +const hasInitialValue = ( + element: UiKit.ActionableElement +): element is UiKit.ActionableElement & { initialValue: number | string } => + 'initialValue' in element; + +const hasInitialTime = ( + element: UiKit.ActionableElement +): element is UiKit.ActionableElement & { initialTime: string } => + 'initialTime' in element; + +const hasInitialDate = ( + element: UiKit.ActionableElement +): element is UiKit.ActionableElement & { initialDate: string } => + 'initialDate' in element; + +const hasInitialOption = ( + element: UiKit.ActionableElement +): element is UiKit.ActionableElement & { initialOption: UiKit.Option } => + 'initialOption' in element; + +const hasInitialOptions = ( + element: UiKit.ActionableElement +): element is UiKit.ActionableElement & { initialOptions: UiKit.Option[] } => + 'initialOptions' in element; + +const getInitialValue = (element: UiKit.ActionableElement) => + (hasInitialValue(element) && element.initialValue) || + (hasInitialTime(element) && element.initialTime) || + (hasInitialDate(element) && element.initialDate) || + (hasInitialOption(element) && element.initialOption.value) || + (hasInitialOptions(element) && + element.initialOptions.map((option) => option.value)) || + undefined; + +const reduceInitialValuesFromLayoutBlock = ( + state: { [actionId: string]: Value }, + block: UiKit.LayoutBlock +) => { + if (hasElement(block)) { + if (isActionableElement(block.element)) { + state[block.element.actionId] = { + value: getInitialValue(block.element), + blockId: block.blockId, + }; + } + } + + if (hasElements(block)) { + for (const element of block.elements) { + if (isActionableElement(element)) { + state[element.actionId] = { + value: getInitialValue(element), + blockId: block.blockId, + }; + } + } + } + + return state; +}; + +export const extractInitialStateFromLayout = (blocks: UiKit.LayoutBlock[]) => + blocks.reduce(reduceInitialValuesFromLayoutBlock, {}); diff --git a/packages/fuselage-ui-kit/src/hooks/useUiKitContext.ts b/packages/fuselage-ui-kit/src/hooks/useUiKitContext.ts deleted file mode 100644 index 1924b96d507e..000000000000 --- a/packages/fuselage-ui-kit/src/hooks/useUiKitContext.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { useContext } from 'react'; - -import { UiKitContext } from '../contexts/UiKitContext'; - -export const useUiKitContext = () => useContext(UiKitContext); diff --git a/packages/fuselage-ui-kit/src/hooks/useUiKitState.ts b/packages/fuselage-ui-kit/src/hooks/useUiKitState.ts index 5cbae5db2b5d..56fc553b1996 100644 --- a/packages/fuselage-ui-kit/src/hooks/useUiKitState.ts +++ b/packages/fuselage-ui-kit/src/hooks/useUiKitState.ts @@ -3,16 +3,6 @@ import * as UiKit from '@rocket.chat/ui-kit'; import { useContext, useMemo, useState } from 'react'; import { UiKitContext } from '../contexts/UiKitContext'; -import { useUiKitStateValue } from './useUiKitStateValue'; - -type UiKitState< - TElement extends UiKit.ActionableElement = UiKit.ActionableElement -> = { - loading: boolean; - setLoading: (loading: boolean) => void; - error?: string; - value: UiKit.ActionOf; -}; const hasInitialValue = ( element: TElement @@ -37,10 +27,48 @@ const hasInitialOptions = ( ): element is TElement & { initialOptions: UiKit.Option[] } => 'initialOptions' in element; -export const useUiKitState: ( +const getInitialValue = ( + element: TElement +) => + (hasInitialValue(element) && element.initialValue) || + (hasInitialTime(element) && element.initialTime) || + (hasInitialDate(element) && element.initialDate) || + (hasInitialOption(element) && element.initialOption.value) || + (hasInitialOptions(element) && + element.initialOptions.map((option) => option.value)) || + undefined; + +const getElementValueFromState = ( + actionId: string, + values: Record< + string, + | { + value: unknown; + } + | undefined + >, + initialValue: string | number | string[] | undefined +) => { + return ( + (values && + (values[actionId]?.value as string | number | string[] | undefined)) ?? + initialValue + ); +}; + +type UiKitState< + TElement extends UiKit.ActionableElement = UiKit.ActionableElement +> = { + loading: boolean; + setLoading: (loading: boolean) => void; + error?: string; + value: UiKit.ActionOf; +}; + +export const useUiKitState = ( element: TElement, context: UiKit.BlockContext -) => [ +): [ state: UiKitState, action: ( pseudoEvent?: @@ -48,8 +76,8 @@ export const useUiKitState: ( | { target: EventTarget } | { target: { value: UiKit.ActionOf } } ) => void -] = (rest, context) => { - const { blockId, actionId, appId, dispatchActionConfig } = rest; +] => { + const { blockId, actionId, appId, dispatchActionConfig } = element; const { action, appId: appIdFromContext, @@ -57,16 +85,13 @@ export const useUiKitState: ( state, } = useContext(UiKitContext); - const initialValue = - (hasInitialValue(rest) && rest.initialValue) || - (hasInitialTime(rest) && rest.initialTime) || - (hasInitialDate(rest) && rest.initialDate) || - (hasInitialOption(rest) && rest.initialOption.value) || - (hasInitialOptions(rest) && - rest.initialOptions.map((option) => option.value)) || - undefined; + const initialValue = getInitialValue(element); + + const { values, errors } = useContext(UiKitContext); + + const _value = getElementValueFromState(actionId, values, initialValue); + const error = errors?.[actionId]; - const { value: _value, error } = useUiKitStateValue(actionId, initialValue); const [value, setValue] = useSafely(useState(_value)); const [loading, setLoading] = useSafely(useState(false)); @@ -147,9 +172,9 @@ export const useUiKitState: ( ); if ( - rest.type === 'plain_text_input' && - Array.isArray(rest?.dispatchActionConfig) && - rest.dispatchActionConfig.includes('on_character_entered') + element.type === 'plain_text_input' && + Array.isArray(element?.dispatchActionConfig) && + element.dispatchActionConfig.includes('on_character_entered') ) { return [result, noLoadStateActionFunction]; } @@ -159,8 +184,8 @@ export const useUiKitState: ( [UiKit.BlockContext.SECTION, UiKit.BlockContext.ACTION].includes( context )) || - (Array.isArray(rest?.dispatchActionConfig) && - rest.dispatchActionConfig.includes('on_item_selected')) + (Array.isArray(element?.dispatchActionConfig) && + element.dispatchActionConfig.includes('on_item_selected')) ) { return [result, actionFunction]; } diff --git a/packages/fuselage-ui-kit/src/hooks/useUiKitStateValue.ts b/packages/fuselage-ui-kit/src/hooks/useUiKitStateValue.ts deleted file mode 100644 index 8d7e81aa69c5..000000000000 --- a/packages/fuselage-ui-kit/src/hooks/useUiKitStateValue.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { useUiKitContext } from './useUiKitContext'; - -export const useUiKitStateValue = < - T extends string | string[] | number | undefined ->( - actionId: string, - initialValue: T -): { - value: T; - error: string | undefined; -} => { - const { values, errors } = useUiKitContext(); - - return { - value: (values && (values[actionId]?.value as T)) ?? initialValue, - error: errors?.[actionId], - }; -}; diff --git a/packages/fuselage-ui-kit/src/index.ts b/packages/fuselage-ui-kit/src/index.ts index 95a713de071a..9db1f2097835 100644 --- a/packages/fuselage-ui-kit/src/index.ts +++ b/packages/fuselage-ui-kit/src/index.ts @@ -2,3 +2,4 @@ export * from './hooks/useUiKitState'; export * from './contexts/UiKitContext'; export * from './surfaces'; export { UiKitComponent } from './utils/UiKitComponent'; +export { extractInitialStateFromLayout } from './extractInitialStateFromLayout'; diff --git a/packages/mock-providers/src/MockedAppRootBuilder.tsx b/packages/mock-providers/src/MockedAppRootBuilder.tsx index 9e114be87d15..ae3eeea4cf1d 100644 --- a/packages/mock-providers/src/MockedAppRootBuilder.tsx +++ b/packages/mock-providers/src/MockedAppRootBuilder.tsx @@ -430,16 +430,13 @@ export class MockedAppRootBuilder { */} Promise.reject(new Error('not implemented')), generateTriggerId: () => '', - getUserInteractionPayloadByViewId: () => undefined, - handlePayloadUserInteraction: () => undefined, + emitInteraction: () => Promise.reject(new Error('not implemented')), + getInteractionPayloadByViewId: () => undefined, + handleServerInteraction: () => undefined, off: () => undefined, on: () => undefined, - triggerActionButtonAction: () => Promise.reject(new Error('not implemented')), - triggerBlockAction: () => Promise.reject(new Error('not implemented')), - triggerCancel: () => Promise.reject(new Error('not implemented')), - triggerSubmitView: () => Promise.reject(new Error('not implemented')), + disposeView: () => undefined, }} > {/* diff --git a/packages/rest-typings/src/apps/index.ts b/packages/rest-typings/src/apps/index.ts index 06ce6d98169e..31427afb3fee 100644 --- a/packages/rest-typings/src/apps/index.ts +++ b/packages/rest-typings/src/apps/index.ts @@ -12,6 +12,7 @@ import type { AppRequestFilter, AppRequestsStats, PaginatedAppRequests, + UiKit, } from '@rocket.chat/core-typings'; export type AppsEndpoints = { @@ -258,15 +259,6 @@ export type AppsEndpoints = { }; '/apps/ui.interaction/:id': { - POST: (params: { - type: string; - actionId: string; - rid: string; - mid: string; - viewId: string; - container: string; - triggerId: string; - payload: any; - }) => any; + POST: (params: UiKit.UserInteraction) => any; }; }; diff --git a/packages/ui-contexts/src/ActionManagerContext.ts b/packages/ui-contexts/src/ActionManagerContext.ts index d4dcdb61bfb9..76ca45cb6080 100644 --- a/packages/ui-contexts/src/ActionManagerContext.ts +++ b/packages/ui-contexts/src/ActionManagerContext.ts @@ -1,45 +1,20 @@ +import type { DistributiveOmit, UiKit } from '@rocket.chat/core-typings'; import { createContext } from 'react'; -type ActionManagerContextValue = { - on: (...args: any[]) => void; - off: (...args: any[]) => void; - generateTriggerId: (appId: any) => string; - handlePayloadUserInteraction: ( - type: any, - { - triggerId, - ...data - }: { - [x: string]: any; - triggerId: any; - }, - ) => any; - triggerAction: ({ - type, - actionId, - appId, - rid, - mid, - viewId, - container, - tmid, - ...rest - }: { - [x: string]: any; - type: any; - actionId: any; - appId: any; - rid: any; - mid: any; - viewId: any; - container: any; - tmid: any; - }) => Promise; - triggerBlockAction: (options: any) => Promise; - triggerActionButtonAction: (options: any) => Promise; - triggerSubmitView: ({ viewId, ...options }: { [x: string]: any; viewId: any }) => Promise; - triggerCancel: ({ view, ...options }: { [x: string]: any; view: any }) => Promise; - getUserInteractionPayloadByViewId: (viewId: any) => any; +type ActionManager = { + on(viewId: string, listener: (data: any) => void): void; + on(eventName: 'busy', listener: ({ busy }: { busy: boolean }) => void): void; + off(viewId: string, listener: (data: any) => any): void; + off(eventName: 'busy', listener: ({ busy }: { busy: boolean }) => void): void; + generateTriggerId(appId: string | undefined): string; + emitInteraction(appId: string, userInteraction: DistributiveOmit): Promise; + handleServerInteraction(interaction: UiKit.ServerInteraction): UiKit.ServerInteraction['type'] | undefined; + getInteractionPayloadByViewId(viewId: UiKit.ContextualBarView['id']): + | { + view: UiKit.ContextualBarView; + } + | undefined; + disposeView(viewId: UiKit.ModalView['id'] | UiKit.BannerView['viewId'] | UiKit.ContextualBarView['id']): void; }; -export const ActionManagerContext = createContext(undefined); +export const ActionManagerContext = createContext(undefined); diff --git a/yarn.lock b/yarn.lock index ce6dc859fc3d..4b4fd7f27bc9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -34263,7 +34263,7 @@ __metadata: "@types/chart.js": ^2.9.37 "@types/js-yaml": ^4.0.5 husky: ^7.0.4 - turbo: ~1.10.14 + turbo: ~1.10.15 languageName: unknown linkType: soft @@ -37678,58 +37678,58 @@ __metadata: languageName: node linkType: hard -"turbo-darwin-64@npm:1.10.14": - version: 1.10.14 - resolution: "turbo-darwin-64@npm:1.10.14" +"turbo-darwin-64@npm:1.10.15": + version: 1.10.15 + resolution: "turbo-darwin-64@npm:1.10.15" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"turbo-darwin-arm64@npm:1.10.14": - version: 1.10.14 - resolution: "turbo-darwin-arm64@npm:1.10.14" +"turbo-darwin-arm64@npm:1.10.15": + version: 1.10.15 + resolution: "turbo-darwin-arm64@npm:1.10.15" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"turbo-linux-64@npm:1.10.14": - version: 1.10.14 - resolution: "turbo-linux-64@npm:1.10.14" +"turbo-linux-64@npm:1.10.15": + version: 1.10.15 + resolution: "turbo-linux-64@npm:1.10.15" conditions: os=linux & cpu=x64 languageName: node linkType: hard -"turbo-linux-arm64@npm:1.10.14": - version: 1.10.14 - resolution: "turbo-linux-arm64@npm:1.10.14" +"turbo-linux-arm64@npm:1.10.15": + version: 1.10.15 + resolution: "turbo-linux-arm64@npm:1.10.15" conditions: os=linux & cpu=arm64 languageName: node linkType: hard -"turbo-windows-64@npm:1.10.14": - version: 1.10.14 - resolution: "turbo-windows-64@npm:1.10.14" +"turbo-windows-64@npm:1.10.15": + version: 1.10.15 + resolution: "turbo-windows-64@npm:1.10.15" conditions: os=win32 & cpu=x64 languageName: node linkType: hard -"turbo-windows-arm64@npm:1.10.14": - version: 1.10.14 - resolution: "turbo-windows-arm64@npm:1.10.14" +"turbo-windows-arm64@npm:1.10.15": + version: 1.10.15 + resolution: "turbo-windows-arm64@npm:1.10.15" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"turbo@npm:~1.10.14": - version: 1.10.14 - resolution: "turbo@npm:1.10.14" +"turbo@npm:~1.10.15": + version: 1.10.15 + resolution: "turbo@npm:1.10.15" dependencies: - turbo-darwin-64: 1.10.14 - turbo-darwin-arm64: 1.10.14 - turbo-linux-64: 1.10.14 - turbo-linux-arm64: 1.10.14 - turbo-windows-64: 1.10.14 - turbo-windows-arm64: 1.10.14 + turbo-darwin-64: 1.10.15 + turbo-darwin-arm64: 1.10.15 + turbo-linux-64: 1.10.15 + turbo-linux-arm64: 1.10.15 + turbo-windows-64: 1.10.15 + turbo-windows-arm64: 1.10.15 dependenciesMeta: turbo-darwin-64: optional: true @@ -37745,7 +37745,7 @@ __metadata: optional: true bin: turbo: bin/turbo - checksum: 219d245bb5cc32a9f76b136b81e86e179228d93a44cab4df3e3d487a55dd2688b5b85f4d585b66568ac53166145352399dd2d7ed0cd47f1aae63d08beb814ebb + checksum: b494c8bf79355874919e76ee0e4a0a53616e0ae5c7126eb1add50e67d4cd1e445ed9aecf99cb6d81c592b7a43ba91cd7dbf30df70410a44cecedba8b5126095d languageName: node linkType: hard From ab0c287a62e91a42ebd4b0da8b0fec76d5f8950e Mon Sep 17 00:00:00 2001 From: Akash Bag <110753356+akash0708@users.noreply.github.com> Date: Thu, 19 Oct 2023 19:15:17 +0530 Subject: [PATCH 12/26] docs: Fixed typo in FEATURES.md (#30683) --- FEATURES.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index e4b11c141184..5601cc0c7ccc 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1,10 +1,10 @@ # Features - Self Host - - Docker - - Multiple Deployment Options (Heroku, Digital Ocean, Sandstorm, etc.) + - Docker + - Multiple Deployment Options (Heroku, Digital Ocean, Sandstorm, etc.) - Authentication Options - - OAuth + - OAuth - SAML - LDAP - CAS (1.0, 2.0 + attribute sync) @@ -19,7 +19,7 @@ - Rich Media - Audio Calls - Video Conferencing - - Screensharing + - Screen Sharing - Notifications - Desktop and Mobile - Use your own gateway From f7b07a0fc58f4c3b46fe1dea773f6a433fed720e Mon Sep 17 00:00:00 2001 From: Murtaza Patrawala <34130764+murtaza98@users.noreply.github.com> Date: Thu, 19 Oct 2023 20:47:38 +0530 Subject: [PATCH 13/26] feat: Allow CE users to customise Business hour timezone (#30565) --- .changeset/tidy-cows-destroy.md | 5 ++++ .../views/app/business-hours/BusinessHours.ts | 4 --- .../business-hours/IBusinessHourBehavior.ts | 1 - .../client/views/app/business-hours/Single.ts | 4 --- .../BusinessHoursFormContainer.js | 15 +++++----- .../businessHours}/BusinessHoursTimeZone.js | 4 +-- .../BusinessHoursTimeZone.stories.tsx | 2 +- .../client/SingleBusinessHour.ts | 7 ----- .../app/livechat-enterprise/client/startup.ts | 9 +++--- .../client/views/business-hours/Multiple.ts | 4 --- .../server/business-hour/Helper.ts | 29 +------------------ .../app/livechat-enterprise/server/startup.ts | 3 -- .../omnichannel/additionalForms/register.ts | 3 -- 13 files changed, 22 insertions(+), 68 deletions(-) create mode 100644 .changeset/tidy-cows-destroy.md rename apps/meteor/{ee/client/omnichannel/additionalForms => client/views/omnichannel/businessHours}/BusinessHoursTimeZone.js (86%) rename apps/meteor/{ee/client/omnichannel/additionalForms => client/views/omnichannel/businessHours}/BusinessHoursTimeZone.stories.tsx (91%) delete mode 100644 apps/meteor/ee/app/livechat-enterprise/client/SingleBusinessHour.ts diff --git a/.changeset/tidy-cows-destroy.md b/.changeset/tidy-cows-destroy.md new file mode 100644 index 000000000000..0b222f8157a9 --- /dev/null +++ b/.changeset/tidy-cows-destroy.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +feat: Community users will now be able to customize their Business hour timezone diff --git a/apps/meteor/app/livechat/client/views/app/business-hours/BusinessHours.ts b/apps/meteor/app/livechat/client/views/app/business-hours/BusinessHours.ts index 0935ac7554e4..3c723cc46257 100644 --- a/apps/meteor/app/livechat/client/views/app/business-hours/BusinessHours.ts +++ b/apps/meteor/app/livechat/client/views/app/business-hours/BusinessHours.ts @@ -29,10 +29,6 @@ class BusinessHoursManager { showBackButton(): boolean { return this.behavior.showBackButton(); } - - showTimezoneTemplate(): boolean { - return this.behavior.showTimezoneTemplate(); - } } export const businessHourManager = new BusinessHoursManager(new SingleBusinessHourBehavior()); diff --git a/apps/meteor/app/livechat/client/views/app/business-hours/IBusinessHourBehavior.ts b/apps/meteor/app/livechat/client/views/app/business-hours/IBusinessHourBehavior.ts index 10a51fe25e25..1ba6e4a56907 100644 --- a/apps/meteor/app/livechat/client/views/app/business-hours/IBusinessHourBehavior.ts +++ b/apps/meteor/app/livechat/client/views/app/business-hours/IBusinessHourBehavior.ts @@ -4,5 +4,4 @@ export interface IBusinessHourBehavior { getView(): string; showCustomTemplate(businessHourData: ILivechatBusinessHour): boolean; showBackButton(): boolean; - showTimezoneTemplate(): boolean; } diff --git a/apps/meteor/app/livechat/client/views/app/business-hours/Single.ts b/apps/meteor/app/livechat/client/views/app/business-hours/Single.ts index 1ce09a63d79a..9b343264ca54 100644 --- a/apps/meteor/app/livechat/client/views/app/business-hours/Single.ts +++ b/apps/meteor/app/livechat/client/views/app/business-hours/Single.ts @@ -12,8 +12,4 @@ export class SingleBusinessHourBehavior implements IBusinessHourBehavior { showBackButton(): boolean { return false; } - - showTimezoneTemplate(): boolean { - return false; - } } diff --git a/apps/meteor/client/views/omnichannel/businessHours/BusinessHoursFormContainer.js b/apps/meteor/client/views/omnichannel/businessHours/BusinessHoursFormContainer.js index c274ebee272d..9acf9d2fd167 100644 --- a/apps/meteor/client/views/omnichannel/businessHours/BusinessHoursFormContainer.js +++ b/apps/meteor/client/views/omnichannel/businessHours/BusinessHoursFormContainer.js @@ -7,6 +7,7 @@ import { useForm } from '../../../hooks/useForm'; import { useReactiveValue } from '../../../hooks/useReactiveValue'; import { useFormsSubscription } from '../additionalForms'; import BusinessHourForm from './BusinessHoursForm'; +import BusinessHoursTimeZone from './BusinessHoursTimeZone'; const useChangeHandler = (name, ref) => useMutableCallback((val) => { @@ -29,12 +30,10 @@ const BusinessHoursFormContainer = ({ data, saveRef, onChange = () => {} }) => { const [hasChangesMultiple, setHasChangesMultiple] = useState(false); const [hasChangesTimeZone, setHasChangesTimeZone] = useState(false); - const { useBusinessHoursTimeZone = cleanFunc, useBusinessHoursMultiple = cleanFunc } = forms; + const { useBusinessHoursMultiple = cleanFunc } = forms; - const TimezoneForm = useBusinessHoursTimeZone(); const MultipleBHForm = useBusinessHoursMultiple(); - const showTimezone = useReactiveValue(useMutableCallback(() => businessHourManager.showTimezoneTemplate())); const showMultipleBHForm = useReactiveValue(useMutableCallback(() => businessHourManager.showCustomTemplate(data))); const onChangeTimezone = useChangeHandler('timezone', saveRef); @@ -45,7 +44,7 @@ const BusinessHoursFormContainer = ({ data, saveRef, onChange = () => {} }) => { saveRef.current.form = values; useEffect(() => { - onChange(hasUnsavedChanges || (showMultipleBHForm && hasChangesMultiple) || (showTimezone && hasChangesTimeZone)); + onChange(hasUnsavedChanges || (showMultipleBHForm && hasChangesMultiple) || hasChangesTimeZone); }); return ( @@ -54,9 +53,11 @@ const BusinessHoursFormContainer = ({ data, saveRef, onChange = () => {} }) => { {showMultipleBHForm && MultipleBHForm && ( )} - {showTimezone && TimezoneForm && ( - - )} + diff --git a/apps/meteor/ee/client/omnichannel/additionalForms/BusinessHoursTimeZone.js b/apps/meteor/client/views/omnichannel/businessHours/BusinessHoursTimeZone.js similarity index 86% rename from apps/meteor/ee/client/omnichannel/additionalForms/BusinessHoursTimeZone.js rename to apps/meteor/client/views/omnichannel/businessHours/BusinessHoursTimeZone.js index c305dc084e68..8826fc621455 100644 --- a/apps/meteor/ee/client/omnichannel/additionalForms/BusinessHoursTimeZone.js +++ b/apps/meteor/client/views/omnichannel/businessHours/BusinessHoursTimeZone.js @@ -2,8 +2,8 @@ import { SelectFiltered, Field } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; import React, { useMemo } from 'react'; -import { useForm } from '../../../../client/hooks/useForm'; -import { useTimezoneNameList } from '../../../../client/hooks/useTimezoneNameList'; +import { useForm } from '../../../hooks/useForm'; +import { useTimezoneNameList } from '../../../hooks/useTimezoneNameList'; const getInitialData = (data = {}) => ({ name: data ?? '', diff --git a/apps/meteor/ee/client/omnichannel/additionalForms/BusinessHoursTimeZone.stories.tsx b/apps/meteor/client/views/omnichannel/businessHours/BusinessHoursTimeZone.stories.tsx similarity index 91% rename from apps/meteor/ee/client/omnichannel/additionalForms/BusinessHoursTimeZone.stories.tsx rename to apps/meteor/client/views/omnichannel/businessHours/BusinessHoursTimeZone.stories.tsx index 35c133caf72a..af00db33322e 100644 --- a/apps/meteor/ee/client/omnichannel/additionalForms/BusinessHoursTimeZone.stories.tsx +++ b/apps/meteor/client/views/omnichannel/businessHours/BusinessHoursTimeZone.stories.tsx @@ -5,7 +5,7 @@ import React from 'react'; import BusinessHoursTimeZone from './BusinessHoursTimeZone'; export default { - title: 'Enterprise/Omnichannel/BusinessHoursTimeZone', + title: 'Omnichannel/BusinessHoursTimeZone', component: BusinessHoursTimeZone, decorators: [ (fn) => ( diff --git a/apps/meteor/ee/app/livechat-enterprise/client/SingleBusinessHour.ts b/apps/meteor/ee/app/livechat-enterprise/client/SingleBusinessHour.ts deleted file mode 100644 index e1f8b852fbc6..000000000000 --- a/apps/meteor/ee/app/livechat-enterprise/client/SingleBusinessHour.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { SingleBusinessHourBehavior } from '../../../../app/livechat/client/views/app/business-hours/Single'; - -export class EESingleBusinessHourBehaviour extends SingleBusinessHourBehavior { - showTimezoneTemplate(): boolean { - return true; - } -} diff --git a/apps/meteor/ee/app/livechat-enterprise/client/startup.ts b/apps/meteor/ee/app/livechat-enterprise/client/startup.ts index 11027a62439b..3c3ec1c02139 100644 --- a/apps/meteor/ee/app/livechat-enterprise/client/startup.ts +++ b/apps/meteor/ee/app/livechat-enterprise/client/startup.ts @@ -2,20 +2,21 @@ import { Meteor } from 'meteor/meteor'; import { businessHourManager } from '../../../../app/livechat/client/views/app/business-hours/BusinessHours'; import type { IBusinessHourBehavior } from '../../../../app/livechat/client/views/app/business-hours/IBusinessHourBehavior'; +import { SingleBusinessHourBehavior } from '../../../../app/livechat/client/views/app/business-hours/Single'; import { settings } from '../../../../app/settings/client'; import { hasLicense } from '../../license/client'; -import { EESingleBusinessHourBehaviour } from './SingleBusinessHour'; import { MultipleBusinessHoursBehavior } from './views/business-hours/Multiple'; const businessHours: Record = { multiple: new MultipleBusinessHoursBehavior(), - single: new EESingleBusinessHourBehaviour(), + single: new SingleBusinessHourBehavior(), }; Meteor.startup(() => { - settings.onload('Livechat_business_hour_type', async (_, value) => { + Tracker.autorun(async () => { + const bhType = settings.get('Livechat_business_hour_type'); if (await hasLicense('livechat-enterprise')) { - businessHourManager.registerBusinessHourBehavior(businessHours[(value as string).toLowerCase()]); + businessHourManager.registerBusinessHourBehavior(businessHours[bhType.toLowerCase()]); } }); }); diff --git a/apps/meteor/ee/app/livechat-enterprise/client/views/business-hours/Multiple.ts b/apps/meteor/ee/app/livechat-enterprise/client/views/business-hours/Multiple.ts index cbe3fe9088fe..a57344da73dc 100644 --- a/apps/meteor/ee/app/livechat-enterprise/client/views/business-hours/Multiple.ts +++ b/apps/meteor/ee/app/livechat-enterprise/client/views/business-hours/Multiple.ts @@ -12,10 +12,6 @@ export class MultipleBusinessHoursBehavior implements IBusinessHourBehavior { return !businessHourData._id || businessHourData.type !== LivechatBusinessHourTypes.DEFAULT; } - showTimezoneTemplate(): boolean { - return true; - } - showBackButton(): boolean { return true; } diff --git a/apps/meteor/ee/app/livechat-enterprise/server/business-hour/Helper.ts b/apps/meteor/ee/app/livechat-enterprise/server/business-hour/Helper.ts index a441e122ef99..a91bb87f28bb 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/business-hour/Helper.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/business-hour/Helper.ts @@ -1,8 +1,6 @@ import type { ILivechatBusinessHour } from '@rocket.chat/core-typings'; import { LivechatBusinessHourTypes } from '@rocket.chat/core-typings'; -import { License } from '@rocket.chat/license'; -import { LivechatBusinessHours, LivechatDepartment, LivechatDepartmentAgents, Users } from '@rocket.chat/models'; -import moment from 'moment-timezone'; +import { LivechatDepartment, LivechatDepartmentAgents, Users } from '@rocket.chat/models'; import { businessHourLogger } from '../../../../../app/livechat/server/lib/logger'; @@ -103,28 +101,3 @@ export const removeBusinessHourByAgentIds = async (agentIds: string[], businessH await Users.removeBusinessHourByAgentIds(agentIds, businessHourId); await Users.updateLivechatStatusBasedOnBusinessHours(); }; - -export const resetDefaultBusinessHourIfNeeded = async (): Promise => { - if (License.hasValidLicense()) { - return; - } - - const defaultBusinessHour = await LivechatBusinessHours.findOneDefaultBusinessHour>({ - projection: { _id: 1 }, - }); - if (!defaultBusinessHour) { - return; - } - - await LivechatBusinessHours.updateOne( - { _id: defaultBusinessHour._id }, - { - $set: { - timezone: { - name: moment.tz.guess(), - utc: String(moment().utcOffset() / 60), - }, - }, - }, - ); -}; diff --git a/apps/meteor/ee/app/livechat-enterprise/server/startup.ts b/apps/meteor/ee/app/livechat-enterprise/server/startup.ts index 29cf5c308d71..1b277d6fba52 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/startup.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/startup.ts @@ -3,7 +3,6 @@ import { Meteor } from 'meteor/meteor'; import { businessHourManager } from '../../../../app/livechat/server/business-hour'; import { SingleBusinessHourBehavior } from '../../../../app/livechat/server/business-hour/Single'; import { settings } from '../../../../app/settings/server'; -import { resetDefaultBusinessHourIfNeeded } from './business-hour/Helper'; import { MultipleBusinessHoursBehavior } from './business-hour/Multiple'; import { updatePredictedVisitorAbandonment, updateQueueInactivityTimeout } from './lib/Helper'; import { VisitorInactivityMonitor } from './lib/VisitorInactivityMonitor'; @@ -43,6 +42,4 @@ Meteor.startup(async () => { logger.debug(`Business hour manager started`); } }); - - await resetDefaultBusinessHourIfNeeded(); }); diff --git a/apps/meteor/ee/client/omnichannel/additionalForms/register.ts b/apps/meteor/ee/client/omnichannel/additionalForms/register.ts index df11a435ab5c..d52a20e40734 100644 --- a/apps/meteor/ee/client/omnichannel/additionalForms/register.ts +++ b/apps/meteor/ee/client/omnichannel/additionalForms/register.ts @@ -6,7 +6,6 @@ import { registerForm } from '../../../../client/views/omnichannel/additionalFor import { hasLicense } from '../../../app/license/client'; import type CurrentChatTags from '../tags/CurrentChatTags'; import type BusinessHoursMultipleContainer from './BusinessHoursMultipleContainer'; -import type BusinessHoursTimeZone from './BusinessHoursTimeZone'; import type ContactManager from './ContactManager'; import type CustomFieldsAdditionalFormContainer from './CustomFieldsAdditionalFormContainer'; import type DepartmentBusinessHours from './DepartmentBusinessHours'; @@ -29,7 +28,6 @@ declare module '../../../../client/views/omnichannel/additionalForms' { useEeTextAreaInput?: () => LazyExoticComponent; useBusinessHoursMultiple?: () => LazyExoticComponent; useEeTextInput?: () => LazyExoticComponent; - useBusinessHoursTimeZone?: () => LazyExoticComponent; useContactManager?: () => LazyExoticComponent; useCurrentChatTags?: () => LazyExoticComponent; @@ -54,7 +52,6 @@ hasLicense('livechat-enterprise').then((enabled) => { useEeTextAreaInput: () => useMemo(() => lazy(() => import('./EeTextAreaInput')), []), useBusinessHoursMultiple: () => useMemo(() => lazy(() => import('./BusinessHoursMultipleContainer')), []), useEeTextInput: () => useMemo(() => lazy(() => import('./EeTextInput')), []), - useBusinessHoursTimeZone: () => useMemo(() => lazy(() => import('./BusinessHoursTimeZone')), []), useContactManager: () => useMemo(() => lazy(() => import('./ContactManager')), []), useCurrentChatTags: () => useMemo(() => lazy(() => import('../tags/CurrentChatTags')), []), useDepartmentBusinessHours: () => useMemo(() => lazy(() => import('./DepartmentBusinessHours')), []), From 704ed0fc7b77b1086d2819072a9bdaad714a28b0 Mon Sep 17 00:00:00 2001 From: Yash Rajpal <58601732+yash-rajpal@users.noreply.github.com> Date: Thu, 19 Oct 2023 22:44:31 +0530 Subject: [PATCH 14/26] fix: i18n translations using sprintf post processor (#30685) --- .changeset/perfect-onions-develop.md | 5 +++++ apps/meteor/app/utils/lib/i18n.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/perfect-onions-develop.md diff --git a/.changeset/perfect-onions-develop.md b/.changeset/perfect-onions-develop.md new file mode 100644 index 000000000000..3ca5c3e00bb7 --- /dev/null +++ b/.changeset/perfect-onions-develop.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fix i18n translations using sprintf post processor diff --git a/apps/meteor/app/utils/lib/i18n.ts b/apps/meteor/app/utils/lib/i18n.ts index 13d5c667709d..7fa491d965e8 100644 --- a/apps/meteor/app/utils/lib/i18n.ts +++ b/apps/meteor/app/utils/lib/i18n.ts @@ -7,7 +7,7 @@ export const i18n = i18next.use(sprintf); export const addSprinfToI18n = function (t: (typeof i18n)['t']) { return function (key: string, ...replaces: any): string { - if (replaces[0] === undefined || isObject(replaces[0])) { + if (replaces[0] === undefined || (isObject(replaces[0]) && !Array.isArray(replaces[0]))) { return t(key, ...replaces); } From 5b3ff91d1bb09f78ff19b92a4997d09b30d2597f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Jaeger=20Foresti?= <60678893+juliajforesti@users.noreply.github.com> Date: Thu, 19 Oct 2023 16:31:40 -0300 Subject: [PATCH 15/26] feat: move a11y features to CE (#30676) --- .../providers/UserProvider/UserProvider.tsx | 9 -- .../accessibility/AccessibilityPage.tsx | 80 ++++-------------- .../accessibility/HighContrastUpsellModal.tsx | 41 --------- .../MentionsWithSymbolUpsellModal.tsx | 40 --------- .../views/account/accessibility/themeItems.ts | 10 ++- .../rocketchat-i18n/i18n/en.i18n.json | 7 -- .../images/high-contrast-upsell-modal.png | Bin 13392 -> 0 bytes .../public/images/mentions-upsell-modal.png | Bin 9723 -> 0 bytes 8 files changed, 24 insertions(+), 163 deletions(-) delete mode 100644 apps/meteor/client/views/account/accessibility/HighContrastUpsellModal.tsx delete mode 100644 apps/meteor/client/views/account/accessibility/MentionsWithSymbolUpsellModal.tsx delete mode 100644 apps/meteor/public/images/high-contrast-upsell-modal.png delete mode 100644 apps/meteor/public/images/mentions-upsell-modal.png diff --git a/apps/meteor/client/providers/UserProvider/UserProvider.tsx b/apps/meteor/client/providers/UserProvider/UserProvider.tsx index 432a197671f3..09f631ffa6a6 100644 --- a/apps/meteor/client/providers/UserProvider/UserProvider.tsx +++ b/apps/meteor/client/providers/UserProvider/UserProvider.tsx @@ -10,7 +10,6 @@ import { Subscriptions, ChatRoom } from '../../../app/models/client'; import { getUserPreference } from '../../../app/utils/client'; import { sdk } from '../../../app/utils/client/lib/SDKClient'; import { afterLogoutCleanUpCallback } from '../../../lib/callbacks/afterLogoutCleanUpCallback'; -import { useIsEnterprise } from '../../hooks/useIsEnterprise'; import { useReactiveValue } from '../../hooks/useReactiveValue'; import { createReactiveSubscriptionFactory } from '../../lib/createReactiveSubscriptionFactory'; import { useCreateFontStyleElement } from '../../views/account/accessibility/hooks/useCreateFontStyleElement'; @@ -180,14 +179,6 @@ const UserProvider = ({ children }: UserProviderProps): ReactElement => { } }, [preferedLanguage, setPreferedLanguage, setUserLanguage, user?.language, userLanguage, userId, setUserPreferences]); - const { data: license } = useIsEnterprise({ enabled: !!userId }); - - useEffect(() => { - if (!license?.isEnterprise && user?.settings?.preferences?.themeAppearence === 'high-contrast') { - setUserPreferences({ data: { themeAppearence: 'light' } }); - } - }, [license?.isEnterprise, setUserPreferences, user?.settings?.preferences?.themeAppearence]); - return ; }; diff --git a/apps/meteor/client/views/account/accessibility/AccessibilityPage.tsx b/apps/meteor/client/views/account/accessibility/AccessibilityPage.tsx index 657548d5a1b9..c8179f08bef2 100644 --- a/apps/meteor/client/views/account/accessibility/AccessibilityPage.tsx +++ b/apps/meteor/client/views/account/accessibility/AccessibilityPage.tsx @@ -1,7 +1,6 @@ import { css } from '@rocket.chat/css-in-js'; import type { SelectOption } from '@rocket.chat/fuselage'; import { - Icon, FieldDescription, Accordion, Box, @@ -14,20 +13,16 @@ import { FieldRow, RadioButton, Select, - Tag, ToggleSwitch, } from '@rocket.chat/fuselage'; -import { useLocalStorage, useUniqueId } from '@rocket.chat/fuselage-hooks'; -import { useSetModal, useTranslation, useToastMessageDispatch, useEndpoint, useSetting } from '@rocket.chat/ui-contexts'; +import { useUniqueId } from '@rocket.chat/fuselage-hooks'; +import { useTranslation, useToastMessageDispatch, useEndpoint, useSetting } from '@rocket.chat/ui-contexts'; import { useMutation } from '@tanstack/react-query'; import React, { useMemo } from 'react'; import { Controller, useForm } from 'react-hook-form'; import Page from '../../../components/Page'; -import { useIsEnterprise } from '../../../hooks/useIsEnterprise'; import { getDirtyFields } from '../../../lib/getDirtyFields'; -import HighContrastUpsellModal from './HighContrastUpsellModal'; -import MentionsWithSymbolUpsellModal from './MentionsWithSymbolUpsellModal'; import { fontSizes } from './fontSizes'; import type { AccessibilityPreferencesData } from './hooks/useAcessibilityPreferencesValues'; import { useAccessiblityPreferencesValues } from './hooks/useAcessibilityPreferencesValues'; @@ -36,14 +31,9 @@ import { themeItems as themes } from './themeItems'; const AccessibilityPage = () => { const t = useTranslation(); - const setModal = useSetModal(); const dispatchToastMessage = useToastMessageDispatch(); const preferencesValues = useAccessiblityPreferencesValues(); - const { data: license } = useIsEnterprise(); - const isEnterprise = license?.isEnterprise; - const { themeAppearence } = preferencesValues; - const [, setPrevTheme] = useLocalStorage('prevTheme', themeAppearence); const createFontStyleElement = useCreateFontStyleElement(); const displayRolesEnabled = useSetting('UI_DisplayRoles'); @@ -82,7 +72,6 @@ const AccessibilityPage = () => { onError: (error) => dispatchToastMessage({ type: 'error', message: error }), onSettled: (_data, _error, { data: { fontSize } }) => { reset(currentData); - dirtyFields.themeAppearence && setPrevTheme(themeAppearence); dirtyFields.fontSize && fontSize && createFontStyleElement(fontSize); }, }); @@ -102,45 +91,25 @@ const AccessibilityPage = () => { - {themes.map(({ id, title, description, ...item }, index) => { - const showCommunityUpsellTriggers = 'isEEOnly' in item && item.isEEOnly && !isEnterprise; - + {themes.map(({ id, title, description }, index) => { return ( - {t.has(title) ? t(title) : title} - {showCommunityUpsellTriggers && ( - - - - {t('Enterprise')} - - - )} + {t(title)} { - if (showCommunityUpsellTriggers) { - return ( - setModal( setModal(null)} />)} - checked={false} - /> - ); - } - return onChange(id)} checked={value === id} />; - }} + render={({ field: { onChange, value, ref } }) => ( + onChange(id)} checked={value === id} /> + )} /> - {t.has(description) ? t(description) : description} + {t(description)} ); @@ -165,32 +134,15 @@ const AccessibilityPage = () => { - - {t('Mentions_with_@_symbol')} - {!isEnterprise && ( - - - - {t('Enterprise')} - - - )} - + {t('Mentions_with_@_symbol')} - {isEnterprise ? ( - ( - - )} - /> - ) : ( - setModal( setModal(null)} />)} - checked={false} - /> - )} + ( + + )} + /> void }) => { - const t = useTranslation(); - - const isAdmin = useRole('admin'); - const { handleGoFullyFeatured, handleTalkToSales } = useUpsellActions(); - - if (!isAdmin) { - return ( - - ); - } - return ( - - ); -}; -export default HighContrastUpsellModal; diff --git a/apps/meteor/client/views/account/accessibility/MentionsWithSymbolUpsellModal.tsx b/apps/meteor/client/views/account/accessibility/MentionsWithSymbolUpsellModal.tsx deleted file mode 100644 index b92ca74d0f6e..000000000000 --- a/apps/meteor/client/views/account/accessibility/MentionsWithSymbolUpsellModal.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { useRole, useTranslation } from '@rocket.chat/ui-contexts'; -import React from 'react'; - -import GenericUpsellModal from '../../../components/GenericUpsellModal'; -import { useUpsellActions } from '../../../components/GenericUpsellModal/hooks'; - -const MentionsWithSymbolUpsellModal = ({ onClose }: { onClose: () => void }) => { - const t = useTranslation(); - - const isAdmin = useRole('admin'); - const { handleGoFullyFeatured, handleTalkToSales } = useUpsellActions(); - - if (!isAdmin) { - return ( - - ); - } - return ( - - ); -}; -export default MentionsWithSymbolUpsellModal; diff --git a/apps/meteor/client/views/account/accessibility/themeItems.ts b/apps/meteor/client/views/account/accessibility/themeItems.ts index 62bf3830d952..f16d9128503d 100644 --- a/apps/meteor/client/views/account/accessibility/themeItems.ts +++ b/apps/meteor/client/views/account/accessibility/themeItems.ts @@ -1,4 +1,11 @@ -export const themeItems = [ +import type { TranslationKey } from '@rocket.chat/ui-contexts'; + +type ThemeItem = { + id: string; + title: TranslationKey; + description: TranslationKey; +}; +export const themeItems: ThemeItem[] = [ { id: 'light', title: 'Theme_light', @@ -10,7 +17,6 @@ export const themeItems = [ description: 'Theme_dark_description', }, { - isEEOnly: true, id: 'high-contrast', title: 'Theme_high_contrast', description: 'Theme_high_contrast_description', diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json index 7ef10e0988b0..46805a1f0e3e 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json @@ -1100,7 +1100,6 @@ "Condition": "Condition", "Commit_details": "Commit Details", "Completed": "Completed", - "Compliant_use_of_color": "Compliant use of color", "Computer": "Computer", "Conference_call_apps": "Conference call apps", "Conference_call_has_ended": "_Call has ended._", @@ -1865,7 +1864,6 @@ "EmojiCustomFilesystem_Description": "Specify how emojis are stored.", "Empty_no_agent_selected": "Empty, no agent selected", "Empty_title": "Empty title", - "Empower_access_move_beyond_color": "Empower access, move beyond color", "Enable": "Enable", "Enable_Auto_Away": "Enable Auto Away", "Enable_CSP": "Enable Content-Security-Policy", @@ -3364,7 +3362,6 @@ "Mentions_only": "Mentions only", "Mentions_with_@_symbol": "Mentions with @ symbol", "Mentions_with_@_symbol_description": "Mentions notify and highlight messages for groups or specific users, facilitating targeted communication.\n\nThe screen reader functionality is optimized when the \"@\" symbol is employed in the mention feature. This ensures that users relying on screen readers can easily interpret and engage with these mentions.", - "Mentions_with_symbol_upsell_description": "Unlock the full potential of a barrier-free business with our premium accessibility feature.\n\nSay goodbye to color-related compliance challenges all while aligning with WCAG (Web Content Accessibility Guidelines) and BITV (Barrierefreie Informationstechnik-Verordnung) standards.\n\nThe use of the @ symbol makes it easier for screen readers to navigate and interact with your content, ensuring the best experience for all users.", "Merge_Channels": "Merge Channels", "message": "message", "Message": "Message", @@ -5976,10 +5973,6 @@ "Theme_high_contrast": "High contrast", "Theme_high_contrast_description": "Maximum tonal differentiation with bold colors and sharp contrasts provide enhanced accessibility.", "Highlighted_chosen_word": "Highlighted chosen word", - "High_contrast_upsell_title": "Enable high contrast theme", - "High_contrast_upsell_subtitle": "Enhance your team’s reading experience", - "High_contrast_upsell_description": "Especially designed for individuals with visual impairments or conditions such as color vision deficiency, low vision, or sensitivity to low contrast.\n\nThis theme increases contrast between text and background elements, making content more distinguishable and easier to read.", - "High_contrast_upsell_annotation": "Talk to your workspace admin about enabling the high contrast theme for everyone.", "Join_your_team": "Join your team", "Create_a_password": "Create a password", "Create_an_account": "Create an account", diff --git a/apps/meteor/public/images/high-contrast-upsell-modal.png b/apps/meteor/public/images/high-contrast-upsell-modal.png deleted file mode 100644 index b761a1b0b76c81cda36007039682c3a5493b8448..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13392 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 ZrjobB@s5`tfeS!L%JLd=)iU6){{_?5;TZq` diff --git a/apps/meteor/public/images/mentions-upsell-modal.png b/apps/meteor/public/images/mentions-upsell-modal.png deleted file mode 100644 index a71b3b837d9a77866e4b2c490543b1f608050871..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9723 zcmc(Fc{r5q+y5;>mZSyQpA^bk*|Me*(Gaq)W#6(6S!R$2l|3>s_O-0p34=tq$MgP<-@oti{xQcK_cix*-`90spYuFF=jWVf_Y8G8+4$K20N}i% zcgq9-jyM7U1Hj5mA2}V;&rAP0=BH;B2mtJ-4nGV)b`BqXkRi}S_XbciEVx9!U~hXO4jsqYOB=VW0t-1b`VwIP&@Kk4Hc6z1-yZ5Q+FMxKBzz>{F35YtNd-$D;hi1&pQ|AHTI+9tL&7=})cecN(UUGV&us(F*(O7r@4tjs?8^!(X z80jx0Cog@Cp94SOZ&Y!dPCkg7x-m(kV&e~7zt>R9-ukadCi(PZ4UP7(Z8&dt4I`Z^XK=COacXFGS} z?8DQ)xA5OH<#}NZe$|1O43aqnLLcq_xi>*bVE@N^FNIcM<$Ilur=3lr&3bRskHH!l z*|o{{pJfdpbYm&;Zh!ySb9B~6_aCe9MztwzeJoAWK8s#^Mmno$U;Kc+rF;V~X(?WN zk=xow0DzDvt_J{7dE8M9^b2o4hHuVSxM8hIY$own+LfMo{hD1AMQ`N<7K?rfjG-?& zaJ6APR$aLj7_83H|DI!X+ZO&o@B8}>s}H()0-E>*hWVYdg4v?2m&Yg#$BWE7JT%dGe-V$Ypv=81CCi@hOim=_B?>jZ zZsxJXLJAF&^DpF31bI#T04stFQrZJ>{hl7L3T-V=jfMd(&=-%8WhJlgO3C=n6DaP> zU?G+2h_k;)@6M%9L}X?9&U7_2il-;cZV(9PVG}`TP9^pgn^DIa3707j*p_V%P&u!| zBSS-3d)?{0(ozx$(Q;SH9+KyEP(1qZ?URLkas-5kWavyuXna3T>YIDz{?eR}d0_R6 zj`dOvW?QAzaNpW*D+?Q(9j*8_&;?>JCD{$09wYpu2ZLDWhy9Rd+e_Q(n zo_VNO9Y5(-$sYR1kKvWg)t9Z;@)KK?;?r7VLmT2!)RiNKhZsLCUsX}~?HS&F#iC8W z&@T%IFC~oGS-qb44hl1pmFa;0p8)u(od2U2mjJ*qLB-Hnb*kFR9xe(#T{0x5cIY^7 z^-eH7RQuyRw+};Y)!h3n3noVZ=)QE7r7YmD-{v{XKu<+)pR+GpUtyyMKTDU>lOl+F zjE9l=>Jj<>4g-~ol6qx@bgM3=u~$l-{2dt*0S@|v3miw5%Zo%0A7;J@A${Qe_pDpS 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 From 79f6388678aa1241628656792e8a03565107a037 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Thu, 19 Oct 2023 16:57:17 -0300 Subject: [PATCH 16/26] test: add cleanup to users tests (#30654) --- apps/meteor/tests/data/api-data.js | 9 +- apps/meteor/tests/data/custom-fields.js | 18 +- apps/meteor/tests/data/users.helper.js | 17 +- apps/meteor/tests/end-to-end/api/01-users.js | 466 ++++++++++-------- .../tests/end-to-end/api/10-subscriptions.js | 12 +- 5 files changed, 275 insertions(+), 247 deletions(-) diff --git a/apps/meteor/tests/data/api-data.js b/apps/meteor/tests/data/api-data.js index d08e4cc50c54..b311af16e764 100644 --- a/apps/meteor/tests/data/api-data.js +++ b/apps/meteor/tests/data/api-data.js @@ -13,10 +13,10 @@ export function wait(cb, time) { return () => setTimeout(cb, time); } -export const apiUsername = `api${username}`; -export const apiEmail = `api${email}`; -export const apiPublicChannelName = `api${publicChannelName}`; -export const apiPrivateChannelName = `api${privateChannelName}`; +export const apiUsername = `api${username}-${Date.now()}`; +export const apiEmail = `api${email}-${Date.now()}`; +export const apiPublicChannelName = `api${publicChannelName}-${Date.now()}`; +export const apiPrivateChannelName = `api${privateChannelName}-${Date.now()}`; export const apiRoleNameUsers = `api${roleNameUsers}`; export const apiRoleNameSubscriptions = `api${roleNameSubscriptions}`; @@ -25,7 +25,6 @@ export const apiRoleScopeSubscriptions = `${roleScopeSubscriptions}`; export const apiRoleDescription = `api${roleDescription}`; export const reservedWords = ['admin', 'administrator', 'system', 'user']; -export const targetUser = {}; export const channel = {}; export const group = {}; export const message = {}; diff --git a/apps/meteor/tests/data/custom-fields.js b/apps/meteor/tests/data/custom-fields.js index 2509dddf5d84..e2e175429b4c 100644 --- a/apps/meteor/tests/data/custom-fields.js +++ b/apps/meteor/tests/data/custom-fields.js @@ -1,4 +1,4 @@ -import { getCredentials, request, api, credentials } from './api-data.js'; +import { credentials, request, api } from './api-data.js'; export const customFieldText = { type: 'text', @@ -7,18 +7,12 @@ export const customFieldText = { maxLength: 10, }; -export function setCustomFields(customFields, done) { - getCredentials((error) => { - if (error) { - return done(error); - } +export function setCustomFields(customFields) { + const stringified = customFields ? JSON.stringify(customFields) : ''; - const stringified = customFields ? JSON.stringify(customFields) : ''; - - request.post(api('settings/Accounts_CustomFields')).set(credentials).send({ value: stringified }).expect(200).end(done); - }); + return request.post(api('settings/Accounts_CustomFields')).set(credentials).send({ value: stringified }).expect(200); } -export function clearCustomFields(done = () => {}) { - setCustomFields(null, done); +export function clearCustomFields() { + return setCustomFields(null); } diff --git a/apps/meteor/tests/data/users.helper.js b/apps/meteor/tests/data/users.helper.js index 92425902cb5b..82ab8446547d 100644 --- a/apps/meteor/tests/data/users.helper.js +++ b/apps/meteor/tests/data/users.helper.js @@ -33,16 +33,13 @@ export const login = (username, password) => }); }); -export const deleteUser = (user) => - new Promise((resolve) => { - request - .post(api('users.delete')) - .set(credentials) - .send({ - userId: user._id, - }) - .end(resolve); - }); +export const deleteUser = async (user) => + request + .post(api('users.delete')) + .set(credentials) + .send({ + userId: user._id, + }); export const getUserByUsername = (username) => new Promise((resolve) => { diff --git a/apps/meteor/tests/end-to-end/api/01-users.js b/apps/meteor/tests/end-to-end/api/01-users.js index b8343dc015da..eaafc97527a3 100644 --- a/apps/meteor/tests/end-to-end/api/01-users.js +++ b/apps/meteor/tests/end-to-end/api/01-users.js @@ -5,23 +5,12 @@ import { expect } from 'chai'; import { after, afterEach, before, beforeEach, describe, it } from 'mocha'; import { sleep } from '../../../lib/utils/sleep'; -import { - getCredentials, - api, - request, - credentials, - apiEmail, - apiUsername, - targetUser, - log, - wait, - reservedWords, -} from '../../data/api-data.js'; +import { getCredentials, api, request, credentials, apiEmail, apiUsername, log, wait, reservedWords } from '../../data/api-data.js'; import { MAX_BIO_LENGTH, MAX_NICKNAME_LENGTH } from '../../data/constants.ts'; import { customFieldText, clearCustomFields, setCustomFields } from '../../data/custom-fields.js'; import { imgURL } from '../../data/interactions'; import { updatePermission, updateSetting } from '../../data/permissions.helper'; -import { createRoom } from '../../data/rooms.helper'; +import { createRoom, deleteRoom } from '../../data/rooms.helper'; import { adminEmail, preferences, password, adminUsername } from '../../data/user'; import { createUser, login, deleteUser, getUserStatus, getUserByUsername } from '../../data/users.helper.js'; @@ -39,11 +28,48 @@ async function joinChannel(userCredentials, roomId) { }); } +const targetUser = {}; + describe('[Users]', function () { this.retries(0); before((done) => getCredentials(done)); + before('should create a new user', async () => { + await request + .post(api('users.create')) + .set(credentials) + .send({ + email: apiEmail, + name: apiUsername, + username: apiUsername, + password, + active: true, + roles: ['user'], + joinDefaultChannels: true, + verified: true, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user.username', apiUsername); + expect(res.body).to.have.nested.property('user.emails[0].address', apiEmail); + expect(res.body).to.have.nested.property('user.active', true); + expect(res.body).to.have.nested.property('user.name', apiUsername); + expect(res.body).to.not.have.nested.property('user.e2e'); + + expect(res.body).to.not.have.nested.property('user.customFields'); + + targetUser._id = res.body.user._id; + targetUser.username = res.body.user.username; + }); + }); + + after(async () => { + await deleteUser(targetUser); + }); + it('enabling E2E in server and generating keys to user...', async () => { await updateSetting('E2E_Enable', true); await request @@ -71,145 +97,101 @@ describe('[Users]', function () { }); describe('[/users.create]', () => { - before((done) => clearCustomFields(done)); - after((done) => clearCustomFields(done)); + before(async () => clearCustomFields()); + after(async () => clearCustomFields()); + + it('should create a new user with custom fields', async () => { + await setCustomFields({ customFieldText }); + + const username = `customField_${apiUsername}`; + const email = `customField_${apiEmail}`; + const customFields = { customFieldText: 'success' }; + + let user; - it('should create a new user', async () => { await request .post(api('users.create')) .set(credentials) .send({ - email: apiEmail, - name: apiUsername, - username: apiUsername, + email, + name: username, + username, password, active: true, roles: ['user'], joinDefaultChannels: true, verified: true, + customFields, }) .expect('Content-Type', 'application/json') .expect(200) .expect((res) => { expect(res.body).to.have.property('success', true); - expect(res.body).to.have.nested.property('user.username', apiUsername); - expect(res.body).to.have.nested.property('user.emails[0].address', apiEmail); + expect(res.body).to.have.nested.property('user.username', username); + expect(res.body).to.have.nested.property('user.emails[0].address', email); expect(res.body).to.have.nested.property('user.active', true); - expect(res.body).to.have.nested.property('user.name', apiUsername); + expect(res.body).to.have.nested.property('user.name', username); + expect(res.body).to.have.nested.property('user.customFields.customFieldText', 'success'); expect(res.body).to.not.have.nested.property('user.e2e'); - expect(res.body).to.not.have.nested.property('user.customFields'); - - targetUser._id = res.body.user._id; - targetUser.username = res.body.user.username; + user = res.body.user; }); - await request - .post(api('login')) - .send({ - user: apiUsername, - password, - }) - .expect('Content-Type', 'application/json') - .expect(200); + await deleteUser(user); }); - it('should create a new user with custom fields', (done) => { - setCustomFields({ customFieldText }, (error) => { - if (error) { - return done(error); - } - - const username = `customField_${apiUsername}`; - const email = `customField_${apiEmail}`; - const customFields = { customFieldText: 'success' }; - + function failCreateUser(name) { + it(`should not create a new user if username is the reserved word ${name}`, (done) => { request .post(api('users.create')) .set(credentials) .send({ - email, - name: username, - username, + email: `create_user_fail_${apiEmail}`, + name: `create_user_fail_${apiUsername}`, + username: name, password, active: true, roles: ['user'], joinDefaultChannels: true, verified: true, - customFields, }) .expect('Content-Type', 'application/json') - .expect(200) + .expect(400) .expect((res) => { - expect(res.body).to.have.property('success', true); - expect(res.body).to.have.nested.property('user.username', username); - expect(res.body).to.have.nested.property('user.emails[0].address', email); - expect(res.body).to.have.nested.property('user.active', true); - expect(res.body).to.have.nested.property('user.name', username); - expect(res.body).to.have.nested.property('user.customFields.customFieldText', 'success'); - expect(res.body).to.not.have.nested.property('user.e2e'); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', `${name} is blocked and can't be used! [error-blocked-username]`); }) .end(done); }); - }); + } - function failCreateUser(name) { - it(`should not create a new user if username is the reserved word ${name}`, (done) => { - request + function failUserWithCustomField(field) { + it(`should not create a user if a custom field ${field.reason}`, async () => { + await setCustomFields({ customFieldText }); + + const customFields = {}; + customFields[field.name] = field.value; + + await request .post(api('users.create')) .set(credentials) .send({ - email: `create_user_fail_${apiEmail}`, - name: `create_user_fail_${apiUsername}`, - username: name, + email: `customField_fail_${apiEmail}`, + name: `customField_fail_${apiUsername}`, + username: `customField_fail_${apiUsername}`, password, active: true, roles: ['user'], joinDefaultChannels: true, verified: true, + customFields, }) .expect('Content-Type', 'application/json') .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('error', `${name} is blocked and can't be used! [error-blocked-username]`); - }) - .end(done); - }); - } - - function failUserWithCustomField(field) { - it(`should not create a user if a custom field ${field.reason}`, (done) => { - setCustomFields({ customFieldText }, (error) => { - if (error) { - return done(error); - } - - const customFields = {}; - customFields[field.name] = field.value; - - request - .post(api('users.create')) - .set(credentials) - .send({ - email: `customField_fail_${apiEmail}`, - name: `customField_fail_${apiUsername}`, - username: `customField_fail_${apiUsername}`, - password, - active: true, - roles: ['user'], - joinDefaultChannels: true, - verified: true, - customFields, - }) - .expect('Content-Type', 'application/json') - .expect(400) - .expect((res) => { - expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('errorType', 'error-user-registration-custom-field'); - }) - .end(done); - }); + expect(res.body).to.have.property('errorType', 'error-user-registration-custom-field'); + }); }); } @@ -226,12 +208,16 @@ describe('[Users]', function () { }); describe('users default roles configuration', () => { + const users = []; + before(async () => { await updateSetting('Accounts_Registration_Users_Default_Roles', 'user,admin'); }); after(async () => { await updateSetting('Accounts_Registration_Users_Default_Roles', 'user'); + + await Promise.all(users.map((user) => deleteUser(user))); }); it('should create a new user with default roles', (done) => { @@ -256,6 +242,8 @@ describe('[Users]', function () { expect(res.body).to.have.nested.property('user.active', true); expect(res.body).to.have.nested.property('user.name', username); expect(res.body.user.roles).to.have.members(['user', 'admin']); + + users.push(res.body.user); }) .end(done); }); @@ -283,6 +271,8 @@ describe('[Users]', function () { expect(res.body).to.have.nested.property('user.active', true); expect(res.body).to.have.nested.property('user.name', username); expect(res.body.user.roles).to.have.members(['guest']); + + users.push(res.body.user); }) .end(done); }); @@ -292,6 +282,10 @@ describe('[Users]', function () { describe('[/users.register]', () => { const email = `email@email${Date.now()}.com`; const username = `myusername${Date.now()}`; + let user; + + after(async () => deleteUser(user)); + it('should register new user', (done) => { request .post(api('users.register')) @@ -308,6 +302,7 @@ describe('[Users]', function () { expect(res.body).to.have.nested.property('user.username', username); expect(res.body).to.have.nested.property('user.active', true); expect(res.body).to.have.nested.property('user.name', 'name'); + user = res.body.user; }) .end(done); }); @@ -331,9 +326,11 @@ describe('[Users]', function () { }); describe('[/users.info]', () => { - after(() => { - updatePermission('view-other-user-channels', ['admin']); - updatePermission('view-full-other-user-info', ['admin']); + after(async () => { + await Promise.all([ + updatePermission('view-other-user-channels', ['admin']), + updatePermission('view-full-other-user-info', ['admin']), + ]); }); it('should return an error when the user does not exist', (done) => { @@ -476,26 +473,30 @@ describe('[Users]', function () { }); it('should correctly route users that have `ufs` in their username', async () => { + const ufsUsername = `ufs-${Date.now()}`; + let user; + await request .post(api('users.create')) .set(credentials) .send({ - email: 'me@email.com', + email: `me-${Date.now()}@email.com`, name: 'testuser', - username: 'ufs', + username: ufsUsername, password: '1234', }) .expect('Content-Type', 'application/json') .expect(200) .expect((res) => { expect(res.body).to.have.property('success', true); + user = res.body.user; }); await request .get(api('users.info')) .set(credentials) .query({ - username: 'ufs', + username: ufsUsername, }) .expect('Content-Type', 'application/json') .expect(200) @@ -503,9 +504,11 @@ describe('[Users]', function () { expect(res.body).to.have.property('success', true); expect(res.body.user).to.have.property('type', 'user'); expect(res.body.user).to.have.property('name', 'testuser'); - expect(res.body.user).to.have.property('username', 'ufs'); + expect(res.body.user).to.have.property('username', ufsUsername); expect(res.body.user).to.have.property('active', true); }); + + await deleteUser(user); }); }); describe('[/users.getPresence]', () => { @@ -549,10 +552,10 @@ describe('[Users]', function () { .expect((res) => { expect(res.body).to.have.property('success', true); expect(res.body).to.have.property('full', true); - expect(res.body) - .to.have.property('users') - .to.have.property('0') - .to.deep.have.all.keys('_id', 'avatarETag', 'username', 'name', 'status', 'utcOffset'); + + const user = res.body.users.find((user) => user.username === 'rocket.cat'); + + expect(user).to.have.all.keys('_id', 'avatarETag', 'username', 'name', 'status', 'utcOffset'); }) .end(done); }); @@ -583,10 +586,10 @@ describe('[Users]', function () { .expect((res) => { expect(res.body).to.have.property('success', true); expect(res.body).to.have.property('full', true); - expect(res.body) - .to.have.property('users') - .to.have.property('0') - .to.deep.have.all.keys('_id', 'avatarETag', 'username', 'name', 'status', 'utcOffset'); + + const user = res.body.users.find((user) => user.username === 'rocket.cat'); + + expect(user).to.have.all.keys('_id', 'avatarETag', 'username', 'name', 'status', 'utcOffset'); }) .end(done); }); @@ -599,68 +602,59 @@ describe('[Users]', function () { let user2; let user2Credentials; - before((done) => { - const createDeactivatedUser = async () => { - const username = `deactivated_${Date.now()}${apiUsername}`; - const email = `deactivated_+${Date.now()}${apiEmail}`; - - const userData = { - email, - name: username, - username, - password, - active: false, - }; - - deactivatedUser = await createUser(userData); - - expect(deactivatedUser).to.not.be.null; - expect(deactivatedUser).to.have.nested.property('username', username); - expect(deactivatedUser).to.have.nested.property('emails[0].address', email); - expect(deactivatedUser).to.have.nested.property('active', false); - expect(deactivatedUser).to.have.nested.property('name', username); - expect(deactivatedUser).to.not.have.nested.property('e2e'); + before(async () => { + const username = `deactivated_${Date.now()}${apiUsername}`; + const email = `deactivated_+${Date.now()}${apiEmail}`; + + const userData = { + email, + name: username, + username, + password, + active: false, }; - createDeactivatedUser().then(done); - }); - before((done) => - setCustomFields({ customFieldText }, async (error) => { - if (error) { - return done(error); - } - - const username = `customField_${Date.now()}${apiUsername}`; - const email = `customField_+${Date.now()}${apiEmail}`; - const customFields = { customFieldText: 'success' }; + deactivatedUser = await createUser(userData); - const userData = { - email, - name: username, - username, - password, - active: true, - roles: ['user'], - joinDefaultChannels: true, - verified: true, - customFields, - }; + expect(deactivatedUser).to.not.be.null; + expect(deactivatedUser).to.have.nested.property('username', username); + expect(deactivatedUser).to.have.nested.property('emails[0].address', email); + expect(deactivatedUser).to.have.nested.property('active', false); + expect(deactivatedUser).to.have.nested.property('name', username); + expect(deactivatedUser).to.not.have.nested.property('e2e'); + }); - user = await createUser(userData); + before(async () => { + await setCustomFields({ customFieldText }); + + const username = `customField_${Date.now()}${apiUsername}`; + const email = `customField_+${Date.now()}${apiEmail}`; + const customFields = { customFieldText: 'success' }; + + const userData = { + email, + name: username, + username, + password, + active: true, + roles: ['user'], + joinDefaultChannels: true, + verified: true, + customFields, + }; - expect(user).to.not.be.null; - expect(user).to.have.nested.property('username', username); - expect(user).to.have.nested.property('emails[0].address', email); - expect(user).to.have.nested.property('active', true); - expect(user).to.have.nested.property('name', username); - expect(user).to.have.nested.property('customFields.customFieldText', 'success'); - expect(user).to.not.have.nested.property('e2e'); + user = await createUser(userData); - return done(); - }), - ); + expect(user).to.not.be.null; + expect(user).to.have.nested.property('username', username); + expect(user).to.have.nested.property('emails[0].address', email); + expect(user).to.have.nested.property('active', true); + expect(user).to.have.nested.property('name', username); + expect(user).to.have.nested.property('customFields.customFieldText', 'success'); + expect(user).to.not.have.nested.property('e2e'); + }); - after((done) => clearCustomFields(done)); + after(async () => clearCustomFields()); before(async () => { user2 = await createUser({ joinDefaultChannels: false }); @@ -668,6 +662,8 @@ describe('[Users]', function () { }); after(async () => { + await deleteUser(deactivatedUser); + await deleteUser(user); await deleteUser(user2); user2 = undefined; @@ -1281,26 +1277,23 @@ describe('[Users]', function () { }); }); - it('should update the user name when the required permission is applied', (done) => { - updatePermission('edit-other-user-info', ['admin']).then(() => { - updateSetting('Accounts_AllowUsernameChange', false).then(() => { - request - .post(api('users.update')) - .set(credentials) - .send({ - userId: targetUser._id, - data: { - username: 'fake.name', - }, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); + it('should update the user name when the required permission is applied', async () => { + await Promise.all([updatePermission('edit-other-user-info', ['admin']), updateSetting('Accounts_AllowUsernameChange', false)]); + + await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: targetUser._id, + data: { + username: `fake.name.${Date.now()}`, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); }); - }); }); it('should return an error when trying update user real name and it is not allowed', (done) => { @@ -2297,6 +2290,8 @@ describe('[Users]', function () { .end(done); }); + after(async () => deleteUser(targetUser)); + it('should return an username suggestion', (done) => { request .get(api('users.getUsernameSuggestion')) @@ -2326,7 +2321,7 @@ describe('[Users]', function () { const testUsername = `test-username-123456-${+new Date()}`; let targetUser; let userCredentials; - it('register a new user...', (done) => { + before((done) => { request .post(api('users.register')) .set(credentials) @@ -2343,7 +2338,7 @@ describe('[Users]', function () { }) .end(done); }); - it('Login...', (done) => { + before((done) => { request .post(api('login')) .send({ @@ -2360,6 +2355,8 @@ describe('[Users]', function () { .end(done); }); + after(async () => deleteUser(targetUser)); + it('should return true if the username is the same user username set', (done) => { request .get(api('users.checkUsernameAvailability')) @@ -2410,7 +2407,7 @@ describe('[Users]', function () { const testUsername = `testuser${+new Date()}`; let targetUser; let userCredentials; - it('register a new user...', (done) => { + before((done) => { request .post(api('users.register')) .set(credentials) @@ -2427,7 +2424,7 @@ describe('[Users]', function () { }) .end(done); }); - it('Login...', (done) => { + before((done) => { request .post(api('login')) .send({ @@ -2444,6 +2441,8 @@ describe('[Users]', function () { .end(done); }); + after(async () => deleteUser(targetUser)); + it('Enable "Accounts_AllowDeleteOwnAccount" setting...', (done) => { request .post('/api/v1/settings/Accounts_AllowDeleteOwnAccount') @@ -2472,23 +2471,22 @@ describe('[Users]', function () { .end(done); }); - it('should delete user own account when the SHA256 hash is in upper case', (done) => { - createUser().then((user) => { - login(user.username, password).then((createdUserCredentials) => { - request - .post(api('users.deleteOwnAccount')) - .set(createdUserCredentials) - .send({ - password: crypto.createHash('sha256').update(password, 'utf8').digest('hex').toUpperCase(), - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); + it('should delete user own account when the SHA256 hash is in upper case', async () => { + const user = await createUser(); + const createdUserCredentials = await login(user.username, password); + await request + .post(api('users.deleteOwnAccount')) + .set(createdUserCredentials) + .send({ + password: crypto.createHash('sha256').update(password, 'utf8').digest('hex').toUpperCase(), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); }); - }); + + await deleteUser(user); }); it('should return an error when trying to delete user own account if user is the last room owner', async () => { @@ -2542,6 +2540,9 @@ describe('[Users]', function () { expect(res.body).to.have.property('error', '[user-last-owner]'); expect(res.body).to.have.property('errorType', 'user-last-owner'); }); + + await deleteRoom({ type: 'c', roomId: room._id }); + await deleteUser(user); }); it('should delete user own account if the user is the last room owner and `confirmRelinquish` is set to `true`', async () => { @@ -2594,6 +2595,8 @@ describe('[Users]', function () { .expect((res) => { expect(res.body).to.have.property('success', true); }); + await deleteRoom({ type: 'c', roomId: room._id }); + await deleteUser(user); }); it('should assign a new owner to the room if the last room owner is deleted', async () => { @@ -2661,6 +2664,8 @@ describe('[Users]', function () { expect(res.body.roles[0].roles).to.eql(['owner']); expect(res.body.roles[0].u).to.have.property('_id', credentials['X-User-Id']); }); + await deleteRoom({ type: 'c', roomId: room._id }); + await deleteUser(user); }); }); @@ -2763,6 +2768,8 @@ describe('[Users]', function () { expect(res.body).to.have.property('error', '[user-last-owner]'); expect(res.body).to.have.property('errorType', 'user-last-owner'); }); + + await deleteRoom({ type: 'c', roomId: room._id }); }); it('should delete user account if the user is the last room owner and `confirmRelinquish` is set to `true`', async () => { @@ -2813,6 +2820,8 @@ describe('[Users]', function () { .expect((res) => { expect(res.body).to.have.property('success', true); }); + + await deleteRoom({ type: 'c', roomId: room._id }); }); it('should delete user account when logged user has "delete-user" permission', async () => { @@ -2893,6 +2902,8 @@ describe('[Users]', function () { expect(res.body.roles[0].roles).to.eql(['owner']); expect(res.body.roles[0].u).to.have.property('_id', credentials['X-User-Id']); }); + + await deleteRoom({ type: 'c', roomId: room._id }); }); }); @@ -3241,6 +3252,8 @@ describe('[Users]', function () { expect(res.body).to.have.property('error', '[user-last-owner]'); expect(res.body).to.have.property('errorType', 'user-last-owner'); }); + + await deleteRoom({ type: 'c', roomId: room._id }); }); it('should set other user status to inactive if the user is the last owner of a room and `confirmRelinquish` is set to `true`', async () => { @@ -3305,6 +3318,8 @@ describe('[Users]', function () { .expect((res) => { expect(res.body).to.have.property('success', true); }); + + await deleteRoom({ type: 'c', roomId: room._id }); }); it('should set other user as room owner if the last owner of a room is deactivated and `confirmRelinquish` is set to `true`', async () => { @@ -3396,6 +3411,8 @@ describe('[Users]', function () { expect(res.body.roles[1].roles).to.eql(['owner']); expect(res.body.roles[1].u).to.have.property('_id', credentials['X-User-Id']); }); + + await deleteRoom({ type: 'c', roomId: room._id }); }); it('should return an error when trying to set other user active status and has not the necessary permission(edit-other-user-active-status)', (done) => { @@ -3464,6 +3481,8 @@ describe('[Users]', function () { expect(user).to.have.property('roles'); expect(user.roles).to.be.an('array').of.length(2); expect(user.roles).to.include('user', 'livechat-agent'); + + await deleteUser(testUser); }); }); @@ -3516,6 +3535,10 @@ describe('[Users]', function () { .end(done); }); + after(async () => { + await deleteUser(testUser); + }); + it('should fail to deactivate if user doesnt have edit-other-user-active-status permission', (done) => { updatePermission('edit-other-user-active-status', []).then(() => { request @@ -3563,7 +3586,7 @@ describe('[Users]', function () { .expect(200) .expect((res) => { expect(res.body).to.have.property('success', true); - expect(res.body).to.have.property('count', 2); + expect(res.body).to.have.property('count', 1); }) .end(done); }); @@ -3690,7 +3713,7 @@ describe('[Users]', function () { updatePermission('view-outside-room', ['admin', 'owner', 'moderator', 'user']); }); - describe('[without permission]', () => { + describe('[without permission]', function () { let user; let userCredentials; let user2; @@ -3711,6 +3734,12 @@ describe('[Users]', function () { roomId = await createChannel(userCredentials, `channel.autocomplete.${Date.now()}`); }); + after(async () => { + await deleteRoom({ type: 'c', roomId }); + await deleteUser(user); + await deleteUser(user2); + }); + it('should return an empty list when the user does not have any subscription', (done) => { request .get(api('users.autocomplete?selector={}')) @@ -4126,6 +4155,10 @@ describe('[Users]', function () { .then(() => done()); }); + after(async () => { + await deleteUser(testUser); + }); + it('should list both channels', (done) => { request .get(api('users.listTeams')) @@ -4151,14 +4184,19 @@ describe('[Users]', function () { describe('[/users.logout]', () => { let user; let otherUser; + let userCredentials; + before(async () => { user = await createUser(); otherUser = await createUser(); }); + before(async () => { + userCredentials = await login(user.username, password); + }); + after(async () => { await deleteUser(user); await deleteUser(otherUser); - user = undefined; }); it('should throw unauthorized error to user w/o "logout-other-user" permission', (done) => { @@ -4187,7 +4225,7 @@ describe('[Users]', function () { it('should logout the requester', (done) => { updatePermission('logout-other-user', []).then(() => { - request.post(api('users.logout')).set(credentials).expect('Content-Type', 'application/json').expect(200).end(done); + request.post(api('users.logout')).set(userCredentials).expect('Content-Type', 'application/json').expect(200).end(done); }); }); }); diff --git a/apps/meteor/tests/end-to-end/api/10-subscriptions.js b/apps/meteor/tests/end-to-end/api/10-subscriptions.js index f547895eb8a4..531291a99216 100644 --- a/apps/meteor/tests/end-to-end/api/10-subscriptions.js +++ b/apps/meteor/tests/end-to-end/api/10-subscriptions.js @@ -236,7 +236,8 @@ describe('[Subscriptions]', function () { before(async () => { user = await createUser({ username: 'testthread123', password: 'testthread123' }); threadUserCredentials = await login('testthread123', 'testthread123'); - request + + const res = await request .post(api('chat.sendMessage')) .set(threadUserCredentials) .send({ @@ -244,14 +245,13 @@ describe('[Subscriptions]', function () { rid: testChannel._id, msg: 'Starting a Thread', }, - }) - .end((_, res) => { - threadId = res.body.message._id; }); + + threadId = res.body.message._id; }); - after((done) => { - deleteUser(user).then(done); + after(async () => { + await deleteUser(user); }); it('should mark threads as read', async () => { From 93a0859e87f0a115152e79e224fb42c10748c5fe Mon Sep 17 00:00:00 2001 From: Yash Rajpal <58601732+yash-rajpal@users.noreply.github.com> Date: Fri, 20 Oct 2023 02:23:53 +0530 Subject: [PATCH 17/26] fix: Unnecessary username validation on account profile form (#30677) --- .changeset/empty-files-know.md | 5 +++++ .../client/views/account/profile/AccountProfileForm.tsx | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 .changeset/empty-files-know.md diff --git a/.changeset/empty-files-know.md b/.changeset/empty-files-know.md new file mode 100644 index 000000000000..5e6fb8f751b2 --- /dev/null +++ b/.changeset/empty-files-know.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fix unnecessary username validation on accounts profile form diff --git a/apps/meteor/client/views/account/profile/AccountProfileForm.tsx b/apps/meteor/client/views/account/profile/AccountProfileForm.tsx index ed97b95caae8..65b3a0967d49 100644 --- a/apps/meteor/client/views/account/profile/AccountProfileForm.tsx +++ b/apps/meteor/client/views/account/profile/AccountProfileForm.tsx @@ -66,6 +66,7 @@ const AccountProfileForm = (props: AllHTMLAttributes): ReactEle const { email, avatar, username } = watch(); const previousEmail = user ? getUserEmailAddress(user) : ''; + const previousUsername = user?.username || ''; const isUserVerified = user?.emails?.[0]?.verified ?? false; const mutateConfirmationEmail = useMutation({ @@ -87,6 +88,10 @@ const AccountProfileForm = (props: AllHTMLAttributes): ReactEle return; } + if (username === previousUsername) { + return; + } + if (!namesRegex.test(username)) { return t('error-invalid-username'); } From b85df55030f9aaf351d15fe66ee0ec008bfc9691 Mon Sep 17 00:00:00 2001 From: csuadev <72958726+csuadev@users.noreply.github.com> Date: Thu, 19 Oct 2023 16:46:06 -0500 Subject: [PATCH 18/26] fix: UI issue on marketplace filters (#30660) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Júlia Jaeger Foresti <60678893+juliajforesti@users.noreply.github.com> --- .changeset/cyan-mangos-do.md | 5 +++ .../CategoryFilter/CategoryDropDownAnchor.tsx | 41 ++++++++++++------- .../RadioDropDown/RadioDownAnchor.tsx | 22 ++++++---- 3 files changed, 44 insertions(+), 24 deletions(-) create mode 100644 .changeset/cyan-mangos-do.md diff --git a/.changeset/cyan-mangos-do.md b/.changeset/cyan-mangos-do.md new file mode 100644 index 000000000000..e188686c82d5 --- /dev/null +++ b/.changeset/cyan-mangos-do.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +fix: UI issue on marketplace filters diff --git a/apps/meteor/client/views/marketplace/components/CategoryFilter/CategoryDropDownAnchor.tsx b/apps/meteor/client/views/marketplace/components/CategoryFilter/CategoryDropDownAnchor.tsx index 91e66683e66f..b3e43fda942f 100644 --- a/apps/meteor/client/views/marketplace/components/CategoryFilter/CategoryDropDownAnchor.tsx +++ b/apps/meteor/client/views/marketplace/components/CategoryFilter/CategoryDropDownAnchor.tsx @@ -1,4 +1,6 @@ -import { Box, Button, Icon } from '@rocket.chat/fuselage'; +import type { Button } from '@rocket.chat/fuselage'; +import { Box, Icon } from '@rocket.chat/fuselage'; +import colorTokens from '@rocket.chat/fuselage-tokens/colors.json'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ComponentProps, MouseEventHandler } from 'react'; import React, { forwardRef } from 'react'; @@ -15,33 +17,42 @@ const CategoryDropDownAnchor = forwardRef {selectedCategoriesCount > 0 && ( {selectedCategoriesCount} diff --git a/apps/meteor/client/views/marketplace/components/RadioDropDown/RadioDownAnchor.tsx b/apps/meteor/client/views/marketplace/components/RadioDropDown/RadioDownAnchor.tsx index 36e4ff55657f..f480b2a60280 100644 --- a/apps/meteor/client/views/marketplace/components/RadioDropDown/RadioDownAnchor.tsx +++ b/apps/meteor/client/views/marketplace/components/RadioDropDown/RadioDownAnchor.tsx @@ -1,4 +1,5 @@ -import { Box, Button, Icon } from '@rocket.chat/fuselage'; +import type { Button } from '@rocket.chat/fuselage'; +import { Box, Icon } from '@rocket.chat/fuselage'; import type { ComponentProps } from 'react'; import React, { forwardRef } from 'react'; @@ -14,22 +15,25 @@ const RadioDownAnchor = forwardRef(functi return ( {selected} From b9a3381d9394d39bb22629c9fc951c2407e00db8 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Thu, 19 Oct 2023 16:47:32 -0600 Subject: [PATCH 19/26] test: `ShouldPreventAction` (#30690) --- ee/packages/license/src/license.spec.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/ee/packages/license/src/license.spec.ts b/ee/packages/license/src/license.spec.ts index 989be7b69ae1..b637ee33ddfd 100644 --- a/ee/packages/license/src/license.spec.ts +++ b/ee/packages/license/src/license.spec.ts @@ -41,6 +41,30 @@ it('should prevent if the counter is equal or over the limit', async () => { await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); }); +it.skip('should not prevent an action if another limit is over the limit', async () => { + const licenseManager = await getReadyLicenseManager(); + + const license = await new MockedLicenseBuilder() + .withLimits('activeUsers', [ + { + max: 10, + behavior: 'prevent_action', + }, + ]) + .withLimits('monthlyActiveContacts', [ + { + max: 10, + behavior: 'prevent_action', + }, + ]); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 11); + licenseManager.setLicenseLimitCounter('monthlyActiveContacts', () => 2); + await expect(licenseManager.shouldPreventAction('monthlyActiveContacts')).resolves.toBe(false); +}); + describe('Validate License Limits', () => { describe('prevent_action behavior', () => { describe('during the licensing apply', () => { From 53cf1f5940c76e6d4df132e3ca8e7118206d1ea5 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Thu, 19 Oct 2023 19:25:00 -0600 Subject: [PATCH 20/26] refactor: Move functions out of `livechat.js` (#30664) --- .../app/apps/server/bridges/livechat.ts | 2 +- .../app/livechat/imports/server/rest/sms.js | 2 +- .../app/livechat/server/api/v1/message.ts | 16 +- .../app/livechat/server/api/v1/visitor.ts | 2 +- apps/meteor/app/livechat/server/lib/Helper.ts | 6 +- .../app/livechat/server/lib/Livechat.js | 191 +--------------- .../app/livechat/server/lib/LivechatTyped.ts | 207 +++++++++++++++++- .../server/methods/returnAsInquiry.ts | 4 +- .../server/methods/sendMessageLivechat.ts | 28 +-- apps/meteor/app/livechat/server/startup.ts | 10 +- .../server/lib/AutoTransferChatScheduler.ts | 4 +- .../EmailInbox/EmailInbox_Incoming.ts | 3 +- 12 files changed, 238 insertions(+), 237 deletions(-) diff --git a/apps/meteor/app/apps/server/bridges/livechat.ts b/apps/meteor/app/apps/server/bridges/livechat.ts index 5b6c76257667..70802f280095 100644 --- a/apps/meteor/app/apps/server/bridges/livechat.ts +++ b/apps/meteor/app/apps/server/bridges/livechat.ts @@ -44,7 +44,7 @@ export class AppLivechatBridge extends LivechatBridge { throw new Error('Invalid token for livechat message'); } - const msg = await Livechat.sendMessage({ + const msg = await LivechatTyped.sendMessage({ guest: this.orch.getConverters()?.get('visitors').convertAppVisitor(message.visitor), message: await this.orch.getConverters()?.get('messages').convertAppMessage(message), agent: undefined, diff --git a/apps/meteor/app/livechat/imports/server/rest/sms.js b/apps/meteor/app/livechat/imports/server/rest/sms.js index 7ecb3b3fc100..9d2bee133784 100644 --- a/apps/meteor/app/livechat/imports/server/rest/sms.js +++ b/apps/meteor/app/livechat/imports/server/rest/sms.js @@ -182,7 +182,7 @@ API.v1.addRoute('livechat/sms-incoming/:service', { }; try { - const msg = SMSService.response.call(this, await Livechat.sendMessage(sendMessage)); + const msg = SMSService.response.call(this, await LivechatTyped.sendMessage(sendMessage)); setImmediate(async () => { if (sms.extra) { if (sms.extra.fromCountry) { diff --git a/apps/meteor/app/livechat/server/api/v1/message.ts b/apps/meteor/app/livechat/server/api/v1/message.ts index 0d5a22b90d89..1dcf54e403a6 100644 --- a/apps/meteor/app/livechat/server/api/v1/message.ts +++ b/apps/meteor/app/livechat/server/api/v1/message.ts @@ -17,7 +17,6 @@ import { isWidget } from '../../../../api/server/helpers/isWidget'; import { loadMessageHistory } from '../../../../lib/server/functions/loadMessageHistory'; import { settings } from '../../../../settings/server'; import { normalizeMessageFileUpload } from '../../../../utils/server/functions/normalizeMessageFileUpload'; -import { Livechat } from '../../lib/Livechat'; import { Livechat as LivechatTyped } from '../../lib/LivechatTyped'; import { findGuest, findRoom, normalizeHttpHeaderData } from '../lib/livechat'; @@ -67,7 +66,7 @@ API.v1.addRoute( }, }; - const result = await Livechat.sendMessage(sendMessage); + const result = await LivechatTyped.sendMessage(sendMessage); if (result) { const message = await Messages.findOneById(_id); if (!message) { @@ -176,7 +175,7 @@ API.v1.addRoute( throw new Error('invalid-message'); } - const result = await Livechat.deleteMessage({ guest, message }); + const result = await LivechatTyped.deleteMessage({ guest, message }); if (result) { return API.v1.success({ message: { @@ -272,10 +271,15 @@ API.v1.addRoute( visitor = await LivechatVisitors.findOneEnabledById(visitorId); } + const guest = visitor; + if (!guest) { + throw new Error('error-invalid-token'); + } + const sentMessages = await Promise.all( this.bodyParams.messages.map(async (message: { msg: string }): Promise<{ username: string; msg: string; ts: number }> => { const sendMessage = { - guest: visitor, + guest, message: { _id: Random.id(), rid, @@ -288,8 +292,8 @@ API.v1.addRoute( }, }, }; - // @ts-expect-error -- Typings on sendMessage are wrong - const sentMessage = await Livechat.sendMessage(sendMessage); + + const sentMessage = await LivechatTyped.sendMessage(sendMessage); return { username: sentMessage.u.username, msg: sentMessage.msg, diff --git a/apps/meteor/app/livechat/server/api/v1/visitor.ts b/apps/meteor/app/livechat/server/api/v1/visitor.ts index 84f7b96e155d..6488d34eab7a 100644 --- a/apps/meteor/app/livechat/server/api/v1/visitor.ts +++ b/apps/meteor/app/livechat/server/api/v1/visitor.ts @@ -121,7 +121,7 @@ API.v1.addRoute('livechat/visitor/:token', { } const { _id } = visitor; - const result = await Livechat.removeGuest(_id); + const result = await LivechatTyped.removeGuest(_id); if (!result.modifiedCount) { throw new Meteor.Error('error-removing-visitor', 'An error ocurred while deleting visitor'); } diff --git a/apps/meteor/app/livechat/server/lib/Helper.ts b/apps/meteor/app/livechat/server/lib/Helper.ts index 63cbbd6998ef..4acbdf5090ad 100644 --- a/apps/meteor/app/livechat/server/lib/Helper.ts +++ b/apps/meteor/app/livechat/server/lib/Helper.ts @@ -437,7 +437,7 @@ export const forwardRoomToAgent = async (room: IOmnichannelRoom, transferData: T return false; } - await Livechat.saveTransferHistory(room, transferData); + await LivechatTyped.saveTransferHistory(room, transferData); const { servedBy } = roomTaken; if (servedBy) { @@ -537,7 +537,7 @@ export const forwardRoomToDepartment = async (room: IOmnichannelRoom, guest: ILi logger.debug( `Routing algorithm doesn't support auto assignment (using ${RoutingManager.methodName}). Chat will be on department queue`, ); - await Livechat.saveTransferHistory(room, transferData); + await LivechatTyped.saveTransferHistory(room, transferData); return RoutingManager.unassignAgent(inquiry, departmentId); } @@ -573,7 +573,7 @@ export const forwardRoomToDepartment = async (room: IOmnichannelRoom, guest: ILi } } - await Livechat.saveTransferHistory(room, transferData); + await LivechatTyped.saveTransferHistory(room, transferData); if (oldServedBy) { // if chat is queued then we don't ignore the new servedBy agent bcs at this // point the chat is not assigned to him/her and it is still in the queue diff --git a/apps/meteor/app/livechat/server/lib/Livechat.js b/apps/meteor/app/livechat/server/lib/Livechat.js index 837a8eb7309b..b208c9fb5e85 100644 --- a/apps/meteor/app/livechat/server/lib/Livechat.js +++ b/apps/meteor/app/livechat/server/lib/Livechat.js @@ -8,11 +8,9 @@ import { LivechatRooms, LivechatInquiry, Subscriptions, - Messages, LivechatDepartment as LivechatDepartmentRaw, Rooms, Users, - ReadReceipts, } from '@rocket.chat/models'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; @@ -25,15 +23,11 @@ import { i18n } from '../../../../server/lib/i18n'; import { addUserRolesAsync } from '../../../../server/lib/roles/addUserRoles'; import { removeUserFromRolesAsync } from '../../../../server/lib/roles/removeUserFromRoles'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; -import { FileUpload } from '../../../file-upload/server'; -import { deleteMessage } from '../../../lib/server/functions/deleteMessage'; -import { sendMessage } from '../../../lib/server/functions/sendMessage'; import * as Mailer from '../../../mailer/server/api'; import { settings } from '../../../settings/server'; import { businessHourManager } from '../business-hour'; import { Analytics } from './Analytics'; -import { normalizeTransferredByData, parseAgentCustomFields, updateDepartmentAgents } from './Helper'; -import { Livechat as LivechatTyped } from './LivechatTyped'; +import { parseAgentCustomFields, updateDepartmentAgents } from './Helper'; import { RoutingManager } from './RoutingManager'; const logger = new Logger('Livechat'); @@ -43,41 +37,6 @@ export const Livechat = { logger, - async sendMessage({ guest, message, roomInfo, agent }) { - const { room, newRoom } = await LivechatTyped.getRoom(guest, message, roomInfo, agent); - if (guest.name) { - message.alias = guest.name; - } - return Object.assign(await sendMessage(guest, message, room), { - newRoom, - showConnecting: this.showConnecting(), - }); - }, - - async deleteMessage({ guest, message }) { - Livechat.logger.debug(`Attempting to delete a message by visitor ${guest._id}`); - check(message, Match.ObjectIncluding({ _id: String })); - - const msg = await Messages.findOneById(message._id); - if (!msg || !msg._id) { - return; - } - - const deleteAllowed = settings.get('Message_AllowDeleting'); - const editOwn = msg.u && msg.u._id === guest._id; - - if (!deleteAllowed || !editOwn) { - Livechat.logger.debug('Cannot delete message: not allowed'); - throw new Meteor.Error('error-action-not-allowed', 'Message deleting not allowed', { - method: 'livechatDeleteMessage', - }); - } - - await deleteMessage(message, guest); - - return true; - }, - async saveGuest(guestData, userId) { const { _id, name, email, phone, livechatData = {} } = guestData; Livechat.logger.debug(`Saving data for visitor ${_id}`); @@ -234,111 +193,6 @@ export const Livechat = { return Message.saveSystemMessage('livechat_navigation_history', roomId, `${pageTitle} - ${pageUrl}`, user, extraData); }, - async saveTransferHistory(room, transferData) { - Livechat.logger.debug(`Saving transfer history for room ${room._id}`); - const { departmentId: previousDepartment } = room; - const { department: nextDepartment, transferredBy, transferredTo, scope, comment } = transferData; - - check( - transferredBy, - Match.ObjectIncluding({ - _id: String, - username: String, - name: Match.Maybe(String), - type: String, - }), - ); - - const { _id, username } = transferredBy; - const scopeData = scope || (nextDepartment ? 'department' : 'agent'); - Livechat.logger.debug(`Storing new chat transfer of ${room._id} [Transfered by: ${_id} to ${scopeData}]`); - - const transfer = { - transferData: { - transferredBy, - ts: new Date(), - scope: scopeData, - comment, - ...(previousDepartment && { previousDepartment }), - ...(nextDepartment && { nextDepartment }), - ...(transferredTo && { transferredTo }), - }, - }; - - const type = 'livechat_transfer_history'; - const transferMessage = { - t: type, - rid: room._id, - ts: new Date(), - msg: '', - u: { - _id, - username, - }, - groupable: false, - }; - - Object.assign(transferMessage, transfer); - - await sendMessage(transferredBy, transferMessage, room); - }, - - async returnRoomAsInquiry(rid, departmentId, overrideTransferData = {}) { - Livechat.logger.debug(`Transfering room ${rid} to ${departmentId ? 'department' : ''} queue`); - const room = await LivechatRooms.findOneById(rid); - if (!room) { - throw new Meteor.Error('error-invalid-room', 'Invalid room', { - method: 'livechat:returnRoomAsInquiry', - }); - } - - if (!room.open) { - throw new Meteor.Error('room-closed', 'Room closed', { - method: 'livechat:returnRoomAsInquiry', - }); - } - - if (room.onHold) { - throw new Meteor.Error('error-room-onHold', 'Room On Hold', { - method: 'livechat:returnRoomAsInquiry', - }); - } - - if (!room.servedBy) { - return false; - } - - const user = await Users.findOneById(room.servedBy._id); - if (!user || !user._id) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { - method: 'livechat:returnRoomAsInquiry', - }); - } - - // find inquiry corresponding to room - const inquiry = await LivechatInquiry.findOne({ rid }); - if (!inquiry) { - return false; - } - - const transferredBy = normalizeTransferredByData(user, room); - Livechat.logger.debug(`Transfering room ${room._id} by user ${transferredBy._id}`); - const transferData = { roomId: rid, scope: 'queue', departmentId, transferredBy, ...overrideTransferData }; - try { - await this.saveTransferHistory(room, transferData); - await RoutingManager.unassignAgent(inquiry, departmentId); - } catch (e) { - this.logger.error(e); - throw new Meteor.Error('error-returning-inquiry', 'Error returning inquiry to the queue', { - method: 'livechat:returnRoomAsInquiry', - }); - } - - callbacks.runAsync('livechat:afterReturnRoomAsInquiry', { room }); - - return true; - }, - async getLivechatRoomGuestInfo(room) { const visitor = await LivechatVisitors.findOneEnabledById(room.v._id); const agent = await Users.findOneById(room.servedBy && room.servedBy._id); @@ -481,55 +335,12 @@ export const Livechat = { return removeUserFromRolesAsync(user._id, ['livechat-manager']); }, - async removeGuest(_id) { - const guest = await LivechatVisitors.findOneEnabledById(_id, { projection: { _id: 1, token: 1 } }); - if (!guest) { - throw new Meteor.Error('error-invalid-guest', 'Invalid guest', { - method: 'livechat:removeGuest', - }); - } - - await this.cleanGuestHistory(guest); - return LivechatVisitors.disableById(_id); - }, - async setUserStatusLivechat(userId, status) { const user = await Users.setLivechatStatus(userId, status); callbacks.runAsync('livechat.setUserStatusLivechat', { userId, status }); return user; }, - async setUserStatusLivechatIf(userId, status, condition, fields) { - const user = await Users.setLivechatStatusIf(userId, status, condition, fields); - callbacks.runAsync('livechat.setUserStatusLivechat', { userId, status }); - return user; - }, - - async cleanGuestHistory(guest) { - const { token } = guest; - - // This shouldn't be possible, but just in case - if (!token) { - throw new Error('error-invalid-guest'); - } - - const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}); - const cursor = LivechatRooms.findByVisitorToken(token, extraQuery); - for await (const room of cursor) { - await Promise.all([ - FileUpload.removeFilesByRoomId(room._id), - Messages.removeByRoomId(room._id), - ReadReceipts.removeByRoomId(room._id), - ]); - } - - await Promise.all([ - Subscriptions.removeByVisitorToken(token), - LivechatRooms.removeByVisitorToken(token), - LivechatInquiry.removeByVisitorToken(token), - ]); - }, - async saveDepartmentAgents(_id, departmentAgents) { check(_id, String); check(departmentAgents, { diff --git a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts index 293b15e8d63c..32cb5c83acd9 100644 --- a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts +++ b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts @@ -15,6 +15,9 @@ import type { ILivechatDepartment, AtLeast, TransferData, + MessageAttachment, + IMessageInbox, + ILivechatAgentStatus, } from '@rocket.chat/core-typings'; import { UserStatus, isOmnichannelRoom } from '@rocket.chat/core-typings'; import { Logger, type MainLogger } from '@rocket.chat/logger'; @@ -34,13 +37,15 @@ import { import { Random } from '@rocket.chat/random'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import moment from 'moment-timezone'; -import type { FindCursor, UpdateFilter } from 'mongodb'; +import type { Filter, FindCursor, UpdateFilter } from 'mongodb'; import { Apps, AppEvents } from '../../../../ee/server/apps'; import { callbacks } from '../../../../lib/callbacks'; import { i18n } from '../../../../server/lib/i18n'; import { canAccessRoomAsync } from '../../../authorization/server'; import { hasRoleAsync } from '../../../authorization/server/functions/hasRole'; +import { FileUpload } from '../../../file-upload/server'; +import { deleteMessage } from '../../../lib/server/functions/deleteMessage'; import { sendMessage } from '../../../lib/server/functions/sendMessage'; import { updateMessage } from '../../../lib/server/functions/updateMessage'; import * as Mailer from '../../../mailer/server/api'; @@ -89,6 +94,40 @@ type OfflineMessageData = { host?: string; }; +export interface ILivechatMessage { + token: string; + _id: string; + rid: string; + msg: string; + file?: { + _id: string; + name?: string; + type?: string; + size?: number; + description?: string; + identify?: { size: { width: number; height: number } }; + format?: string; + }; + files?: { + _id: string; + name?: string; + type?: string; + size?: number; + description?: string; + identify?: { size: { width: number; height: number } }; + format?: string; + }[]; + attachments?: MessageAttachment[]; + alias?: string; + groupable?: boolean; + blocks?: IMessage['blocks']; + email?: IMessageInbox['email']; +} + +type AKeyOf = { + [K in keyof T]?: T[K]; +}; + const dnsResolveMx = util.promisify(dns.resolveMx); class LivechatClass { @@ -1123,6 +1162,172 @@ class LivechatClass { void callbacks.run('livechat.offlineMessage', data); }); } + + async sendMessage({ + guest, + message, + roomInfo, + agent, + }: { + guest: ILivechatVisitor; + message: ILivechatMessage; + roomInfo: { + source?: IOmnichannelRoom['source']; + [key: string]: unknown; + }; + agent?: SelectedAgent; + }) { + const { room, newRoom } = await this.getRoom(guest, message, roomInfo, agent); + if (guest.name) { + message.alias = guest.name; + } + return Object.assign(await sendMessage(guest, message, room), { + newRoom, + showConnecting: this.showConnecting(), + }); + } + + async removeGuest(_id: string) { + const guest = await LivechatVisitors.findOneEnabledById(_id, { projection: { _id: 1, token: 1 } }); + if (!guest) { + throw new Error('error-invalid-guest'); + } + + await this.cleanGuestHistory(guest); + return LivechatVisitors.disableById(_id); + } + + async cleanGuestHistory(guest: ILivechatVisitor) { + const { token } = guest; + + // This shouldn't be possible, but just in case + if (!token) { + throw new Error('error-invalid-guest'); + } + + const cursor = LivechatRooms.findByVisitorToken(token); + for await (const room of cursor) { + await Promise.all([ + FileUpload.removeFilesByRoomId(room._id), + Messages.removeByRoomId(room._id), + ReadReceipts.removeByRoomId(room._id), + ]); + } + + await Promise.all([ + Subscriptions.removeByVisitorToken(token), + LivechatRooms.removeByVisitorToken(token), + LivechatInquiry.removeByVisitorToken(token), + ]); + } + + async deleteMessage({ guest, message }: { guest: ILivechatVisitor; message: IMessage }) { + const deleteAllowed = settings.get('Message_AllowDeleting'); + const editOwn = message.u && message.u._id === guest._id; + + if (!deleteAllowed || !editOwn) { + throw new Error('error-action-not-allowed'); + } + + await deleteMessage(message, guest as unknown as IUser); + + return true; + } + + async setUserStatusLivechatIf(userId: string, status: ILivechatAgentStatus, condition?: Filter, fields?: AKeyOf) { + const user = await Users.setLivechatStatusIf(userId, status, condition, fields); + callbacks.runAsync('livechat.setUserStatusLivechat', { userId, status }); + return user; + } + + async returnRoomAsInquiry(room: IOmnichannelRoom, departmentId?: string, overrideTransferData: any = {}) { + this.logger.debug({ msg: `Transfering room to ${departmentId ? 'department' : ''} queue`, room }); + if (!room.open) { + throw new Meteor.Error('room-closed'); + } + + if (room.onHold) { + throw new Meteor.Error('error-room-onHold'); + } + + if (!room.servedBy) { + return false; + } + + const user = await Users.findOneById(room.servedBy._id); + if (!user?._id) { + throw new Meteor.Error('error-invalid-user'); + } + + // find inquiry corresponding to room + const inquiry = await LivechatInquiry.findOne({ rid: room._id }); + if (!inquiry) { + return false; + } + + const transferredBy = normalizeTransferredByData(user, room); + this.logger.debug(`Transfering room ${room._id} by user ${transferredBy._id}`); + const transferData = { roomId: room._id, scope: 'queue', departmentId, transferredBy, ...overrideTransferData }; + try { + await this.saveTransferHistory(room, transferData); + await RoutingManager.unassignAgent(inquiry, departmentId); + } catch (e) { + this.logger.error(e); + throw new Meteor.Error('error-returning-inquiry'); + } + + callbacks.runAsync('livechat:afterReturnRoomAsInquiry', { room }); + + return true; + } + + async saveTransferHistory(room: IOmnichannelRoom, transferData: TransferData) { + const { departmentId: previousDepartment } = room; + const { department: nextDepartment, transferredBy, transferredTo, scope, comment } = transferData; + + check( + transferredBy, + Match.ObjectIncluding({ + _id: String, + username: String, + name: Match.Maybe(String), + type: String, + }), + ); + + const { _id, username } = transferredBy; + const scopeData = scope || (nextDepartment ? 'department' : 'agent'); + this.logger.info(`Storing new chat transfer of ${room._id} [Transfered by: ${_id} to ${scopeData}]`); + + const transfer = { + transferData: { + transferredBy, + ts: new Date(), + scope: scopeData, + comment, + ...(previousDepartment && { previousDepartment }), + ...(nextDepartment && { nextDepartment }), + ...(transferredTo && { transferredTo }), + }, + }; + + const type = 'livechat_transfer_history'; + const transferMessage = { + t: type, + rid: room._id, + ts: new Date(), + msg: '', + u: { + _id, + username, + }, + groupable: false, + }; + + Object.assign(transferMessage, transfer); + + await sendMessage(transferredBy, transferMessage, room); + } } export const Livechat = new LivechatClass(); diff --git a/apps/meteor/app/livechat/server/methods/returnAsInquiry.ts b/apps/meteor/app/livechat/server/methods/returnAsInquiry.ts index 57a2b0afa3d5..0c12d0df5275 100644 --- a/apps/meteor/app/livechat/server/methods/returnAsInquiry.ts +++ b/apps/meteor/app/livechat/server/methods/returnAsInquiry.ts @@ -4,7 +4,7 @@ import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; -import { Livechat } from '../lib/Livechat'; +import { Livechat } from '../lib/LivechatTyped'; declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -33,6 +33,6 @@ Meteor.methods({ throw new Meteor.Error('room-closed', 'Room closed', { method: 'livechat:returnAsInquiry' }); } - return Livechat.returnRoomAsInquiry(rid, departmentId); + return Livechat.returnRoomAsInquiry(room, departmentId); }, }); diff --git a/apps/meteor/app/livechat/server/methods/sendMessageLivechat.ts b/apps/meteor/app/livechat/server/methods/sendMessageLivechat.ts index c7d412ea4a06..516a9bc5081f 100644 --- a/apps/meteor/app/livechat/server/methods/sendMessageLivechat.ts +++ b/apps/meteor/app/livechat/server/methods/sendMessageLivechat.ts @@ -1,36 +1,12 @@ import { OmnichannelSourceType } from '@rocket.chat/core-typings'; -import type { MessageAttachment } from '@rocket.chat/core-typings'; import { LivechatVisitors } from '@rocket.chat/models'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { settings } from '../../../settings/server'; -import { Livechat } from '../lib/Livechat'; - -interface ILivechatMessage { - token: string; - _id: string; - rid: string; - msg: string; - file?: { - _id: string; - name?: string; - type?: string; - size?: number; - description?: string; - identify?: { size: { width: number; height: number } }; - }; - files?: { - _id: string; - name?: string; - type?: string; - size?: number; - description?: string; - identify?: { size: { width: number; height: number } }; - }[]; - attachments?: MessageAttachment[]; -} +import { Livechat } from '../lib/LivechatTyped'; +import type { ILivechatMessage } from '../lib/LivechatTyped'; interface ILivechatMessageAgent { agentId: string; diff --git a/apps/meteor/app/livechat/server/startup.ts b/apps/meteor/app/livechat/server/startup.ts index f9fce509e39a..c8487f742b3a 100644 --- a/apps/meteor/app/livechat/server/startup.ts +++ b/apps/meteor/app/livechat/server/startup.ts @@ -1,5 +1,5 @@ import type { IUser } from '@rocket.chat/core-typings'; -import { isOmnichannelRoom } from '@rocket.chat/core-typings'; +import { ILivechatAgentStatus, isOmnichannelRoom } from '@rocket.chat/core-typings'; import { LivechatRooms } from '@rocket.chat/models'; import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; @@ -13,6 +13,7 @@ import { settings } from '../../settings/server'; import { businessHourManager } from './business-hour'; import { createDefaultBusinessHourIfNotExists } from './business-hour/Helper'; import { Livechat } from './lib/Livechat'; +import { Livechat as LivechatTyped } from './lib/LivechatTyped'; import { RoutingManager } from './lib/RoutingManager'; import { LivechatAgentActivityMonitor } from './statistics/LivechatAgentActivityMonitor'; import './roomAccessValidator.internalService'; @@ -79,6 +80,11 @@ Meteor.startup(async () => { ({ user }: { user: IUser }) => user?.roles?.includes('livechat-agent') && !user?.roles?.includes('bot') && - void Livechat.setUserStatusLivechatIf(user._id, 'not-available', {}, { livechatStatusSystemModified: true }).catch(), + void LivechatTyped.setUserStatusLivechatIf( + user._id, + ILivechatAgentStatus.NOT_AVAILABLE, + {}, + { livechatStatusSystemModified: true }, + ).catch(), ); }); diff --git a/apps/meteor/ee/app/livechat-enterprise/server/lib/AutoTransferChatScheduler.ts b/apps/meteor/ee/app/livechat-enterprise/server/lib/AutoTransferChatScheduler.ts index 9d4590836ac9..68044a550277 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/lib/AutoTransferChatScheduler.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/lib/AutoTransferChatScheduler.ts @@ -5,8 +5,8 @@ import { LivechatRooms, Users } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import { MongoInternals } from 'meteor/mongo'; -import { Livechat } from '../../../../../app/livechat/server'; import { forwardRoomToAgent } from '../../../../../app/livechat/server/lib/Helper'; +import { Livechat as LivechatTyped } from '../../../../../app/livechat/server/lib/LivechatTyped'; import { RoutingManager } from '../../../../../app/livechat/server/lib/RoutingManager'; import { settings } from '../../../../../app/settings/server'; import { schedulerLogger } from './logger'; @@ -90,7 +90,7 @@ class AutoTransferChatSchedulerClass { if (!RoutingManager.getConfig()?.autoAssignAgent) { this.logger.debug(`Auto-assign agent is disabled, returning room ${roomId} as inquiry`); - await Livechat.returnRoomAsInquiry(room._id, departmentId, { + await LivechatTyped.returnRoomAsInquiry(room, departmentId, { scope: 'autoTransferUnansweredChatsToQueue', comment: timeoutDuration, transferredBy: await this.getSchedulerUser(), diff --git a/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts b/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts index 939d91661650..ebdd9cdcac01 100644 --- a/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts +++ b/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts @@ -12,7 +12,6 @@ import type { ParsedMail, Attachment } from 'mailparser'; import stripHtml from 'string-strip-html'; import { FileUpload } from '../../../app/file-upload/server'; -import { Livechat } from '../../../app/livechat/server/lib/Livechat'; import { Livechat as LivechatTyped } from '../../../app/livechat/server/lib/LivechatTyped'; import { QueueManager } from '../../../app/livechat/server/lib/QueueManager'; import { settings } from '../../../app/settings/server'; @@ -148,7 +147,7 @@ export async function onEmailReceived(email: ParsedMail, inbox: string, departme const rid = room?._id ?? Random.id(); const msgId = Random.id(); - Livechat.sendMessage({ + LivechatTyped.sendMessage({ guest, message: { _id: msgId, From a3b3dea4816c6a93829d5be3e56c9b68fbd9ad48 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 19 Oct 2023 18:27:43 -0700 Subject: [PATCH 21/26] regression: validateLicenseLimits not using the expected limit (#30693) --- ee/packages/license/src/license.spec.ts | 3 ++- ee/packages/license/src/license.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/ee/packages/license/src/license.spec.ts b/ee/packages/license/src/license.spec.ts index b637ee33ddfd..c605d6467118 100644 --- a/ee/packages/license/src/license.spec.ts +++ b/ee/packages/license/src/license.spec.ts @@ -41,7 +41,7 @@ it('should prevent if the counter is equal or over the limit', async () => { await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); }); -it.skip('should not prevent an action if another limit is over the limit', async () => { +it('should not prevent an action if another limit is over the limit', async () => { const licenseManager = await getReadyLicenseManager(); const license = await new MockedLicenseBuilder() @@ -63,6 +63,7 @@ it.skip('should not prevent an action if another limit is over the limit', async licenseManager.setLicenseLimitCounter('activeUsers', () => 11); licenseManager.setLicenseLimitCounter('monthlyActiveContacts', () => 2); await expect(licenseManager.shouldPreventAction('monthlyActiveContacts')).resolves.toBe(false); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); }); describe('Validate License Limits', () => { diff --git a/ee/packages/license/src/license.ts b/ee/packages/license/src/license.ts index 14dceedd735a..8212a4a0da27 100644 --- a/ee/packages/license/src/license.ts +++ b/ee/packages/license/src/license.ts @@ -268,6 +268,7 @@ export class LicenseManager extends Emitter { ...(extraCount && { behaviors: ['prevent_action'] }), isNewLicense: false, suppressLog: !!suppressLog, + limits: [action], context: { [action]: { extraCount, From c29f5ff4172845d8c8880fd2e93fb5a16e7c8337 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Jaeger=20Foresti?= <60678893+juliajforesti@users.noreply.github.com> Date: Thu, 19 Oct 2023 23:01:44 -0300 Subject: [PATCH 22/26] chore: bump fuselage packages (#30696) --- .../admin/info/UsagePieGraph.stories.tsx | 10 +-- .../views/room/Header/icons/Encrypted.tsx | 2 +- .../users/ActiveUsersSection.tsx | 6 +- .../users/UsersByTimeOfTheDaySection.tsx | 14 ++-- .../ee/client/views/admin/info/SeatsCard.tsx | 2 +- apps/meteor/package.json | 6 +- ee/packages/pdf-worker/package.json | 2 +- ee/packages/ui-theming/package.json | 2 +- packages/fuselage-ui-kit/package.json | 2 +- packages/gazzodown/package.json | 4 +- packages/livechat/package.json | 4 +- packages/ui-client/package.json | 2 +- packages/ui-composer/package.json | 2 +- packages/ui-video-conf/package.json | 2 +- packages/uikit-playground/package.json | 6 +- yarn.lock | 65 +++++++++---------- 16 files changed, 62 insertions(+), 69 deletions(-) diff --git a/apps/meteor/client/views/admin/info/UsagePieGraph.stories.tsx b/apps/meteor/client/views/admin/info/UsagePieGraph.stories.tsx index 12f211d47c5e..d76731f0d2c4 100644 --- a/apps/meteor/client/views/admin/info/UsagePieGraph.stories.tsx +++ b/apps/meteor/client/views/admin/info/UsagePieGraph.stories.tsx @@ -38,27 +38,27 @@ export const Animated: Story, 'size' | { total: 100, used: 0, - color: colorTokens.s500, + color: colorTokens.g500, }, { total: 100, used: 25, - color: colorTokens.p500, + color: colorTokens.b500, }, { total: 100, used: 50, - color: colorTokens.w500, + color: colorTokens.y500, }, { total: 100, used: 75, - color: colorTokens['s1-500'], + color: colorTokens.o500, }, { total: 100, used: 100, - color: colorTokens.d500, + color: colorTokens.r500, }, ]); diff --git a/apps/meteor/client/views/room/Header/icons/Encrypted.tsx b/apps/meteor/client/views/room/Header/icons/Encrypted.tsx index dbfda21f5b7a..bd380c5d8af2 100644 --- a/apps/meteor/client/views/room/Header/icons/Encrypted.tsx +++ b/apps/meteor/client/views/room/Header/icons/Encrypted.tsx @@ -29,7 +29,7 @@ const Encrypted = ({ room }: { room: IRoom }) => { }); }); return e2eEnabled && room?.encrypted ? ( - + ) : null; }; diff --git a/apps/meteor/ee/client/views/admin/engagementDashboard/users/ActiveUsersSection.tsx b/apps/meteor/ee/client/views/admin/engagementDashboard/users/ActiveUsersSection.tsx index e067f777090f..eb504033e1e6 100644 --- a/apps/meteor/ee/client/views/admin/engagementDashboard/users/ActiveUsersSection.tsx +++ b/apps/meteor/ee/client/views/admin/engagementDashboard/users/ActiveUsersSection.tsx @@ -127,7 +127,7 @@ const ActiveUsersSection = ({ timezone }: ActiveUsersSectionProps): ReactElement variation: diffDailyActiveUsers ?? 0, description: ( <> - {t('Daily_Active_Users')} + {t('Daily_Active_Users')} ), }, @@ -136,7 +136,7 @@ const ActiveUsersSection = ({ timezone }: ActiveUsersSectionProps): ReactElement variation: diffWeeklyActiveUsers ?? 0, description: ( <> - {t('Weekly_Active_Users')} + {t('Weekly_Active_Users')} ), }, @@ -203,7 +203,7 @@ const ActiveUsersSection = ({ timezone }: ActiveUsersSectionProps): ReactElement right: 0, left: 40, }} - colors={[colors.p200, colors.p300, colors.p500]} + colors={[colors.b200, colors.b300, colors.b500]} axisLeft={{ // TODO: Get it from theme tickSize: 0, diff --git a/apps/meteor/ee/client/views/admin/engagementDashboard/users/UsersByTimeOfTheDaySection.tsx b/apps/meteor/ee/client/views/admin/engagementDashboard/users/UsersByTimeOfTheDaySection.tsx index d8f13bb891a3..fa5664ebca27 100644 --- a/apps/meteor/ee/client/views/admin/engagementDashboard/users/UsersByTimeOfTheDaySection.tsx +++ b/apps/meteor/ee/client/views/admin/engagementDashboard/users/UsersByTimeOfTheDaySection.tsx @@ -119,13 +119,13 @@ const UsersByTimeOfTheDaySection = ({ timezone }: UsersByTimeOfTheDaySectionProp type: 'quantize', colors: [ // TODO: Get it from theme - colors.p100, - colors.p200, - colors.p300, - colors.p400, - colors.p500, - colors.p600, - colors.p700, + colors.b100, + colors.b200, + colors.b300, + colors.b400, + colors.b500, + colors.b600, + colors.b700, ], }} emptyColor='transparent' diff --git a/apps/meteor/ee/client/views/admin/info/SeatsCard.tsx b/apps/meteor/ee/client/views/admin/info/SeatsCard.tsx index b595dd9c1fae..804893ae8458 100644 --- a/apps/meteor/ee/client/views/admin/info/SeatsCard.tsx +++ b/apps/meteor/ee/client/views/admin/info/SeatsCard.tsx @@ -23,7 +23,7 @@ const SeatsCard = ({ seatsCap }: SeatsCardProps): ReactElement => { const isNearLimit = seatsCap && seatsCap.activeUsers / seatsCap.maxActiveUsers >= 0.8; - const color = isNearLimit ? colors.d500 : undefined; + const color = isNearLimit ? colors.r500 : undefined; return ( diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 3ee3366f47dd..18e4725ffc40 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -236,11 +236,11 @@ "@rocket.chat/favicon": "workspace:^", "@rocket.chat/forked-matrix-appservice-bridge": "^4.0.1", "@rocket.chat/forked-matrix-bot-sdk": "^0.6.0-beta.2", - "@rocket.chat/fuselage": "^0.34.0", + "@rocket.chat/fuselage": "^0.35.0", "@rocket.chat/fuselage-hooks": "^0.32.1", "@rocket.chat/fuselage-polyfills": "next", "@rocket.chat/fuselage-toastbar": "next", - "@rocket.chat/fuselage-tokens": "next", + "@rocket.chat/fuselage-tokens": "^0.32.0", "@rocket.chat/fuselage-ui-kit": "workspace:^", "@rocket.chat/gazzodown": "workspace:^", "@rocket.chat/i18n": "workspace:^", @@ -251,7 +251,7 @@ "@rocket.chat/license": "workspace:^", "@rocket.chat/log-format": "workspace:^", "@rocket.chat/logger": "workspace:^", - "@rocket.chat/logo": "^0.31.27", + "@rocket.chat/logo": "^0.31.28", "@rocket.chat/memo": "next", "@rocket.chat/message-parser": "next", "@rocket.chat/model-typings": "workspace:^", diff --git a/ee/packages/pdf-worker/package.json b/ee/packages/pdf-worker/package.json index 9081c64fba34..f4bd7c5b44f6 100644 --- a/ee/packages/pdf-worker/package.json +++ b/ee/packages/pdf-worker/package.json @@ -34,7 +34,7 @@ "dependencies": { "@react-pdf/renderer": "^3.1.12", "@rocket.chat/core-typings": "workspace:^", - "@rocket.chat/fuselage-tokens": "next", + "@rocket.chat/fuselage-tokens": "^0.32.0", "@types/react": "~17.0.62", "emoji-assets": "^7.0.1", "emoji-toolkit": "^7.0.1", diff --git a/ee/packages/ui-theming/package.json b/ee/packages/ui-theming/package.json index 11aa5fd57ff8..52b8062f332d 100644 --- a/ee/packages/ui-theming/package.json +++ b/ee/packages/ui-theming/package.json @@ -4,7 +4,7 @@ "private": true, "devDependencies": { "@rocket.chat/css-in-js": "next", - "@rocket.chat/fuselage": "^0.34.0", + "@rocket.chat/fuselage": "^0.35.0", "@rocket.chat/fuselage-hooks": "^0.32.1", "@rocket.chat/icons": "^0.32.0", "@rocket.chat/ui-contexts": "workspace:~", diff --git a/packages/fuselage-ui-kit/package.json b/packages/fuselage-ui-kit/package.json index 4555216c2d44..a32d2456752b 100644 --- a/packages/fuselage-ui-kit/package.json +++ b/packages/fuselage-ui-kit/package.json @@ -56,7 +56,7 @@ "devDependencies": { "@rocket.chat/apps-engine": "1.41.0-alpha.290", "@rocket.chat/eslint-config": "workspace:^", - "@rocket.chat/fuselage": "^0.34.0", + "@rocket.chat/fuselage": "^0.35.0", "@rocket.chat/fuselage-hooks": "^0.32.1", "@rocket.chat/fuselage-polyfills": "next", "@rocket.chat/icons": "^0.32.0", diff --git a/packages/gazzodown/package.json b/packages/gazzodown/package.json index e891e5677c75..311950f2222d 100644 --- a/packages/gazzodown/package.json +++ b/packages/gazzodown/package.json @@ -6,8 +6,8 @@ "@babel/core": "~7.22.9", "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/css-in-js": "next", - "@rocket.chat/fuselage": "^0.34.0", - "@rocket.chat/fuselage-tokens": "next", + "@rocket.chat/fuselage": "^0.35.0", + "@rocket.chat/fuselage-tokens": "^0.32.0", "@rocket.chat/message-parser": "next", "@rocket.chat/styled": "next", "@rocket.chat/ui-client": "workspace:^", diff --git a/packages/livechat/package.json b/packages/livechat/package.json index 756248c1df0c..bb92a0716669 100644 --- a/packages/livechat/package.json +++ b/packages/livechat/package.json @@ -30,8 +30,8 @@ "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/ddp-client": "workspace:^", "@rocket.chat/eslint-config": "workspace:^", - "@rocket.chat/fuselage-tokens": "next", - "@rocket.chat/logo": "^0.31.27", + "@rocket.chat/fuselage-tokens": "^0.32.0", + "@rocket.chat/logo": "^0.31.28", "@storybook/addon-essentials": "~6.5.16", "@storybook/addon-postcss": "~2.0.0", "@storybook/preact": "~6.5.16", diff --git a/packages/ui-client/package.json b/packages/ui-client/package.json index f8ef2d3a1e93..7edd02cc6e56 100644 --- a/packages/ui-client/package.json +++ b/packages/ui-client/package.json @@ -5,7 +5,7 @@ "devDependencies": { "@babel/core": "~7.22.9", "@rocket.chat/css-in-js": "next", - "@rocket.chat/fuselage": "^0.34.0", + "@rocket.chat/fuselage": "^0.35.0", "@rocket.chat/fuselage-hooks": "^0.32.1", "@rocket.chat/icons": "^0.32.0", "@rocket.chat/mock-providers": "workspace:^", diff --git a/packages/ui-composer/package.json b/packages/ui-composer/package.json index b5c804b4a2ad..b13328bd001b 100644 --- a/packages/ui-composer/package.json +++ b/packages/ui-composer/package.json @@ -5,7 +5,7 @@ "devDependencies": { "@babel/core": "~7.22.9", "@rocket.chat/eslint-config": "workspace:^", - "@rocket.chat/fuselage": "^0.34.0", + "@rocket.chat/fuselage": "^0.35.0", "@rocket.chat/icons": "^0.32.0", "@storybook/addon-actions": "~6.5.16", "@storybook/addon-docs": "~6.5.16", diff --git a/packages/ui-video-conf/package.json b/packages/ui-video-conf/package.json index 7ca7b1d86140..2e880b3db8bb 100644 --- a/packages/ui-video-conf/package.json +++ b/packages/ui-video-conf/package.json @@ -6,7 +6,7 @@ "@babel/core": "~7.22.9", "@rocket.chat/css-in-js": "next", "@rocket.chat/eslint-config": "workspace:^", - "@rocket.chat/fuselage": "^0.34.0", + "@rocket.chat/fuselage": "^0.35.0", "@rocket.chat/fuselage-hooks": "^0.32.1", "@rocket.chat/icons": "^0.32.0", "@rocket.chat/styled": "next", diff --git a/packages/uikit-playground/package.json b/packages/uikit-playground/package.json index 5a8b1276defb..d9abdf001162 100644 --- a/packages/uikit-playground/package.json +++ b/packages/uikit-playground/package.json @@ -15,13 +15,13 @@ "@codemirror/tooltip": "^0.19.16", "@lezer/highlight": "^1.1.6", "@rocket.chat/css-in-js": "next", - "@rocket.chat/fuselage": "^0.34.0", + "@rocket.chat/fuselage": "^0.35.0", "@rocket.chat/fuselage-hooks": "^0.32.1", "@rocket.chat/fuselage-polyfills": "next", - "@rocket.chat/fuselage-tokens": "next", + "@rocket.chat/fuselage-tokens": "^0.32.0", "@rocket.chat/fuselage-ui-kit": "workspace:~", "@rocket.chat/icons": "^0.32.0", - "@rocket.chat/logo": "^0.31.27", + "@rocket.chat/logo": "^0.31.28", "@rocket.chat/styled": "next", "@rocket.chat/ui-contexts": "workspace:~", "codemirror": "^6.0.1", diff --git a/yarn.lock b/yarn.lock index 4b4fd7f27bc9..dc01a3898eba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8219,17 +8219,10 @@ __metadata: languageName: node linkType: hard -"@rocket.chat/fuselage-tokens@npm:^0.31.25": - version: 0.31.25 - resolution: "@rocket.chat/fuselage-tokens@npm:0.31.25" - checksum: d05460f2f7b7f01b1498aab6fb7d932b7d752d55ce5a6bad6e7a42f2c1f056164ff8caa7dd8ec11bc0f4441a83d8aad0b8aab5e02c03f3452c4583d159b1a2f7 - languageName: node - linkType: hard - -"@rocket.chat/fuselage-tokens@npm:next": - version: 0.32.0-dev.379 - resolution: "@rocket.chat/fuselage-tokens@npm:0.32.0-dev.379" - checksum: c5cf40295c4ae1a5918651b9e156629d6400d5823b8cf5f81a14c66da986a9302d79392b45e991c2fc37aad9633f3d8e2f7f29c68969592340b05968265244e6 +"@rocket.chat/fuselage-tokens@npm:^0.32.0": + version: 0.32.0 + resolution: "@rocket.chat/fuselage-tokens@npm:0.32.0" + checksum: 8da7836877ba93462f90d13de6d3d3add8b2758b58c7988e14a8f0deffd1ceef0547f26d4c60a7ddc881e21e3327b5a04cbf17336e5ca8ab9c19789d8e6af3c0 languageName: node linkType: hard @@ -8239,7 +8232,7 @@ __metadata: dependencies: "@rocket.chat/apps-engine": 1.41.0-alpha.290 "@rocket.chat/eslint-config": "workspace:^" - "@rocket.chat/fuselage": ^0.34.0 + "@rocket.chat/fuselage": ^0.35.0 "@rocket.chat/fuselage-hooks": ^0.32.1 "@rocket.chat/fuselage-polyfills": next "@rocket.chat/gazzodown": "workspace:^" @@ -8288,13 +8281,13 @@ __metadata: languageName: unknown linkType: soft -"@rocket.chat/fuselage@npm:^0.34.0": - version: 0.34.0 - resolution: "@rocket.chat/fuselage@npm:0.34.0" +"@rocket.chat/fuselage@npm:^0.35.0": + version: 0.35.0 + resolution: "@rocket.chat/fuselage@npm:0.35.0" dependencies: "@rocket.chat/css-in-js": ^0.31.25 "@rocket.chat/css-supports": ^0.31.25 - "@rocket.chat/fuselage-tokens": ^0.31.25 + "@rocket.chat/fuselage-tokens": ^0.32.0 "@rocket.chat/memo": ^0.31.25 "@rocket.chat/styled": ^0.31.25 invariant: ^2.2.4 @@ -8308,7 +8301,7 @@ __metadata: react: ^17.0.2 react-dom: ^17.0.2 react-virtuoso: 1.2.4 - checksum: 72cd1dd7ef13cc3b69fadac5c064a45cd2b65b8a221cde2e8149fa873ac6de89648c677caedb10979e5cf08d39b79f1d7a30caa6378bdeeb873414c7fbac5e6e + checksum: 46deea587a1ab4c80a25f4e93882905e2f24778c0e612b7cdd18bfb0c72b2c079d4eee6fe7ad4c52a62354197ebed0a62eaf939b5714859b7086c923668f3f05 languageName: node linkType: hard @@ -8319,8 +8312,8 @@ __metadata: "@babel/core": ~7.22.9 "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/css-in-js": next - "@rocket.chat/fuselage": ^0.34.0 - "@rocket.chat/fuselage-tokens": next + "@rocket.chat/fuselage": ^0.35.0 + "@rocket.chat/fuselage-tokens": ^0.32.0 "@rocket.chat/message-parser": next "@rocket.chat/styled": next "@rocket.chat/ui-client": "workspace:^" @@ -8468,9 +8461,9 @@ __metadata: "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/ddp-client": "workspace:^" "@rocket.chat/eslint-config": "workspace:^" - "@rocket.chat/fuselage-tokens": next + "@rocket.chat/fuselage-tokens": ^0.32.0 "@rocket.chat/gazzodown": "workspace:^" - "@rocket.chat/logo": ^0.31.27 + "@rocket.chat/logo": ^0.31.28 "@rocket.chat/message-parser": next "@rocket.chat/random": "workspace:~" "@rocket.chat/sdk": ^1.0.0-alpha.42 @@ -8581,16 +8574,16 @@ __metadata: languageName: unknown linkType: soft -"@rocket.chat/logo@npm:^0.31.27": - version: 0.31.27 - resolution: "@rocket.chat/logo@npm:0.31.27" +"@rocket.chat/logo@npm:^0.31.28": + version: 0.31.28 + resolution: "@rocket.chat/logo@npm:0.31.28" dependencies: "@rocket.chat/fuselage-hooks": ^0.32.1 "@rocket.chat/styled": ^0.31.25 peerDependencies: react: 17.0.2 react-dom: 17.0.2 - checksum: acc56410813a0d4f634f9e847bc4b49275c26aff4e2f285720818cb012a2ad42554982fcc4078c485222a9c9a78244d1a4b16b60588b5c50441b8928c3957efb + checksum: 2ba185326fadb0d1ccf7d2767435204dd3cd857400d18e59eb8a07055ac0183c6e780d0e8e45436410c551aef516ecea7491a5c87b59406252b2be4694034af8 languageName: node linkType: hard @@ -8665,11 +8658,11 @@ __metadata: "@rocket.chat/favicon": "workspace:^" "@rocket.chat/forked-matrix-appservice-bridge": ^4.0.1 "@rocket.chat/forked-matrix-bot-sdk": ^0.6.0-beta.2 - "@rocket.chat/fuselage": ^0.34.0 + "@rocket.chat/fuselage": ^0.35.0 "@rocket.chat/fuselage-hooks": ^0.32.1 "@rocket.chat/fuselage-polyfills": next "@rocket.chat/fuselage-toastbar": next - "@rocket.chat/fuselage-tokens": next + "@rocket.chat/fuselage-tokens": ^0.32.0 "@rocket.chat/fuselage-ui-kit": "workspace:^" "@rocket.chat/gazzodown": "workspace:^" "@rocket.chat/i18n": "workspace:^" @@ -8681,7 +8674,7 @@ __metadata: "@rocket.chat/livechat": "workspace:^" "@rocket.chat/log-format": "workspace:^" "@rocket.chat/logger": "workspace:^" - "@rocket.chat/logo": ^0.31.27 + "@rocket.chat/logo": ^0.31.28 "@rocket.chat/memo": next "@rocket.chat/message-parser": next "@rocket.chat/mock-providers": "workspace:^" @@ -9175,7 +9168,7 @@ __metadata: dependencies: "@react-pdf/renderer": ^3.1.12 "@rocket.chat/core-typings": "workspace:^" - "@rocket.chat/fuselage-tokens": next + "@rocket.chat/fuselage-tokens": ^0.32.0 "@storybook/addon-essentials": ~6.5.16 "@storybook/react": ~6.5.16 "@testing-library/jest-dom": ^5.16.5 @@ -9528,7 +9521,7 @@ __metadata: dependencies: "@babel/core": ~7.22.9 "@rocket.chat/css-in-js": next - "@rocket.chat/fuselage": ^0.34.0 + "@rocket.chat/fuselage": ^0.35.0 "@rocket.chat/fuselage-hooks": ^0.32.1 "@rocket.chat/icons": ^0.32.0 "@rocket.chat/mock-providers": "workspace:^" @@ -9579,7 +9572,7 @@ __metadata: dependencies: "@babel/core": ~7.22.9 "@rocket.chat/eslint-config": "workspace:^" - "@rocket.chat/fuselage": ^0.34.0 + "@rocket.chat/fuselage": ^0.35.0 "@rocket.chat/icons": ^0.32.0 "@storybook/addon-actions": ~6.5.16 "@storybook/addon-docs": ~6.5.16 @@ -9650,7 +9643,7 @@ __metadata: resolution: "@rocket.chat/ui-theming@workspace:ee/packages/ui-theming" dependencies: "@rocket.chat/css-in-js": next - "@rocket.chat/fuselage": ^0.34.0 + "@rocket.chat/fuselage": ^0.35.0 "@rocket.chat/fuselage-hooks": ^0.32.1 "@rocket.chat/icons": ^0.32.0 "@rocket.chat/ui-contexts": "workspace:~" @@ -9693,7 +9686,7 @@ __metadata: "@rocket.chat/css-in-js": next "@rocket.chat/emitter": next "@rocket.chat/eslint-config": "workspace:^" - "@rocket.chat/fuselage": ^0.34.0 + "@rocket.chat/fuselage": ^0.35.0 "@rocket.chat/fuselage-hooks": ^0.32.1 "@rocket.chat/icons": ^0.32.0 "@rocket.chat/styled": next @@ -9736,13 +9729,13 @@ __metadata: "@codemirror/tooltip": ^0.19.16 "@lezer/highlight": ^1.1.6 "@rocket.chat/css-in-js": next - "@rocket.chat/fuselage": ^0.34.0 + "@rocket.chat/fuselage": ^0.35.0 "@rocket.chat/fuselage-hooks": ^0.32.1 "@rocket.chat/fuselage-polyfills": next - "@rocket.chat/fuselage-tokens": next + "@rocket.chat/fuselage-tokens": ^0.32.0 "@rocket.chat/fuselage-ui-kit": "workspace:~" "@rocket.chat/icons": ^0.32.0 - "@rocket.chat/logo": ^0.31.27 + "@rocket.chat/logo": ^0.31.28 "@rocket.chat/styled": next "@rocket.chat/ui-contexts": "workspace:~" "@types/react": ~17.0.62 From febc7165dc62a37599acde295cd1e61d8f9c8654 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 20 Oct 2023 05:59:05 -0700 Subject: [PATCH 23/26] test: License v3 - add test cases for empty limitations (#30695) --- ee/packages/license/src/license.spec.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/ee/packages/license/src/license.spec.ts b/ee/packages/license/src/license.spec.ts index c605d6467118..73cf13d75035 100644 --- a/ee/packages/license/src/license.spec.ts +++ b/ee/packages/license/src/license.spec.ts @@ -22,6 +22,19 @@ it('should not prevent if the counter is under the limit', async () => { await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); }); +it('should not prevent actions if there is no limit set in the license', async () => { + const licenseManager = await getReadyLicenseManager(); + + const license = await new MockedLicenseBuilder(); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 5); + licenseManager.setLicenseLimitCounter('monthlyActiveContacts', () => 5); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); + await expect(licenseManager.shouldPreventAction('monthlyActiveContacts')).resolves.toBe(false); +}); + it('should prevent if the counter is equal or over the limit', async () => { const licenseManager = await getReadyLicenseManager(); From 832df7f4cd1fd67e5552cf38af272d27c99820dc Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 20 Oct 2023 06:22:34 -0700 Subject: [PATCH 24/26] feat: License v3 store prevent action results and just fire it if changes (#30692) --- ee/packages/license/__tests__/emitter.spec.ts | 176 ++++++++++++++++++ .../license/src/definition/LicenseBehavior.ts | 8 +- .../license/src/definition/LicenseInfo.ts | 1 + ee/packages/license/src/definition/events.ts | 6 + ee/packages/license/src/events/emitter.ts | 15 +- ee/packages/license/src/events/listeners.ts | 8 + ee/packages/license/src/index.ts | 3 + ee/packages/license/src/license.ts | 38 +++- 8 files changed, 249 insertions(+), 6 deletions(-) diff --git a/ee/packages/license/__tests__/emitter.spec.ts b/ee/packages/license/__tests__/emitter.spec.ts index 6147d12623bc..5682715d6d2b 100644 --- a/ee/packages/license/__tests__/emitter.spec.ts +++ b/ee/packages/license/__tests__/emitter.spec.ts @@ -116,4 +116,180 @@ describe('Event License behaviors', () => { await expect(fn).toBeCalledWith(undefined); }); }); + + /** + * this is only called when the prevent_action behavior is triggered for the first time + * it will not be called again until the behavior is toggled + */ + describe('Toggled behaviors', () => { + it('should emit `behaviorToggled:prevent_action` event when the limit is reached once but `behavior:prevent_action` twice', async () => { + const licenseManager = await getReadyLicenseManager(); + const fn = jest.fn(); + const toggleFn = jest.fn(); + + licenseManager.onBehaviorTriggered('prevent_action', fn); + + licenseManager.onBehaviorToggled('prevent_action', toggleFn); + + const license = await new MockedLicenseBuilder().withLimits('activeUsers', [ + { + max: 10, + behavior: 'prevent_action', + }, + ]); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 10); + + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + + await expect(fn).toBeCalledTimes(2); + await expect(toggleFn).toBeCalledTimes(1); + + await expect(fn).toBeCalledWith({ + reason: 'limit', + limit: 'activeUsers', + }); + }); + + it('should emit `behaviorToggled:allow_action` event when the limit is not reached once but `behavior:allow_action` twice', async () => { + const licenseManager = await getReadyLicenseManager(); + const fn = jest.fn(); + const toggleFn = jest.fn(); + + licenseManager.onBehaviorTriggered('allow_action', fn); + + licenseManager.onBehaviorToggled('allow_action', toggleFn); + + const license = await new MockedLicenseBuilder().withLimits('activeUsers', [ + { + max: 10, + behavior: 'prevent_action', + }, + ]); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 9); + + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); + + await expect(fn).toBeCalledTimes(2); + await expect(toggleFn).toBeCalledTimes(1); + + await expect(fn).toBeCalledWith({ + reason: 'limit', + limit: 'activeUsers', + }); + }); + + it('should emit `behaviorToggled:prevent_action` and `behaviorToggled:allow_action` events when the shouldPreventAction function changes the result', async () => { + const licenseManager = await getReadyLicenseManager(); + const preventFn = jest.fn(); + const preventToggleFn = jest.fn(); + const allowFn = jest.fn(); + const allowToggleFn = jest.fn(); + + licenseManager.onBehaviorTriggered('prevent_action', preventFn); + licenseManager.onBehaviorToggled('prevent_action', preventToggleFn); + licenseManager.onBehaviorTriggered('allow_action', allowFn); + licenseManager.onBehaviorToggled('allow_action', allowToggleFn); + + const license = await new MockedLicenseBuilder().withLimits('activeUsers', [ + { + max: 10, + behavior: 'prevent_action', + }, + ]); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 5); + + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); + expect(preventFn).toBeCalledTimes(0); + expect(preventToggleFn).toBeCalledTimes(0); + expect(allowFn).toBeCalledTimes(1); + expect(allowToggleFn).toBeCalledTimes(1); + + preventFn.mockClear(); + preventToggleFn.mockClear(); + allowFn.mockClear(); + allowToggleFn.mockClear(); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); + expect(preventFn).toBeCalledTimes(0); + expect(preventToggleFn).toBeCalledTimes(0); + expect(allowFn).toBeCalledTimes(1); + expect(allowToggleFn).toBeCalledTimes(0); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 10); + + preventFn.mockClear(); + preventToggleFn.mockClear(); + allowFn.mockClear(); + allowToggleFn.mockClear(); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + expect(preventFn).toBeCalledTimes(1); + expect(preventToggleFn).toBeCalledTimes(1); + expect(allowFn).toBeCalledTimes(0); + expect(allowToggleFn).toBeCalledTimes(0); + + preventFn.mockClear(); + preventToggleFn.mockClear(); + allowFn.mockClear(); + allowToggleFn.mockClear(); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + expect(preventFn).toBeCalledTimes(1); + expect(preventToggleFn).toBeCalledTimes(0); + expect(allowFn).toBeCalledTimes(0); + expect(allowToggleFn).toBeCalledTimes(0); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 5); + + preventFn.mockClear(); + preventToggleFn.mockClear(); + allowFn.mockClear(); + allowToggleFn.mockClear(); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); + expect(preventFn).toBeCalledTimes(0); + expect(preventToggleFn).toBeCalledTimes(0); + expect(allowFn).toBeCalledTimes(1); + expect(allowToggleFn).toBeCalledTimes(1); + }); + }); + + describe('Allow actions', () => { + it('should emit `behavior:allow_action` event when the limit is not reached', async () => { + const licenseManager = await getReadyLicenseManager(); + const fn = jest.fn(); + const preventFn = jest.fn(); + + licenseManager.onBehaviorTriggered('allow_action', fn); + licenseManager.onBehaviorTriggered('prevent_action', preventFn); + + const license = await new MockedLicenseBuilder().withLimits('activeUsers', [ + { + max: 10, + behavior: 'prevent_action', + }, + ]); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 9); + + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); + + await expect(fn).toBeCalledTimes(1); + await expect(preventFn).toBeCalledTimes(0); + + await expect(fn).toBeCalledWith({ + reason: 'limit', + limit: 'activeUsers', + }); + }); + }); }); diff --git a/ee/packages/license/src/definition/LicenseBehavior.ts b/ee/packages/license/src/definition/LicenseBehavior.ts index 8b5af5f3c481..ac2249233ab5 100644 --- a/ee/packages/license/src/definition/LicenseBehavior.ts +++ b/ee/packages/license/src/definition/LicenseBehavior.ts @@ -1,7 +1,13 @@ import type { LicenseLimitKind } from './ILicenseV3'; import type { LicenseModule } from './LicenseModule'; -export type LicenseBehavior = 'invalidate_license' | 'start_fair_policy' | 'prevent_action' | 'prevent_installation' | 'disable_modules'; +export type LicenseBehavior = + | 'invalidate_license' + | 'start_fair_policy' + | 'prevent_action' + | 'allow_action' + | 'prevent_installation' + | 'disable_modules'; export type BehaviorWithContext = | { diff --git a/ee/packages/license/src/definition/LicenseInfo.ts b/ee/packages/license/src/definition/LicenseInfo.ts index 4c4e34d30528..019d1b9e1ca0 100644 --- a/ee/packages/license/src/definition/LicenseInfo.ts +++ b/ee/packages/license/src/definition/LicenseInfo.ts @@ -5,6 +5,7 @@ import type { LicenseModule } from './LicenseModule'; export type LicenseInfo = { license?: ILicenseV3; activeModules: LicenseModule[]; + preventedActions: Record; limits: Record; tags: ILicenseTag[]; trial: boolean; diff --git a/ee/packages/license/src/definition/events.ts b/ee/packages/license/src/definition/events.ts index 53f3afe846db..ad1114738cce 100644 --- a/ee/packages/license/src/definition/events.ts +++ b/ee/packages/license/src/definition/events.ts @@ -4,9 +4,15 @@ import type { LicenseModule } from './LicenseModule'; type ModuleValidation = Record<`${'invalid' | 'valid'}:${LicenseModule}`, undefined>; type BehaviorTriggered = Record<`behavior:${LicenseBehavior}`, { reason: BehaviorWithContext['reason']; limit?: LicenseLimitKind }>; +type BehaviorTriggeredToggled = Record< + `behaviorToggled:${LicenseBehavior}`, + { reason: BehaviorWithContext['reason']; limit?: LicenseLimitKind } +>; + type LimitReached = Record<`limitReached:${LicenseLimitKind}`, undefined>; export type LicenseEvents = ModuleValidation & + BehaviorTriggeredToggled & BehaviorTriggered & LimitReached & { validate: undefined; diff --git a/ee/packages/license/src/events/emitter.ts b/ee/packages/license/src/events/emitter.ts index 9256bcafe5f7..51f3282a9742 100644 --- a/ee/packages/license/src/events/emitter.ts +++ b/ee/packages/license/src/events/emitter.ts @@ -33,7 +33,7 @@ export function behaviorTriggered(this: LicenseManager, options: BehaviorWithCon logger.error({ msg: 'Error running behavior triggered event', error }); } - if (behavior !== 'prevent_action') { + if (!['prevent_action'].includes(behavior)) { return; } @@ -48,6 +48,19 @@ export function behaviorTriggered(this: LicenseManager, options: BehaviorWithCon } } +export function behaviorTriggeredToggled(this: LicenseManager, options: BehaviorWithContext) { + const { behavior, reason, modules: _, ...rest } = options; + + try { + this.emit(`behaviorToggled:${behavior}`, { + reason, + ...rest, + }); + } catch (error) { + logger.error({ msg: 'Error running behavior triggered event', error }); + } +} + export function licenseValidated(this: LicenseManager) { try { this.emit('validate'); diff --git a/ee/packages/license/src/events/listeners.ts b/ee/packages/license/src/events/listeners.ts index ecabecb28c0f..6c80867b7ac8 100644 --- a/ee/packages/license/src/events/listeners.ts +++ b/ee/packages/license/src/events/listeners.ts @@ -79,6 +79,14 @@ export function onBehaviorTriggered( this.on(`behavior:${behavior}`, cb); } +export function onBehaviorToggled( + this: LicenseManager, + behavior: Exclude, + cb: (data: { reason: BehaviorWithContext['reason']; limit?: LicenseLimitKind }) => void, +) { + this.on(`behaviorToggled:${behavior}`, cb); +} + export function onLimitReached(this: LicenseManager, limitKind: LicenseLimitKind, cb: () => void) { this.on(`limitReached:${limitKind}`, cb); } diff --git a/ee/packages/license/src/index.ts b/ee/packages/license/src/index.ts index 9707a41d96ab..92b30c4af40d 100644 --- a/ee/packages/license/src/index.ts +++ b/ee/packages/license/src/index.ts @@ -4,6 +4,7 @@ import type { LimitContext } from './definition/LimitContext'; import { getAppsConfig, getMaxActiveUsers, getUnmodifiedLicenseAndModules } from './deprecated'; import { onLicense } from './events/deprecated'; import { + onBehaviorToggled, onBehaviorTriggered, onInvalidFeature, onInvalidateLicense, @@ -97,6 +98,8 @@ export class LicenseImp extends LicenseManager implements License { onBehaviorTriggered = onBehaviorTriggered; + onBehaviorToggled = onBehaviorToggled; + // Deprecated: onLicense = onLicense; diff --git a/ee/packages/license/src/license.ts b/ee/packages/license/src/license.ts index 8212a4a0da27..5987065bd697 100644 --- a/ee/packages/license/src/license.ts +++ b/ee/packages/license/src/license.ts @@ -12,7 +12,7 @@ import type { LicenseEvents } from './definition/events'; import { DuplicatedLicenseError } from './errors/DuplicatedLicenseError'; import { InvalidLicenseError } from './errors/InvalidLicenseError'; import { NotReadyForValidation } from './errors/NotReadyForValidation'; -import { behaviorTriggered, licenseInvalidated, licenseValidated } from './events/emitter'; +import { behaviorTriggered, behaviorTriggeredToggled, licenseInvalidated, licenseValidated } from './events/emitter'; import { logger } from './logger'; import { getModules, invalidateAll, replaceModules } from './modules'; import { applyPendingLicense, clearPendingLicense, hasPendingLicense, isPendingLicense, setPendingLicense } from './pendingLicense'; @@ -49,6 +49,8 @@ export class LicenseManager extends Emitter { private _lockedLicense: string | undefined; + public shouldPreventActionResults = new Map(); + constructor() { super(); @@ -106,6 +108,8 @@ export class LicenseManager extends Emitter { this._unmodifiedLicense = undefined; this._valid = false; this._lockedLicense = undefined; + + this.shouldPreventActionResults.clear(); clearPendingLicense.call(this); } @@ -243,6 +247,12 @@ export class LicenseManager extends Emitter { } } + private triggerBehaviorEventsToggled(validationResult: BehaviorWithContext[]): void { + for (const { ...options } of validationResult) { + behaviorTriggeredToggled.call(this, { ...options }); + } + } + public hasValidLicense(): boolean { return Boolean(this.getLicense()); } @@ -279,18 +289,37 @@ export class LicenseManager extends Emitter { const validationResult = await runValidation.call(this, license, options); + const shouldPreventAction = isBehaviorsInResult(validationResult, ['prevent_action']); + // extra values should not call events since they are not actually reaching the limit just checking if they would if (extraCount) { - return isBehaviorsInResult(validationResult, ['prevent_action']); + return shouldPreventAction; } if (isBehaviorsInResult(validationResult, ['invalidate_license', 'disable_modules', 'start_fair_policy'])) { await this.revalidateLicense(); } - this.triggerBehaviorEvents(filterBehaviorsResult(validationResult, ['prevent_action'])); + const eventsToEmit = shouldPreventAction + ? filterBehaviorsResult(validationResult, ['prevent_action']) + : [ + { + behavior: 'allow_action', + modules: [], + reason: 'limit', + limit: action, + } as BehaviorWithContext, + ]; + + if (this.shouldPreventActionResults.get(action) !== shouldPreventAction) { + this.shouldPreventActionResults.set(action, shouldPreventAction); + + this.triggerBehaviorEventsToggled(eventsToEmit); + } + + this.triggerBehaviorEvents(eventsToEmit); - return isBehaviorsInResult(validationResult, ['prevent_action']); + return shouldPreventAction; } public async getInfo({ @@ -331,6 +360,7 @@ export class LicenseManager extends Emitter { return { license: (includeLicense && license) || undefined, activeModules, + preventedActions: Object.fromEntries(this.shouldPreventActionResults.entries()) as Record, limits: limits as Record, tags: license?.information.tags || [], trial: Boolean(license?.information.trial), From 94c6e897264ba54ae52a0f187b6d6e05f643d977 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 20 Oct 2023 06:31:56 -0700 Subject: [PATCH 25/26] chore: replace endpoint to licenses.info (#30697) --- apps/meteor/client/hooks/useLicense.ts | 6 +- .../hooks/useAdministrationItems.spec.tsx | 47 +++++++------- .../client/views/admin/info/LicenseCard.tsx | 63 +++++++++++-------- .../client/views/hooks/useUpgradeTabParams.ts | 10 +-- apps/meteor/ee/server/api/licenses.ts | 12 ++-- .../tests/end-to-end/api/20-licenses.js | 12 ++-- packages/rest-typings/src/v1/licenses.ts | 2 +- 7 files changed, 80 insertions(+), 72 deletions(-) diff --git a/apps/meteor/client/hooks/useLicense.ts b/apps/meteor/client/hooks/useLicense.ts index 0f568d9bd5cc..1549b431eeb7 100644 --- a/apps/meteor/client/hooks/useLicense.ts +++ b/apps/meteor/client/hooks/useLicense.ts @@ -3,8 +3,8 @@ import { useEndpoint, usePermission } from '@rocket.chat/ui-contexts'; import type { UseQueryResult } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query'; -export const useLicense = (): UseQueryResult> => { - const getLicenses = useEndpoint('GET', '/v1/licenses.get'); +export const useLicense = (): UseQueryResult> => { + const getLicenses = useEndpoint('GET', '/v1/licenses.info'); const canViewLicense = usePermission('view-privileged-setting'); return useQuery( @@ -13,7 +13,7 @@ export const useLicense = (): UseQueryResult { const { result, waitFor } = renderHook(() => useAdministrationItems(), { wrapper: mockAppRoot() - .withEndpoint('GET', '/v1/licenses.get', () => ({ - licenses: [ - { - modules: ['testModule'], - meta: { trial: false }, - } as any, - ], + .withEndpoint('GET', '/v1/licenses.info', () => ({ + license: { + // @ts-expect-error this is a mock + license: { activeModules: ['testModule'] }, + trial: false, + }, })) .withEndpoint('GET', '/v1/cloud.registrationStatus', () => ({ registrationStatus: { @@ -25,19 +24,21 @@ it('should not show upgrade item if has license and not have trial', async () => }); await waitFor(() => !!(result.all.length > 1)); - expect(result.current.length).toEqual(1); + + expect(result.current[0]).toEqual( + expect.objectContaining({ + id: 'workspace', + }), + ); }); it('should return an upgrade item if not have license or if have a trial', async () => { const { result, waitFor } = renderHook(() => useAdministrationItems(), { wrapper: mockAppRoot() - .withEndpoint('GET', '/v1/licenses.get', () => ({ - licenses: [ - { - modules: [], - } as any, - ], + .withEndpoint('GET', '/v1/licenses.info', () => ({ + // @ts-expect-error this is a mock + license: {}, })) .withEndpoint('GET', '/v1/cloud.registrationStatus', () => ({ registrationStatus: { @@ -62,12 +63,9 @@ it('should return an upgrade item if not have license or if have a trial', async it('should return omnichannel item if has `view-livechat-manager` permission ', async () => { const { result, waitFor } = renderHook(() => useAdministrationItems(), { wrapper: mockAppRoot() - .withEndpoint('GET', '/v1/licenses.get', () => ({ - licenses: [ - { - modules: [], - } as any, - ], + .withEndpoint('GET', '/v1/licenses.info', () => ({ + // @ts-expect-error this is a mock + license: {}, })) .withEndpoint('GET', '/v1/cloud.registrationStatus', () => ({ registrationStatus: { @@ -90,12 +88,9 @@ it('should return omnichannel item if has `view-livechat-manager` permission ', it('should show administration item if has at least one admin permission', async () => { const { result, waitFor } = renderHook(() => useAdministrationItems(), { wrapper: mockAppRoot() - .withEndpoint('GET', '/v1/licenses.get', () => ({ - licenses: [ - { - modules: [], - } as any, - ], + .withEndpoint('GET', '/v1/licenses.info', () => ({ + // @ts-expect-error this is a mock + license: {}, })) .withEndpoint('GET', '/v1/cloud.registrationStatus', () => ({ registrationStatus: { diff --git a/apps/meteor/client/views/admin/info/LicenseCard.tsx b/apps/meteor/client/views/admin/info/LicenseCard.tsx index bccbddaa6db7..8aab636f4720 100644 --- a/apps/meteor/client/views/admin/info/LicenseCard.tsx +++ b/apps/meteor/client/views/admin/info/LicenseCard.tsx @@ -19,15 +19,7 @@ const LicenseCard = (): ReactElement => { const isAirGapped = true; - const { data, isError, isLoading } = useLicense(); - - const { modules = [] } = isLoading || isError || !data?.licenses?.length ? {} : data?.licenses[0]; - - const hasEngagement = modules.includes('engagement-dashboard'); - const hasOmnichannel = modules.includes('livechat-enterprise'); - const hasAuditing = modules.includes('auditing'); - const hasCannedResponses = modules.includes('canned-responses'); - const hasReadReceipts = modules.includes('message-read-receipt'); + const request = useLicense(); const handleApplyLicense = useMutableCallback(() => setModal( @@ -41,6 +33,37 @@ const LicenseCard = (): ReactElement => { ), ); + if (request.isLoading || request.isError) { + return ( + + {t('License')} + + + + + + + {t('Features')} + + + + + + + + + + ); + } + + const { activeModules } = request.data.license; + + const hasEngagement = activeModules.includes('engagement-dashboard'); + const hasOmnichannel = activeModules.includes('livechat-enterprise'); + const hasAuditing = activeModules.includes('auditing'); + const hasCannedResponses = activeModules.includes('canned-responses'); + const hasReadReceipts = activeModules.includes('message-read-receipt'); + return ( {t('License')} @@ -51,22 +74,12 @@ const LicenseCard = (): ReactElement => { {t('Features')} - {isLoading ? ( - <> - - - - - - ) : ( - <> - - - - - - - )} + + + + + + diff --git a/apps/meteor/client/views/hooks/useUpgradeTabParams.ts b/apps/meteor/client/views/hooks/useUpgradeTabParams.ts index 65dd4cb1e396..1d152b08d5b9 100644 --- a/apps/meteor/client/views/hooks/useUpgradeTabParams.ts +++ b/apps/meteor/client/views/hooks/useUpgradeTabParams.ts @@ -1,4 +1,3 @@ -import type { ILicenseV2, ILicenseV3 } from '@rocket.chat/license'; import { useSetting } from '@rocket.chat/ui-contexts'; import { format } from 'date-fns'; @@ -14,14 +13,11 @@ export const useUpgradeTabParams = (): { tabType: UpgradeTabVariant | false; tri const { data: registrationStatusData, isSuccess: isSuccessRegistrationStatus } = useRegistrationStatus(); const registered = registrationStatusData?.registrationStatus?.workspaceRegistered ?? false; - const hasValidLicense = licensesData?.licenses.some((license) => license.modules.length > 0) ?? false; + const hasValidLicense = Boolean(licensesData?.license?.license ?? false); const hadExpiredTrials = cloudWorkspaceHadTrial ?? false; - const licenses = (licensesData?.licenses || []) as (Partial & { modules: string[] })[]; - - const trialLicense = licenses.find(({ meta, information }) => information?.trial ?? meta?.trial); - const isTrial = Boolean(trialLicense); - const trialEndDateStr = trialLicense?.information?.visualExpiration || trialLicense?.meta?.trialEnd || trialLicense?.cloudMeta?.trialEnd; + const isTrial = Boolean(licensesData?.license?.trial); + const trialEndDateStr = licensesData?.license?.license?.information?.visualExpiration; const trialEndDate = trialEndDateStr ? format(new Date(trialEndDateStr), 'yyyy-MM-dd') : undefined; const upgradeTabType = getUpgradeTabType({ diff --git a/apps/meteor/ee/server/api/licenses.ts b/apps/meteor/ee/server/api/licenses.ts index b7ac3ba81e9c..a2e7a75b072a 100644 --- a/apps/meteor/ee/server/api/licenses.ts +++ b/apps/meteor/ee/server/api/licenses.ts @@ -5,12 +5,15 @@ import { check } from 'meteor/check'; import { API } from '../../../app/api/server/api'; import { hasPermissionAsync } from '../../../app/authorization/server/functions/hasPermission'; +import { apiDeprecationLogger } from '../../../app/lib/server/lib/deprecationWarningLogger'; API.v1.addRoute( 'licenses.get', { authRequired: true }, { async get() { + apiDeprecationLogger.endpoint(this.request.route, '7.0.0', this.response, ' Use licenses.info instead.'); + if (!(await hasPermissionAsync(this.userId, 'view-privileged-setting'))) { return API.v1.unauthorized(); } @@ -31,9 +34,9 @@ API.v1.addRoute( const unrestrictedAccess = await hasPermissionAsync(this.userId, 'view-privileged-setting'); const loadCurrentValues = unrestrictedAccess && Boolean(this.queryParams.loadValues); - const data = await License.getInfo({ limits: unrestrictedAccess, license: unrestrictedAccess, currentValues: loadCurrentValues }); + const license = await License.getInfo({ limits: unrestrictedAccess, license: unrestrictedAccess, currentValues: loadCurrentValues }); - return API.v1.success({ data }); + return API.v1.success({ license }); }, }, ); @@ -81,8 +84,9 @@ API.v1.addRoute( { authOrAnonRequired: true }, { get() { - const isEnterpriseEdtion = License.hasValidLicense(); - return API.v1.success({ isEnterprise: isEnterpriseEdtion }); + apiDeprecationLogger.endpoint(this.request.route, '7.0.0', this.response, ' Use licenses.info instead.'); + const isEnterpriseEdition = License.hasValidLicense(); + return API.v1.success({ isEnterprise: isEnterpriseEdition }); }, }, ); diff --git a/apps/meteor/tests/end-to-end/api/20-licenses.js b/apps/meteor/tests/end-to-end/api/20-licenses.js index 302011addef9..9088e4e9e1d9 100644 --- a/apps/meteor/tests/end-to-end/api/20-licenses.js +++ b/apps/meteor/tests/end-to-end/api/20-licenses.js @@ -126,9 +126,9 @@ describe('licenses', function () { .expect(200) .expect((res) => { expect(res.body).to.have.property('success', true); - expect(res.body).to.have.property('data').and.to.be.an('object'); - expect(res.body.data).to.not.have.property('license'); - expect(res.body.data).to.have.property('tags').and.to.be.an('array'); + expect(res.body).to.have.property('license').and.to.be.an('object'); + expect(res.body.license).to.not.have.property('license'); + expect(res.body.license).to.have.property('tags').and.to.be.an('array'); }) .end(done); }); @@ -140,11 +140,11 @@ describe('licenses', function () { .expect(200) .expect((res) => { expect(res.body).to.have.property('success', true); - expect(res.body).to.have.property('data').and.to.be.an('object'); + expect(res.body).to.have.property('license').and.to.be.an('object'); if (process.env.IS_EE) { - expect(res.body.data).to.have.property('license').and.to.be.an('object'); + expect(res.body.license).to.have.property('license').and.to.be.an('object'); } - expect(res.body.data).to.have.property('tags').and.to.be.an('array'); + expect(res.body.license).to.have.property('tags').and.to.be.an('array'); }) .end(done); diff --git a/packages/rest-typings/src/v1/licenses.ts b/packages/rest-typings/src/v1/licenses.ts index d229ca49f1fc..4eb1ac196840 100644 --- a/packages/rest-typings/src/v1/licenses.ts +++ b/packages/rest-typings/src/v1/licenses.ts @@ -45,7 +45,7 @@ export type LicensesEndpoints = { }; '/v1/licenses.info': { GET: (params: licensesInfoProps) => { - data: LicenseInfo; + license: LicenseInfo; }; }; '/v1/licenses.add': { From daa28f02a404599243a734c7cf8a1d2a0ff6c96b Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Fri, 20 Oct 2023 14:31:54 -0300 Subject: [PATCH 26/26] test: fix API test flakiness (#30657) --- .../rocketchat-mongo-config/server/index.js | 19 +++++++++---------- .../server/lib/dataExport/uploadZipFile.ts | 6 ++---- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/apps/meteor/packages/rocketchat-mongo-config/server/index.js b/apps/meteor/packages/rocketchat-mongo-config/server/index.js index 80e664a9c209..65464a31095c 100644 --- a/apps/meteor/packages/rocketchat-mongo-config/server/index.js +++ b/apps/meteor/packages/rocketchat-mongo-config/server/index.js @@ -34,20 +34,19 @@ if (Object.keys(mongoConnectionOptions).length > 0) { process.env.HTTP_FORWARDED_COUNT = process.env.HTTP_FORWARDED_COUNT || '1'; -// Send emails to a "fake" stream instead of print them in console in case MAIL_URL or SMTP is not configured -if (process.env.NODE_ENV !== 'development') { - const { sendAsync } = Email; +// Just print to logs if in TEST_MODE due to a bug in Meteor 2.5: TypeError: Cannot read property '_syncSendMail' of null +if (process.env.TEST_MODE === 'true') { + Email.sendAsync = function _sendAsync(options) { + console.log('Email.sendAsync', options); + }; +} else if (process.env.NODE_ENV !== 'development') { + // Send emails to a "fake" stream instead of print them in console in case MAIL_URL or SMTP is not configured const stream = new PassThrough(); stream.on('data', () => {}); stream.on('end', () => {}); - Email.sendAsync = function _sendAsync(options) { - return sendAsync.call(this, { stream, ...options }); - }; -} -// Just print to logs if in TEST_MODE due to a bug in Meteor 2.5: TypeError: Cannot read property '_syncSendMail' of null -if (process.env.TEST_MODE === 'true') { + const { sendAsync } = Email; Email.sendAsync = function _sendAsync(options) { - console.log('Email.sendAsync', options); + return sendAsync.call(this, { stream, ...options }); }; } diff --git a/apps/meteor/server/lib/dataExport/uploadZipFile.ts b/apps/meteor/server/lib/dataExport/uploadZipFile.ts index e6a76472db7f..5fe9ea2d57dd 100644 --- a/apps/meteor/server/lib/dataExport/uploadZipFile.ts +++ b/apps/meteor/server/lib/dataExport/uploadZipFile.ts @@ -1,5 +1,5 @@ import { createReadStream } from 'fs'; -import { open, stat } from 'fs/promises'; +import { stat } from 'fs/promises'; import type { IUser } from '@rocket.chat/core-typings'; import { Users } from '@rocket.chat/models'; @@ -28,9 +28,7 @@ export const uploadZipFile = async (filePath: string, userId: IUser['_id'], expo name: newFileName, }; - const { fd } = await open(filePath); - - const stream = createReadStream('', { fd }); // @todo once upgrades to Node.js v16.x, use createReadStream from fs.promises.open + const stream = createReadStream(filePath); const userDataStore = FileUpload.getStore('UserDataFiles');