From 3a417efd5dcebd0240841e28133699d70c9fba96 Mon Sep 17 00:00:00 2001 From: Tasso Evangelista Date: Fri, 14 Aug 2026 19:31:23 +0000 Subject: [PATCH 1/3] chore: migrate `@rocket.chat/emitter` from the Fuselage repository (#41768) Co-authored-by: Claude Opus 5 (1M context) --- apps/meteor/ee/server/services/package.json | 2 +- apps/meteor/package.json | 2 +- apps/uikit-playground/package.json | 2 +- ee/apps/account-service/package.json | 2 +- ee/apps/authorization-service/package.json | 2 +- ee/apps/ddp-streamer/package.json | 2 +- ee/apps/omnichannel-transcript/package.json | 2 +- ee/apps/presence-service/package.json | 2 +- ee/apps/queue-worker/package.json | 2 +- ee/packages/federation-matrix/package.json | 2 +- ee/packages/media-calls/package.json | 2 +- ee/packages/omnichannel-services/package.json | 2 +- fuselage.sh | 5 +- packages/ddp-client/package.json | 2 +- packages/emitter/CHANGELOG.md | 113 ++++++++++++ packages/emitter/jest.config.ts | 6 + packages/emitter/package.json | 53 ++++++ packages/emitter/rollup.config.mjs | 32 ++++ packages/emitter/src/emitter.spec.ts | 102 +++++++++++ packages/emitter/src/index.ts | 161 ++++++++++++++++++ packages/emitter/tsconfig.build.json | 8 + packages/emitter/tsconfig.json | 10 ++ packages/fuselage-ui-kit/package.json | 2 +- packages/gazzodown/package.json | 2 +- packages/livechat/package.json | 2 +- packages/logger/package.json | 2 +- packages/media-signaling/package.json | 2 +- packages/mock-providers/package.json | 2 +- packages/storybook-config/package.json | 2 +- packages/ui-avatar/package.json | 2 +- packages/ui-client/package.json | 2 +- packages/ui-composer/package.json | 2 +- packages/ui-contexts/package.json | 4 +- packages/ui-video-conf/package.json | 2 +- packages/ui-voip/package.json | 2 +- packages/web-ui-registration/package.json | 2 +- yarn.lock | 159 +++++++++++++---- 37 files changed, 638 insertions(+), 67 deletions(-) create mode 100644 packages/emitter/CHANGELOG.md create mode 100644 packages/emitter/jest.config.ts create mode 100644 packages/emitter/package.json create mode 100644 packages/emitter/rollup.config.mjs create mode 100644 packages/emitter/src/emitter.spec.ts create mode 100644 packages/emitter/src/index.ts create mode 100644 packages/emitter/tsconfig.build.json create mode 100644 packages/emitter/tsconfig.json diff --git a/apps/meteor/ee/server/services/package.json b/apps/meteor/ee/server/services/package.json index 338bc071b947e..d6b127944e08b 100644 --- a/apps/meteor/ee/server/services/package.json +++ b/apps/meteor/ee/server/services/package.json @@ -19,7 +19,7 @@ "@rocket.chat/apps-engine": "workspace:^", "@rocket.chat/core-services": "workspace:^", "@rocket.chat/core-typings": "workspace:^", - "@rocket.chat/emitter": "^0.33.0", + "@rocket.chat/emitter": "workspace:~", "@rocket.chat/message-parser": "workspace:^", "@rocket.chat/model-typings": "workspace:^", "@rocket.chat/models": "workspace:^", diff --git a/apps/meteor/package.json b/apps/meteor/package.json index df57be59df8de..86fa4efff2e4a 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -105,7 +105,7 @@ "@rocket.chat/cron": "workspace:^", "@rocket.chat/css-in-js": "^0.33.1", "@rocket.chat/ddp-client": "workspace:^", - "@rocket.chat/emitter": "^0.33.0", + "@rocket.chat/emitter": "workspace:~", "@rocket.chat/favicon": "workspace:^", "@rocket.chat/federation-matrix": "workspace:^", "@rocket.chat/federation-sdk": "0.7.0", diff --git a/apps/uikit-playground/package.json b/apps/uikit-playground/package.json index e300b527f998f..ea3a031e01aac 100644 --- a/apps/uikit-playground/package.json +++ b/apps/uikit-playground/package.json @@ -40,7 +40,7 @@ "reactflow": "^11.11.4" }, "devDependencies": { - "@rocket.chat/emitter": "^0.33.0", + "@rocket.chat/emitter": "workspace:~", "@rocket.chat/tsconfig": "workspace:*", "@types/lodash": "~4.17.25", "@types/react": "~19.2.18", diff --git a/ee/apps/account-service/package.json b/ee/apps/account-service/package.json index f5b94b84ede2d..5938cfc74f407 100644 --- a/ee/apps/account-service/package.json +++ b/ee/apps/account-service/package.json @@ -21,7 +21,7 @@ "dependencies": { "@rocket.chat/core-services": "workspace:^", "@rocket.chat/core-typings": "workspace:^", - "@rocket.chat/emitter": "^0.33.0", + "@rocket.chat/emitter": "workspace:~", "@rocket.chat/logger": "workspace:^", "@rocket.chat/model-typings": "workspace:^", "@rocket.chat/models": "workspace:^", diff --git a/ee/apps/authorization-service/package.json b/ee/apps/authorization-service/package.json index 4333000b221cb..997e41aa2ae27 100644 --- a/ee/apps/authorization-service/package.json +++ b/ee/apps/authorization-service/package.json @@ -22,7 +22,7 @@ "@rocket.chat/abac": "workspace:^", "@rocket.chat/core-services": "workspace:^", "@rocket.chat/core-typings": "workspace:^", - "@rocket.chat/emitter": "^0.33.0", + "@rocket.chat/emitter": "workspace:~", "@rocket.chat/logger": "workspace:^", "@rocket.chat/model-typings": "workspace:^", "@rocket.chat/models": "workspace:^", diff --git a/ee/apps/ddp-streamer/package.json b/ee/apps/ddp-streamer/package.json index 073c81fdea495..aa8dfa972ada0 100644 --- a/ee/apps/ddp-streamer/package.json +++ b/ee/apps/ddp-streamer/package.json @@ -22,7 +22,7 @@ "dependencies": { "@rocket.chat/core-services": "workspace:^", "@rocket.chat/core-typings": "workspace:^", - "@rocket.chat/emitter": "^0.33.0", + "@rocket.chat/emitter": "workspace:~", "@rocket.chat/instance-status": "workspace:^", "@rocket.chat/logger": "workspace:^", "@rocket.chat/model-typings": "workspace:^", diff --git a/ee/apps/omnichannel-transcript/package.json b/ee/apps/omnichannel-transcript/package.json index 94da63df2c5a0..bbd2dbe4d5c78 100644 --- a/ee/apps/omnichannel-transcript/package.json +++ b/ee/apps/omnichannel-transcript/package.json @@ -22,7 +22,7 @@ "@react-pdf/renderer": "~4.5.1", "@rocket.chat/core-services": "workspace:^", "@rocket.chat/core-typings": "workspace:^", - "@rocket.chat/emitter": "^0.33.0", + "@rocket.chat/emitter": "workspace:~", "@rocket.chat/i18n": "workspace:^", "@rocket.chat/logger": "workspace:^", "@rocket.chat/model-typings": "workspace:^", diff --git a/ee/apps/presence-service/package.json b/ee/apps/presence-service/package.json index cd526b60c6a79..b450274899ac5 100644 --- a/ee/apps/presence-service/package.json +++ b/ee/apps/presence-service/package.json @@ -22,7 +22,7 @@ "@rocket.chat/core-services": "workspace:^", "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/cron": "workspace:^", - "@rocket.chat/emitter": "^0.33.0", + "@rocket.chat/emitter": "workspace:~", "@rocket.chat/logger": "workspace:^", "@rocket.chat/model-typings": "workspace:^", "@rocket.chat/models": "workspace:^", diff --git a/ee/apps/queue-worker/package.json b/ee/apps/queue-worker/package.json index 52329110e9825..7b15c509f2d90 100644 --- a/ee/apps/queue-worker/package.json +++ b/ee/apps/queue-worker/package.json @@ -21,7 +21,7 @@ "dependencies": { "@rocket.chat/core-services": "workspace:^", "@rocket.chat/core-typings": "workspace:^", - "@rocket.chat/emitter": "^0.33.0", + "@rocket.chat/emitter": "workspace:~", "@rocket.chat/logger": "workspace:^", "@rocket.chat/model-typings": "workspace:^", "@rocket.chat/models": "workspace:^", diff --git a/ee/packages/federation-matrix/package.json b/ee/packages/federation-matrix/package.json index c21d467e945d5..5d7a996d343a5 100644 --- a/ee/packages/federation-matrix/package.json +++ b/ee/packages/federation-matrix/package.json @@ -21,7 +21,7 @@ "dependencies": { "@rocket.chat/core-services": "workspace:^", "@rocket.chat/core-typings": "workspace:^", - "@rocket.chat/emitter": "^0.33.0", + "@rocket.chat/emitter": "workspace:~", "@rocket.chat/federation-sdk": "0.7.0", "@rocket.chat/http-router": "workspace:^", "@rocket.chat/license": "workspace:^", diff --git a/ee/packages/media-calls/package.json b/ee/packages/media-calls/package.json index 4fc97dcbf26de..084ba0ec69994 100644 --- a/ee/packages/media-calls/package.json +++ b/ee/packages/media-calls/package.json @@ -17,7 +17,7 @@ }, "dependencies": { "@rocket.chat/core-typings": "workspace:^", - "@rocket.chat/emitter": "^0.33.0", + "@rocket.chat/emitter": "workspace:~", "@rocket.chat/logger": "workspace:^", "@rocket.chat/media-signaling": "workspace:^", "@rocket.chat/models": "workspace:^", diff --git a/ee/packages/omnichannel-services/package.json b/ee/packages/omnichannel-services/package.json index 3ebb47319ef08..d423462dc6a2b 100644 --- a/ee/packages/omnichannel-services/package.json +++ b/ee/packages/omnichannel-services/package.json @@ -19,7 +19,7 @@ "dependencies": { "@rocket.chat/core-services": "workspace:^", "@rocket.chat/core-typings": "workspace:^", - "@rocket.chat/emitter": "^0.33.0", + "@rocket.chat/emitter": "workspace:~", "@rocket.chat/i18n": "workspace:^", "@rocket.chat/logger": "workspace:^", "@rocket.chat/message-types": "workspace:~", diff --git a/fuselage.sh b/fuselage.sh index d58544335c540..4a65b5c8374a9 100755 --- a/fuselage.sh +++ b/fuselage.sh @@ -109,8 +109,7 @@ if [[ $action == "next-all" || $action == "latest-all" ]]; then targetVersion="latest" fi - echo "📦 @rocket.chat/emitter [UPDATING to $targetVersion version...] -📦 @rocket.chat/fuselage-toastbar [UPDATING to $targetVersion version...] + echo "📦 @rocket.chat/fuselage-toastbar [UPDATING to $targetVersion version...] 📦 @rocket.chat/fuselage-tokens [UPDATING to $targetVersion version...] 📦 @rocket.chat/css-in-js [UPDATING to $targetVersion version...] 📦 @rocket.chat/styled [UPDATING to $targetVersion version...] @@ -122,7 +121,7 @@ if [[ $action == "next-all" || $action == "latest-all" ]]; then 📦 @rocket.chat/onboarding-ui [UPDATING to $targetVersion version...] 📦 @rocket.chat/layout [UPDATING to $targetVersion version...]" - eval "yarn up @rocket.chat/emitter@$targetVersion @rocket.chat/fuselage-toastbar@$targetVersion @rocket.chat/fuselage-tokens@$targetVersion @rocket.chat/css-in-js@$targetVersion @rocket.chat/styled@$targetVersion @rocket.chat/fuselage@$targetVersion @rocket.chat/fuselage-hooks@$targetVersion @rocket.chat/icons@$targetVersion @rocket.chat/logo@$targetVersion @rocket.chat/memo@$targetVersion @rocket.chat/onboarding-ui@$targetVersion @rocket.chat/layout@$targetVersion" + eval "yarn up @rocket.chat/fuselage-toastbar@$targetVersion @rocket.chat/fuselage-tokens@$targetVersion @rocket.chat/css-in-js@$targetVersion @rocket.chat/styled@$targetVersion @rocket.chat/fuselage@$targetVersion @rocket.chat/fuselage-hooks@$targetVersion @rocket.chat/icons@$targetVersion @rocket.chat/logo@$targetVersion @rocket.chat/memo@$targetVersion @rocket.chat/onboarding-ui@$targetVersion @rocket.chat/layout@$targetVersion" exit 1 fi diff --git a/packages/ddp-client/package.json b/packages/ddp-client/package.json index 607d6a9361f9b..598f7f3946ea1 100644 --- a/packages/ddp-client/package.json +++ b/packages/ddp-client/package.json @@ -33,7 +33,7 @@ "ws": "~8.21.3" }, "peerDependencies": { - "@rocket.chat/emitter": "*" + "@rocket.chat/emitter": "workspace:~" }, "volta": { "extends": "../../package.json" diff --git a/packages/emitter/CHANGELOG.md b/packages/emitter/CHANGELOG.md new file mode 100644 index 0000000000000..997e69ddd18c4 --- /dev/null +++ b/packages/emitter/CHANGELOG.md @@ -0,0 +1,113 @@ +# Change Log + +## 0.33.0 + +### Minor Changes + +- [#2072](https://github.com/RocketChat/fuselage/pull/2072) [`46e9cc3`](https://github.com/RocketChat/fuselage/commit/46e9cc36b03bcdd8668f6146dd6e326857416a39) Thanks [@tassoevan](https://github.com/tassoevan)! - feat: Remove unused UMD builds from all packages + +### Patch Changes + +- [#2060](https://github.com/RocketChat/fuselage/pull/2060) [`ee381bb`](https://github.com/RocketChat/fuselage/commit/ee381bb32982d0f4ed9ef26385b5016e0a2e4023) Thanks [@tassoevan](https://github.com/tassoevan)! - feat: Remove deprecations and keep documentation comments + +## 0.32.0 + +### Minor Changes + +- [#1717](https://github.com/RocketChat/fuselage/pull/1717) [`1179d3f`](https://github.com/RocketChat/fuselage/commit/1179d3f3fb954f00741fd99ddd979b00f39c937e) Thanks [@tassoevan](https://github.com/tassoevan)! - feat: ES2024 standard requirement + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [0.31.0](https://github.com/RocketChat/fuselage/compare/v0.30.1...v0.31.0) (2021-12-28) + +### Features + +- New hooks for element size tracking ([#413](https://github.com/RocketChat/fuselage/issues/413)) ([8ca682c](https://github.com/RocketChat/fuselage/commit/8ca682c636d2e4813f7d346cb881513382be63cf)) + +# [0.30.0](https://github.com/RocketChat/fuselage/compare/v0.29.0...v0.30.0) (2021-10-06) + +### Bug Fixes + +- **jest:** Adjust jest and ts-jest dependencies ([#547](https://github.com/RocketChat/fuselage/issues/547)) ([91a4fa1](https://github.com/RocketChat/fuselage/commit/91a4fa1365394001afe1bd46480bda3bafed5505)) + +# [0.29.0](https://github.com/RocketChat/fuselage/compare/v0.28.0...v0.29.0) (2021-08-31) + +**Note:** Version bump only for package @rocket.chat/emitter + +# [0.28.0](https://github.com/RocketChat/fuselage/compare/v0.27.0...v0.28.0) (2021-07-30) + +### Features + +- **onboarding-ui:** Administrator information form and Organization information form ([#489](https://github.com/RocketChat/fuselage/issues/489)) ([b289f68](https://github.com/RocketChat/fuselage/commit/b289f68676954b91c792d8d97680314178bf2c60)) +- styled API; monorepo grooming ([#482](https://github.com/RocketChat/fuselage/issues/482)) ([1b6b70c](https://github.com/RocketChat/fuselage/commit/1b6b70cf67ec16927b1566adc2350295a8927223)) + +# [0.27.0](https://github.com/RocketChat/fuselage/compare/v0.26.0...v0.27.0) (2021-06-28) + +**Note:** Version bump only for package @rocket.chat/emitter + +# [0.26.0](https://github.com/RocketChat/fuselage/compare/v0.25.0...v0.26.0) (2021-05-28) + +**Note:** Version bump only for package @rocket.chat/emitter + +# [0.25.0](https://github.com/RocketChat/fuselage/compare/v0.24.0...v0.25.0) (2021-05-19) + +### Features + +- [@rocket](https://github.com/rocket).chat/message-parser ([#443](https://github.com/RocketChat/fuselage/issues/443)) ([4722cdf](https://github.com/RocketChat/fuselage/commit/4722cdff46f5987f335d989be59649c7652bb12a)) + +# [0.24.0](https://github.com/RocketChat/fuselage/compare/v0.23.0...v0.24.0) (2021-04-28) + +### Features + +- [@rocket](https://github.com/rocket).chat/string-helpers ([#431](https://github.com/RocketChat/fuselage/issues/431)) ([2509d6a](https://github.com/RocketChat/fuselage/commit/2509d6acdbe5ec8b216e8d4430373797c5f5dfe2)) + +# [0.23.0](https://github.com/RocketChat/fuselage/compare/v0.22.0...v0.23.0) (2021-04-01) + +### Bug Fixes + +- **npm:** Wrong paths in "files" field of package.json ([6d3c811](https://github.com/RocketChat/fuselage/commit/6d3c811f6fd747de7f47aff145902d88476272ee)) + +# [0.22.0](https://github.com/RocketChat/fuselage/compare/v0.21.0...v0.22.0) (2021-02-26) + +### Features + +- Type Emitter Implementation ([#399](https://github.com/RocketChat/fuselage/issues/399)) ([cd0d3fc](https://github.com/RocketChat/fuselage/commit/cd0d3fc7b50d911dbacbc122d09745fa9fb4d921)) + +# [0.21.0](https://github.com/RocketChat/fuselage/compare/v0.20.3...v0.21.0) (2021-01-31) + +### Features + +- Built modules for design tokens ([#356](https://github.com/RocketChat/fuselage/issues/356)) ([f9c3449](https://github.com/RocketChat/fuselage/commit/f9c344953b8161a4385cab3a3dcc8b6a7210446f)) + +## [0.20.1](https://github.com/RocketChat/fuselage/compare/v0.20.0...v0.20.1) (2020-12-22) + +**Note:** Version bump only for package @rocket.chat/emitter + +# [0.20.0](https://github.com/RocketChat/fuselage/compare/v0.19.0...v0.20.0) (2020-12-21) + +**Note:** Version bump only for package @rocket.chat/emitter + +# [0.19.0](https://github.com/RocketChat/fuselage/compare/v0.18.0...v0.19.0) (2020-11-28) + +**Note:** Version bump only for package @rocket.chat/emitter + +# [0.18.0](https://github.com/RocketChat/fuselage/compare/v0.17.3...v0.18.0) (2020-11-16) + +**Note:** Version bump only for package @rocket.chat/emitter + +## [0.17.2](https://github.com/RocketChat/fuselage/compare/v0.17.1...v0.17.2) (2020-10-28) + +**Note:** Version bump only for package @rocket.chat/emitter + +## [0.17.1](https://github.com/RocketChat/fuselage/compare/v0.17.0...v0.17.1) (2020-10-26) + +### Bug Fixes + +- Emitter: Created events method ([#311](https://github.com/RocketChat/fuselage/issues/311)) ([e36240f](https://github.com/RocketChat/fuselage/commit/e36240f396a33bd68a2d71c6b00e845e6e22604d)) + +# [0.17.0](https://github.com/RocketChat/fuselage/compare/v0.16.0...v0.17.0) (2020-10-25) + +### Features + +- Event Emitter ([#300](https://github.com/RocketChat/fuselage/issues/300)) ([4ec074c](https://github.com/RocketChat/fuselage/commit/4ec074c17b311b2ac3caaa4fa01360cb2b65cefe)) diff --git a/packages/emitter/jest.config.ts b/packages/emitter/jest.config.ts new file mode 100644 index 0000000000000..c18c8ae02465c --- /dev/null +++ b/packages/emitter/jest.config.ts @@ -0,0 +1,6 @@ +import server from '@rocket.chat/jest-presets/server'; +import type { Config } from 'jest'; + +export default { + preset: server.preset, +} satisfies Config; diff --git a/packages/emitter/package.json b/packages/emitter/package.json new file mode 100644 index 0000000000000..3a9cb36d7d3ce --- /dev/null +++ b/packages/emitter/package.json @@ -0,0 +1,53 @@ +{ + "name": "@rocket.chat/emitter", + "version": "0.33.0", + "description": "Event Emitter by Rocket.Chat", + "keywords": [ + "rocket.chat" + ], + "bugs": { + "url": "https://github.com/RocketChat/Rocket.Chat/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/RocketChat/Rocket.Chat.git" + }, + "license": "MIT", + "author": { + "name": "Rocket.Chat", + "url": "https://rocket.chat/" + }, + "main": "dist/index.js", + "module": "dist/index.module.js", + "source": "src/index.ts", + "types": "dist/index.d.ts", + "files": [ + "/dist" + ], + "scripts": { + "build": "rollup -c", + "lint": "eslint .", + "lint:fix": "eslint --fix .", + "test": "jest", + "testunit": "jest", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@rocket.chat/jest-presets": "workspace:~", + "@rocket.chat/tsconfig": "workspace:~", + "@rollup/plugin-terser": "~1.0.0", + "@rollup/plugin-typescript": "~12.3.0", + "eslint": "~9.39.5", + "jest": "~30.2.0", + "prettier": "~3.3.3", + "rollup": "~4.62.3", + "ts-jest": "~29.4.11", + "typescript": "~5.9.3" + }, + "publishConfig": { + "access": "public" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/packages/emitter/rollup.config.mjs b/packages/emitter/rollup.config.mjs new file mode 100644 index 0000000000000..4e4e088cfb7fc --- /dev/null +++ b/packages/emitter/rollup.config.mjs @@ -0,0 +1,32 @@ +import terser from '@rollup/plugin-terser'; +import typescript from '@rollup/plugin-typescript'; +import { defineConfig } from 'rollup'; + +import pkg from './package.json' with { type: 'json' }; + +export default defineConfig({ + input: 'src/index.ts', + output: [ + { + file: pkg.main, + format: 'cjs', + sourcemap: true, + }, + { + file: pkg.module, + format: 'es', + sourcemap: true, + }, + ], + plugins: [ + typescript({ + tsconfig: './tsconfig.build.json', + }), + terser({ + module: true, + output: { + comments: false, + }, + }), + ], +}); diff --git a/packages/emitter/src/emitter.spec.ts b/packages/emitter/src/emitter.spec.ts new file mode 100644 index 0000000000000..440189a72da39 --- /dev/null +++ b/packages/emitter/src/emitter.spec.ts @@ -0,0 +1,102 @@ +import { Emitter } from './index'; + +const times = (n: number, fn: () => void): number => { + Array.from({ length: n }, () => fn()); + return n; +}; + +let handler: () => void; +let emitter: Emitter; + +beforeEach(() => { + handler = jest.fn(); + emitter = new Emitter(); +}); + +describe('`on` method', () => { + it('should call `test` handler 5 times - with only one listener', () => { + emitter.on('test', handler); + times(5, () => emitter.emit('test')); + expect(handler).toHaveBeenCalledTimes(5); + }); + + it('should call `test2` handler 0 times - with multiple listeners', () => { + emitter.on('test', () => null); + emitter.on('test2', handler); + times(5, () => emitter.emit('test')); + expect(handler).toHaveBeenCalledTimes(0); + }); +}); + +describe('`once` method', () => { + it('should call `test` handler only once', () => { + emitter.once('test', handler); + times(5, () => emitter.emit('test')); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('should call `test` handler only once - with multiple events and same handler', () => { + emitter.once('test', handler); + emitter.once('test2', handler); + times(5, () => emitter.emit('test')); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('should call `test` handler 5 times afert `once + remove + on` same event', () => { + emitter.once('test', handler); + emitter.off('test', handler); + emitter.on('test', handler); + times(5, () => emitter.emit('test')); + expect(handler).toHaveBeenCalledTimes(5); + }); + + it('should call `test` handler once after add multiple `once` and remove n-1', () => { + emitter.once('test', handler); + emitter.once('test', handler)(); + emitter.once('test', handler)(); + emitter.once('test', handler)(); + emitter.once('test', handler)(); + emitter.once('test', handler)(); + times(5, () => emitter.emit('test')); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('should call `test` handler once after add same handler with different `once` events and remove n-1', () => { + emitter.once('test', handler); + emitter.once('test2', handler)(); + times(5, () => emitter.emit('test')); + expect(handler).toHaveBeenCalledTimes(1); + }); +}); + +describe('`off` method', () => { + it('should have no `test` handler after removal', () => { + emitter.on('test', handler); + emitter.off('test', handler); + expect(emitter.has('test')).toBe(false); + }); + + it('should have no `test` handler after use stop callback', () => { + emitter.on('test', handler)(); + expect(emitter.has('test')).toBe(false); + }); + + it('should have no `test` handler after emit once', () => { + emitter.once('test', handler); + emitter.emit('test'); + expect(emitter.has('test')).toBe(false); + }); + + it('should remove only the specified handler', () => { + const handler = jest.fn(); + const unusedHandler = jest.fn(); + + emitter.on('test', handler); + emitter.off('test', unusedHandler); + + emitter.emit('test'); + + expect(handler).toHaveBeenCalledTimes(1); + expect(unusedHandler).toHaveBeenCalledTimes(0); + }); +}); diff --git a/packages/emitter/src/index.ts b/packages/emitter/src/index.ts new file mode 100644 index 0000000000000..5b588a32c511b --- /dev/null +++ b/packages/emitter/src/index.ts @@ -0,0 +1,161 @@ +/** @public */ +export type DefaultEventMap = Record; + +/** @public */ +export type AnyEventTypeOf = keyof EventMap; + +/** @public */ +export type AnyEventOf = EventMap[keyof EventMap]; + +/** @public */ +export type AnyEventHandlerOf = { + [EventType in keyof EventMap]: EventMap[EventType] extends void ? () => void : (event: EventMap[EventType]) => void; +}[keyof EventMap]; + +/** @public */ +export type EventTypeOf = { + [EventType in keyof EventMap]: EventMap[EventType] extends EventValue ? EventType : never; +}[keyof EventMap]; + +/** @public */ +export type EventOf> = EventMap[EventType] extends void + ? never + : EventMap[EventType]; + +/** @public */ +export type EventHandlerOf> = EventMap[EventType] extends void + ? () => void + : (event: EventMap[EventType]) => void; + +/** @public */ +export type OffCallbackHandler = () => void; + +/** @public */ +export interface IEmitter { + on, EventType extends AnyEventTypeOf = EventTypeOf>( + type: EventType, + handler: EventHandlerOf, + ): OffCallbackHandler; + once, EventType extends AnyEventTypeOf = EventTypeOf>( + type: EventType, + handler: EventHandlerOf, + ): OffCallbackHandler; + off, EventType extends AnyEventTypeOf = EventTypeOf>( + type: EventType, + handler: EventHandlerOf, + ): void; + emit, EventType extends AnyEventTypeOf = EventTypeOf>( + type: EventType, + ...[event]: EventOf extends void ? [undefined?] : [EventOf] + ): void; + has(key: AnyEventTypeOf): boolean; + events(): AnyEventTypeOf[]; +} + +const once = Symbol('once'); +const evts = Symbol('evts'); + +/** + * The event emitter class. + * + * @public + */ +export class Emitter implements IEmitter { + private [evts] = new Map, AnyEventHandlerOf[]>(); + + private [once] = new WeakMap, number>(); + + /** + * Returns the whole EventType list + */ + events(): AnyEventTypeOf[] { + return Array.from(this[evts].keys()); + } + + /** + * Returns `true` if this emmiter has a listener attached to the `key` event type + */ + has(key: AnyEventTypeOf): boolean { + return this[evts].has(key); + } + + /** + * Adds the `handler` function to listen events of the `type` type. + * + * @returns a function to unsubscribe the handler invoking `this.off(type, handler)` + */ + on, TType extends AnyEventTypeOf = EventTypeOf>( + type: TType, + handler: EventHandlerOf, + ): OffCallbackHandler; + + on(type: keyof EventMap, handler: (...args: any[]) => void) { + const handlers = this[evts].get(type) ?? []; + handlers.push(handler); + this[evts].set(type, handlers); + return () => this.off(type, handler); + } + + /** + * Adds a *one-time* `handler` function for the event of the `type` type. + * + * @returns a function to unsubscribe the handler invoking `this.off(type, handler)` + */ + once, EventType extends AnyEventTypeOf = EventTypeOf>( + type: EventType, + handler: EventHandlerOf, + ): OffCallbackHandler; + + once(type: keyof EventMap, handler: (...args: any[]) => void) { + const counter = this[once].get(handler) || 0; + this[once].set(handler, counter + 1); + return this.on(type, handler); + } + + /** + * Removes the specified `handler` from the list of handlers of the event of the `type` type + */ + off, EventType extends AnyEventTypeOf = EventTypeOf>( + type: EventType, + handler: EventHandlerOf, + ): void; + + off(type: keyof EventMap, handler: (...args: any[]) => void) { + const handlers = this[evts].get(type); + if (!handlers) { + return; + } + + const counter = this[once].get(handler) ?? 0; + if (counter > 1) { + this[once].set(handler, counter - 1); + } else { + this[once].delete(handler); + } + + handlers.splice(handlers.findIndex((callback) => callback === handler) >>> 0, 1); + + if (handlers.length === 0) { + this[evts].delete(type); + } + } + + /** + * Calls each of the handlers registered for the event of `type` type, in the + * order they were registered, passing the supplied argument `e` to each. + */ + emit, EventType extends AnyEventTypeOf = EventTypeOf>( + type: EventType, + ...[event]: EventOf extends void ? [undefined?] : [EventOf] + ): void; + + emit(type: keyof EventMap, ...[event]: any[]) { + [...(this[evts].get(type) ?? [])].forEach((handler) => { + handler(event); + + if (this[once].get(handler)) { + this.off(type, handler); + } + }); + } +} diff --git a/packages/emitter/tsconfig.build.json b/packages/emitter/tsconfig.build.json new file mode 100644 index 0000000000000..d8792a5e5c532 --- /dev/null +++ b/packages/emitter/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true + }, + "exclude": ["src/**/*.spec.*", "jest.config.*"] +} diff --git a/packages/emitter/tsconfig.json b/packages/emitter/tsconfig.json new file mode 100644 index 0000000000000..27a531a499501 --- /dev/null +++ b/packages/emitter/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@rocket.chat/tsconfig/base.json", + "compilerOptions": { + "target": "es2024", + "lib": ["ES2024"], + + "rootDirs": ["./src", "./"], + "outDir": "./dist" + } +} diff --git a/packages/fuselage-ui-kit/package.json b/packages/fuselage-ui-kit/package.json index 2633314f58e01..d4d1be8d3bf22 100644 --- a/packages/fuselage-ui-kit/package.json +++ b/packages/fuselage-ui-kit/package.json @@ -45,7 +45,7 @@ "devDependencies": { "@rocket.chat/apps-engine": "workspace:^", "@rocket.chat/core-typings": "workspace:^", - "@rocket.chat/emitter": "^0.33.0", + "@rocket.chat/emitter": "workspace:~", "@rocket.chat/fuselage": "^0.86.0", "@rocket.chat/fuselage-hooks": "^0.43.1", "@rocket.chat/fuselage-tokens": "^0.34.0", diff --git a/packages/gazzodown/package.json b/packages/gazzodown/package.json index 31c29fc2421fc..58abf91531825 100644 --- a/packages/gazzodown/package.json +++ b/packages/gazzodown/package.json @@ -29,7 +29,7 @@ "devDependencies": { "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/css-in-js": "^0.33.1", - "@rocket.chat/emitter": "^0.33.0", + "@rocket.chat/emitter": "workspace:~", "@rocket.chat/fuselage": "^0.86.0", "@rocket.chat/fuselage-hooks": "^0.43.1", "@rocket.chat/fuselage-tokens": "^0.34.0", diff --git a/packages/livechat/package.json b/packages/livechat/package.json index 69f58c5414819..475572affcd22 100644 --- a/packages/livechat/package.json +++ b/packages/livechat/package.json @@ -28,7 +28,7 @@ "Firefox ESR" ], "dependencies": { - "@rocket.chat/emitter": "^0.33.0", + "@rocket.chat/emitter": "workspace:~", "@rocket.chat/gazzodown": "workspace:^", "@rocket.chat/message-parser": "workspace:^", "@rocket.chat/random": "workspace:~", diff --git a/packages/logger/package.json b/packages/logger/package.json index 25518e1b22d54..d3d35caa57365 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -14,7 +14,7 @@ "lint:fix": "eslint --fix ." }, "dependencies": { - "@rocket.chat/emitter": "^0.33.0", + "@rocket.chat/emitter": "workspace:~", "pino": "10.3.1" }, "devDependencies": { diff --git a/packages/media-signaling/package.json b/packages/media-signaling/package.json index 12f7a2389305d..56bc66b253c77 100644 --- a/packages/media-signaling/package.json +++ b/packages/media-signaling/package.json @@ -29,7 +29,7 @@ "test": "jest" }, "dependencies": { - "@rocket.chat/emitter": "^0.33.0", + "@rocket.chat/emitter": "workspace:~", "ajv": "^8.20.0" }, "devDependencies": { diff --git a/packages/mock-providers/package.json b/packages/mock-providers/package.json index 1e187a92883bc..f3ecdbba793a2 100644 --- a/packages/mock-providers/package.json +++ b/packages/mock-providers/package.json @@ -16,7 +16,7 @@ "testunit": "jest" }, "dependencies": { - "@rocket.chat/emitter": "^0.33.0", + "@rocket.chat/emitter": "workspace:~", "@rocket.chat/i18n": "workspace:~", "@rocket.chat/ui-contexts": "workspace:^", "i18next": "~23.4.9", diff --git a/packages/storybook-config/package.json b/packages/storybook-config/package.json index 42d187fec587f..81f5d487a367b 100644 --- a/packages/storybook-config/package.json +++ b/packages/storybook-config/package.json @@ -19,7 +19,7 @@ "lint:fix": "eslint --fix ." }, "dependencies": { - "@rocket.chat/emitter": "^0.33.0", + "@rocket.chat/emitter": "workspace:~", "@rocket.chat/fuselage-hooks": "^0.43.1", "@rocket.chat/fuselage-tokens": "^0.34.0", "@storybook/addon-a11y": "^9.1.20", diff --git a/packages/ui-avatar/package.json b/packages/ui-avatar/package.json index efe14b6d80fb2..92c90222b414b 100644 --- a/packages/ui-avatar/package.json +++ b/packages/ui-avatar/package.json @@ -16,7 +16,7 @@ }, "devDependencies": { "@rocket.chat/core-typings": "workspace:~", - "@rocket.chat/emitter": "^0.33.0", + "@rocket.chat/emitter": "workspace:~", "@rocket.chat/fuselage": "^0.86.0", "@rocket.chat/fuselage-hooks": "^0.43.1", "@rocket.chat/fuselage-tokens": "^0.34.0", diff --git a/packages/ui-client/package.json b/packages/ui-client/package.json index 20d9e563e35ae..029dee0b2ac2a 100644 --- a/packages/ui-client/package.json +++ b/packages/ui-client/package.json @@ -26,7 +26,7 @@ "@react-aria/toolbar": "^3.0.0-nightly.5042", "@rocket.chat/core-typings": "workspace:~", "@rocket.chat/css-in-js": "^0.33.1", - "@rocket.chat/emitter": "^0.33.0", + "@rocket.chat/emitter": "workspace:~", "@rocket.chat/fuselage": "^0.86.0", "@rocket.chat/fuselage-hooks": "^0.43.1", "@rocket.chat/fuselage-tokens": "^0.34.0", diff --git a/packages/ui-composer/package.json b/packages/ui-composer/package.json index 389fc729686e4..0b589cfc56135 100644 --- a/packages/ui-composer/package.json +++ b/packages/ui-composer/package.json @@ -21,7 +21,7 @@ }, "devDependencies": { "@react-aria/toolbar": "^3.0.0-nightly.5042", - "@rocket.chat/emitter": "^0.33.0", + "@rocket.chat/emitter": "workspace:~", "@rocket.chat/fuselage": "^0.86.0", "@rocket.chat/fuselage-hooks": "^0.43.1", "@rocket.chat/fuselage-tokens": "^0.34.0", diff --git a/packages/ui-contexts/package.json b/packages/ui-contexts/package.json index b5f46652c810f..0e44ac893c971 100644 --- a/packages/ui-contexts/package.json +++ b/packages/ui-contexts/package.json @@ -22,7 +22,7 @@ "devDependencies": { "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/ddp-client": "workspace:~", - "@rocket.chat/emitter": "^0.33.0", + "@rocket.chat/emitter": "workspace:~", "@rocket.chat/fuselage": "^0.86.0", "@rocket.chat/fuselage-hooks": "^0.43.1", "@rocket.chat/fuselage-tokens": "^0.34.0", @@ -46,7 +46,7 @@ "peerDependencies": { "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/ddp-client": "workspace:^", - "@rocket.chat/emitter": "*", + "@rocket.chat/emitter": "workspace:~", "@rocket.chat/fuselage-hooks": "*", "@rocket.chat/i18n": "workspace:~", "@rocket.chat/rest-typings": "workspace:^", diff --git a/packages/ui-video-conf/package.json b/packages/ui-video-conf/package.json index eb9c3559deab2..4f4195432b97e 100644 --- a/packages/ui-video-conf/package.json +++ b/packages/ui-video-conf/package.json @@ -18,7 +18,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@rocket.chat/emitter": "^0.33.0" + "@rocket.chat/emitter": "workspace:~" }, "devDependencies": { "@rocket.chat/core-typings": "workspace:^", diff --git a/packages/ui-voip/package.json b/packages/ui-voip/package.json index 63447cf118646..689411336cbe3 100644 --- a/packages/ui-voip/package.json +++ b/packages/ui-voip/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "@rocket.chat/desktop-api": "workspace:^", - "@rocket.chat/emitter": "^0.33.0", + "@rocket.chat/emitter": "workspace:~", "@rocket.chat/media-signaling": "workspace:~", "@tanstack/react-query": "~5.65.1", "react-i18next": "~13.2.2" diff --git a/packages/web-ui-registration/package.json b/packages/web-ui-registration/package.json index da97e667cd6f1..cde9ab58c8afb 100644 --- a/packages/web-ui-registration/package.json +++ b/packages/web-ui-registration/package.json @@ -22,7 +22,7 @@ "devDependencies": { "@rocket.chat/core-typings": "workspace:~", "@rocket.chat/css-in-js": "^0.33.1", - "@rocket.chat/emitter": "^0.33.0", + "@rocket.chat/emitter": "workspace:~", "@rocket.chat/fuselage": "^0.86.0", "@rocket.chat/fuselage-hooks": "^0.43.1", "@rocket.chat/fuselage-tokens": "^0.34.0", diff --git a/yarn.lock b/yarn.lock index 814259390fc91..cf07e2ab30f3a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9045,7 +9045,7 @@ __metadata: dependencies: "@rocket.chat/core-services": "workspace:^" "@rocket.chat/core-typings": "workspace:^" - "@rocket.chat/emitter": "npm:^0.33.0" + "@rocket.chat/emitter": "workspace:~" "@rocket.chat/logger": "workspace:^" "@rocket.chat/model-typings": "workspace:^" "@rocket.chat/models": "workspace:^" @@ -9191,7 +9191,7 @@ __metadata: "@rocket.chat/abac": "workspace:^" "@rocket.chat/core-services": "workspace:^" "@rocket.chat/core-typings": "workspace:^" - "@rocket.chat/emitter": "npm:^0.33.0" + "@rocket.chat/emitter": "workspace:~" "@rocket.chat/logger": "workspace:^" "@rocket.chat/model-typings": "workspace:^" "@rocket.chat/models": "workspace:^" @@ -9356,7 +9356,7 @@ __metadata: typescript: "npm:~5.9.3" ws: "npm:~8.21.3" peerDependencies: - "@rocket.chat/emitter": "*" + "@rocket.chat/emitter": "workspace:~" languageName: unknown linkType: soft @@ -9368,7 +9368,7 @@ __metadata: "@rocket.chat/core-services": "workspace:^" "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/ddp-client": "workspace:~" - "@rocket.chat/emitter": "npm:^0.33.0" + "@rocket.chat/emitter": "workspace:~" "@rocket.chat/instance-status": "workspace:^" "@rocket.chat/jest-presets": "workspace:*" "@rocket.chat/logger": "workspace:^" @@ -9419,12 +9419,22 @@ __metadata: languageName: unknown linkType: soft -"@rocket.chat/emitter@npm:^0.33.0": - version: 0.33.0 - resolution: "@rocket.chat/emitter@npm:0.33.0" - checksum: 10/3da59b699e71e3a10c1759cea9b9c9f91ff5d9d19b39cc1b843e09d53fba397d4999a9366f492376fee4dcd70cf89dc2c2bd2ba6fff134d69527d28023dc1417 - languageName: node - linkType: hard +"@rocket.chat/emitter@npm:^0.33.0, @rocket.chat/emitter@workspace:packages/emitter, @rocket.chat/emitter@workspace:~": + version: 0.0.0-use.local + resolution: "@rocket.chat/emitter@workspace:packages/emitter" + dependencies: + "@rocket.chat/jest-presets": "workspace:~" + "@rocket.chat/tsconfig": "workspace:~" + "@rollup/plugin-terser": "npm:~1.0.0" + "@rollup/plugin-typescript": "npm:~12.3.0" + eslint: "npm:~9.39.5" + jest: "npm:~30.2.0" + prettier: "npm:~3.3.3" + rollup: "npm:~4.62.3" + ts-jest: "npm:~29.4.11" + typescript: "npm:~5.9.3" + languageName: unknown + linkType: soft "@rocket.chat/eslint-config@workspace:packages/eslint-config, @rocket.chat/eslint-config@workspace:~": version: 0.0.0-use.local @@ -9471,7 +9481,7 @@ __metadata: "@rocket.chat/core-services": "workspace:^" "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/ddp-client": "workspace:^" - "@rocket.chat/emitter": "npm:^0.33.0" + "@rocket.chat/emitter": "workspace:~" "@rocket.chat/federation-sdk": "npm:0.7.0" "@rocket.chat/http-router": "workspace:^" "@rocket.chat/license": "workspace:^" @@ -9575,7 +9585,7 @@ __metadata: dependencies: "@rocket.chat/apps-engine": "workspace:^" "@rocket.chat/core-typings": "workspace:^" - "@rocket.chat/emitter": "npm:^0.33.0" + "@rocket.chat/emitter": "workspace:~" "@rocket.chat/fuselage": "npm:^0.86.0" "@rocket.chat/fuselage-hooks": "npm:^0.43.1" "@rocket.chat/fuselage-tokens": "npm:^0.34.0" @@ -9659,7 +9669,7 @@ __metadata: dependencies: "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/css-in-js": "npm:^0.33.1" - "@rocket.chat/emitter": "npm:^0.33.0" + "@rocket.chat/emitter": "workspace:~" "@rocket.chat/fuselage": "npm:^0.86.0" "@rocket.chat/fuselage-hooks": "npm:^0.43.1" "@rocket.chat/fuselage-tokens": "npm:^0.34.0" @@ -9853,7 +9863,7 @@ __metadata: "@babel/preset-typescript": "npm:~7.28.5" "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/ddp-client": "workspace:^" - "@rocket.chat/emitter": "npm:^0.33.0" + "@rocket.chat/emitter": "workspace:~" "@rocket.chat/fuselage-hooks": "npm:^0.43.1" "@rocket.chat/fuselage-tokens": "npm:^0.34.0" "@rocket.chat/gazzodown": "workspace:^" @@ -9939,7 +9949,7 @@ __metadata: version: 0.0.0-use.local resolution: "@rocket.chat/logger@workspace:packages/logger" dependencies: - "@rocket.chat/emitter": "npm:^0.33.0" + "@rocket.chat/emitter": "workspace:~" eslint: "npm:~9.39.5" pino: "npm:10.3.1" typescript: "npm:~5.9.3" @@ -9963,7 +9973,7 @@ __metadata: resolution: "@rocket.chat/media-calls@workspace:ee/packages/media-calls" dependencies: "@rocket.chat/core-typings": "workspace:^" - "@rocket.chat/emitter": "npm:^0.33.0" + "@rocket.chat/emitter": "workspace:~" "@rocket.chat/logger": "workspace:^" "@rocket.chat/media-signaling": "workspace:^" "@rocket.chat/models": "workspace:^" @@ -9979,7 +9989,7 @@ __metadata: version: 0.0.0-use.local resolution: "@rocket.chat/media-signaling@workspace:packages/media-signaling" dependencies: - "@rocket.chat/emitter": "npm:^0.33.0" + "@rocket.chat/emitter": "workspace:~" "@rocket.chat/jest-presets": "workspace:~" "@rocket.chat/tsconfig": "workspace:*" "@types/jest": "npm:~30.0.0" @@ -10090,7 +10100,7 @@ __metadata: "@rocket.chat/css-in-js": "npm:^0.33.1" "@rocket.chat/ddp-client": "workspace:^" "@rocket.chat/desktop-api": "workspace:~" - "@rocket.chat/emitter": "npm:^0.33.0" + "@rocket.chat/emitter": "workspace:~" "@rocket.chat/favicon": "workspace:^" "@rocket.chat/federation-matrix": "workspace:^" "@rocket.chat/federation-sdk": "npm:0.7.0" @@ -10453,7 +10463,7 @@ __metadata: dependencies: "@faker-js/faker": "npm:~8.0.2" "@rocket.chat/ddp-client": "workspace:~" - "@rocket.chat/emitter": "npm:^0.33.0" + "@rocket.chat/emitter": "workspace:~" "@rocket.chat/i18n": "workspace:~" "@rocket.chat/mongo-adapter": "workspace:~" "@rocket.chat/tools": "workspace:~" @@ -10613,7 +10623,7 @@ __metadata: dependencies: "@rocket.chat/core-services": "workspace:^" "@rocket.chat/core-typings": "workspace:^" - "@rocket.chat/emitter": "npm:^0.33.0" + "@rocket.chat/emitter": "workspace:~" "@rocket.chat/i18n": "workspace:^" "@rocket.chat/jest-presets": "workspace:~" "@rocket.chat/logger": "workspace:^" @@ -10648,7 +10658,7 @@ __metadata: "@react-pdf/renderer": "npm:~4.5.1" "@rocket.chat/core-services": "workspace:^" "@rocket.chat/core-typings": "workspace:^" - "@rocket.chat/emitter": "npm:^0.33.0" + "@rocket.chat/emitter": "workspace:~" "@rocket.chat/i18n": "workspace:^" "@rocket.chat/logger": "workspace:^" "@rocket.chat/model-typings": "workspace:^" @@ -10813,7 +10823,7 @@ __metadata: "@rocket.chat/core-services": "workspace:^" "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/cron": "workspace:^" - "@rocket.chat/emitter": "npm:^0.33.0" + "@rocket.chat/emitter": "workspace:~" "@rocket.chat/logger": "workspace:^" "@rocket.chat/model-typings": "workspace:^" "@rocket.chat/models": "workspace:^" @@ -10874,7 +10884,7 @@ __metadata: dependencies: "@rocket.chat/core-services": "workspace:^" "@rocket.chat/core-typings": "workspace:^" - "@rocket.chat/emitter": "npm:^0.33.0" + "@rocket.chat/emitter": "workspace:~" "@rocket.chat/logger": "workspace:^" "@rocket.chat/model-typings": "workspace:^" "@rocket.chat/models": "workspace:^" @@ -11013,7 +11023,7 @@ __metadata: version: 0.0.0-use.local resolution: "@rocket.chat/storybook-config@workspace:packages/storybook-config" dependencies: - "@rocket.chat/emitter": "npm:^0.33.0" + "@rocket.chat/emitter": "workspace:~" "@rocket.chat/fuselage": "npm:^0.86.0" "@rocket.chat/fuselage-hooks": "npm:^0.43.1" "@rocket.chat/fuselage-tokens": "npm:^0.34.0" @@ -11101,7 +11111,7 @@ __metadata: resolution: "@rocket.chat/ui-avatar@workspace:packages/ui-avatar" dependencies: "@rocket.chat/core-typings": "workspace:~" - "@rocket.chat/emitter": "npm:^0.33.0" + "@rocket.chat/emitter": "workspace:~" "@rocket.chat/fuselage": "npm:^0.86.0" "@rocket.chat/fuselage-hooks": "npm:^0.43.1" "@rocket.chat/fuselage-tokens": "npm:^0.34.0" @@ -11128,7 +11138,7 @@ __metadata: "@react-aria/toolbar": "npm:^3.0.0-nightly.5042" "@rocket.chat/core-typings": "workspace:~" "@rocket.chat/css-in-js": "npm:^0.33.1" - "@rocket.chat/emitter": "npm:^0.33.0" + "@rocket.chat/emitter": "workspace:~" "@rocket.chat/fuselage": "npm:^0.86.0" "@rocket.chat/fuselage-hooks": "npm:^0.43.1" "@rocket.chat/fuselage-tokens": "npm:^0.34.0" @@ -11186,7 +11196,7 @@ __metadata: resolution: "@rocket.chat/ui-composer@workspace:packages/ui-composer" dependencies: "@react-aria/toolbar": "npm:^3.0.0-nightly.5042" - "@rocket.chat/emitter": "npm:^0.33.0" + "@rocket.chat/emitter": "workspace:~" "@rocket.chat/fuselage": "npm:^0.86.0" "@rocket.chat/fuselage-hooks": "npm:^0.43.1" "@rocket.chat/fuselage-tokens": "npm:^0.34.0" @@ -11227,7 +11237,7 @@ __metadata: dependencies: "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/ddp-client": "workspace:~" - "@rocket.chat/emitter": "npm:^0.33.0" + "@rocket.chat/emitter": "workspace:~" "@rocket.chat/fuselage": "npm:^0.86.0" "@rocket.chat/fuselage-hooks": "npm:^0.43.1" "@rocket.chat/fuselage-tokens": "npm:^0.34.0" @@ -11251,7 +11261,7 @@ __metadata: peerDependencies: "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/ddp-client": "workspace:^" - "@rocket.chat/emitter": "*" + "@rocket.chat/emitter": "workspace:~" "@rocket.chat/fuselage-hooks": "*" "@rocket.chat/i18n": "workspace:~" "@rocket.chat/rest-typings": "workspace:^" @@ -11287,7 +11297,7 @@ __metadata: dependencies: "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/css-in-js": "npm:^0.33.1" - "@rocket.chat/emitter": "npm:^0.33.0" + "@rocket.chat/emitter": "workspace:~" "@rocket.chat/fuselage": "npm:^0.86.0" "@rocket.chat/fuselage-hooks": "npm:^0.43.1" "@rocket.chat/fuselage-tokens": "npm:^0.34.0" @@ -11334,7 +11344,7 @@ __metadata: "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/css-in-js": "npm:^0.33.1" "@rocket.chat/desktop-api": "workspace:^" - "@rocket.chat/emitter": "npm:^0.33.0" + "@rocket.chat/emitter": "workspace:~" "@rocket.chat/fuselage": "npm:^0.86.0" "@rocket.chat/fuselage-hooks": "npm:^0.43.1" "@rocket.chat/fuselage-tokens": "npm:^0.34.0" @@ -11400,7 +11410,7 @@ __metadata: "@lezer/highlight": "npm:^1.2.3" "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/css-in-js": "npm:^0.33.1" - "@rocket.chat/emitter": "npm:^0.33.0" + "@rocket.chat/emitter": "workspace:~" "@rocket.chat/fuselage": "npm:^0.86.0" "@rocket.chat/fuselage-hooks": "npm:^0.43.1" "@rocket.chat/fuselage-toastbar": "npm:~0.36.1" @@ -11438,7 +11448,7 @@ __metadata: dependencies: "@rocket.chat/core-typings": "workspace:~" "@rocket.chat/css-in-js": "npm:^0.33.1" - "@rocket.chat/emitter": "npm:^0.33.0" + "@rocket.chat/emitter": "workspace:~" "@rocket.chat/fuselage": "npm:^0.86.0" "@rocket.chat/fuselage-hooks": "npm:^0.43.1" "@rocket.chat/fuselage-tokens": "npm:^0.34.0" @@ -11633,6 +11643,22 @@ __metadata: languageName: node linkType: hard +"@rollup/plugin-terser@npm:~1.0.0": + version: 1.0.0 + resolution: "@rollup/plugin-terser@npm:1.0.0" + dependencies: + serialize-javascript: "npm:^7.0.3" + smob: "npm:^1.0.0" + terser: "npm:^5.17.4" + peerDependencies: + rollup: ^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + checksum: 10/eb4389d427d704f10a4d9a8c316fda5d2f8c92419bf24e7cf69497739a10236aa2f3004249b0bbf4290174588a5c274b1653b22e63068cea06f844c8b09aa5cf + languageName: node + linkType: hard + "@rollup/plugin-typescript@npm:~12.3.0": version: 12.3.0 resolution: "@rollup/plugin-typescript@npm:12.3.0" @@ -34043,7 +34069,7 @@ __metadata: "@rocket.chat/apps-engine": "workspace:^" "@rocket.chat/core-services": "workspace:^" "@rocket.chat/core-typings": "workspace:^" - "@rocket.chat/emitter": "npm:^0.33.0" + "@rocket.chat/emitter": "workspace:~" "@rocket.chat/icons": "npm:^0.48.0" "@rocket.chat/message-parser": "workspace:^" "@rocket.chat/model-typings": "workspace:^" @@ -34141,7 +34167,7 @@ __metadata: languageName: node linkType: hard -"rollup@npm:~4.62.4": +"rollup@npm:~4.62.3, rollup@npm:~4.62.4": version: 4.62.4 resolution: "rollup@npm:4.62.4" dependencies: @@ -34566,7 +34592,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.8.5": +"semver@npm:^7.8.0, semver@npm:^7.8.5": version: 7.8.5 resolution: "semver@npm:7.8.5" bin: @@ -35045,6 +35071,13 @@ __metadata: languageName: node linkType: hard +"smob@npm:^1.0.0": + version: 1.6.2 + resolution: "smob@npm:1.6.2" + checksum: 10/500799e7887cd8557de610666d206f1e2d073b34ef2732d9565c940afa9e9555f0e42268044e10eb7f061054fa3ed7c2a6f6387b13c1ceb3370f50f299acd34f + languageName: node + linkType: hard + "snake-case@npm:^3.0.4": version: 3.0.4 resolution: "snake-case@npm:3.0.4" @@ -36380,6 +36413,20 @@ __metadata: languageName: node linkType: hard +"terser@npm:^5.17.4": + version: 5.50.0 + resolution: "terser@npm:5.50.0" + dependencies: + "@jridgewell/source-map": "npm:^0.3.3" + acorn: "npm:^8.15.0" + commander: "npm:^2.20.0" + source-map-support: "npm:~0.5.20" + bin: + terser: bin/terser + checksum: 10/d94ccee6ab8cd73dfe041c9f34729026e3b00e26ed1544497e7b54bb90b0d162a44621d830c0dd26684dc1a0e3ac91a75a6aab2f233ee6b82df9c21c7bab3354 + languageName: node + linkType: hard + "test-exclude@npm:^6.0.0": version: 6.0.0 resolution: "test-exclude@npm:6.0.0" @@ -36763,6 +36810,46 @@ __metadata: languageName: node linkType: hard +"ts-jest@npm:~29.4.11": + version: 29.4.11 + resolution: "ts-jest@npm:29.4.11" + dependencies: + bs-logger: "npm:^0.2.6" + fast-json-stable-stringify: "npm:^2.1.0" + handlebars: "npm:^4.7.9" + json5: "npm:^2.2.3" + lodash.memoize: "npm:^4.1.2" + make-error: "npm:^1.3.6" + semver: "npm:^7.8.0" + type-fest: "npm:^4.41.0" + yargs-parser: "npm:^21.1.1" + peerDependencies: + "@babel/core": ">=7.0.0-beta.0 <8" + "@jest/transform": ^29.0.0 || ^30.0.0 + "@jest/types": ^29.0.0 || ^30.0.0 + babel-jest: ^29.0.0 || ^30.0.0 + jest: ^29.0.0 || ^30.0.0 + jest-util: ^29.0.0 || ^30.0.0 + typescript: ">=4.3 <7" + peerDependenciesMeta: + "@babel/core": + optional: true + "@jest/transform": + optional: true + "@jest/types": + optional: true + babel-jest: + optional: true + esbuild: + optional: true + jest-util: + optional: true + bin: + ts-jest: cli.js + checksum: 10/8a76800cac1c102c789429a82655adde1d540d7ff025fc888006f4f458afe07ca4d3f73622a10a604edea06aa12c298605cd1a208a6380c0e3d591a3daed8af6 + languageName: node + linkType: hard + "ts-jest@npm:~29.4.12": version: 29.4.12 resolution: "ts-jest@npm:29.4.12" From 0fe06ca9c92bd9ff1c5c459b74e1538656746c95 Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Fri, 14 Aug 2026 20:28:15 +0000 Subject: [PATCH 2/3] chore(ci): skip fips builds and tests on fork PRs (#41748) --- .github/workflows/ci.yml | 52 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d045ebbbdf3cd..1f23aa10a3471 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,7 @@ jobs: node-version: ${{ steps.var.outputs.node-version }} deno-version: ${{ steps.var.outputs.deno-version }} source-hash: ${{ steps.source.outputs.hash }} + fips-skip: ${{ steps.fips.outputs.fips-skip }} packages-build-cache-key: ${{ steps.ci-cache-keys.outputs.packages-build-cache-key }} meteor-rc-cache-key: ${{ steps.ci-cache-keys.outputs.meteor-rc-cache-key }} github-actions-changed: ${{ steps.diff.outputs.actions-changed }} @@ -143,6 +144,43 @@ jobs: echo "DOCKER_TAG: ${DOCKER_TAG}" echo "gh-docker-tag=${DOCKER_TAG}" >> "$GITHUB_OUTPUT" + # How much of the FIPS suite to skip: + # all - build no fips images and run no fips tests + # ui-suite - build the images, run the api suites, skip the expensive UI shards (default) + # none - skip nothing, the full fips suite runs + # Forks skip 'all': the fips base image (rocketchat/dhi-node) sits in a private DockerHub + # repo and secrets are unavailable to forks, so the pull could never succeed. + - id: fips + env: + EVENT_NAME: ${{ github.event_name }} + BASE_REPO: ${{ github.repository }} + PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + REPO_IS_FORK: ${{ github.event.repository.fork }} + HAS_FIPS_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'fips') }} + run: | + if [[ "$EVENT_NAME" == 'pull_request' ]]; then + if [[ "$PR_HEAD_REPO" == "$BASE_REPO" ]]; then + IS_FORK='false' + else + IS_FORK='true' + fi + else + IS_FORK="$REPO_IS_FORK" + fi + + echo "EVENT_NAME: ${EVENT_NAME} / IS_FORK: ${IS_FORK}" + + if [[ "$IS_FORK" == 'true' ]]; then + FIPS_SKIP='all' + elif [[ "$HAS_FIPS_LABEL" == 'true' ]]; then + FIPS_SKIP='none' + else + FIPS_SKIP='ui-suite' + fi + + echo "FIPS_SKIP: ${FIPS_SKIP}" + echo "fips-skip=${FIPS_SKIP}" >> "$GITHUB_OUTPUT" + - id: diff env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -365,6 +403,8 @@ jobs: exclude: - arch: arm64 type: fips + # drop the whole fips leg when there is nothing downstream to consume it (fork PRs) + - type: ${{ needs.release-versions.outputs.fips-skip == 'all' && 'fips' || '' }} include: - arch: amd64 service: [rocketchat] @@ -763,6 +803,7 @@ jobs: test-api-fips: name: 🔨 Test API (FIPS) + if: needs.release-versions.outputs.fips-skip != 'all' needs: [checks, build-gh-docker-publish, release-versions] uses: ./.github/workflows/ci-test-e2e.yml @@ -784,6 +825,7 @@ jobs: test-api-livechat-fips: name: 🔨 Test API Livechat (FIPS) + if: needs.release-versions.outputs.fips-skip != 'all' needs: [checks, build-gh-docker-publish, release-versions] uses: ./.github/workflows/ci-test-e2e.yml @@ -805,7 +847,8 @@ jobs: test-ui-fips: name: 🔨 Test UI (FIPS) - if: contains(github.event.pull_request.labels.*.name, 'fips') + # only the 'fips' label opts a PR into the UI shards + if: needs.release-versions.outputs.fips-skip == 'none' needs: [checks, build-gh-docker-publish, release-versions] uses: ./.github/workflows/ci-test-e2e.yml with: @@ -1026,11 +1069,14 @@ jobs: exit 1 fi - if [[ '${{ needs.test-api-fips.result }}' != 'success' ]]; then + # the fips jobs are gated by the release-versions 'fips-skip' output, so any of them + # can legitimately report 'skipped' (always on fork PRs, and test-ui-fips unless the + # PR carries the 'fips' label) + if [[ '${{ needs.test-api-fips.result }}' != 'success' && '${{ needs.test-api-fips.result }}' != 'skipped' ]]; then exit 1 fi - if [[ '${{ needs.test-api-livechat-fips.result }}' != 'success' ]]; then + if [[ '${{ needs.test-api-livechat-fips.result }}' != 'success' && '${{ needs.test-api-livechat-fips.result }}' != 'skipped' ]]; then exit 1 fi From 126e446ca50ee6ae17abf0b31f4f1917c72e5a2f Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Fri, 14 Aug 2026 20:32:06 +0000 Subject: [PATCH 3/3] feat(apps): allow action button role filter to match custom roles by name (#41765) Co-authored-by: Claude Opus 5 (1M context) --- .changeset/app-action-button-role-names.md | 6 ++ .../hooks/useApplyButtonFilters.spec.ts | 67 +++++++++++++++++++ .../client/hooks/useApplyButtonFilters.ts | 12 ++-- .../ui/IUIActionButtonDescriptor.ts | 22 ++++++ .../src/MockedAppRootBuilder.tsx | 41 ++++++++---- .../src/hooks/useRoleIdResolver.ts | 40 +++++++++++ packages/ui-contexts/src/index.ts | 1 + 7 files changed, 173 insertions(+), 16 deletions(-) create mode 100644 .changeset/app-action-button-role-names.md create mode 100644 packages/ui-contexts/src/hooks/useRoleIdResolver.ts diff --git a/.changeset/app-action-button-role-names.md b/.changeset/app-action-button-role-names.md new file mode 100644 index 0000000000000..2582c4bd7cb1c --- /dev/null +++ b/.changeset/app-action-button-role-names.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/apps-engine': minor +'@rocket.chat/meteor': minor +--- + +Accepts a role name in the `when.hasOneRole` and `when.hasAllRoles` filters of an app action button diff --git a/apps/meteor/client/hooks/useApplyButtonFilters.spec.ts b/apps/meteor/client/hooks/useApplyButtonFilters.spec.ts index e46c0902116dd..430d36d9ffbe8 100644 --- a/apps/meteor/client/hooks/useApplyButtonFilters.spec.ts +++ b/apps/meteor/client/hooks/useApplyButtonFilters.spec.ts @@ -134,6 +134,73 @@ describe('useApplyButtonAuthFilter', () => { }); }); + describe('Custom role name resolution', () => { + // A custom role gets a random id, so an app can only know its name. + const customRole = { _id: 'aBcDeF1234567890x', name: 'Support Agent' }; + + const buttonRequiring = (role: string): IUIActionButton => ({ + appId: 'test-app', + actionId: 'test-action', + labelI18n: 'test_label', + context: UIActionButtonContext.USER_DROPDOWN_ACTION, + when: { + hasOneRole: [role], + }, + }); + + it('should show button when the user holds the role given by name', () => { + const { result } = renderHook(() => useApplyButtonAuthFilter(), { + wrapper: mockAppRoot().withJohnDoe().withRoleDefinition(customRole).withRole(customRole._id).build(), + }); + + expect(result.current(buttonRequiring(customRole.name))).toBe(true); + }); + + it('should filter button when the user does not hold the role given by name', () => { + const { result } = renderHook(() => useApplyButtonAuthFilter(), { + wrapper: mockAppRoot() + .withJohnDoe({ roles: ['user'] }) + .withRoleDefinition(customRole) + .build(), + }); + + expect(result.current(buttonRequiring(customRole.name))).toBe(false); + }); + + it('should still show button when the role is given by id', () => { + const { result } = renderHook(() => useApplyButtonAuthFilter(), { + wrapper: mockAppRoot().withJohnDoe().withRoleDefinition(customRole).withRole(customRole._id).build(), + }); + + expect(result.current(buttonRequiring(customRole._id))).toBe(true); + }); + + it('should filter button when the role name is unknown', () => { + const { result } = renderHook(() => useApplyButtonAuthFilter(), { + wrapper: mockAppRoot().withJohnDoe().withRoleDefinition(customRole).withRole(customRole._id).build(), + }); + + expect(result.current(buttonRequiring('No Such Role'))).toBe(false); + }); + + it('should prefer the id over a role whose name collides with it', () => { + // `decoy.name` equals `customRole._id`. The user holds only the decoy, so + // resolving by name would wrongly show the button. + const decoy = { _id: 'zZyYxXw9876543210', name: customRole._id }; + + const { result } = renderHook(() => useApplyButtonAuthFilter(), { + wrapper: mockAppRoot() + .withJohnDoe({ roles: ['user'] }) + .withRoleDefinition(customRole) + .withRoleDefinition(decoy) + .withRole(decoy._id) + .build(), + }); + + expect(result.current(buttonRequiring(customRole._id))).toBe(false); + }); + }); + describe('Permission-based filtering', () => { it('should filter button when user does not have required permission (hasAllPermissions)', () => { const button: IUIActionButton = { diff --git a/apps/meteor/client/hooks/useApplyButtonFilters.ts b/apps/meteor/client/hooks/useApplyButtonFilters.ts index 415d648acdc3c..1b792dee49956 100644 --- a/apps/meteor/client/hooks/useApplyButtonFilters.ts +++ b/apps/meteor/client/hooks/useApplyButtonFilters.ts @@ -10,7 +10,7 @@ import { isPublicDiscussion, isPublicTeamRoom, } from '@rocket.chat/core-typings'; -import { AuthorizationContext, useUserId } from '@rocket.chat/ui-contexts'; +import { AuthorizationContext, useRoleIdResolver, useUserId } from '@rocket.chat/ui-contexts'; import { useCallback, useContext } from 'react'; import { useRoom } from '../views/room/contexts/RoomContext'; @@ -69,17 +69,21 @@ export const useApplyButtonAuthFilter = (): ((button: IUIActionButton, room?: IR const { queryAllPermissions, queryAtLeastOnePermission, queryRole } = useContext(AuthorizationContext); + // An app knows the name of a custom role, not the random id the workspace gave it, + // so accept either form in the role filters. + const resolveRoleId = useRoleIdResolver(); + return useCallback( (button: IUIActionButton, room?: IRoom) => { const { hasAllPermissions, hasOnePermission, hasAllRoles, hasOneRole } = button.when || {}; const hasAllPermissionsResult = hasAllPermissions ? queryAllPermissions(hasAllPermissions)[1]() : true; const hasOnePermissionResult = hasOnePermission ? queryAtLeastOnePermission(hasOnePermission)[1]() : true; - const hasAllRolesResult = hasAllRoles ? !!uid && hasAllRoles.every((role) => queryRole(role, room?._id)[1]()) : true; - const hasOneRoleResult = hasOneRole ? !!uid && hasOneRole.some((role) => queryRole(role, room?._id)[1]()) : true; + const hasAllRolesResult = hasAllRoles ? !!uid && hasAllRoles.every((role) => queryRole(resolveRoleId(role), room?._id)[1]()) : true; + const hasOneRoleResult = hasOneRole ? !!uid && hasOneRole.some((role) => queryRole(resolveRoleId(role), room?._id)[1]()) : true; return hasAllPermissionsResult && hasOnePermissionResult && hasAllRolesResult && hasOneRoleResult; }, - [queryAllPermissions, queryAtLeastOnePermission, queryRole, uid], + [queryAllPermissions, queryAtLeastOnePermission, queryRole, resolveRoleId, uid], ); }; diff --git a/packages/apps-engine/src/definition/ui/IUIActionButtonDescriptor.ts b/packages/apps-engine/src/definition/ui/IUIActionButtonDescriptor.ts index be5515ec78e8a..0ac445a4ec6d4 100644 --- a/packages/apps-engine/src/definition/ui/IUIActionButtonDescriptor.ts +++ b/packages/apps-engine/src/definition/ui/IUIActionButtonDescriptor.ts @@ -24,7 +24,29 @@ export interface IUActionButtonWhen { messageActionContext?: Array; hasOnePermission?: Array; hasAllPermissions?: Array; + /** + * Show the button when the user holds at least one of these roles. + * + * Each entry is a role id or a role name. Prefer the name for a custom role, + * because its id differs between workspaces. + * + * A role scoped to `Subscriptions` — `owner`, `moderator`, `leader`, or a custom + * one — is granted per room, so it matches only on surfaces bound to a room. On a + * surface with no room of its own, the user dropdown for instance, only roles + * scoped to `Users` match. + */ hasOneRole?: Array; + /** + * Show the button when the user holds every one of these roles. + * + * Each entry is a role id or a role name. Prefer the name for a custom role, + * because its id differs between workspaces. + * + * A role scoped to `Subscriptions` — `owner`, `moderator`, `leader`, or a custom + * one — is granted per room, so it matches only on surfaces bound to a room. On a + * surface with no room of its own, the user dropdown for instance, only roles + * scoped to `Users` match. + */ hasAllRoles?: Array; } diff --git a/packages/mock-providers/src/MockedAppRootBuilder.tsx b/packages/mock-providers/src/MockedAppRootBuilder.tsx index 1106907ef114f..df504505dee05 100644 --- a/packages/mock-providers/src/MockedAppRootBuilder.tsx +++ b/packages/mock-providers/src/MockedAppRootBuilder.tsx @@ -1,6 +1,7 @@ import type { CallPreferences, DirectCallData, + IRole, IRoom, ISetting, IUser, @@ -236,18 +237,18 @@ export class MockedAppRootBuilder { }, }; - private authorization: ContextType = (() => { - const dummyRolesMap: ReturnType['getRoles']> = new Map(); - - return { - queryPermission: () => [() => () => undefined, () => false], - queryAtLeastOnePermission: () => [() => () => undefined, () => false], - queryAllPermissions: () => [() => () => undefined, () => false], - queryRole: () => [() => () => undefined, () => false], - getRoles: () => dummyRolesMap, - subscribeToRoles: () => () => undefined, - }; - })(); + // Mutated by `withRoleDefinition` before render, then held stable, so the identity is + // a safe `useSyncExternalStore` snapshot. + private rolesMap = new Map(); + + private authorization: ContextType = { + queryPermission: () => [() => () => undefined, () => false], + queryAtLeastOnePermission: () => [() => () => undefined, () => false], + queryAllPermissions: () => [() => () => undefined, () => false], + queryRole: () => [() => () => undefined, () => false], + getRoles: () => this.rolesMap, + subscribeToRoles: () => () => undefined, + }; private authServices: LoginService[] = []; @@ -558,6 +559,22 @@ export class MockedAppRootBuilder { return this; } + /** + * Registers a role in the workspace roles map without granting it. A custom role + * has an id that differs from its name, so pass both to exercise code that + * resolves a name to an id. Chain `withRole(_id)` to grant it to the user. + */ + withRoleDefinition(role: Pick & Partial): this { + this.rolesMap.set(role._id, { + description: '', + protected: false, + scope: 'Users', + ...role, + } as IRole); + + return this; + } + withSetting(id: string, value: SettingValue, settingStructure?: Partial): this { const setting = { ...settingStructure, diff --git a/packages/ui-contexts/src/hooks/useRoleIdResolver.ts b/packages/ui-contexts/src/hooks/useRoleIdResolver.ts new file mode 100644 index 0000000000000..a8a86b7f686ec --- /dev/null +++ b/packages/ui-contexts/src/hooks/useRoleIdResolver.ts @@ -0,0 +1,40 @@ +import type { IRole } from '@rocket.chat/core-typings'; +import { useCallback, useContext, useMemo, useSyncExternalStore } from 'react'; + +import { AuthorizationContext } from '../AuthorizationContext'; + +/** + * Returns a function that maps a role reference to a role id. + * + * Authorization checks such as `queryRole` match on `IRole._id`. The default roles + * are seeded with `_id === name`, but a custom role gets a random id, so its name + * never matches. Use this resolver whenever the caller only knows the role name — + * an app that declares an action button filter, for example. + * + * A known id wins over a name, so an existing caller that already passes an id is + * unaffected. An unknown value passes through unchanged, which keeps the failure + * mode of the check it feeds. + */ +export const useRoleIdResolver = (): ((role: string) => IRole['_id']) => { + const { getRoles, subscribeToRoles } = useContext(AuthorizationContext); + + const roles = useSyncExternalStore(subscribeToRoles, getRoles); + + const idsByName = useMemo(() => { + const index = new Map(); + for (const role of roles.values()) { + // `roles.create` and `roles.update` reject a name already taken by another role, so a + // name maps to at most one role. Should duplicates still exist (e.g. written straight + // to the database), the first one iterated wins instead of the last, so adding another + // duplicate later does not silently repoint every check that names it. + if (index.has(role.name)) { + continue; + } + + index.set(role.name, role._id); + } + return index; + }, [roles]); + + return useCallback((role: string) => (roles.has(role) ? role : (idsByName.get(role) ?? role)), [idsByName, roles]); +}; diff --git a/packages/ui-contexts/src/index.ts b/packages/ui-contexts/src/index.ts index 7e8893d288791..bdb5e2908d056 100644 --- a/packages/ui-contexts/src/index.ts +++ b/packages/ui-contexts/src/index.ts @@ -63,6 +63,7 @@ export { useModal } from './hooks/useModal'; export { usePermission } from './hooks/usePermission'; export { usePermissionWithScopedRoles } from './hooks/usePermissionWithScopedRoles'; export { useRole } from './hooks/useRole'; +export { useRoleIdResolver } from './hooks/useRoleIdResolver'; export { useRolesDescription } from './hooks/useRolesDescription'; export { useRoomAvatarPath } from './hooks/useRoomAvatarPath'; export { useRoomToolbox } from './hooks/useRoomToolbox';