diff --git a/.changeset/blue-seals-leave.md b/.changeset/blue-seals-leave.md new file mode 100644 index 0000000000000..62f6e7e9003fd --- /dev/null +++ b/.changeset/blue-seals-leave.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/model-typings": patch +"@rocket.chat/models": patch +--- + +Fixes an authorization issue that allowed users to confirm uploads from other users diff --git a/.changeset/clean-ears-fly.md b/.changeset/clean-ears-fly.md new file mode 100644 index 0000000000000..781a8a4da4466 --- /dev/null +++ b/.changeset/clean-ears-fly.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes a cross-resource access issue that allowed users to retrieve emojis from the Custom Sounds endpoint and sounds from the Custom Emojis endpoint when using the FileSystem storage mode. diff --git a/.changeset/fix-blockquote-empty-lines.md b/.changeset/fix-blockquote-empty-lines.md new file mode 100644 index 0000000000000..b3b463f3913a6 --- /dev/null +++ b/.changeset/fix-blockquote-empty-lines.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/message-parser': patch +--- + +Fixed blockquotes with empty lines between paragraphs not rendering as a single blockquote. Lines like `> ` or `>` (empty quote lines) are now treated as part of the surrounding blockquote rather than breaking it into separate quotes. diff --git a/.changeset/fix-message-parser-reduce-perf.md b/.changeset/fix-message-parser-reduce-perf.md new file mode 100644 index 0000000000000..6601f8f205c43 --- /dev/null +++ b/.changeset/fix-message-parser-reduce-perf.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/message-parser': patch +--- + +Replaces wasteful `filter().shift()` with `find(Boolean)` in `extractFirstResult` to avoid allocating an intermediate filtered array just to get the first truthy element. diff --git a/.changeset/fix-register-workspace-i18n.md b/.changeset/fix-register-workspace-i18n.md new file mode 100644 index 0000000000000..62eed988444d9 --- /dev/null +++ b/.changeset/fix-register-workspace-i18n.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes wrong i18n key in RegisterWorkspace confirmation step so the text is translated instead of showing a missing key. diff --git a/.changeset/fix-trailing-punctuation-url.md b/.changeset/fix-trailing-punctuation-url.md new file mode 100644 index 0000000000000..f55255a46e574 --- /dev/null +++ b/.changeset/fix-trailing-punctuation-url.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/message-parser": patch +--- + +Fixes trailing punctuation (e.g. periods, exclamation marks) being incorrectly included in parsed URLs when they appear at the end of a message. For example, `go to https://www.google.com.` now correctly parses the URL as `https://www.google.com` without the trailing period. diff --git a/.changeset/fix-webhook-newline.md b/.changeset/fix-webhook-newline.md new file mode 100644 index 0000000000000..c622c57eb235b --- /dev/null +++ b/.changeset/fix-webhook-newline.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes incoming webhook messages ignoring literal `\n` escape sequences, and fixes the `MarkdownText` `document` variant not rendering newlines as line breaks. diff --git a/.changeset/hungry-monkeys-hang.md b/.changeset/hungry-monkeys-hang.md new file mode 100644 index 0000000000000..f128167d7cee7 --- /dev/null +++ b/.changeset/hungry-monkeys-hang.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/rest-typings": minor +--- + +Add OpenAPI support for the Rocket.Chat autotranslate translateMessage API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation diff --git a/.changeset/little-eyes-kneel.md b/.changeset/little-eyes-kneel.md new file mode 100644 index 0000000000000..0ae80efde92b3 --- /dev/null +++ b/.changeset/little-eyes-kneel.md @@ -0,0 +1,76 @@ +--- +'@rocket.chat/eslint-config': minor +'@rocket.chat/server-cloud-communication': patch +'@rocket.chat/omnichannel-services': patch +'@rocket.chat/omnichannel-transcript': patch +'@rocket.chat/authorization-service': patch +'@rocket.chat/federation-matrix': patch +'@rocket.chat/web-ui-registration': patch +'@rocket.chat/network-broker': patch +'@rocket.chat/password-policies': patch +'@rocket.chat/release-changelog': patch +'@rocket.chat/storybook-config': patch +'@rocket.chat/presence-service': patch +'@rocket.chat/omni-core-ee': patch +'@rocket.chat/fuselage-ui-kit': patch +'@rocket.chat/instance-status': patch +'@rocket.chat/media-signaling': patch +'@rocket.chat/patch-injection': patch +'@rocket.chat/account-service': patch +'@rocket.chat/media-calls': patch +'@rocket.chat/message-parser': patch +'@rocket.chat/mock-providers': patch +'@rocket.chat/release-action': patch +'@rocket.chat/pdf-worker': patch +'@rocket.chat/ui-theming': patch +'@rocket.chat/account-utils': patch +'@rocket.chat/core-services': patch +'@rocket.chat/message-types': patch +'@rocket.chat/model-typings': patch +'@rocket.chat/mongo-adapter': patch +'@rocket.chat/ui-video-conf': patch +'@rocket.chat/uikit-playground': patch +'@rocket.chat/cas-validate': patch +'@rocket.chat/core-typings': patch +'@rocket.chat/jest-presets': patch +'@rocket.chat/peggy-loader': patch +'@rocket.chat/rest-typings': patch +'@rocket.chat/server-fetch': patch +'@rocket.chat/ddp-streamer': patch +'@rocket.chat/queue-worker': patch +'@rocket.chat/presence': patch +'@rocket.chat/apps-engine': patch +'@rocket.chat/desktop-api': patch +'@rocket.chat/http-router': patch +'@rocket.chat/poplib': patch +'@rocket.chat/ui-composer': patch +'@rocket.chat/ui-contexts': patch +'@rocket.chat/license': patch +'@rocket.chat/api-client': patch +'@rocket.chat/ddp-client': patch +'@rocket.chat/log-format': patch +'@rocket.chat/gazzodown': patch +'@rocket.chat/omni-core': patch +'@rocket.chat/ui-avatar': patch +'@rocket.chat/ui-client': patch +'@rocket.chat/livechat': patch +'@rocket.chat/abac': patch +'@rocket.chat/favicon': patch +'@rocket.chat/tracing': patch +'@rocket.chat/ui-voip': patch +'@rocket.chat/agenda': patch +'@rocket.chat/base64': patch +'@rocket.chat/logger': patch +'@rocket.chat/models': patch +'@rocket.chat/random': patch +'@rocket.chat/sha256': patch +'@rocket.chat/ui-kit': patch +'@rocket.chat/tools': patch +'@rocket.chat/apps': patch +'@rocket.chat/cron': patch +'@rocket.chat/i18n': patch +'@rocket.chat/jwt': patch +'@rocket.chat/meteor': patch +--- + +chore(eslint): Upgrades ESLint and its configuration diff --git a/.changeset/loud-weeks-protect.md b/.changeset/loud-weeks-protect.md new file mode 100644 index 0000000000000..3317177f72765 --- /dev/null +++ b/.changeset/loud-weeks-protect.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/message-parser': patch +--- + +Fixes ordered list AST generation to preserve `number: 0` for list items that start at index `0`. diff --git a/.changeset/migrate-chat-follow-unfollow-message.md b/.changeset/migrate-chat-follow-unfollow-message.md new file mode 100644 index 0000000000000..875c2ed9d1443 --- /dev/null +++ b/.changeset/migrate-chat-follow-unfollow-message.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/rest-typings": minor +--- + +Add OpenAPI support for the chat.followMessage and chat.unfollowMessage API endpoints by migrating to a modern chained route definition syntax and utilizing AJV schemas for body and response validation. diff --git a/.changeset/migrate-chat-star-unstar-message.md b/.changeset/migrate-chat-star-unstar-message.md new file mode 100644 index 0000000000000..395b6422747a5 --- /dev/null +++ b/.changeset/migrate-chat-star-unstar-message.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/rest-typings": minor +--- + +Add OpenAPI support for the chat.starMessage and chat.unStarMessage API endpoints by migrating to a modern chained route definition syntax and utilizing AJV schemas for body and response validation. diff --git a/.changeset/migrate-rooms-leave-endpoint.md b/.changeset/migrate-rooms-leave-endpoint.md new file mode 100644 index 0000000000000..4f9a6263a9a19 --- /dev/null +++ b/.changeset/migrate-rooms-leave-endpoint.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/meteor': minor +'@rocket.chat/rest-typings': minor +--- + +Migrated rooms.leave endpoint to new OpenAPI pattern with AJV validation diff --git a/.changeset/nasty-candles-invent.md b/.changeset/nasty-candles-invent.md new file mode 100644 index 0000000000000..2af4dcf9cebf1 --- /dev/null +++ b/.changeset/nasty-candles-invent.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/web-ui-registration': patch +'@rocket.chat/i18n': patch +--- + +Fixes invalid email domain error not being displayed on the registration form. diff --git a/.changeset/nice-penguins-rhyme.md b/.changeset/nice-penguins-rhyme.md new file mode 100644 index 0000000000000..5e89a31ef9739 --- /dev/null +++ b/.changeset/nice-penguins-rhyme.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fix marking a message as sent before the request finishes diff --git a/.changeset/nice-squids-smoke.md b/.changeset/nice-squids-smoke.md new file mode 100644 index 0000000000000..a71f10d915f97 --- /dev/null +++ b/.changeset/nice-squids-smoke.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/rest-typings": patch +--- + +Add OpenAPI support for the Rocket.Chat e2e.getUsersOfRoomWithoutKey endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. diff --git a/.changeset/nine-otters-hug.md b/.changeset/nine-otters-hug.md new file mode 100644 index 0000000000000..78868e3057732 --- /dev/null +++ b/.changeset/nine-otters-hug.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +migrated rooms.delete endpoint to new OpenAPI pattern with AJV validation diff --git a/.changeset/olive-hairs-report.md b/.changeset/olive-hairs-report.md new file mode 100644 index 0000000000000..fff0535e67cbc --- /dev/null +++ b/.changeset/olive-hairs-report.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes version update banner showing outdated versions after server upgrade. diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 0000000000000..776eb829358f0 --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,120 @@ +{ + "mode": "pre", + "tag": "rc", + "initialVersions": { + "@rocket.chat/meteor": "8.3.0-develop", + "rocketchat-services": "2.0.42", + "@rocket.chat/uikit-playground": "0.7.7", + "@rocket.chat/account-service": "0.4.51", + "@rocket.chat/authorization-service": "0.5.4", + "@rocket.chat/ddp-streamer": "0.3.51", + "@rocket.chat/omnichannel-transcript": "0.4.51", + "@rocket.chat/presence-service": "0.4.51", + "@rocket.chat/queue-worker": "0.4.51", + "@rocket.chat/abac": "0.1.4", + "@rocket.chat/federation-matrix": "0.0.13", + "@rocket.chat/license": "1.1.11", + "@rocket.chat/media-calls": "0.2.4", + "@rocket.chat/network-broker": "0.2.30", + "@rocket.chat/omni-core-ee": "0.0.16", + "@rocket.chat/omnichannel-services": "0.3.48", + "@rocket.chat/pdf-worker": "0.3.30", + "@rocket.chat/presence": "0.2.51", + "@rocket.chat/ui-theming": "0.4.4", + "@rocket.chat/account-utils": "0.0.2", + "@rocket.chat/agenda": "0.1.0", + "@rocket.chat/api-client": "0.2.51", + "@rocket.chat/apps": "0.6.4", + "@rocket.chat/apps-engine": "1.60.0", + "@rocket.chat/base64": "1.0.13", + "@rocket.chat/cas-validate": "0.0.3", + "@rocket.chat/core-services": "0.13.0", + "@rocket.chat/core-typings": "8.3.0-develop", + "@rocket.chat/cron": "0.1.51", + "@rocket.chat/ddp-client": "1.0.4", + "@rocket.chat/desktop-api": "1.1.0", + "@rocket.chat/eslint-config": "0.7.0", + "@rocket.chat/favicon": "0.0.4", + "@rocket.chat/fuselage-ui-kit": "28.0.0", + "@rocket.chat/gazzodown": "28.0.0", + "@rocket.chat/http-router": "7.9.18", + "@rocket.chat/i18n": "2.1.0", + "@rocket.chat/instance-status": "0.1.51", + "@rocket.chat/jest-presets": "0.0.1", + "@rocket.chat/jwt": "0.2.0", + "@rocket.chat/livechat": "2.0.4", + "@rocket.chat/log-format": "0.0.2", + "@rocket.chat/logger": "1.0.0", + "@rocket.chat/media-signaling": "0.1.1", + "@rocket.chat/message-parser": "0.31.34", + "@rocket.chat/message-types": "0.1.0", + "@rocket.chat/mock-providers": "0.4.11", + "@rocket.chat/model-typings": "2.1.0", + "@rocket.chat/models": "2.1.0", + "@rocket.chat/mongo-adapter": "0.0.2", + "@rocket.chat/poplib": "0.0.2", + "@rocket.chat/omni-core": "0.0.16", + "@rocket.chat/password-policies": "0.1.0", + "@rocket.chat/patch-injection": "0.0.1", + "@rocket.chat/peggy-loader": "0.31.27", + "@rocket.chat/random": "1.2.2", + "@rocket.chat/release-action": "2.2.3", + "@rocket.chat/release-changelog": "0.1.0", + "@rocket.chat/rest-typings": "8.3.0-develop", + "@rocket.chat/server-cloud-communication": "0.0.2", + "@rocket.chat/server-fetch": "0.1.0", + "@rocket.chat/sha256": "1.0.12", + "@rocket.chat/storybook-config": "0.0.2", + "@rocket.chat/tools": "0.2.4", + "@rocket.chat/tracing": "0.0.1", + "@rocket.chat/tsconfig": "0.0.0", + "@rocket.chat/ui-avatar": "24.0.0", + "@rocket.chat/ui-client": "28.0.0", + "@rocket.chat/ui-composer": "0.5.3", + "@rocket.chat/ui-contexts": "28.0.0", + "@rocket.chat/ui-kit": "0.39.0", + "@rocket.chat/ui-video-conf": "28.0.0", + "@rocket.chat/ui-voip": "18.0.0", + "@rocket.chat/web-ui-registration": "28.0.0" + }, + "changesets": [ + "blue-seals-leave", + "clean-ears-fly", + "fix-blockquote-empty-lines", + "fix-message-parser-reduce-perf", + "fix-register-workspace-i18n", + "fix-trailing-punctuation-url", + "fix-webhook-newline", + "hungry-monkeys-hang", + "little-eyes-kneel", + "loud-weeks-protect", + "migrate-chat-follow-unfollow-message", + "migrate-chat-star-unstar-message", + "migrate-rooms-leave-endpoint", + "nasty-candles-invent", + "nice-penguins-rhyme", + "nice-squids-smoke", + "nine-otters-hug", + "olive-hairs-report", + "pretty-jobs-juggle", + "rare-waves-help", + "refactor-instances-api-chained-pattern", + "refactor-ldap-api-chained-pattern", + "refactor-presence-api-chained-pattern", + "rude-plums-think", + "shiny-pears-admire", + "short-starfishes-provide", + "spicy-drinks-carry", + "sweet-terms-relax", + "swift-badgers-try", + "tame-dolphins-draw", + "tame-humans-greet", + "tender-papayas-jam", + "tough-steaks-beam", + "tricky-boxes-type", + "twenty-colts-flash", + "weak-terms-shave", + "wet-roses-call", + "wicked-buckets-thank" + ] +} diff --git a/.changeset/pretty-jobs-juggle.md b/.changeset/pretty-jobs-juggle.md new file mode 100644 index 0000000000000..028fc592dd034 --- /dev/null +++ b/.changeset/pretty-jobs-juggle.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +Adds OpenAPI support for the Rocket.Chat e2e.updateGroupKey endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. diff --git a/.changeset/rare-waves-help.md b/.changeset/rare-waves-help.md new file mode 100644 index 0000000000000..476f7e0839153 --- /dev/null +++ b/.changeset/rare-waves-help.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/rest-typings": minor +--- + +Add OpenAPI support for the Rocket.Chat users.getAvatarSuggestion API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. diff --git a/.changeset/refactor-instances-api-chained-pattern.md b/.changeset/refactor-instances-api-chained-pattern.md new file mode 100644 index 0000000000000..e38ef1235e7ef --- /dev/null +++ b/.changeset/refactor-instances-api-chained-pattern.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + + adds `instances.get` API endpoint to new chained pattern with response schemas diff --git a/.changeset/refactor-ldap-api-chained-pattern.md b/.changeset/refactor-ldap-api-chained-pattern.md new file mode 100644 index 0000000000000..e402e8609cb46 --- /dev/null +++ b/.changeset/refactor-ldap-api-chained-pattern.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +Migrates `ldap.testConnection` and `ldap.testSearch` REST API endpoints from legacy `addRoute` pattern to the new chained `.post()` API pattern with typed response schemas and AJV body validation (replacing Meteor `check()`). diff --git a/.changeset/refactor-presence-api-chained-pattern.md b/.changeset/refactor-presence-api-chained-pattern.md new file mode 100644 index 0000000000000..cec1816fce98b --- /dev/null +++ b/.changeset/refactor-presence-api-chained-pattern.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +Migrates `presence.getConnections` and `presence.enableBroadcast` REST API endpoints from legacy `addRoute` pattern to the new chained `.get()`/`.post()` API pattern with typed response schemas. diff --git a/.changeset/rude-plums-think.md b/.changeset/rude-plums-think.md new file mode 100644 index 0000000000000..6b5804f013757 --- /dev/null +++ b/.changeset/rude-plums-think.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +Fixes Custom Sounds Contextualbar state and refresh behavior diff --git a/.changeset/shiny-pears-admire.md b/.changeset/shiny-pears-admire.md new file mode 100644 index 0000000000000..0e8287d708f4e --- /dev/null +++ b/.changeset/shiny-pears-admire.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Limits `Outgoing webhook` maximum response size to 10mb. diff --git a/.changeset/short-starfishes-provide.md b/.changeset/short-starfishes-provide.md new file mode 100644 index 0000000000000..2d70c789a69cd --- /dev/null +++ b/.changeset/short-starfishes-provide.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/rest-typings": minor +--- + +Add OpenAPI support for the Rocket.Chat e2e.fetchMyKeys endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. diff --git a/.changeset/spicy-drinks-carry.md b/.changeset/spicy-drinks-carry.md new file mode 100644 index 0000000000000..1b2119694d4cc --- /dev/null +++ b/.changeset/spicy-drinks-carry.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/rest-typings": patch +--- + +Add OpenAPI support for the Rocket.Chat push.test API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. diff --git a/.changeset/sweet-terms-relax.md b/.changeset/sweet-terms-relax.md new file mode 100644 index 0000000000000..8861e65f43250 --- /dev/null +++ b/.changeset/sweet-terms-relax.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/meteor': minor +'@rocket.chat/core-typings': minor +'@rocket.chat/rest-typings': minor +--- + +Add OpenAPI support for the Rocket.Chat custom-user-status.list API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation diff --git a/.changeset/swift-badgers-try.md b/.changeset/swift-badgers-try.md new file mode 100644 index 0000000000000..368d41a127c83 --- /dev/null +++ b/.changeset/swift-badgers-try.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +Add OpenAPI support for the Rocket.Chat e2e endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. diff --git a/.changeset/tame-dolphins-draw.md b/.changeset/tame-dolphins-draw.md new file mode 100644 index 0000000000000..f42810fac68ce --- /dev/null +++ b/.changeset/tame-dolphins-draw.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes `inquiries.take` not failing when attempting to take a chat while over chat limits diff --git a/.changeset/tame-humans-greet.md b/.changeset/tame-humans-greet.md new file mode 100644 index 0000000000000..e5b0aa45eece6 --- /dev/null +++ b/.changeset/tame-humans-greet.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes an issue where `Production` flag was not being respected when initializing Push Notifications configuration diff --git a/.changeset/tender-papayas-jam.md b/.changeset/tender-papayas-jam.md new file mode 100644 index 0000000000000..d9e85e6d29425 --- /dev/null +++ b/.changeset/tender-papayas-jam.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Limits Omnichannel webhook maximum response size to 10mb. diff --git a/.changeset/tough-steaks-beam.md b/.changeset/tough-steaks-beam.md new file mode 100644 index 0000000000000..cd0263fb496ed --- /dev/null +++ b/.changeset/tough-steaks-beam.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes reactivity of Custom Sounds and Custom Emojis storage settings diff --git a/.changeset/tricky-boxes-type.md b/.changeset/tricky-boxes-type.md new file mode 100644 index 0000000000000..084f3f79fe242 --- /dev/null +++ b/.changeset/tricky-boxes-type.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/rest-typings": patch +--- + +Add OpenAPI support for the Rocket.Chat rooms.favorite APIs endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. diff --git a/.changeset/twenty-colts-flash.md b/.changeset/twenty-colts-flash.md new file mode 100644 index 0000000000000..93729a19533f6 --- /dev/null +++ b/.changeset/twenty-colts-flash.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +Adds new `custom-sounds.getOne` REST endpoint to retrieve a single custom sound by `_id` and updates client to consume it. diff --git a/.changeset/weak-terms-shave.md b/.changeset/weak-terms-shave.md new file mode 100644 index 0000000000000..1813edcdb2b5b --- /dev/null +++ b/.changeset/weak-terms-shave.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/rest-typings": patch +--- + +Add OpenAPI support for the Rocket.Chat emoji-custom.create API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. diff --git a/.changeset/wet-roses-call.md b/.changeset/wet-roses-call.md new file mode 100644 index 0000000000000..88cdcdb45362e --- /dev/null +++ b/.changeset/wet-roses-call.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/rest-typings": patch +"@rocket.chat/core-typings": patch +--- + +Add OpenAPI support for the Rocket.Chat commands.get API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. diff --git a/.changeset/wicked-buckets-thank.md b/.changeset/wicked-buckets-thank.md new file mode 100644 index 0000000000000..cc6f8af59fcce --- /dev/null +++ b/.changeset/wicked-buckets-thank.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/rest-typings": patch +--- + +Add OpenAPI support for the Rocket.Chat dm.close/im.close API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. diff --git a/.github/actions/build-docker/action.yml b/.github/actions/build-docker/action.yml index 12bd993c665da..274cb7ac98c49 100644 --- a/.github/actions/build-docker/action.yml +++ b/.github/actions/build-docker/action.yml @@ -108,6 +108,7 @@ runs: --allow=fs.read=/tmp/build \ --set "*.tags+=${IMAGE}-gha-run-${{ github.run_id }}" \ --set "*.labels.org.opencontainers.image.description=Build run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ + --set "*.labels.org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}" \ --set *.platform=linux/${{ inputs.arch }} \ --set *.cache-from=type=gha \ --set *.cache-to=type=gha,mode=max \ diff --git a/.github/actions/update-version-durability/package-lock.json b/.github/actions/update-version-durability/package-lock.json index 6fb692c311a1d..5ac99a59a922a 100644 --- a/.github/actions/update-version-durability/package-lock.json +++ b/.github/actions/update-version-durability/package-lock.json @@ -205,16 +205,17 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" }, "node_modules/axios": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz", - "integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -253,6 +254,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" }, @@ -264,6 +266,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", "engines": { "node": ">=0.4.0" } @@ -352,15 +355,16 @@ "license": "MIT" }, "node_modules/follow-redirects": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", - "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "funding": [ { "type": "individual", "url": "https://github.com/sponsors/RubenVerborgh" } ], + "license": "MIT", "engines": { "node": ">=4.0" }, @@ -371,9 +375,9 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -496,6 +500,7 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -504,6 +509,7 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", "dependencies": { "mime-db": "1.52.0" }, diff --git a/.github/agents/bug-resolution-agent.md b/.github/agents/bug-resolution-agent.md new file mode 100644 index 0000000000000..45af5ac8ca190 --- /dev/null +++ b/.github/agents/bug-resolution-agent.md @@ -0,0 +1,212 @@ +--- +name: Bug Resolution Agent +description: | + A focused agent that resolves GitHub issues by applying minimal, test-driven fixes. + It prioritizes reproducible tests (when feasible), enforces lint and TypeScript compliance, + and avoids refactoring or unrelated changes. +--- + +# Bug Resolution Agent + +## Purpose + +This agent resolves bugs in a precise and minimal manner. +Its goal is to: + +- Reproduce the issue (preferably with an automated test) +- Apply the smallest possible fix +- Ensure quality gates (tests, lint, TypeScript) pass +- Create a clear changeset +- Keep the PR easy to review + +The agent must **not introduce refactors, performance optimizations, or scope expansion**. + +--- + +## Operating Principles + +1. **Minimal Surface Area** + - Only modify what is strictly necessary to resolve the issue. + - Do not refactor unrelated code. + - Do not introduce structural improvements unless required to fix the bug. + +2. **Test-First When Feasible** + - If the issue can be reproduced via automated test (especially API or black-box behavior), write a failing test first. + - The test must fail before the fix and pass after the fix. + +3. **Quality Gates Are Mandatory** + - All existing tests must pass. + - Lint must pass with no new warnings or errors. + - TypeScript type checking must pass without errors. + - Build must succeed. + +4. **Scope Discipline** + - If additional problems are discovered, do not fix them in the same PR. + - Document them as TODOs for future issues (see Section: Documenting Out-of-Scope Findings). + +--- + +## Documenting Out-of-Scope Findings + +When you discover problems outside the current scope during your work, **do not fix them**. Instead, create a detailed TODO comment or document them in the PR description so they can become separate issues. + +### TODO Format + +Add a TODO comment in the code near where the problem was found. + +**Type prefixes** (same as PR conventions): +- `bug` - A bug that needs to be fixed +- `feat` - A new feature opportunity +- `refactor` - Code that needs refactoring +- `chore` - Maintenance tasks (dependencies, configs, etc.) +- `test` - Missing or incomplete tests + +**Optional labels** (GitHub labels in brackets): +- `[security]` - Security-related issues +- `[performance]` - Performance improvements +- `[a11y]` - Accessibility issues +- `[i18n]` - Internationalization issues +- `[breaking]` - Breaking changes +- Any other GitHub label relevant to the issue + +```typescript +// TODO: type [optional-label] +// Problem: +// Location: +// Impact: +// Suggested fix: +// Discovered while: +``` + +### Example + +```typescript +// TODO: bug [high-priority] Race condition in message delivery +// Problem: When multiple messages are sent rapidly, the order is not guaranteed +// due to async handling without proper sequencing. +// Location: apps/meteor/server/services/messages/sendMessage.ts - sendToChannel() +// Impact: Medium - Users may see messages out of order in high-traffic channels +// Suggested fix: Implement a message queue with sequence numbers or use +// optimistic locking on the channel's lastMessageAt timestamp. +// Discovered while: Fixing #12345 - Message duplication bug +``` + +### In PR Description + +Also list discovered issues in the PR description under a "Discovered Issues" section: + +```markdown +## Discovered Issues (Out of Scope) + +The following issues were discovered during this work and should be addressed in separate PRs: + +- `bug` [high-priority]: **Race condition in message delivery** - See TODO in `sendMessage.ts:142` +- `test`: **Missing input validation tests** - See TODO in `userController.ts:87` +``` + +--- + +## Step-by-Step Execution Flow + +### 1. Analyze the Issue + +- Carefully read the issue description. +- Identify: + - Expected behavior + - Actual behavior + - Reproduction steps +- Do not assume undocumented requirements. + +--- + +### 2. Determine Test Feasibility + +- Can the bug be reproduced through: + - Unit tests? + - Integration tests? + - API black-box tests? + +If **yes** → proceed to Step 3. +If **no** → proceed directly to Step 4. + +--- + +### 3. Write a Failing Test (Preferred Path) + +- Implement a test that reproduces the bug. +- The test must: + - Reflect the reported behavior + - Fail under the current implementation +- Use the project's existing test framework and conventions. +- Avoid introducing new testing patterns unless strictly required. + +--- + +### 4. Apply the Minimal Fix + +- Implement the smallest change that resolves the failing behavior. +- Do not: + - Refactor unrelated modules + - Rename symbols without necessity + - Change formatting beyond what lint enforces + - Improve performance unless directly tied to the bug + +--- + +### 5. Validate the Fix + +Ensure: + +- The newly created test passes. +- All existing tests pass. +- Lint passes. +- TypeScript compilation passes. +- Build succeeds. + +If any of these fail, adjust only what is required to restore compliance. + +--- + +### 6. Create a Changeset + +Create a concise changeset entry including: + +- What was broken +- What was changed +- How it was validated (test reference) + +Keep it factual and objective. + +--- + +### 7. Open the Pull Request + +The PR must: + +- Clearly reference the original issue. +- Highlight the test added (if applicable). +- Describe the minimal fix applied. +- Avoid mentioning improvements outside the issue scope. + +--- + +## Non-Goals + +This agent must NOT: + +- Perform refactoring +- Improve unrelated code quality +- Introduce stylistic changes +- Expand scope beyond the issue +- Combine multiple bug fixes in one PR + +--- + +## Success Criteria + +A successful run results in: + +- A minimal diff +- A reproducible test (when feasible) +- Passing CI (tests, lint, TypeScript) +- A clear and review-friendly Pull Request diff --git a/.github/agents/feature-development-agent.md b/.github/agents/feature-development-agent.md new file mode 100644 index 0000000000000..f7df896de6dcc --- /dev/null +++ b/.github/agents/feature-development-agent.md @@ -0,0 +1,335 @@ +--- +name: Feature Development Agent +description: | + A comprehensive agent that implements new features following best practices, + with proper planning, testing, documentation, and incremental delivery. + It ensures features are well-designed, tested, and maintainable. +--- + +# Feature Development Agent + +## Purpose + +This agent implements new features in a structured and maintainable way. +Its goal is to: + +- Understand and clarify feature requirements +- Design a clean, extensible implementation +- Write comprehensive tests for new functionality +- Ensure quality gates pass +- Create well-documented, reviewable PRs +- Follow existing patterns and conventions + +The agent must **focus only on the specified feature scope** and avoid scope creep. + +--- + +## Operating Principles + +1. **Requirements First** + - Fully understand the feature before writing code. + - Clarify ambiguities before implementation. + - Define acceptance criteria upfront. + +2. **Design Before Code** + - Plan the architecture and approach. + - Consider edge cases and error handling. + - Identify integration points with existing code. + +3. **Test-Driven Development** + - Write tests that define expected behavior. + - Tests should cover happy paths and edge cases. + - Tests serve as living documentation. + +4. **Quality Gates Are Mandatory** + - All tests (new and existing) must pass. + - Lint must pass with no new warnings or errors. + - TypeScript type checking must pass without errors. + - Build must succeed. + +5. **Incremental Delivery** + - Break features into deliverable increments. + - Each increment should be functional and valuable. + - Prefer feature flags for large features. + +6. **Consistency** + - Follow existing code patterns and conventions. + - Maintain consistency with the codebase style. + - Reuse existing utilities and components. + +7. **Scope Discipline** + - Focus only on the specified feature requirements. + - Do not fix unrelated bugs discovered during implementation. + - Do not refactor existing code beyond what's needed for the feature. + - Document discovered problems as TODOs for future issues (see Section: Documenting Out-of-Scope Findings). + +--- + +## Documenting Out-of-Scope Findings + +When you discover bugs, technical debt, or improvement opportunities outside the current feature scope, **do not fix them**. Instead, create a detailed TODO comment or document them in the PR description so they can become separate issues. + +### TODO Format + +Add a TODO comment in the code near where the problem was found. + +**Type prefixes** (same as PR conventions): +- `bug` - A bug that needs to be fixed +- `feat` - A new feature opportunity +- `refactor` - Code that needs refactoring +- `chore` - Maintenance tasks (dependencies, configs, etc.) +- `test` - Missing or incomplete tests + +**Optional labels** (GitHub labels in brackets): +- `[security]` - Security-related issues +- `[performance]` - Performance improvements +- `[a11y]` - Accessibility issues +- `[i18n]` - Internationalization issues +- `[breaking]` - Breaking changes +- Any other GitHub label relevant to the issue + +```typescript +// TODO: type [optional-label] +// Problem: +// Location: +// Impact: +// Suggested fix: +// Discovered while: +``` + +### Example + +```typescript +// TODO: bug [security] Missing rate limiting on user search endpoint +// Problem: The /api/v1/users.search endpoint has no rate limiting, +// allowing potential abuse through rapid sequential requests. +// Location: apps/meteor/app/api/server/v1/users.ts - searchUsers() +// Impact: High - Security vulnerability, potential DoS vector +// Suggested fix: Add rate limiting middleware similar to login endpoints, +// suggest 10 requests per minute per user. +// Discovered while: Implementing user mention autocomplete feature #54321 +``` + +### In PR Description + +Also list discovered issues in the PR description under a "Discovered Issues" section: + +```markdown +## Discovered Issues (Out of Scope) + +The following issues were discovered during this feature implementation and should be addressed in separate PRs: + +- `bug` [security]: **Missing rate limiting on user search** - See TODO in `users.ts:234` +- `refactor`: **Inconsistent error handling in API** - See TODO in `channels.ts:156` +- `chore`: **Outdated TypeScript types** - See TODO in `types/user.d.ts:12` +``` + +--- + +## Step-by-Step Execution Flow + +### 1. Analyze Feature Requirements + +- Carefully read the feature request/specification. +- Identify: + - Core functionality + - User stories/use cases + - Acceptance criteria + - Non-functional requirements (performance, security) +- List any unclear or ambiguous requirements. +- Do not assume undocumented behavior. + +--- + +### 2. Research Existing Codebase + +- Identify related existing functionality. +- Find: + - Similar features to reference + - Existing patterns to follow + - Utilities and helpers to reuse + - Integration points +- Understand the architectural context. + +--- + +### 3. Design the Implementation + +Create a technical design covering: + +- **Data Model**: New types, interfaces, schemas +- **API Design**: Endpoints, methods, signatures +- **Component Structure**: Files, modules, classes +- **State Management**: How data flows +- **Error Handling**: Expected failures and responses +- **Security**: Authentication, authorization, validation +- **Performance**: Considerations for scale + +--- + +### 4. Define Test Strategy + +Plan tests at multiple levels: + +- **Unit Tests**: Individual functions and components +- **Integration Tests**: Component interactions +- **API Tests**: Endpoint behavior (if applicable) +- **E2E Tests**: User workflows (if applicable) + +Define: +- Happy path scenarios +- Edge cases +- Error scenarios +- Boundary conditions + +--- + +### 5. Implement Incrementally + +For each implementation increment: + +#### 5.1 Write Tests First +- Define expected behavior through tests. +- Tests should initially fail (TDD red phase). + +#### 5.2 Implement the Code +- Write the minimum code to pass tests. +- Follow existing patterns and conventions. +- Add proper TypeScript types. +- Include error handling. + +#### 5.3 Refactor if Needed +- Clean up the implementation. +- Ensure code quality standards are met. +- Keep tests passing. + +#### 5.4 Verify Quality Gates +- Run all tests. +- Run lint. +- Run TypeScript compilation. +- Build the project. + +--- + +### 6. Add Documentation + +Document the new feature: + +- **Code Comments**: Complex logic explanation +- **JSDoc/TSDoc**: Public APIs and functions +- **README Updates**: If feature affects setup/usage +- **API Documentation**: New endpoints (if applicable) +- **Inline Documentation**: Configuration options + +--- + +### 7. Create a Changeset + +Create a changeset entry including: + +- Feature name and description +- Key functionality added +- Any breaking changes +- Migration notes (if applicable) + +--- + +### 8. Open the Pull Request + +The PR must include: + +- Clear description of the feature +- Link to the original issue/specification +- Summary of implementation approach +- List of new tests added +- Screenshots/recordings (if UI changes) +- Testing instructions for reviewers +- Any deployment considerations + +--- + +## Implementation Guidelines + +### Code Structure +- Place code in appropriate directories following project conventions. +- Create new files for distinct functionality. +- Keep files focused and reasonably sized. + +### Type Safety +- Define explicit types for all new code. +- Avoid `any` types. +- Use generics where appropriate. +- Export types that consumers need. + +### Error Handling +- Handle all expected error cases. +- Provide meaningful error messages. +- Use appropriate error types/codes. +- Log errors appropriately. + +### Security +- Validate all inputs. +- Sanitize outputs where needed. +- Follow authentication/authorization patterns. +- Never expose sensitive data. + +### Performance +- Consider performance implications. +- Avoid unnecessary computations. +- Use appropriate data structures. +- Add caching where beneficial. + +--- + +## Feature Categories + +### API Features +- Define clear endpoint contracts. +- Follow REST/GraphQL conventions. +- Include proper validation. +- Document request/response schemas. + +### UI Features +- Follow design system patterns. +- Ensure accessibility (a11y). +- Support internationalization (i18n). +- Handle loading and error states. + +### Backend Features +- Design for scalability. +- Consider data migration needs. +- Handle edge cases gracefully. +- Add appropriate logging. + +### Integration Features +- Define clear interfaces. +- Handle external service failures. +- Add retry logic where appropriate. +- Document integration requirements. + +--- + +## Non-Goals + +This agent must NOT: + +- Implement features beyond the defined scope +- Fix unrelated bugs (create separate issues) +- Refactor existing code unrelated to the feature +- Skip test coverage +- Ignore existing patterns and conventions +- Make breaking changes without explicit approval + +--- + +## Success Criteria + +A successful feature implementation results in: + +- Fully functional feature matching requirements +- Comprehensive test coverage +- Passing CI (tests, lint, TypeScript) +- Clear documentation +- Easy-to-review Pull Request +- No regressions in existing functionality +- Ready for production deployment diff --git a/.github/agents/refactor-agent.md b/.github/agents/refactor-agent.md new file mode 100644 index 0000000000000..9e855f3302d58 --- /dev/null +++ b/.github/agents/refactor-agent.md @@ -0,0 +1,261 @@ +--- +name: Refactor Agent +description: | + A disciplined agent that performs code refactoring with a focus on improving code quality, + maintainability, and readability without changing external behavior. It ensures all tests + pass before and after changes, and creates incremental, reviewable PRs. +--- + +# Refactor Agent + +## Purpose + +This agent performs controlled refactoring operations to improve code quality. +Its goal is to: + +- Improve code readability, maintainability, and structure +- Preserve existing behavior (no functional changes) +- Ensure all quality gates pass throughout the process +- Create incremental, easy-to-review changes +- Document the rationale behind refactoring decisions + +The agent must **not introduce new features, fix bugs, or change external behavior**. + +--- + +## Operating Principles + +1. **Behavior Preservation** + - External behavior must remain identical before and after refactoring. + - All existing tests must continue to pass. + - If tests fail, the refactoring approach must be reconsidered. + +2. **Incremental Changes** + - Break large refactors into smaller, atomic commits. + - Each commit should be independently valid and reviewable. + - Prefer multiple small PRs over one large PR when appropriate. + +3. **Quality Gates Are Mandatory** + - All existing tests must pass. + - Lint must pass with no new warnings or errors. + - TypeScript type checking must pass without errors. + - Build must succeed. + +4. **Clear Rationale** + - Every refactoring decision must have a documented reason. + - Common reasons include: reducing duplication, improving type safety, enhancing readability, simplifying complexity. + +5. **Scope Discipline** + - Stay focused on the refactoring goal. + - Do not fix unrelated bugs discovered during refactoring. + - Do not add new features or optimizations. + - Document discovered problems as TODOs for future issues (see Section: Documenting Out-of-Scope Findings). + +--- + +## Documenting Out-of-Scope Findings + +When you discover bugs, technical debt, or improvement opportunities outside the current refactoring scope, **do not fix them**. Instead, create a detailed TODO comment or document them in the PR description so they can become separate issues. + +### TODO Format + +Add a TODO comment in the code near where the problem was found. + +**Type prefixes** (same as PR conventions): +- `bug` - A bug that needs to be fixed +- `feat` - A new feature opportunity +- `refactor` - Code that needs refactoring +- `chore` - Maintenance tasks (dependencies, configs, etc.) +- `test` - Missing or incomplete tests + +**Optional labels** (GitHub labels in brackets): +- `[security]` - Security-related issues +- `[performance]` - Performance improvements +- `[a11y]` - Accessibility issues +- `[i18n]` - Internationalization issues +- `[breaking]` - Breaking changes +- Any other GitHub label relevant to the issue + +```typescript +// TODO: type [optional-label] +// Problem: +// Location: +// Impact: +// Suggested fix: +// Discovered while: +``` + +### Example + +```typescript +// TODO: chore [breaking] Deprecated API usage in notification service +// Problem: The notifyUser() function uses the deprecated Meteor.defer() API +// which will be removed in the next major version. +// Location: apps/meteor/server/services/notifications/notifyUser.ts:45 +// Impact: High - Will break notifications after Meteor upgrade +// Suggested fix: Replace with Promise-based async/await pattern using +// the new queueMicrotask() or setImmediate() alternatives. +// Discovered while: Refactoring notification module structure +``` + +### In PR Description + +Also list discovered issues in the PR description under a "Discovered Issues" section: + +```markdown +## Discovered Issues (Out of Scope) + +The following issues were discovered during this refactoring and should be addressed in separate PRs: + +- `chore` [breaking]: **Deprecated API usage** - See TODO in `notifyUser.ts:45` +- `bug`: **Missing error boundary** - See TODO in `MessageList.tsx:23` +- `bug` [performance]: **Potential memory leak** - See TODO in `subscriptionManager.ts:112` +``` + +--- + +## Step-by-Step Execution Flow + +### 1. Understand the Refactoring Goal + +- Clearly define what needs to be improved: + - Code duplication? + - Complex conditionals? + - Poor naming? + - Missing type safety? + - Tight coupling? + - Large files/functions? +- Identify the scope and boundaries of the refactoring. + +--- + +### 2. Analyze Current State + +- Review the existing code structure. +- Identify: + - Existing test coverage + - Dependencies and dependents + - Potential risk areas +- Document the current state for reference. + +--- + +### 3. Ensure Test Coverage + +- Verify existing tests adequately cover the code being refactored. +- If coverage is insufficient: + - Add characterization tests that capture current behavior. + - These tests act as a safety net during refactoring. +- Tests must pass before any refactoring begins. + +--- + +### 4. Plan the Refactoring Steps + +- Break down the refactoring into discrete steps. +- Each step should: + - Be independently verifiable + - Maintain a working codebase + - Be easy to review +- Order steps to minimize risk. + +--- + +### 5. Execute Refactoring Incrementally + +For each step: + +1. Make the targeted change. +2. Run tests to verify behavior preservation. +3. Run lint and type checking. +4. Commit with a clear message explaining the change. + +Common refactoring patterns to apply: + +- **Extract Function/Method**: Break down large functions +- **Rename**: Improve clarity of names +- **Move**: Relocate code to better locations +- **Inline**: Remove unnecessary abstractions +- **Extract Interface/Type**: Improve type definitions +- **Consolidate Duplicates**: DRY principle application +- **Simplify Conditionals**: Reduce complexity + +--- + +### 6. Validate Final State + +After all refactoring steps: + +- All tests pass. +- Lint passes. +- TypeScript compilation passes. +- Build succeeds. +- Code review checklist: + - Is the code more readable? + - Is the code more maintainable? + - Is the structure improved? + - Are there any regressions? + +--- + +### 7. Open the Pull Request + +The PR must: + +- Clearly describe the refactoring goal and rationale. +- List the refactoring patterns applied. +- Confirm that no behavioral changes were introduced. +- Reference any related issues or technical debt items. +- Include before/after examples if helpful. + +--- + +## Refactoring Categories + +### Structural Refactoring +- File/folder reorganization +- Module extraction +- Dependency restructuring + +### Code Quality Refactoring +- Naming improvements +- Function decomposition +- Duplication removal +- Complexity reduction + +### Type Safety Refactoring +- Adding explicit types +- Removing `any` types +- Improving generic usage +- Adding type guards + +### Pattern Application +- Applying design patterns +- Removing anti-patterns +- Standardizing approaches + +--- + +## Non-Goals + +This agent must NOT: + +- Change external behavior +- Fix bugs (create separate issues) +- Add new features +- Optimize performance (unless part of explicit refactoring goal) +- Make changes outside the defined scope +- Skip quality gate verification + +--- + +## Success Criteria + +A successful refactoring results in: + +- Improved code quality metrics (readability, maintainability) +- All tests passing (no behavioral changes) +- Passing CI (tests, lint, TypeScript) +- Clear documentation of changes +- Easy-to-review Pull Request +- No new bugs introduced diff --git a/.github/workflows/auto-close-duplicates.yml b/.github/workflows/auto-close-duplicates.yml new file mode 100644 index 0000000000000..6f24ce2028b8a --- /dev/null +++ b/.github/workflows/auto-close-duplicates.yml @@ -0,0 +1,31 @@ +name: Auto-close duplicate issues +description: Auto-closes issues that are duplicates of existing issues +on: + schedule: + - cron: '0 9 * * *' + workflow_dispatch: + +jobs: + auto-close-duplicates: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + issues: write + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Auto-close duplicate issues + run: bun run scripts/auto-close-duplicates.ts + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} + GITHUB_REPOSITORY_NAME: ${{ github.event.repository.name }} + STATSIG_API_KEY: ${{ secrets.STATSIG_API_KEY }} diff --git a/.github/workflows/ci-code-check.yml b/.github/workflows/ci-code-check.yml index 52e3b7c085d9d..2b522a1c32565 100644 --- a/.github/workflows/ci-code-check.yml +++ b/.github/workflows/ci-code-check.yml @@ -44,7 +44,7 @@ jobs: - uses: rharkor/caching-for-turbo@v1.8 - name: Restore packages build - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: packages-build path: /tmp @@ -56,7 +56,6 @@ jobs: - name: Cache TypeCheck uses: actions/cache@v5 - if: matrix.check == 'ts' with: path: ./apps/meteor/tsconfig.typecheck.tsbuildinfo key: typecheck-cache-${{ runner.OS }}-${{ hashFiles('yarn.lock') }}-${{ github.event.issue.number }} @@ -66,7 +65,6 @@ jobs: typecheck-cache - name: Install Meteor - if: matrix.check == 'ts' shell: bash run: | # Restore bin from cache diff --git a/.github/workflows/ci-test-e2e.yml b/.github/workflows/ci-test-e2e.yml index 0e15ee6c9fb5a..4838332eff2b7 100644 --- a/.github/workflows/ci-test-e2e.yml +++ b/.github/workflows/ci-test-e2e.yml @@ -74,7 +74,7 @@ jobs: # if building for production on develop branch or release, add suffix for coverage images DOCKER_TAG_SUFFIX_ROCKETCHAT: ${{ inputs.coverage == matrix.mongodb-version && (github.event_name == 'release' || github.ref == 'refs/heads/develop') && '-cov' || '' }} MONGODB_VERSION: ${{ matrix.mongodb-version }} - COVERAGE_DIR: '/tmp/coverage/${{ inputs.type }}' + COVERAGE_DIR: '/tmp/coverage/${{ startsWith(inputs.type, ''api'') && ''api'' || inputs.type }}' COVERAGE_FILE_NAME: '${{ inputs.type }}-${{ matrix.shard }}.json' COVERAGE_REPORTER: ${{ inputs.coverage == matrix.mongodb-version && 'json' || '' }} @@ -126,7 +126,7 @@ jobs: - uses: rharkor/caching-for-turbo@v1.8 - name: Restore packages build - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: packages-build path: /tmp @@ -138,7 +138,7 @@ jobs: # Download Docker images from build artifacts - name: Download Docker images - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 if: github.event.pull_request.head.repo.full_name != github.repository && github.event_name != 'release' && github.ref != 'refs/heads/develop' with: pattern: ${{ inputs.release == 'ce' && 'docker-image-rocketchat-amd64-coverage' || 'docker-image-*-amd64-coverage' }} @@ -169,7 +169,7 @@ jobs: run: echo "DEBUG_LOG_LEVEL=2" >> $GITHUB_ENV - name: Start httpbin container and wait for it to be ready - if: inputs.type == 'api' + if: inputs.type == 'api' || inputs.type == 'api-livechat' run: | docker compose -f docker-compose-ci.yml up -d httpbin @@ -227,6 +227,22 @@ jobs: ls -la $COVERAGE_DIR exit $s + - name: E2E Test API (Livechat) + if: inputs.type == 'api-livechat' + working-directory: ./apps/meteor + env: + WEBHOOK_TEST_URL: 'http://httpbin' + IS_EE: ${{ inputs.release == 'ee' && 'true' || '' }} + run: | + set -o xtrace + + npm run testapi:livechat + + docker compose -f ../../docker-compose-ci.yml stop + + ls -la $COVERAGE_DIR + exit $s + - name: E2E Test UI (${{ matrix.shard }}/${{ inputs.total-shard }}) if: inputs.type == 'ui' env: @@ -263,7 +279,7 @@ jobs: - name: Store playwright test trace if: inputs.type == 'ui' && always() - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: playwright-test-trace-${{ inputs.release }}-${{ matrix.mongodb-version }}-${{ matrix.shard }}${{ inputs.db-watcher-disabled == 'true' && '-no-watcher' || '' }} path: ./apps/meteor/tests/e2e/.playwright* @@ -279,7 +295,7 @@ jobs: - name: Store coverage if: inputs.coverage == matrix.mongodb-version - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: coverage-${{ inputs.type }}-${{ matrix.shard }} path: /tmp/coverage diff --git a/.github/workflows/ci-test-storybook.yml b/.github/workflows/ci-test-storybook.yml index 0606607121db3..f1442829c2438 100644 --- a/.github/workflows/ci-test-storybook.yml +++ b/.github/workflows/ci-test-storybook.yml @@ -38,7 +38,7 @@ jobs: - uses: rharkor/caching-for-turbo@v1.8 - name: Restore packages build - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: packages-build path: /tmp diff --git a/.github/workflows/ci-test-unit.yml b/.github/workflows/ci-test-unit.yml index 7d249f60ff48e..3989da7c8a9df 100644 --- a/.github/workflows/ci-test-unit.yml +++ b/.github/workflows/ci-test-unit.yml @@ -42,7 +42,7 @@ jobs: - uses: rharkor/caching-for-turbo@v1.8 - name: Restore packages build - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: packages-build path: /tmp @@ -53,7 +53,7 @@ jobs: tar -xzf /tmp/RocketChat-packages-build.tar.gz -C . - name: Unit Test - run: yarn testunit + run: yarn testunit --concurrency=1 - uses: codecov/codecov-action@v5 with: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 389be8c766bdf..6250eb56e37f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,7 @@ on: branches: '**' paths-ignore: - '**.md' + merge_group: push: branches: - develop @@ -116,6 +117,8 @@ jobs: else DOCKER_TAG=$GITHUB_REF_NAME fi + # Docker tags cannot contain '/'; merge queue refs do (e.g. gh-readonly-queue/develop/pr-123-sha) + DOCKER_TAG="${DOCKER_TAG//\//-}" echo "DOCKER_TAG: ${DOCKER_TAG}" echo "gh-docker-tag=${DOCKER_TAG}" >> $GITHUB_OUTPUT @@ -220,7 +223,7 @@ jobs: $(git ls-files -oi --exclude-standard -- ':(exclude)node_modules/*' ':(exclude)**/node_modules/*' ':(exclude)**/.meteor/*' ':(exclude)**/.turbo/*' ':(exclude).turbo/*' ':(exclude)**/.yarn/*' ':(exclude).yarn/*' ':(exclude).git/*') - name: Upload packages build artifact - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: packages-build path: /tmp/RocketChat-packages-build.tar.gz @@ -228,7 +231,7 @@ jobs: - name: Store turbo build if: steps.packages-cache-build.outputs.cache-hit != 'true' - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: turbo-build path: .turbo/cache @@ -250,7 +253,6 @@ jobs: - type: ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'production' || '' }} steps: - - uses: actions/checkout@v6 - uses: ./.github/actions/meteor-build @@ -295,7 +297,7 @@ jobs: - uses: actions/checkout@v6 - name: Restore packages build - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: packages-build path: /tmp @@ -394,7 +396,7 @@ jobs: - name: Download manifests if: github.actor != 'dependabot[bot]' && (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop') - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: pattern: manifests-* path: /tmp/manifests @@ -508,6 +510,22 @@ jobs: CR_USER: ${{ secrets.CR_USER }} CR_PAT: ${{ secrets.CR_PAT }} + test-api-livechat: + name: 🔨 Test API Livechat (CE) + needs: [checks, build-gh-docker-publish, release-versions] + + uses: ./.github/workflows/ci-test-e2e.yml + with: + type: api-livechat + release: ce + node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} + lowercase-repo: ${{ needs.release-versions.outputs.lowercase-repo }} + gh-docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + secrets: + CR_USER: ${{ secrets.CR_USER }} + CR_PAT: ${{ secrets.CR_PAT }} + test-ui: name: 🔨 Test UI (CE) needs: [checks, build-gh-docker-publish, release-versions] @@ -553,6 +571,26 @@ jobs: CR_USER: ${{ secrets.CR_USER }} CR_PAT: ${{ secrets.CR_PAT }} + test-api-livechat-ee: + name: 🔨 Test API Livechat (EE) + needs: [checks, build-gh-docker-publish, release-versions] + + uses: ./.github/workflows/ci-test-e2e.yml + with: + type: api-livechat + release: ee + transporter: 'nats://nats:4222' + enterprise-license: ${{ needs.release-versions.outputs.enterprise-license }} + mongodb-version: "['8.0']" + coverage: '8.0' + node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} + lowercase-repo: ${{ needs.release-versions.outputs.lowercase-repo }} + gh-docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + secrets: + CR_USER: ${{ secrets.CR_USER }} + CR_PAT: ${{ secrets.CR_PAT }} + test-ui-ee: name: 🔨 Test UI (EE) needs: [checks, build-gh-docker-publish, release-versions] @@ -600,7 +638,7 @@ jobs: - uses: rharkor/caching-for-turbo@v1.8 - name: Restore turbo build - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 continue-on-error: true with: name: turbo-build @@ -624,7 +662,7 @@ jobs: # Download Docker images from build artifacts - name: Download Docker images - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 if: github.event.pull_request.head.repo.full_name != github.repository && github.event_name != 'release' && github.ref != 'refs/heads/develop' with: pattern: 'docker-image-rocketchat-amd64-coverage' @@ -679,18 +717,18 @@ jobs: report-coverage: name: 📊 Report Coverage runs-on: ubuntu-24.04 - needs: [release-versions, test-api-ee, test-ui-ee] + needs: [release-versions, test-api-ee, test-api-livechat-ee, test-ui-ee] steps: - uses: actions/checkout@v6 - name: Use Node.js - uses: actions/setup-node@v6.1.0 + uses: actions/setup-node@v6.2.0 with: node-version: ${{ needs.release-versions.outputs.node-version }} - name: Restore coverage folder - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: pattern: coverage-* path: /tmp/coverage @@ -704,7 +742,7 @@ jobs: npx nyc report --reporter=lcovonly --report-dir=/tmp/coverage_report/ui --temp-dir=/tmp/coverage/ui - name: Store coverage-reports - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: reports-coverage path: /tmp/coverage_report @@ -731,7 +769,7 @@ jobs: tests-done: name: ✅ Tests Done runs-on: ubuntu-24.04-arm - needs: [checks, test-unit, test-api, test-ui, test-api-ee, test-ui-ee, test-federation-matrix] + needs: [checks, test-unit, test-api, test-ui, test-api-ee, test-ui-ee, test-api-livechat, test-api-livechat-ee, test-federation-matrix] if: always() steps: - name: Test finish aggregation @@ -760,6 +798,14 @@ jobs: exit 1 fi + if [[ '${{ needs.test-api-livechat.result }}' != 'success' ]]; then + exit 1 + fi + + if [[ '${{ needs.test-api-livechat-ee.result }}' != 'success' ]]; then + exit 1 + fi + if [[ '${{ needs.test-federation-matrix.result }}' != 'success' ]]; then exit 1 fi @@ -781,7 +827,7 @@ jobs: ref: ${{ github.ref }} - name: Restore build - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: build-production path: /tmp/build @@ -852,7 +898,7 @@ jobs: - name: Download manifests if: github.actor != 'dependabot[bot]' && (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop') - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: pattern: manifests-* path: /tmp/manifests @@ -869,6 +915,7 @@ jobs: # 'develop' or 'tag' DOCKER_TAG=$GITHUB_REF_NAME + DOCKER_TAG="${DOCKER_TAG//\//-}" declare -a TAGS=() diff --git a/.github/workflows/dedupe-issues.yml b/.github/workflows/dedupe-issues.yml new file mode 100644 index 0000000000000..3c44d475ef671 --- /dev/null +++ b/.github/workflows/dedupe-issues.yml @@ -0,0 +1,83 @@ +name: Rocket.Chat Issue Dedupe +description: Automatically dedupe GitHub issues using AI +on: + issues: + types: [opened] + workflow_dispatch: + inputs: + issue_number: + description: 'Issue number to process for duplicate detection' + required: true + type: string + +jobs: + dedupe-issues: + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + issues: write + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Run Claude Code slash command + uses: anthropics/claude-code-base-action@beta + with: + prompt: '/dedupe ${{ github.repository }}/issues/${{ github.event.issue.number || inputs.issue_number }}' + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + claude_args: '--model claude-sonnet-4-5-20250929' + claude_env: | + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Log duplicate comment event to Statsig + if: always() + env: + STATSIG_API_KEY: ${{ secrets.STATSIG_API_KEY }} + run: | + ISSUE_NUMBER=${{ github.event.issue.number || inputs.issue_number }} + REPO=${{ github.repository }} + + if [ -z "$STATSIG_API_KEY" ]; then + echo "STATSIG_API_KEY not found, skipping Statsig logging" + exit 0 + fi + + # Prepare the event payload + EVENT_PAYLOAD=$(jq -n \ + --arg issue_number "$ISSUE_NUMBER" \ + --arg repo "$REPO" \ + --arg triggered_by "${{ github.event_name }}" \ + --arg actor "${{ github.actor }}" \ + '{ + events: [{ + eventName: "github_duplicate_comment_added", + value: 1, + metadata: { + repository: $repo, + issue_number: ($issue_number | tonumber), + triggered_by: $triggered_by, + workflow_run_id: "${{ github.run_id }}", + actor: $actor + }, + time: (now | floor | tostring) + }] + }') + + # Send to Statsig API + echo "Logging duplicate comment event to Statsig for issue #${ISSUE_NUMBER}" + + RESPONSE=$(curl -s -w "\n%{http_code}" -X POST https://events.statsigapi.net/v1/log_event \ + -H "Content-Type: application/json" \ + -H "STATSIG-API-KEY: ${STATSIG_API_KEY}" \ + -d "$EVENT_PAYLOAD") + + HTTP_CODE=$(echo "$RESPONSE" | tail -n1) + BODY=$(echo "$RESPONSE" | head -n-1) + + if [ "$HTTP_CODE" -eq 200 ] || [ "$HTTP_CODE" -eq 202 ]; then + echo "Successfully logged duplicate comment event for issue #${ISSUE_NUMBER}" + else + echo "Failed to log duplicate comment event for issue #${ISSUE_NUMBER}. HTTP ${HTTP_CODE}: ${BODY}" + fi diff --git a/.github/workflows/todo.yml b/.github/workflows/todo.yml new file mode 100644 index 0000000000000..cee1e97a864e0 --- /dev/null +++ b/.github/workflows/todo.yml @@ -0,0 +1,58 @@ +name: Create issues from TODOs + +on: + workflow_dispatch: + inputs: + importAll: + default: false + required: false + type: boolean + description: Enable, if you want to import all TODOs. Runs on checked out branch! Only use if you're sure what you are doing. + sha: + default: '' + required: false + type: string + description: 'A commit SHA or range (e.g. "abc123" or "abc123...def456"). Single SHA compares against its parent.' + path: + default: '' + required: false + type: string + description: 'Import TODOs from a specific path (e.g. "apps/meteor/client" or "packages/core-typings/src/IMessage.ts").' + push: + branches: # do not set multiple branches, todos might be added and then get referenced by themselves in case of a merge + - develop + +permissions: + issues: write + repository-projects: read + contents: read + +jobs: + todos: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v6 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + working-directory: scripts/todo-issue + + - name: Create issues from TODOs + run: bun run src/index.ts + working-directory: scripts/todo-issue + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} + GITHUB_REPOSITORY_NAME: ${{ github.event.repository.name }} + GITHUB_SHA: ${{ github.sha }} + BEFORE_SHA: ${{ github.event.before }} + IMPORT_ALL: ${{ inputs.importAll || 'false' }} + SHA_INPUT: ${{ inputs.sha || '' }} + PATH_FILTER: ${{ inputs.path || '' }} diff --git a/.github/workflows/update-version-durability.yml b/.github/workflows/update-version-durability.yml index 00e1ee8125ad6..46f9fbb0c3b0e 100644 --- a/.github/workflows/update-version-durability.yml +++ b/.github/workflows/update-version-durability.yml @@ -21,7 +21,7 @@ jobs: - uses: actions/checkout@v6 - name: Use Node.js - uses: actions/setup-node@v6.1.0 + uses: actions/setup-node@v6.2.0 with: node-version: 22.16.0 diff --git a/.vscode/settings.json b/.vscode/settings.json index 2dcd055310d14..9bc41d08b8468 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,22 +1,4 @@ { - "eslint.workingDirectories": [ - { - "pattern": "packages/*", - "changeProcessCWD": true - }, - { - "pattern": "apps/*", - "changeProcessCWD": true - }, - { - "pattern": "ee/apps/*", - "changeProcessCWD": true - }, - { - "pattern": "ee/packages/*", - "changeProcessCWD": true - } - ], "typescript.tsdk": "./node_modules/typescript/lib", "cSpell.words": [ "autotranslate", diff --git a/README.md b/README.md index f663b0113e1a0..51c049b0fcfee 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ We're hiring developers, technical support, and product managers all the time. C - [Twitter](https://twitter.com/RocketChat) - [Facebook](https://www.facebook.com/RocketChatApp) - [LinkedIn](https://www.linkedin.com/company/rocket-chat) -- [Youtube](https://www.youtube.com/channel/UCin9nv7mUjoqrRiwrzS5UVQ) +- [YouTube](https://www.youtube.com/channel/UCin9nv7mUjoqrRiwrzS5UVQ) # 🗒️ Credits diff --git a/apps/meteor/.eslintrc.json b/apps/meteor/.eslintrc.json deleted file mode 100644 index 47376a4e7fddf..0000000000000 --- a/apps/meteor/.eslintrc.json +++ /dev/null @@ -1,156 +0,0 @@ -{ - "extends": [ - "@rocket.chat/eslint-config", - "@rocket.chat/eslint-config/react", - "plugin:you-dont-need-lodash-underscore/compatible", - "plugin:storybook/recommended" - ], - "globals": { - "__meteor_bootstrap__": false, - "__meteor_runtime_config__": false, - "Assets": false, - "chrome": false, - "jscolor": false - }, - "rules": { - "import/named": "error", - "react-hooks/exhaustive-deps": [ - "warn", - { - "additionalHooks": "(useComponentDidUpdate)" - } - ], - "prefer-arrow-callback": [ - "error", - { - "allowNamedFunctions": true - } - ] - }, - "ignorePatterns": [ - "app/emoji-emojione/generateEmojiIndex.js", - "public", - "private/moment-locales", - "imports", - "ee/server/services/dist", - "!.mocharc.js", - "!.mocharc.*.js", - "!.scripts", - "!.storybook", - "!client/.eslintrc.js", - "!ee/client/.eslintrc.js", - "storybook-static", - "packages" - ], - "overrides": [ - { - "files": ["**/*.ts", "**/*.tsx"], - "rules": { - "@typescript-eslint/naming-convention": [ - "error", - { - "selector": ["function", "parameter", "variable"], - "modifiers": ["destructured"], - "format": null - }, - { - "selector": ["variable"], - "format": ["camelCase", "UPPER_CASE", "PascalCase"], - "leadingUnderscore": "allowSingleOrDouble" - }, - { - "selector": ["function"], - "format": ["camelCase", "PascalCase"], - "leadingUnderscore": "allowSingleOrDouble" - }, - { - "selector": ["parameter"], - "format": ["PascalCase"], - "filter": { - "regex": "Component$", - "match": true - } - }, - { - "selector": ["parameter"], - "format": ["camelCase"], - "leadingUnderscore": "allow" - }, - { - "selector": ["parameter"], - "format": ["camelCase"], - "modifiers": ["unused"], - "leadingUnderscore": "require" - }, - { - "selector": "parameter", - "format": null, - "filter": { - "regex": "^Story$", - "match": true - } - }, - { - "selector": ["interface"], - "format": ["PascalCase"], - "custom": { - "regex": "^I[A-Z]", - "match": true - } - } - ], - "no-unreachable-loop": "error" - }, - "parserOptions": { - "project": ["./tsconfig.json"] - }, - "excludedFiles": [".scripts/*.ts"] - }, - { - "files": ["**/*.tests.js", "**/*.tests.ts", "**/*.spec.ts"], - "env": { - "mocha": true - } - }, - { - "files": ["**/*.spec.ts", "**/*.spec.tsx"], - "extends": ["plugin:testing-library/react"], - "rules": { - "testing-library/no-await-sync-events": "warn", - "testing-library/no-manual-cleanup": "warn", - "testing-library/prefer-explicit-assert": "warn", - "testing-library/prefer-user-event": "warn" - }, - "env": { - "mocha": true - } - }, - { - "files": ["**/*.stories.js", "**/*.stories.jsx", "**/*.stories.ts", "**/*.stories.tsx", "**/*.spec.tsx"], - "rules": { - "react/display-name": "off", - "react/no-multi-comp": "off" - } - }, - { - "files": ["**/*.stories.ts", "**/*.stories.tsx"], - "rules": { - "@typescript-eslint/explicit-function-return-type": "off", - "@typescript-eslint/explicit-module-boundary-types": "off" - } - }, - { - "files": ["client/**/*.ts", "client/**/*.tsx", "ee/client/**/*.ts", "ee/client/**/*.tsx"], - "rules": { - "@typescript-eslint/no-misused-promises": "off", - "@typescript-eslint/no-floating-promises": "off" - } - }, - { - "files": ["**/*.d.ts"], - "rules": { - "@typescript-eslint/naming-convention": "off" - } - } - ] -} diff --git a/apps/meteor/.mocharc.api.js b/apps/meteor/.mocharc.api.js index b73a24a275e43..6bccb8c9a06df 100644 --- a/apps/meteor/.mocharc.api.js +++ b/apps/meteor/.mocharc.api.js @@ -10,5 +10,5 @@ module.exports = /** @satisfies {import('mocha').MochaOptions} */ ({ bail: true, retries: 0, file: 'tests/end-to-end/teardown.ts', - spec: ['tests/end-to-end/api/**/*', 'tests/end-to-end/apps/*'], + spec: ['tests/end-to-end/api/*.ts', 'tests/end-to-end/api/helpers/**/*', 'tests/end-to-end/api/methods/**/*', 'tests/end-to-end/apps/*'], }); diff --git a/apps/meteor/.mocharc.api.livechat.js b/apps/meteor/.mocharc.api.livechat.js new file mode 100644 index 0000000000000..48a3a13e2751f --- /dev/null +++ b/apps/meteor/.mocharc.api.livechat.js @@ -0,0 +1,14 @@ +'use strict'; + +/* + * Mocha configuration for Livechat REST API integration tests. + */ + +module.exports = /** @satisfies {import('mocha').MochaOptions} */ ({ + ...require('./.mocharc.base.json'), // see https://github.com/mochajs/mocha/issues/3916 + timeout: 10000, + bail: true, + retries: 0, + file: 'tests/end-to-end/teardown.ts', + spec: ['tests/end-to-end/api/livechat/**/*'], +}); diff --git a/apps/meteor/.mocharc.js b/apps/meteor/.mocharc.js index 2ec06b5257b65..11d408b22a601 100644 --- a/apps/meteor/.mocharc.js +++ b/apps/meteor/.mocharc.js @@ -30,6 +30,7 @@ module.exports = { 'app/file-upload/server/**/*.spec.ts', 'app/statistics/server/**/*.spec.ts', 'app/livechat/server/lib/**/*.spec.ts', + 'app/push/server/**/*.spec.ts', 'app/utils/server/**/*.spec.ts', ], }; diff --git a/apps/meteor/CHANGELOG.md b/apps/meteor/CHANGELOG.md index 860e06c2b96d9..487f19458488f 100644 --- a/apps/meteor/CHANGELOG.md +++ b/apps/meteor/CHANGELOG.md @@ -1,5 +1,135 @@ # @rocket.chat/meteor +## 8.3.0-rc.0 + +### Minor Changes + +- ([#38978](https://github.com/RocketChat/Rocket.Chat/pull/38978) by [@ahmed-n-abdeltwab](https://github.com/ahmed-n-abdeltwab)) Add OpenAPI support for the Rocket.Chat autotranslate translateMessage API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation + +- ([#39225](https://github.com/RocketChat/Rocket.Chat/pull/39225) by [@sezallagwal](https://github.com/sezallagwal)) Add OpenAPI support for the chat.followMessage and chat.unfollowMessage API endpoints by migrating to a modern chained route definition syntax and utilizing AJV schemas for body and response validation. + +- ([#39227](https://github.com/RocketChat/Rocket.Chat/pull/39227) by [@sezallagwal](https://github.com/sezallagwal)) Add OpenAPI support for the chat.starMessage and chat.unStarMessage API endpoints by migrating to a modern chained route definition syntax and utilizing AJV schemas for body and response validation. + +- ([#38957](https://github.com/RocketChat/Rocket.Chat/pull/38957) by [@Verifieddanny](https://github.com/Verifieddanny)) Migrated rooms.leave endpoint to new OpenAPI pattern with AJV validation + +- ([#38549](https://github.com/RocketChat/Rocket.Chat/pull/38549) by [@Rohitgiri02](https://github.com/Rohitgiri02)) migrated rooms.delete endpoint to new OpenAPI pattern with AJV validation + +- ([#39094](https://github.com/RocketChat/Rocket.Chat/pull/39094) by [@ahmed-n-abdeltwab](https://github.com/ahmed-n-abdeltwab)) Adds OpenAPI support for the Rocket.Chat e2e.updateGroupKey endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. + +- ([#36402](https://github.com/RocketChat/Rocket.Chat/pull/36402) by [@ahmed-n-abdeltwab](https://github.com/ahmed-n-abdeltwab)) Add OpenAPI support for the Rocket.Chat users.getAvatarSuggestion API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. + +- ([#38881](https://github.com/RocketChat/Rocket.Chat/pull/38881) by [@smirk-dev](https://github.com/smirk-dev)) adds `instances.get` API endpoint to new chained pattern with response schemas + +- ([#38883](https://github.com/RocketChat/Rocket.Chat/pull/38883) by [@smirk-dev](https://github.com/smirk-dev)) Migrates `ldap.testConnection` and `ldap.testSearch` REST API endpoints from legacy `addRoute` pattern to the new chained `.post()` API pattern with typed response schemas and AJV body validation (replacing Meteor `check()`). + +- ([#38882](https://github.com/RocketChat/Rocket.Chat/pull/38882) by [@smirk-dev](https://github.com/smirk-dev)) Migrates `presence.getConnections` and `presence.enableBroadcast` REST API endpoints from legacy `addRoute` pattern to the new chained `.get()`/`.post()` API pattern with typed response schemas. + +- ([#38610](https://github.com/RocketChat/Rocket.Chat/pull/38610)) Fixes Custom Sounds Contextualbar state and refresh behavior + +- ([#36779](https://github.com/RocketChat/Rocket.Chat/pull/36779) by [@ahmed-n-abdeltwab](https://github.com/ahmed-n-abdeltwab)) Add OpenAPI support for the Rocket.Chat e2e.fetchMyKeys endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. + +- ([#36916](https://github.com/RocketChat/Rocket.Chat/pull/36916) by [@ahmed-n-abdeltwab](https://github.com/ahmed-n-abdeltwab)) Add OpenAPI support for the Rocket.Chat custom-user-status.list API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation + +- ([#39219](https://github.com/RocketChat/Rocket.Chat/pull/39219) by [@ahmed-n-abdeltwab](https://github.com/ahmed-n-abdeltwab)) Add OpenAPI support for the Rocket.Chat e2e endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. + +- ([#38610](https://github.com/RocketChat/Rocket.Chat/pull/38610)) Adds new `custom-sounds.getOne` REST endpoint to retrieve a single custom sound by `_id` and updates client to consume it. + +### Patch Changes + +- ([#39010](https://github.com/RocketChat/Rocket.Chat/pull/39010)) Fixes an authorization issue that allowed users to confirm uploads from other users + +- ([#38531](https://github.com/RocketChat/Rocket.Chat/pull/38531)) Fixes a cross-resource access issue that allowed users to retrieve emojis from the Custom Sounds endpoint and sounds from the Custom Emojis endpoint when using the FileSystem storage mode. + +- ([#38662](https://github.com/RocketChat/Rocket.Chat/pull/38662) by [@TheRazorbill](https://github.com/TheRazorbill)) Fixes wrong i18n key in RegisterWorkspace confirmation step so the text is translated instead of showing a missing key. + +- ([#38983](https://github.com/RocketChat/Rocket.Chat/pull/38983) by [@copilot-swe-agent](https://github.com/copilot-swe-agent)) Fixes incoming webhook messages ignoring literal `\n` escape sequences, and fixes the `MarkdownText` `document` variant not rendering newlines as line breaks. + +- ([#38989](https://github.com/RocketChat/Rocket.Chat/pull/38989)) chore(eslint): Upgrades ESLint and its configuration + +- ([#39003](https://github.com/RocketChat/Rocket.Chat/pull/39003)) Fix marking a message as sent before the request finishes + +- ([#36786](https://github.com/RocketChat/Rocket.Chat/pull/36786) by [@ahmed-n-abdeltwab](https://github.com/ahmed-n-abdeltwab)) Add OpenAPI support for the Rocket.Chat e2e.getUsersOfRoomWithoutKey endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. + +- ([#38932](https://github.com/RocketChat/Rocket.Chat/pull/38932)) Fixes version update banner showing outdated versions after server upgrade. + +- ([#38760](https://github.com/RocketChat/Rocket.Chat/pull/38760) by [@Khizarshah01](https://github.com/Khizarshah01)) Limits `Outgoing webhook` maximum response size to 10mb. + +- ([#36882](https://github.com/RocketChat/Rocket.Chat/pull/36882) by [@ahmed-n-abdeltwab](https://github.com/ahmed-n-abdeltwab)) Add OpenAPI support for the Rocket.Chat push.test API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. + +- ([#39250](https://github.com/RocketChat/Rocket.Chat/pull/39250)) Fixes `inquiries.take` not failing when attempting to take a chat while over chat limits + +- ([#38852](https://github.com/RocketChat/Rocket.Chat/pull/38852)) Fixes an issue where `Production` flag was not being respected when initializing Push Notifications configuration + +- ([#38944](https://github.com/RocketChat/Rocket.Chat/pull/38944) by [@Khizarshah01](https://github.com/Khizarshah01)) Limits Omnichannel webhook maximum response size to 10mb. + +- ([#38954](https://github.com/RocketChat/Rocket.Chat/pull/38954)) Fixes reactivity of Custom Sounds and Custom Emojis storage settings + +- ([#35995](https://github.com/RocketChat/Rocket.Chat/pull/35995) by [@ahmed-n-abdeltwab](https://github.com/ahmed-n-abdeltwab)) Add OpenAPI support for the Rocket.Chat rooms.favorite APIs endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. + +- ([#36523](https://github.com/RocketChat/Rocket.Chat/pull/36523) by [@ahmed-n-abdeltwab](https://github.com/ahmed-n-abdeltwab)) Add OpenAPI support for the Rocket.Chat emoji-custom.create API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. + +- ([#36953](https://github.com/RocketChat/Rocket.Chat/pull/36953) by [@ahmed-n-abdeltwab](https://github.com/ahmed-n-abdeltwab)) Add OpenAPI support for the Rocket.Chat commands.get API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. + +- ([#38974](https://github.com/RocketChat/Rocket.Chat/pull/38974) by [@ahmed-n-abdeltwab](https://github.com/ahmed-n-abdeltwab)) Add OpenAPI support for the Rocket.Chat dm.close/im.close API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. + +-
Updated dependencies [602b20a8c570b895eb296ecfe39c9b7fcb12fabd, d1bf2cc675e80403659d388a1fbbdc6f73889dad, 02b1e6e6a184850d21e335077ca30382a1c7a66b, 9a70095296dbf516b0113a9a65e09f25137b2eaf, a4e3c1635d55ec4ce04cbde741426770e43581fb, 539659af22bc19880eda047dfc0b152472ccb65c, b1b1d6ccd81c90d231a7e594f834965c6e5f4fae, 5518503736b72674753e711ba4089d177ab988a5, a4341ec67d1f0413f30bbabfd292d1b0a41728b2, 40253146de8d8f83737e71b0ade7c67e0c295a28, 85c0ac7d8c7a5b7b89ef58f4a42b18467a8e2dd4, 803b8075514de54c9ff34ba0c9aa3ee5fc3bbe61, 1361a1f4f1e3c0cc3f2a191cef8eccc12a714cde, 2a2701098536b32143003be8d267891978c708c9, 37acece030bc9f39bdaa86ab0130eb818332033e, d8baf395181b70fef9ce448eb509f65b66049615, ddc0ed34b03072362d166f1160104a9332b362e8, 722df6f60bc86c51b204e28a39acb3dc8710bdeb, 78b3fe3ef20e3a545b84551ba3f85cb40e862ba7, 98a6c58a38c053c60db2b4d53a9df0e94fecf0ba, 29b453e1def8092a8d78c28736e2bfb24229717b, 39f2e87e1caa6842e69155f033205cfdc4767b9e, c117492ad90d291a361eedc929506f557495caf7, 7c7324184589a15bf3e67b4f0c1cc222f8d48db3]: + + - @rocket.chat/model-typings@2.1.1-rc.0 + - @rocket.chat/models@2.1.1-rc.0 + - @rocket.chat/message-parser@0.31.35-rc.0 + - @rocket.chat/rest-typings@8.3.0-rc.0 + - @rocket.chat/server-cloud-communication@0.0.3-rc.0 + - @rocket.chat/omnichannel-services@0.3.49-rc.0 + - @rocket.chat/federation-matrix@0.0.14-rc.0 + - @rocket.chat/web-ui-registration@29.0.0-rc.0 + - @rocket.chat/network-broker@0.2.31-rc.0 + - @rocket.chat/password-policies@0.1.1-rc.0 + - @rocket.chat/omni-core-ee@0.0.17-rc.0 + - @rocket.chat/fuselage-ui-kit@29.0.0-rc.0 + - @rocket.chat/instance-status@0.1.52-rc.0 + - @rocket.chat/media-signaling@0.1.2-rc.0 + - @rocket.chat/patch-injection@0.0.2-rc.0 + - @rocket.chat/media-calls@0.2.5-rc.0 + - @rocket.chat/pdf-worker@0.3.31-rc.0 + - @rocket.chat/ui-theming@0.4.5-rc.0 + - @rocket.chat/account-utils@0.0.3-rc.0 + - @rocket.chat/core-services@0.13.1-rc.0 + - @rocket.chat/message-types@0.1.1-rc.0 + - @rocket.chat/mongo-adapter@0.0.3-rc.0 + - @rocket.chat/ui-video-conf@29.0.0-rc.0 + - @rocket.chat/cas-validate@0.0.4-rc.0 + - @rocket.chat/core-typings@8.3.0-rc.0 + - @rocket.chat/server-fetch@0.1.1-rc.0 + - @rocket.chat/presence@0.2.52-rc.0 + - @rocket.chat/apps-engine@1.60.1-rc.0 + - @rocket.chat/http-router@7.9.19-rc.0 + - @rocket.chat/poplib@0.0.3-rc.0 + - @rocket.chat/ui-composer@0.5.4-rc.0 + - @rocket.chat/ui-contexts@29.0.0-rc.0 + - @rocket.chat/license@1.1.12-rc.0 + - @rocket.chat/api-client@0.2.52-rc.0 + - @rocket.chat/log-format@0.0.3-rc.0 + - @rocket.chat/gazzodown@29.0.0-rc.0 + - @rocket.chat/omni-core@0.0.17-rc.0 + - @rocket.chat/ui-avatar@25.0.0-rc.0 + - @rocket.chat/ui-client@29.0.0-rc.0 + - @rocket.chat/abac@0.1.5-rc.0 + - @rocket.chat/favicon@0.0.5-rc.0 + - @rocket.chat/tracing@0.0.2-rc.0 + - @rocket.chat/ui-voip@19.0.0-rc.0 + - @rocket.chat/agenda@0.1.1-rc.0 + - @rocket.chat/base64@1.0.14-rc.0 + - @rocket.chat/logger@1.0.1-rc.0 + - @rocket.chat/random@1.2.3-rc.0 + - @rocket.chat/sha256@1.0.13-rc.0 + - @rocket.chat/ui-kit@0.39.1-rc.0 + - @rocket.chat/tools@0.2.5-rc.0 + - @rocket.chat/apps@0.6.5-rc.0 + - @rocket.chat/cron@0.1.52-rc.0 + - @rocket.chat/i18n@2.1.1-rc.0 + - @rocket.chat/jwt@0.2.1-rc.0 +
+ ## 8.2.0 ### Minor Changes diff --git a/apps/meteor/app/api/server/ApiClass.ts b/apps/meteor/app/api/server/ApiClass.ts index cf7cf77ac8c1f..8d44fb85941ad 100644 --- a/apps/meteor/app/api/server/ApiClass.ts +++ b/apps/meteor/app/api/server/ApiClass.ts @@ -825,13 +825,11 @@ export class APIClass Meteor.callAsync('login', args)); - this.user = await Users.findOne( + const user = await Users.findOne( { _id: auth.id, }, @@ -1098,18 +1096,16 @@ export class APIClass = { readonly logger: Logger; userId: TOptions['authRequired'] extends true ? string : string | undefined; - user: TOptions['authRequired'] extends true ? IUser : IUser | null; token: TOptions['authRequired'] extends true ? string : string | undefined; queryParams: TOptions['query'] extends ValidateFunction ? Query : never; urlParams: UrlParams extends Record ? UrlParams : never; @@ -313,11 +312,17 @@ export type TypedThis query: Record; }>; bodyParams: TOptions['body'] extends ValidateFunction ? Body : never; - + request: Request; requestIp?: string; route: string; response: Response; -}; +} & (TOptions['authRequired'] extends true + ? { + user: IUser; + } + : { + user?: IUser; + }); type PromiseOrValue = T | Promise; diff --git a/apps/meteor/app/api/server/helpers/getLoggedInUser.ts b/apps/meteor/app/api/server/helpers/getLoggedInUser.ts deleted file mode 100644 index d3fc562eeb20f..0000000000000 --- a/apps/meteor/app/api/server/helpers/getLoggedInUser.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { IUser } from '@rocket.chat/core-typings'; -import { Users } from '@rocket.chat/models'; -import { Accounts } from 'meteor/accounts-base'; - -export async function getLoggedInUser(request: Request): Promise | null> { - const token = request.headers.get('x-auth-token'); - const userId = request.headers.get('x-user-id'); - if (!token || !userId || typeof token !== 'string' || typeof userId !== 'string') { - return null; - } - - return Users.findOneByIdAndLoginToken(userId, Accounts._hashLoginToken(token), { projection: { username: 1 } }); -} diff --git a/apps/meteor/app/api/server/helpers/getUserInfo.spec.ts b/apps/meteor/app/api/server/helpers/getUserInfo.spec.ts index eaf6122fb99c4..4d00e1292f62f 100644 --- a/apps/meteor/app/api/server/helpers/getUserInfo.spec.ts +++ b/apps/meteor/app/api/server/helpers/getUserInfo.spec.ts @@ -1,6 +1,16 @@ import { getUserInfo } from './getUserInfo'; import type { CachedSettings } from '../../../settings/server/CachedSettings'; +const mockInfoVersion = jest.fn(() => '7.5.0'); + +jest.mock('../../../utils/rocketchat.info', () => ({ + Info: { + get version() { + return mockInfoVersion(); + }, + }, +})); + jest.mock('@rocket.chat/models', () => ({ Users: { findOneById: jest.fn().mockResolvedValue({ @@ -198,4 +208,50 @@ describe('getUserInfo', () => { }); }); }); + + describe('version update banner filtering', () => { + beforeEach(() => { + mockInfoVersion.mockReturnValue('7.5.0'); + }); + + it('should filter out versionUpdate banners for versions <= current installed', async () => { + user.banners = { + 'versionUpdate-6_2_0': { + id: 'versionUpdate-6_2_0', + priority: 10, + title: 'Update', + text: 'New version', + modifiers: [], + link: '', + read: false, + }, + }; + const userInfo = await getUserInfo(user); + expect(userInfo.banners).toEqual({}); + }); + + it('should keep versionUpdate banners for versions > current installed', async () => { + user.banners = { + 'versionUpdate-8_0_0': { + id: 'versionUpdate-8_0_0', + priority: 10, + title: 'Update', + text: 'New version', + modifiers: [], + link: '', + read: false, + }, + }; + const userInfo = await getUserInfo(user); + expect(userInfo.banners).toHaveProperty('versionUpdate-8_0_0'); + }); + + it('should keep non-versionUpdate banners unchanged', async () => { + user.banners = { + 'other-banner': { id: 'other-banner', priority: 10, title: 'Other', text: 'Other banner', modifiers: [], link: '', read: false }, + }; + const userInfo = await getUserInfo(user); + expect(userInfo.banners).toHaveProperty('other-banner'); + }); + }); }); diff --git a/apps/meteor/app/api/server/helpers/getUserInfo.ts b/apps/meteor/app/api/server/helpers/getUserInfo.ts index beed975452c7a..4c0e338d69ae1 100644 --- a/apps/meteor/app/api/server/helpers/getUserInfo.ts +++ b/apps/meteor/app/api/server/helpers/getUserInfo.ts @@ -1,6 +1,8 @@ import { isOAuthUser, type IUser, type IUserEmail, type IUserCalendar } from '@rocket.chat/core-typings'; +import semver from 'semver'; import { settings } from '../../../settings/server'; +import { Info } from '../../../utils/rocketchat.info'; import { getURL } from '../../../utils/server/getURL'; import { getUserPreference } from '../../../utils/server/lib/getUserPreference'; @@ -25,6 +27,23 @@ const getUserPreferences = async (me: IUser): Promise> = return accumulator; }; +const filterOutdatedVersionUpdateBanners = (banners: NonNullable): IUser['banners'] => { + return Object.fromEntries( + Object.entries(banners).filter(([id]) => { + if (!id.startsWith('versionUpdate-')) { + return true; + } + + const version = id.replace('versionUpdate-', '').replace(/_/g, '.'); + if (!semver.valid(version) || semver.lte(version, Info.version)) { + return false; + } + + return true; + }), + ); +}; + /** * Returns the user's calendar settings based on their email domain and the configured mapping. * If the email is not provided or the domain is not found in the mapping, @@ -80,6 +99,7 @@ export async function getUserInfo( return { ...me, + ...(me.banners && { banners: filterOutdatedVersionUpdateBanners(me.banners) }), email: verifiedEmail ? verifiedEmail.address : undefined, settings: { profile: {}, diff --git a/apps/meteor/app/api/server/helpers/parseJsonQuery.ts b/apps/meteor/app/api/server/helpers/parseJsonQuery.ts index 9dd6c9c0448af..fafaebcf59c94 100644 --- a/apps/meteor/app/api/server/helpers/parseJsonQuery.ts +++ b/apps/meteor/app/api/server/helpers/parseJsonQuery.ts @@ -26,7 +26,8 @@ export async function parseJsonQuery(api: GenericRouteExecutionContext): Promise query: Record; }> { const { userId = '', response, route, logger } = api; - + const isUsersRoute = route.includes('/v1/users.'); + const canViewFullOtherUserInfo = isUsersRoute && (await hasPermissionAsync(userId, 'view-full-other-user-info')); const params = isPlainObject(api.queryParams) ? api.queryParams : {}; const queryFields = Array.isArray(api.queryFields) ? (api.queryFields as string[]) : []; const queryOperations = Array.isArray(api.queryOperations) ? (api.queryOperations as string[]) : []; @@ -85,13 +86,9 @@ export async function parseJsonQuery(api: GenericRouteExecutionContext): Promise // Verify the user's selected fields only contains ones which their role allows if (typeof fields === 'object') { let nonSelectableFields = Object.keys(API.v1.defaultFieldsToExclude); - if (route.includes('/v1/users.')) { + if (isUsersRoute) { nonSelectableFields = nonSelectableFields.concat( - Object.keys( - (await hasPermissionAsync(userId, 'view-full-other-user-info')) - ? API.v1.limitedUserFieldsToExcludeIfIsPrivilegedUser - : API.v1.limitedUserFieldsToExclude, - ), + Object.keys(canViewFullOtherUserInfo ? API.v1.limitedUserFieldsToExcludeIfIsPrivilegedUser : API.v1.limitedUserFieldsToExclude), ); } @@ -104,8 +101,8 @@ export async function parseJsonQuery(api: GenericRouteExecutionContext): Promise // Limit the fields by default fields = Object.assign({}, fields, API.v1.defaultFieldsToExclude); - if (route.includes('/v1/users.')) { - if (await hasPermissionAsync(userId, 'view-full-other-user-info')) { + if (isUsersRoute) { + if (canViewFullOtherUserInfo) { fields = Object.assign(fields, API.v1.limitedUserFieldsToExcludeIfIsPrivilegedUser); } else { fields = Object.assign(fields, API.v1.limitedUserFieldsToExclude); @@ -134,8 +131,8 @@ export async function parseJsonQuery(api: GenericRouteExecutionContext): Promise if (typeof query === 'object') { let nonQueryableFields = Object.keys(API.v1.defaultFieldsToExclude); - if (route.includes('/v1/users.')) { - if (await hasPermissionAsync(userId, 'view-full-other-user-info')) { + if (isUsersRoute) { + if (canViewFullOtherUserInfo) { nonQueryableFields = nonQueryableFields.concat(Object.keys(API.v1.limitedUserFieldsToExcludeIfIsPrivilegedUser)); } else { nonQueryableFields = nonQueryableFields.concat(Object.keys(API.v1.limitedUserFieldsToExclude)); diff --git a/apps/meteor/app/api/server/index.ts b/apps/meteor/app/api/server/index.ts index 59986d6e2da87..176141af83e08 100644 --- a/apps/meteor/app/api/server/index.ts +++ b/apps/meteor/app/api/server/index.ts @@ -1,6 +1,5 @@ import './ajv'; import './helpers/composeRoomWithLastMessage'; -import './helpers/getLoggedInUser'; import './helpers/getPaginationItems'; import './helpers/getUserFromParams'; import './helpers/getUserInfo'; diff --git a/apps/meteor/app/api/server/router.ts b/apps/meteor/app/api/server/router.ts index 41ca09d4f1a32..583976cb7efc4 100644 --- a/apps/meteor/app/api/server/router.ts +++ b/apps/meteor/app/api/server/router.ts @@ -9,10 +9,9 @@ import type { TypedOptions } from './definition'; type HonoContext = Context<{ Bindings: { incoming: IncomingMessage }; Variables: { - 'remoteAddress': string; - 'bodyParams': Record; - 'bodyParams-override': Record | undefined; - 'queryParams': Record; + remoteAddress: string; + bodyParams: Record; + queryParams: Record; }; }>; @@ -46,7 +45,7 @@ export class RocketChatAPIRouter< requestIp: c.get('remoteAddress'), urlParams: req.param(), queryParams: c.get('queryParams'), - bodyParams: c.get('bodyParams-override') || c.get('bodyParams'), + bodyParams: c.get('bodyParams'), request, path: req.path, response: res, diff --git a/apps/meteor/app/api/server/v1/autotranslate.ts b/apps/meteor/app/api/server/v1/autotranslate.ts index a5c167f7d0a89..76f5ea1debbc3 100644 --- a/apps/meteor/app/api/server/v1/autotranslate.ts +++ b/apps/meteor/app/api/server/v1/autotranslate.ts @@ -1,7 +1,10 @@ +import type { IMessage } from '@rocket.chat/core-typings'; import { Messages } from '@rocket.chat/models'; import { + ajv, + validateUnauthorizedErrorResponse, + validateBadRequestErrorResponse, isAutotranslateSaveSettingsParamsPOST, - isAutotranslateTranslateMessageParamsPOST, isAutotranslateGetSupportedLanguagesParamsGET, } from '@rocket.chat/rest-typings'; @@ -9,6 +12,7 @@ import { getSupportedLanguages } from '../../../autotranslate/server/functions/g import { saveAutoTranslateSettings } from '../../../autotranslate/server/functions/saveSettings'; import { translateMessage } from '../../../autotranslate/server/functions/translateMessage'; import { settings } from '../../../settings/server'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; import { API } from '../api'; API.v1.addRoute( @@ -69,29 +73,75 @@ API.v1.addRoute( }, ); -API.v1.addRoute( +type AutotranslateTranslateMessageParamsPOST = { + messageId: string; + targetLanguage?: string; +}; + +const AutotranslateTranslateMessageParamsPostSchema = { + type: 'object', + properties: { + messageId: { + type: 'string', + }, + targetLanguage: { + type: 'string', + nullable: true, + }, + }, + required: ['messageId'], + additionalProperties: false, +}; + +const isAutotranslateTranslateMessageParamsPOST = ajv.compile( + AutotranslateTranslateMessageParamsPostSchema, +); + +const autotranslateEndpoints = API.v1.post( 'autotranslate.translateMessage', { authRequired: true, - validateParams: isAutotranslateTranslateMessageParamsPOST, + body: isAutotranslateTranslateMessageParamsPOST, + response: { + 200: ajv.compile<{ message: IMessage }>({ + type: 'object', + properties: { + message: { $ref: '#/components/schemas/IMessage' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['message', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async post() { - const { messageId, targetLanguage } = this.bodyParams; - if (!settings.get('AutoTranslate_Enabled')) { - return API.v1.failure('AutoTranslate is disabled.'); - } - if (!messageId) { - return API.v1.failure('The bodyParam "messageId" is required.'); - } - const message = await Messages.findOneById(messageId); - if (!message) { - return API.v1.failure('Message not found.'); - } + async function action() { + const { messageId, targetLanguage } = this.bodyParams; + if (!settings.get('AutoTranslate_Enabled')) { + return API.v1.failure('AutoTranslate is disabled.'); + } + if (!messageId) { + return API.v1.failure('The bodyParam "messageId" is required.'); + } + const message = await Messages.findOneById(messageId); + if (!message) { + return API.v1.failure('Message not found.'); + } - const translatedMessage = await translateMessage(targetLanguage, message); + const translatedMessage = await translateMessage(targetLanguage, message); - return API.v1.success({ message: translatedMessage }); - }, + if (!translatedMessage) { + return API.v1.failure('Failed to translate message.'); + } + + return API.v1.success({ message: translatedMessage }); }, ); + +type AutotranslateEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends AutotranslateEndpoints {} +} diff --git a/apps/meteor/app/api/server/v1/channels.ts b/apps/meteor/app/api/server/v1/channels.ts index 0aa24f5097b04..5dfdc22c237ee 100644 --- a/apps/meteor/app/api/server/v1/channels.ts +++ b/apps/meteor/app/api/server/v1/channels.ts @@ -54,7 +54,6 @@ import { normalizeMessagesForUser } from '../../../utils/server/lib/normalizeMes import { API } from '../api'; import { addUserToFileObj } from '../helpers/addUserToFileObj'; import { composeRoomWithLastMessage } from '../helpers/composeRoomWithLastMessage'; -import { getLoggedInUser } from '../helpers/getLoggedInUser'; import { getPaginationItems } from '../helpers/getPaginationItems'; import { getUserFromParams, getUserListFromParams } from '../helpers/getUserFromParams'; @@ -1147,9 +1146,7 @@ API.v1.addRoute( return API.v1.failure('Channel does not exists'); } - const user = await getLoggedInUser(this.request); - - if (!room || !user || !(await canAccessRoomAsync(room, user))) { + if (!(await canAccessRoomAsync(room, this.user))) { throw new Meteor.Error('error-not-allowed', 'Not Allowed'); } diff --git a/apps/meteor/app/api/server/v1/chat.ts b/apps/meteor/app/api/server/v1/chat.ts index f5a9250fe29b6..a00d57e46ae72 100644 --- a/apps/meteor/app/api/server/v1/chat.ts +++ b/apps/meteor/app/api/server/v1/chat.ts @@ -14,12 +14,8 @@ import { isChatPostMessageProps, isChatSearchProps, isChatSendMessageProps, - isChatStarMessageProps, - isChatUnstarMessageProps, isChatIgnoreUserProps, isChatGetPinnedMessagesProps, - isChatFollowMessageProps, - isChatUnfollowMessageProps, isChatGetMentionedMessagesProps, isChatReactProps, isChatGetDeletedMessagesProps, @@ -59,6 +55,78 @@ import { API } from '../api'; import { getPaginationItems } from '../helpers/getPaginationItems'; import { findDiscussionsFromRoom, findMentionedMessages, findStarredMessages } from '../lib/messages'; +type ChatStarMessageLocal = { + messageId: IMessage['_id']; +}; + +type ChatUnstarMessageLocal = { + messageId: IMessage['_id']; +}; + +const ChatStarMessageLocalSchema = { + type: 'object', + properties: { + messageId: { + type: 'string', + minLength: 1, + }, + }, + required: ['messageId'], + additionalProperties: false, +}; + +const ChatUnstarMessageLocalSchema = { + type: 'object', + properties: { + messageId: { + type: 'string', + minLength: 1, + }, + }, + required: ['messageId'], + additionalProperties: false, +}; + +type ChatFollowMessageLocal = { + mid: string; +}; + +const ChatFollowMessageLocalSchema = { + type: 'object', + properties: { + mid: { + type: 'string', + minLength: 1, + }, + }, + required: ['mid'], + additionalProperties: false, +}; + +type ChatUnfollowMessageLocal = { + mid: string; +}; + +const ChatUnfollowMessageLocalSchema = { + type: 'object', + properties: { + mid: { + type: 'string', + minLength: 1, + }, + }, + required: ['mid'], + additionalProperties: false, +}; + +const isChatStarMessageLocalProps = ajv.compile(ChatStarMessageLocalSchema); + +const isChatUnstarMessageLocalProps = ajv.compile(ChatUnstarMessageLocalSchema); + +const isChatFollowMessageLocalProps = ajv.compile(ChatFollowMessageLocalSchema); + +const isChatUnfollowMessageLocalProps = ajv.compile(ChatUnfollowMessageLocalSchema); + API.v1.addRoute( 'chat.delete', { authRequired: true, validateParams: isChatDeleteProps }, @@ -350,6 +418,146 @@ const chatEndpoints = API.v1 message, }); }, + ) + .post( + 'chat.starMessage', + { + authRequired: true, + body: isChatStarMessageLocalProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['success'], + additionalProperties: false, + }), + }, + }, + async function action() { + const msg = await Messages.findOneById(this.bodyParams.messageId); + + if (!msg) { + throw new Meteor.Error('error-message-not-found', 'The provided "messageId" does not match any existing message.'); + } + + await starMessage(this.user, { + _id: msg._id, + rid: msg.rid, + starred: true, + }); + + return API.v1.success(); + }, + ) + .post( + 'chat.unStarMessage', + { + authRequired: true, + body: isChatUnstarMessageLocalProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['success'], + additionalProperties: false, + }), + }, + }, + async function action() { + const msg = await Messages.findOneById(this.bodyParams.messageId); + + if (!msg) { + throw new Meteor.Error('error-message-not-found', 'The provided "messageId" does not match any existing message.'); + } + + await starMessage(this.user, { + _id: msg._id, + rid: msg.rid, + starred: false, + }); + + return API.v1.success(); + }, + ) + .post( + 'chat.followMessage', + { + authRequired: true, + body: isChatFollowMessageLocalProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['success'], + additionalProperties: false, + }), + }, + }, + async function action() { + const { mid } = this.bodyParams; + + if (!mid) { + throw new Meteor.Error('The required "mid" body param is missing.'); + } + + await followMessage(this.user, { mid }); + + return API.v1.success(); + }, + ) + .post( + 'chat.unfollowMessage', + { + authRequired: true, + body: isChatUnfollowMessageLocalProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['success'], + additionalProperties: false, + }), + }, + }, + async function action() { + const { mid } = this.bodyParams; + + if (!mid) { + throw new Meteor.Error('The required "mid" body param is missing.'); + } + + await unfollowMessage(this.user, { mid }); + + return API.v1.success(); + }, ); API.v1.addRoute( @@ -434,7 +642,7 @@ API.v1.addRoute( } const sent = await applyAirGappedRestrictionsValidation(() => - executeSendMessage(this.userId, this.bodyParams.message as Pick, { previewUrls: this.bodyParams.previewUrls }), + executeSendMessage(this.user, this.bodyParams.message as Pick, { previewUrls: this.bodyParams.previewUrls }), ); const [message] = await normalizeMessagesForUser([sent], this.userId); @@ -445,50 +653,6 @@ API.v1.addRoute( }, ); -API.v1.addRoute( - 'chat.starMessage', - { authRequired: true, validateParams: isChatStarMessageProps }, - { - async post() { - const msg = await Messages.findOneById(this.bodyParams.messageId); - - if (!msg) { - throw new Meteor.Error('error-message-not-found', 'The provided "messageId" does not match any existing message.'); - } - - await starMessage(this.user, { - _id: msg._id, - rid: msg.rid, - starred: true, - }); - - return API.v1.success(); - }, - }, -); - -API.v1.addRoute( - 'chat.unStarMessage', - { authRequired: true, validateParams: isChatUnstarMessageProps }, - { - async post() { - const msg = await Messages.findOneById(this.bodyParams.messageId); - - if (!msg) { - throw new Meteor.Error('error-message-not-found', 'The provided "messageId" does not match any existing message.'); - } - - await starMessage(this.user, { - _id: msg._id, - rid: msg.rid, - starred: false, - }); - - return API.v1.success(); - }, - }, -); - API.v1.addRoute( 'chat.react', { authRequired: true, validateParams: isChatReactProps }, @@ -793,42 +957,6 @@ API.v1.addRoute( }, ); -API.v1.addRoute( - 'chat.followMessage', - { authRequired: true, validateParams: isChatFollowMessageProps }, - { - async post() { - const { mid } = this.bodyParams; - - if (!mid) { - throw new Meteor.Error('The required "mid" body param is missing.'); - } - - await followMessage(this.user, { mid }); - - return API.v1.success(); - }, - }, -); - -API.v1.addRoute( - 'chat.unfollowMessage', - { authRequired: true, validateParams: isChatUnfollowMessageProps }, - { - async post() { - const { mid } = this.bodyParams; - - if (!mid) { - throw new Meteor.Error('The required "mid" body param is missing.'); - } - - await unfollowMessage(this.user, { mid }); - - return API.v1.success(); - }, - }, -); - API.v1.addRoute( 'chat.getMentionedMessages', { authRequired: true, validateParams: isChatGetMentionedMessagesProps }, diff --git a/apps/meteor/app/api/server/v1/commands.ts b/apps/meteor/app/api/server/v1/commands.ts index fea1d978cbafe..bf96a983c3ae5 100644 --- a/apps/meteor/app/api/server/v1/commands.ts +++ b/apps/meteor/app/api/server/v1/commands.ts @@ -1,35 +1,86 @@ import { Apps } from '@rocket.chat/apps'; +import type { SlashCommand } from '@rocket.chat/core-typings'; import { Messages } from '@rocket.chat/models'; import { Random } from '@rocket.chat/random'; +import { ajv, validateUnauthorizedErrorResponse, validateBadRequestErrorResponse } from '@rocket.chat/rest-typings'; import objectPath from 'object-path'; import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; import { executeSlashCommandPreview } from '../../../lib/server/methods/executeSlashCommandPreview'; import { getSlashCommandPreviews } from '../../../lib/server/methods/getSlashCommandPreviews'; import { slashCommands } from '../../../utils/server/slashCommand'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; import { API } from '../api'; -import { getLoggedInUser } from '../helpers/getLoggedInUser'; import { getPaginationItems } from '../helpers/getPaginationItems'; -API.v1.addRoute( +type CommandsGetParams = { command: string }; + +const CommandsGetParamsSchema = { + type: 'object', + properties: { + command: { type: 'string' }, + }, + required: ['command'], + additionalProperties: false, +}; + +const isCommandsGetParams = ajv.compile(CommandsGetParamsSchema); + +const commandsEndpoints = API.v1.get( 'commands.get', - { authRequired: true }, { - get() { - const params = this.queryParams; + authRequired: true, + query: isCommandsGetParams, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile<{ + command: Pick; + success: true; + }>({ + type: 'object', + properties: { + command: { + type: 'object', + properties: { + clientOnly: { type: 'boolean' }, + command: { type: 'string' }, + description: { type: 'string' }, + params: { type: 'string' }, + providesPreview: { type: 'boolean' }, + }, + required: ['command', 'providesPreview'], + additionalProperties: false, + }, + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['command', 'success'], + additionalProperties: false, + }), + }, + }, - if (typeof params.command !== 'string') { - return API.v1.failure('The query param "command" must be provided.'); - } + async function action() { + const params = this.queryParams; - const cmd = slashCommands.commands[params.command.toLowerCase()]; + const cmd = slashCommands.commands[params.command.toLowerCase()]; - if (!cmd) { - return API.v1.failure(`There is no command in the system by the name of: ${params.command}`); - } + if (!cmd) { + return API.v1.failure(`There is no command in the system by the name of: ${params.command}`); + } - return API.v1.success({ command: cmd }); - }, + return API.v1.success({ + command: { + command: cmd.command, + description: cmd.description, + params: cmd.params, + clientOnly: cmd.clientOnly, + providesPreview: cmd.providesPreview, + }, + }); }, ); @@ -248,7 +299,6 @@ API.v1.addRoute( // Expects these query params: command: 'giphy', params: 'mine', roomId: 'value' async get() { const query = this.queryParams; - const user = await getLoggedInUser(this.request); if (typeof query.command !== 'string') { return API.v1.failure('You must provide a command to get the previews from.'); @@ -267,7 +317,7 @@ API.v1.addRoute( return API.v1.failure('The command provided does not exist (or is disabled).'); } - if (!(await canAccessRoomIdAsync(query.roomId, user?._id))) { + if (!(await canAccessRoomIdAsync(query.roomId, this.userId))) { return API.v1.forbidden(); } @@ -352,3 +402,10 @@ API.v1.addRoute( }, }, ); + +export type CommandsEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends CommandsEndpoints {} +} diff --git a/apps/meteor/app/api/server/v1/custom-sounds.ts b/apps/meteor/app/api/server/v1/custom-sounds.ts index d41362641f75b..a2fa2b675f07f 100644 --- a/apps/meteor/app/api/server/v1/custom-sounds.ts +++ b/apps/meteor/app/api/server/v1/custom-sounds.ts @@ -2,8 +2,10 @@ import type { ICustomSound } from '@rocket.chat/core-typings'; import { CustomSounds } from '@rocket.chat/models'; import type { PaginatedRequest, PaginatedResult } from '@rocket.chat/rest-typings'; import { + isCustomSoundsGetOneProps, ajv, validateBadRequestErrorResponse, + validateNotFoundErrorResponse, validateForbiddenErrorResponse, validateUnauthorizedErrorResponse, } from '@rocket.chat/rest-typings'; @@ -45,76 +47,115 @@ const CustomSoundsListSchema = { export const isCustomSoundsListProps = ajv.compile(CustomSoundsListSchema); -const customSoundsEndpoints = API.v1.get( - 'custom-sounds.list', - { - response: { - 400: validateBadRequestErrorResponse, - 401: validateUnauthorizedErrorResponse, - 403: validateForbiddenErrorResponse, - 200: ajv.compile< - PaginatedResult<{ - sounds: ICustomSound[]; - }> - >({ - additionalProperties: false, - type: 'object', - properties: { - count: { - type: 'number', - description: 'The number of sounds returned in this response.', - }, - offset: { - type: 'number', - description: 'The number of sounds that were skipped in this response.', - }, - total: { - type: 'number', - description: 'The total number of sounds that match the query.', - }, - success: { - type: 'boolean', - description: 'Indicates if the request was successful.', +const customSoundsEndpoints = API.v1 + .get( + 'custom-sounds.list', + { + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: ajv.compile< + PaginatedResult<{ + sounds: ICustomSound[]; + }> + >({ + additionalProperties: false, + type: 'object', + properties: { + count: { + type: 'number', + description: 'The number of sounds returned in this response.', + }, + offset: { + type: 'number', + description: 'The number of sounds that were skipped in this response.', + }, + total: { + type: 'number', + description: 'The total number of sounds that match the query.', + }, + success: { + type: 'boolean', + description: 'Indicates if the request was successful.', + }, + sounds: { + type: 'array', + items: { + $ref: '#/components/schemas/ICustomSound', + }, + }, }, - sounds: { - type: 'array', - items: { + required: ['count', 'offset', 'total', 'sounds', 'success'], + }), + }, + query: isCustomSoundsListProps, + authRequired: true, + }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams as Record); + const { sort, query } = await this.parseJsonQuery(); + + const { name } = this.queryParams; + + const filter = { + ...query, + ...(name ? { name: { $regex: escapeRegExp(name as string), $options: 'i' } } : {}), + }; + + const { cursor, totalCount } = CustomSounds.findPaginated(filter, { + sort: sort || { name: 1 }, + skip: offset, + limit: count, + }); + const [sounds, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + sounds, + count: sounds.length, + offset, + total, + }); + }, + ) + .get( + 'custom-sounds.getOne', + { + response: { + 200: ajv.compile<{ sound: ICustomSound; success: boolean }>({ + additionalProperties: false, + type: 'object', + properties: { + sound: { $ref: '#/components/schemas/ICustomSound', }, + success: { + type: 'boolean', + description: 'Indicates if the request was successful.', + }, }, - }, - required: ['count', 'offset', 'total', 'sounds', 'success'], - }), + required: ['sound', 'success'], + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 404: validateNotFoundErrorResponse, + }, + query: isCustomSoundsGetOneProps, + authRequired: true, }, - query: isCustomSoundsListProps, - authRequired: true, - }, - async function action() { - const { offset, count } = await getPaginationItems(this.queryParams as Record); - const { sort, query } = await this.parseJsonQuery(); - - const { name } = this.queryParams; + async function action() { + const { _id } = this.queryParams; - const filter = { - ...query, - ...(name ? { name: { $regex: escapeRegExp(name as string), $options: 'i' } } : {}), - }; + const sound = await CustomSounds.findOneById(_id); - const { cursor, totalCount } = CustomSounds.findPaginated(filter, { - sort: sort || { name: 1 }, - skip: offset, - limit: count, - }); - const [sounds, total] = await Promise.all([cursor.toArray(), totalCount]); + if (!sound) { + return API.v1.notFound('Custom Sound not found.'); + } - return API.v1.success({ - sounds, - count: sounds.length, - offset, - total, - }); - }, -); + return API.v1.success({ sound }); + }, + ); export type CustomSoundEndpoints = ExtractRoutesFromAPI; diff --git a/apps/meteor/app/api/server/v1/custom-user-status.ts b/apps/meteor/app/api/server/v1/custom-user-status.ts index 037928cf1cdcf..4d4297cfe001c 100644 --- a/apps/meteor/app/api/server/v1/custom-user-status.ts +++ b/apps/meteor/app/api/server/v1/custom-user-status.ts @@ -1,46 +1,124 @@ +import type { ICustomUserStatus } from '@rocket.chat/core-typings'; import { CustomUserStatus } from '@rocket.chat/models'; -import { isCustomUserStatusListProps } from '@rocket.chat/rest-typings'; +import { ajv, validateUnauthorizedErrorResponse, validateBadRequestErrorResponse } from '@rocket.chat/rest-typings'; +import type { PaginatedRequest, PaginatedResult } from '@rocket.chat/rest-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { deleteCustomUserStatus } from '../../../user-status/server/methods/deleteCustomUserStatus'; import { insertOrUpdateUserStatus } from '../../../user-status/server/methods/insertOrUpdateUserStatus'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; import { API } from '../api'; import { getPaginationItems } from '../helpers/getPaginationItems'; -API.v1.addRoute( - 'custom-user-status.list', - { authRequired: true, validateParams: isCustomUserStatusListProps }, - { - async get() { - const { offset, count } = await getPaginationItems(this.queryParams as Record); - const { sort, query } = await this.parseJsonQuery(); - - const { name, _id } = this.queryParams; - - const filter = { - ...query, - ...(name ? { name: { $regex: escapeRegExp(name as string), $options: 'i' } } : {}), - ...(_id ? { _id } : {}), - }; +type CustomUserStatusListProps = PaginatedRequest<{ name?: string; _id?: string; query?: string }>; - const { cursor, totalCount } = CustomUserStatus.findPaginated(filter, { - sort: sort || { name: 1 }, - skip: offset, - limit: count, - }); +const CustomUserStatusListSchema = { + type: 'object', + properties: { + count: { + type: 'number', + nullable: true, + }, + offset: { + type: 'number', + nullable: true, + }, + sort: { + type: 'string', + nullable: true, + }, + name: { + type: 'string', + nullable: true, + }, + _id: { + type: 'string', + nullable: true, + }, + query: { + type: 'string', + nullable: true, + }, + }, + required: [], + additionalProperties: false, +}; - const [statuses, total] = await Promise.all([cursor.toArray(), totalCount]); +const isCustomUserStatusListProps = ajv.compile(CustomUserStatusListSchema); - return API.v1.success({ - statuses, - count: statuses.length, - offset, - total, - }); +const customUserStatusEndpoints = API.v1.get( + 'custom-user-status.list', + { + authRequired: true, + query: isCustomUserStatusListProps, + response: { + 200: ajv.compile< + PaginatedResult<{ + statuses: ICustomUserStatus[]; + }> + >({ + type: 'object', + properties: { + statuses: { + type: 'array', + items: { + $ref: '#/components/schemas/ICustomUserStatus', + }, + }, + count: { + type: 'number', + description: 'The number of custom user statuses returned in this response.', + }, + offset: { + type: 'number', + description: 'The number of custom user statuses that were skipped in this response.', + }, + total: { + type: 'number', + description: 'The total number of custom user statuses that match the query.', + }, + success: { + type: 'boolean', + enum: [true], + description: 'Indicates if the request was successful.', + }, + }, + required: ['success', 'statuses', 'count', 'offset', 'total'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams as Record); + const { sort, query } = await this.parseJsonQuery(); + + const { name, _id } = this.queryParams; + + const filter = { + ...query, + ...(name ? { name: { $regex: escapeRegExp(name as string), $options: 'i' } } : {}), + ...(_id ? { _id } : {}), + }; + + const { cursor, totalCount } = CustomUserStatus.findPaginated(filter, { + sort: sort || { name: 1 }, + skip: offset, + limit: count, + }); + + const [statuses, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + statuses, + count: statuses.length, + offset, + total, + }); + }, ); API.v1.addRoute( @@ -127,3 +205,10 @@ API.v1.addRoute( }, }, ); + +export type CustomUserStatusEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends CustomUserStatusEndpoints {} +} diff --git a/apps/meteor/app/api/server/v1/e2e.ts b/apps/meteor/app/api/server/v1/e2e.ts index bfc70ba8b31a1..aba7e3a0db952 100644 --- a/apps/meteor/app/api/server/v1/e2e.ts +++ b/apps/meteor/app/api/server/v1/e2e.ts @@ -1,14 +1,11 @@ +import type { IRoom, ISubscription, IUser } from '@rocket.chat/core-typings'; import { Subscriptions, Users } from '@rocket.chat/models'; import { ajv, validateUnauthorizedErrorResponse, validateBadRequestErrorResponse, - ise2eGetUsersOfRoomWithoutKeyParamsGET, + validateForbiddenErrorResponse, ise2eSetUserPublicAndPrivateKeysParamsPOST, - ise2eUpdateGroupKeyParamsPOST, - isE2EProvideUsersGroupKeyProps, - isE2EFetchUsersWaitingForGroupKeyProps, - isE2EResetRoomKeyProps, } from '@rocket.chat/rest-typings'; import ExpiryMap from 'expiry-map'; @@ -34,6 +31,28 @@ type E2eSetRoomKeyIdProps = { keyID: string; }; +type e2eGetUsersOfRoomWithoutKeyParamsGET = { + rid: string; +}; + +type e2eUpdateGroupKeyParamsPOST = { + uid: string; + rid: string; + key: string; +}; + +type E2EFetchUsersWaitingForGroupKeyProps = { roomIds: string[] }; + +type E2EProvideUsersGroupKeyProps = { + usersSuggestedGroupKeys: Record; +}; + +type E2EResetRoomKeyProps = { + rid: string; + e2eKey: string; + e2eKeyId: string; +}; + const E2eSetRoomKeyIdSchema = { type: 'object', properties: { @@ -48,213 +67,293 @@ const E2eSetRoomKeyIdSchema = { additionalProperties: false, }; -const isE2eSetRoomKeyIdProps = ajv.compile(E2eSetRoomKeyIdSchema); - -const e2eEndpoints = API.v1.post( - 'e2e.setRoomKeyID', - { - authRequired: true, - body: isE2eSetRoomKeyIdProps, - response: { - 400: validateBadRequestErrorResponse, - 401: validateUnauthorizedErrorResponse, - 200: ajv.compile({ - type: 'object', - properties: { - success: { type: 'boolean', enum: [true] }, - }, - required: ['success'], - }), +const e2eGetUsersOfRoomWithoutKeyParamsGETSchema = { + type: 'object', + properties: { + rid: { + type: 'string', }, }, + additionalProperties: false, + required: ['rid'], +}; - async function action() { - const { rid, keyID } = this.bodyParams; - - await setRoomKeyIDMethod(this.userId, rid, keyID); - - return API.v1.success(); +const e2eUpdateGroupKeyParamsPOSTSchema = { + type: 'object', + properties: { + uid: { + type: 'string', + }, + rid: { + type: 'string', + }, + key: { + type: 'string', + }, }, -); + additionalProperties: false, + required: ['uid', 'rid', 'key'], +}; -API.v1.addRoute( - 'e2e.fetchMyKeys', - { - authRequired: true, +const E2EFetchUsersWaitingForGroupKeySchema = { + type: 'object', + properties: { + roomIds: { + type: 'array', + items: { + type: 'string', + }, + }, }, - { - async get() { - const result = await Users.fetchKeysByUserId(this.userId); + required: ['roomIds'], + additionalProperties: false, +}; - return API.v1.success(result); +const E2EProvideUsersGroupKeySchema = { + type: 'object', + properties: { + usersSuggestedGroupKeys: { + type: 'object', + additionalProperties: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + key: { type: 'string' }, + oldKeys: { + type: 'array', + items: { + type: 'object', + properties: { e2eKeyId: { type: 'string' }, ts: { type: 'string' }, E2EKey: { type: 'string' } }, + }, + }, + }, + required: ['_id', 'key'], + additionalProperties: false, + }, + }, }, }, -); + required: ['usersSuggestedGroupKeys'], + additionalProperties: false, +}; -API.v1.addRoute( - 'e2e.getUsersOfRoomWithoutKey', - { - authRequired: true, - validateParams: ise2eGetUsersOfRoomWithoutKeyParamsGET, +const E2EResetRoomKeySchema = { + type: 'object', + properties: { + rid: { + type: 'string', + }, + e2eKey: { + type: 'string', + }, + e2eKeyId: { + type: 'string', + }, }, - { - async get() { - const { rid } = this.queryParams; + required: ['rid', 'e2eKey', 'e2eKeyId'], + additionalProperties: false, +}; - const result = await getUsersOfRoomWithoutKeyMethod(this.userId, rid); +const isE2eSetRoomKeyIdProps = ajv.compile(E2eSetRoomKeyIdSchema); - return API.v1.success(result); - }, - }, +const ise2eGetUsersOfRoomWithoutKeyParamsGET = ajv.compile( + e2eGetUsersOfRoomWithoutKeyParamsGETSchema, ); -/** - * @openapi - * /api/v1/e2e.setUserPublicAndPrivateKeys: - * post: - * description: Sets the end-to-end encryption keys for the authenticated user - * security: - * - autenticated: {} - * requestBody: - * description: A tuple containing the public and the private keys - * content: - * application/json: - * schema: - * type: object - * properties: - * public_key: - * type: string - * private_key: - * type: string - * force: - * type: boolean - * responses: - * 200: - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ApiSuccessV1' - * default: - * description: Unexpected error - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ApiFailureV1' - */ -API.v1.addRoute( - 'e2e.setUserPublicAndPrivateKeys', - { - authRequired: true, - validateParams: ise2eSetUserPublicAndPrivateKeysParamsPOST, - }, - { - async post() { - // eslint-disable-next-line @typescript-eslint/naming-convention - const { public_key, private_key, force } = this.bodyParams; +const ise2eUpdateGroupKeyParamsPOST = ajv.compile(e2eUpdateGroupKeyParamsPOSTSchema); + +const isE2EFetchUsersWaitingForGroupKeyProps = ajv.compile(E2EFetchUsersWaitingForGroupKeySchema); + +const isE2EProvideUsersGroupKeyProps = ajv.compile(E2EProvideUsersGroupKeySchema); + +const isE2EResetRoomKeyProps = ajv.compile(E2EResetRoomKeySchema); + +const e2eEndpoints = API.v1 + .post( + 'e2e.setRoomKeyID', + { + authRequired: true, + body: isE2eSetRoomKeyIdProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + }), + }, + }, - await setUserPublicAndPrivateKeysMethod(this.userId, { - public_key, - private_key, - force, - }); + async function action() { + const { rid, keyID } = this.bodyParams; + + await setRoomKeyIDMethod(this.userId, rid, keyID); return API.v1.success(); }, - }, -); + ) + .get( + 'e2e.fetchMyKeys', + { + authRequired: true, + query: undefined, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile<{ public_key?: string; private_key?: string }>({ + type: 'object', + properties: { + public_key: { type: 'string' }, + private_key: { type: 'string' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + }), + }, + }, + async function action() { + const result = await Users.fetchKeysByUserId(this.userId); -/** - * @openapi - * /api/v1/e2e.updateGroupKey: - * post: - * description: Updates the end-to-end encryption key for a user on a room - * security: - * - autenticated: {} - * requestBody: - * description: A tuple containing the user ID, the room ID, and the key - * content: - * application/json: - * schema: - * type: object - * properties: - * uid: - * type: string - * rid: - * type: string - * key: - * type: string - * responses: - * 200: - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ApiSuccessV1' - * default: - * description: Unexpected error - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ApiFailureV1' - */ -API.v1.addRoute( - 'e2e.updateGroupKey', - { - authRequired: true, - validateParams: ise2eUpdateGroupKeyParamsPOST, - }, - { - async post() { - const { uid, rid, key } = this.bodyParams; + return API.v1.success(result); + }, + ) + .get( + 'e2e.getUsersOfRoomWithoutKey', + { + authRequired: true, + query: ise2eGetUsersOfRoomWithoutKeyParamsGET, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile<{ + users: Pick[]; + }>({ + type: 'object', + properties: { + users: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + e2e: { + type: 'object', + properties: { + private_key: { type: 'string' }, + public_key: { type: 'string' }, + }, + }, + }, + required: ['_id'], + }, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['users', 'success'], + }), + }, + }, - await updateGroupKey(rid, uid, key, this.userId); + async function action() { + const { rid } = this.queryParams; - return API.v1.success(); + const result = await getUsersOfRoomWithoutKeyMethod(this.userId, rid); + + return API.v1.success(result); + }, + ) + .post( + 'e2e.rejectSuggestedGroupKey', + { + authRequired: true, + body: ise2eGetUsersOfRoomWithoutKeyParamsGET, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + }), + }, }, - }, -); -API.v1.addRoute( - 'e2e.acceptSuggestedGroupKey', - { - authRequired: true, - validateParams: ise2eGetUsersOfRoomWithoutKeyParamsGET, - }, - { - async post() { + async function action() { const { rid } = this.bodyParams; - await handleSuggestedGroupKey('accept', rid, this.userId, 'e2e.acceptSuggestedGroupKey'); + await handleSuggestedGroupKey('reject', rid, this.userId, 'e2e.rejectSuggestedGroupKey'); return API.v1.success(); }, - }, -); + ) + .post( + 'e2e.acceptSuggestedGroupKey', + { + authRequired: true, + body: ise2eGetUsersOfRoomWithoutKeyParamsGET, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + }), + }, + }, -API.v1.addRoute( - 'e2e.rejectSuggestedGroupKey', - { - authRequired: true, - validateParams: ise2eGetUsersOfRoomWithoutKeyParamsGET, - }, - { - async post() { + async function action() { const { rid } = this.bodyParams; - await handleSuggestedGroupKey('reject', rid, this.userId, 'e2e.rejectSuggestedGroupKey'); + await handleSuggestedGroupKey('accept', rid, this.userId, 'e2e.acceptSuggestedGroupKey'); return API.v1.success(); }, - }, -); + ) + .get( + 'e2e.fetchUsersWaitingForGroupKey', + { + authRequired: true, + query: isE2EFetchUsersWaitingForGroupKeyProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile<{ + usersWaitingForE2EKeys: Record; + }>({ + type: 'object', + properties: { + usersWaitingForE2EKeys: { + type: 'object', + additionalProperties: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + public_key: { type: 'string' }, + }, + required: ['_id', 'public_key'], + }, + }, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['usersWaitingForE2EKeys', 'success'], + }), + }, + }, -API.v1.addRoute( - 'e2e.fetchUsersWaitingForGroupKey', - { - authRequired: true, - validateParams: isE2EFetchUsersWaitingForGroupKeyProps, - }, - { - async get() { + async function action() { if (!settings.get('E2E_Enable')) { return API.v1.success({ usersWaitingForE2EKeys: {} }); } @@ -268,17 +367,52 @@ API.v1.addRoute( usersWaitingForE2EKeys, }); }, - }, -); + ) + + .post( + 'e2e.updateGroupKey', + { + authRequired: true, + body: ise2eUpdateGroupKeyParamsPOST, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + }), + }, + }, + async function action() { + const { uid, rid, key } = this.bodyParams; -API.v1.addRoute( - 'e2e.provideUsersSuggestedGroupKeys', - { - authRequired: true, - validateParams: isE2EProvideUsersGroupKeyProps, - }, - { - async post() { + await updateGroupKey(rid, uid, key, this.userId); + + return API.v1.success(); + }, + ) + .post( + 'e2e.provideUsersSuggestedGroupKeys', + { + authRequired: true, + body: isE2EProvideUsersGroupKeyProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + }), + }, + }, + + async function action() { if (!settings.get('E2E_Enable')) { return API.v1.success(); } @@ -287,18 +421,31 @@ API.v1.addRoute( return API.v1.success(); }, - }, -); + ) + // This should have permissions + .post( + 'e2e.resetRoomKey', + { + authRequired: true, + body: isE2EResetRoomKeyProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + }), + }, + }, -// This should have permissions -API.v1.addRoute( - 'e2e.resetRoomKey', - { authRequired: true, validateParams: isE2EResetRoomKeyProps }, - { - async post() { + async function action() { const { rid, e2eKey, e2eKeyId } = this.bodyParams; if (!(await hasPermissionAsync(this.userId, 'toggle-room-e2e-encryption', rid))) { - return API.v1.forbidden(); + return API.v1.forbidden('error-not-allowed'); } if (LockMap.has(rid)) { throw new Error('error-e2e-key-reset-in-progress'); @@ -320,10 +467,64 @@ API.v1.addRoute( LockMap.delete(rid); } }, + ); + +/** + * @openapi + * /api/v1/e2e.setUserPublicAndPrivateKeys: + * post: + * description: Sets the end-to-end encryption keys for the authenticated user + * security: + * - autenticated: {} + * requestBody: + * description: A tuple containing the public and the private keys + * content: + * application/json: + * schema: + * type: object + * properties: + * public_key: + * type: string + * private_key: + * type: string + * force: + * type: boolean + * responses: + * 200: + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiSuccessV1' + * default: + * description: Unexpected error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiFailureV1' + */ +API.v1.addRoute( + 'e2e.setUserPublicAndPrivateKeys', + { + authRequired: true, + validateParams: ise2eSetUserPublicAndPrivateKeysParamsPOST, + }, + { + async post() { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { public_key, private_key, force } = this.bodyParams; + + await setUserPublicAndPrivateKeysMethod(this.userId, { + public_key, + private_key, + force, + }); + + return API.v1.success(); + }, }, ); -export type E2eEndpoints = ExtractRoutesFromAPI; +type E2eEndpoints = ExtractRoutesFromAPI; declare module '@rocket.chat/rest-typings' { // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface diff --git a/apps/meteor/app/api/server/v1/emoji-custom.ts b/apps/meteor/app/api/server/v1/emoji-custom.ts index 15764b7f74e7d..0cc6bb53720d5 100644 --- a/apps/meteor/app/api/server/v1/emoji-custom.ts +++ b/apps/meteor/app/api/server/v1/emoji-custom.ts @@ -1,7 +1,7 @@ import { Media } from '@rocket.chat/core-services'; import type { IEmojiCustom } from '@rocket.chat/core-typings'; import { EmojiCustom } from '@rocket.chat/models'; -import { isEmojiCustomList } from '@rocket.chat/rest-typings'; +import { ajv, isEmojiCustomList } from '@rocket.chat/rest-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import { Meteor } from 'meteor/meteor'; @@ -11,6 +11,7 @@ import { insertOrUpdateEmoji } from '../../../emoji-custom/server/lib/insertOrUp import { uploadEmojiCustomWithBuffer } from '../../../emoji-custom/server/lib/uploadEmojiCustom'; import { deleteEmojiCustom } from '../../../emoji-custom/server/methods/deleteEmojiCustom'; import { settings } from '../../../settings/server'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; import { API } from '../api'; import { getPaginationItems } from '../helpers/getPaginationItems'; import { findEmojisCustom } from '../lib/emoji-custom'; @@ -103,45 +104,73 @@ API.v1.addRoute( }, ); -API.v1.addRoute( +const emojiCustomCreateEndpoints = API.v1.post( 'emoji-custom.create', - { authRequired: true }, { - async post() { - const emoji = await getUploadFormData( - { - request: this.request, + authRequired: true, + response: { + 400: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [false] }, + stack: { type: 'string' }, + error: { type: 'string' }, + errorType: { type: 'string' }, + details: { type: 'string' }, }, - { field: 'emoji', sizeLimit: settings.get('FileUpload_MaxFileSize') }, - ); + required: ['success'], + additionalProperties: false, + }), + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['success'], + additionalProperties: false, + }), + }, + }, + async function action() { + const emoji = await getUploadFormData( + { + request: this.request, + }, + { + field: 'emoji', + sizeLimit: settings.get('FileUpload_MaxFileSize'), + }, + ); - const { fields, fileBuffer, mimetype } = emoji; + const { fields, fileBuffer, mimetype } = emoji; - const isUploadable = await Media.isImage(fileBuffer); - if (!isUploadable) { - throw new Meteor.Error('emoji-is-not-image', "Emoji file provided cannot be uploaded since it's not an image"); - } + const isUploadable = await Media.isImage(fileBuffer); + if (!isUploadable) { + throw new Meteor.Error('emoji-is-not-image', "Emoji file provided cannot be uploaded since it's not an image"); + } - const [, extension] = mimetype.split('/'); - fields.extension = extension; + const [, extension] = mimetype.split('/'); + fields.extension = extension; - try { - const emojiData = await insertOrUpdateEmoji(this.userId, { - ...fields, - newFile: true, - aliases: fields.aliases || '', - name: fields.name, - extension: fields.extension, - }); + try { + const emojiData = await insertOrUpdateEmoji(this.userId, { + ...fields, + newFile: true, + aliases: fields.aliases || '', + name: fields.name, + extension: fields.extension, + }); - await uploadEmojiCustomWithBuffer(this.userId, fileBuffer, mimetype, emojiData); - } catch (err) { - SystemLogger.error({ err }); - return API.v1.failure(); - } + await uploadEmojiCustomWithBuffer(this.userId, fileBuffer, mimetype, emojiData); + } catch (err) { + SystemLogger.error({ err }); + return API.v1.failure(); + } - return API.v1.success(); - }, + return API.v1.success(); }, ); @@ -219,3 +248,12 @@ API.v1.addRoute( }, }, ); + +type EmojiCustomCreateEndpoints = ExtractRoutesFromAPI; + +export type EmojiCustomEndpoints = EmojiCustomCreateEndpoints; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends EmojiCustomCreateEndpoints {} +} diff --git a/apps/meteor/app/api/server/v1/groups.ts b/apps/meteor/app/api/server/v1/groups.ts index 3fbe9c967a8e9..2437813860836 100644 --- a/apps/meteor/app/api/server/v1/groups.ts +++ b/apps/meteor/app/api/server/v1/groups.ts @@ -34,7 +34,6 @@ import { normalizeMessagesForUser } from '../../../utils/server/lib/normalizeMes import { API } from '../api'; import { addUserToFileObj } from '../helpers/addUserToFileObj'; import { composeRoomWithLastMessage } from '../helpers/composeRoomWithLastMessage'; -import { getLoggedInUser } from '../helpers/getLoggedInUser'; import { getPaginationItems } from '../helpers/getPaginationItems'; import { getUserFromParams, getUserListFromParams } from '../helpers/getUserFromParams'; @@ -510,7 +509,7 @@ API.v1.addRoute( oldestDate = new Date(this.queryParams.oldest); } - const inclusive = this.queryParams.inclusive || false; + const inclusive = this.queryParams.inclusive === 'true'; let count = 20; if (this.queryParams.count) { @@ -522,7 +521,7 @@ API.v1.addRoute( offset = parseInt(String(this.queryParams.offset)); } - const unreads = this.queryParams.unreads || false; + const unreads = this.queryParams.unreads === 'true'; const showThreadMessages = this.queryParams.showThreadMessages !== 'false'; @@ -839,12 +838,7 @@ API.v1.addRoute( return API.v1.failure('Group does not exists'); } - const user = await getLoggedInUser(this.request); - if (!user) { - return API.v1.failure('User does not exists'); - } - - if (!(await canAccessRoomAsync(room, user))) { + if (!(await canAccessRoomAsync(room, this.user))) { throw new Meteor.Error('error-not-allowed', 'Not Allowed'); } diff --git a/apps/meteor/app/api/server/v1/im.ts b/apps/meteor/app/api/server/v1/im.ts index 9972de8ce10cb..f97bd6e7e9161 100644 --- a/apps/meteor/app/api/server/v1/im.ts +++ b/apps/meteor/app/api/server/v1/im.ts @@ -6,6 +6,7 @@ import { Subscriptions, Uploads, Messages, Rooms, Users } from '@rocket.chat/mod import { ajv, validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, validateBadRequestErrorResponse, isDmFileProps, isDmMemberProps, @@ -99,6 +100,10 @@ type DmDeleteProps = username: string; }; +type DmCloseProps = { + roomId: string; +}; + const isDmDeleteProps = ajv.compile({ oneOf: [ { @@ -144,6 +149,43 @@ const dmDeleteEndpointsProps = { }, } as const; +const DmClosePropsSchema = { + type: 'object', + properties: { + roomId: { + type: 'string', + }, + userId: { + type: 'string', + }, + }, + required: ['roomId', 'userId'], + additionalProperties: false, +}; + +const isDmCloseProps = ajv.compile(DmClosePropsSchema); + +const dmCloseEndpointsProps = { + authRequired: true, + body: isDmCloseProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['success'], + additionalProperties: false, + }), + }, +}; + const dmDeleteAction = (_path: Path): TypedAction => async function action() { const { room } = await findDirectMessageRoom(this.bodyParams, this.userId); @@ -160,51 +202,52 @@ const dmDeleteAction = (_path: Path): TypedAction(_path: Path): TypedAction => + async function action() { + const { roomId } = this.bodyParams; + if (!roomId) { + throw new Meteor.Error('error-room-param-not-provided', 'Body param "roomId" is required'); + } + if (!this.userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'dm.close', + }); + } + let subscription; -API.v1.addRoute( - ['dm.close', 'im.close'], - { authRequired: true }, - { - async post() { - const { roomId } = this.bodyParams; - if (!roomId) { - throw new Meteor.Error('error-room-param-not-provided', 'Body param "roomId" is required'); + const roomExists = !!(await Rooms.findOneById(roomId)); + if (!roomExists) { + // even if the room doesn't exist, we should allow the user to close the subscription anyways + subscription = await Subscriptions.findOneByRoomIdAndUserId(roomId, this.userId); + } else { + const canAccess = await canAccessRoomIdAsync(roomId, this.userId); + if (!canAccess) { + return API.v1.forbidden('error-not-allowed'); } - let subscription; - - const roomExists = !!(await Rooms.findOneById(roomId)); - if (!roomExists) { - // even if the room doesn't exist, we should allow the user to close the subscription anyways - subscription = await Subscriptions.findOneByRoomIdAndUserId(roomId, this.userId); - } else { - const canAccess = await canAccessRoomIdAsync(roomId, this.userId); - if (!canAccess) { - return API.v1.forbidden(); - } + const { subscription: subs } = await findDirectMessageRoom({ roomId }, this.userId); - const { subscription: subs } = await findDirectMessageRoom({ roomId }, this.userId); + subscription = subs; + } - subscription = subs; - } + if (!subscription) { + return API.v1.failure(`The user is not subscribed to the room`); + } - if (!subscription) { - return API.v1.failure(`The user is not subscribed to the room`); - } + if (!subscription.open) { + return API.v1.failure(`The direct message room, is already closed to the sender`); + } - if (!subscription.open) { - return API.v1.failure(`The direct message room, is already closed to the sender`); - } + await hideRoomMethod(this.userId, roomId); - await hideRoomMethod(this.userId, roomId); + return API.v1.success(); + }; - return API.v1.success(); - }, - }, -); +const dmEndpoints = API.v1 + .post('im.delete', dmDeleteEndpointsProps, dmDeleteAction('im.delete')) + .post('dm.delete', dmDeleteEndpointsProps, dmDeleteAction('dm.delete')) + .post('dm.close', dmCloseEndpointsProps, dmCloseAction('dm.close')) + .post('im.close', dmCloseEndpointsProps, dmCloseAction('im.close')); // https://github.com/RocketChat/Rocket.Chat/pull/9679 as reference API.v1.addRoute( diff --git a/apps/meteor/app/api/server/v1/instances.ts b/apps/meteor/app/api/server/v1/instances.ts index 47f98c856f446..6fad69d1c33b9 100644 --- a/apps/meteor/app/api/server/v1/instances.ts +++ b/apps/meteor/app/api/server/v1/instances.ts @@ -1,4 +1,5 @@ import { InstanceStatus } from '@rocket.chat/models'; +import { ajv, validateUnauthorizedErrorResponse, validateForbiddenErrorResponse } from '@rocket.chat/rest-typings'; import { isRunningMs } from '../../../../server/lib/isRunningMs'; import { API } from '../api'; @@ -12,33 +13,82 @@ const getConnections = (() => { return () => getInstanceList(); })(); -API.v1.addRoute( +API.v1.get( 'instances.get', - { authRequired: true, permissionsRequired: ['view-statistics'] }, { - async get() { - const instanceRecords = await InstanceStatus.find().toArray(); - - const connections = await getConnections(); - - const result = instanceRecords.map((instanceRecord) => { - const connection = connections.find((c) => c.id === instanceRecord._id); - - return { - address: connection?.ipList[0], + authRequired: true, + permissionsRequired: ['view-statistics'], + response: { + 200: ajv.compile<{ + instances: { + address?: string; currentStatus: { - connected: connection?.available || false, - lastHeartbeatTime: connection?.lastHeartbeatTime, - local: connection?.local, + connected: boolean; + lastHeartbeatTime?: number; + local?: boolean; + }; + instanceRecord: object; + broadcastAuth: boolean; + }[]; + success: true; + }>({ + type: 'object', + properties: { + instances: { + type: 'array', + items: { + type: 'object', + properties: { + address: { type: 'string' }, + currentStatus: { + type: 'object', + properties: { + connected: { type: 'boolean' }, + lastHeartbeatTime: { type: 'number' }, + local: { type: 'boolean' }, + }, + required: ['connected'], + }, + instanceRecord: { type: 'object' }, + broadcastAuth: { type: 'boolean' }, + }, + required: ['currentStatus', 'instanceRecord', 'broadcastAuth'], + }, }, - instanceRecord, - broadcastAuth: true, - }; - }); - - return API.v1.success({ - instances: result, - }); + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['instances', 'success'], + additionalProperties: false, + }), + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + const instanceRecords = await InstanceStatus.find().toArray(); + + const connections = await getConnections(); + + const result = instanceRecords.map((instanceRecord) => { + const connection = connections.find((c) => c.id === instanceRecord._id); + + return { + address: connection?.ipList[0], + currentStatus: { + connected: connection?.available || false, + lastHeartbeatTime: connection?.lastHeartbeatTime, + local: connection?.local, + }, + instanceRecord, + broadcastAuth: true, + }; + }); + + return API.v1.success({ + instances: result, + }); + }, ); diff --git a/apps/meteor/app/api/server/v1/ldap.ts b/apps/meteor/app/api/server/v1/ldap.ts index 3f9a2c29deded..efbf9a898d02c 100644 --- a/apps/meteor/app/api/server/v1/ldap.ts +++ b/apps/meteor/app/api/server/v1/ldap.ts @@ -1,62 +1,86 @@ import { LDAP } from '@rocket.chat/core-services'; -import { Match, check } from 'meteor/check'; +import { ajv, isLdapTestSearch, validateUnauthorizedErrorResponse, validateForbiddenErrorResponse } from '@rocket.chat/rest-typings'; import { SystemLogger } from '../../../../server/lib/logger/system'; import { settings } from '../../../settings/server'; import { API } from '../api'; -API.v1.addRoute( +const messageResponseSchema = { + type: 'object' as const, + properties: { + message: { type: 'string' as const }, + success: { + type: 'boolean' as const, + enum: [true] as const, + }, + }, + required: ['message', 'success'] as const, + additionalProperties: false, +}; + +API.v1.post( 'ldap.testConnection', - { authRequired: true, permissionsRequired: ['test-admin-options'] }, { - async post() { - if (!this.userId) { - throw new Error('error-invalid-user'); - } - - if (settings.get('LDAP_Enable') !== true) { - throw new Error('LDAP_disabled'); - } - - try { - await LDAP.testConnection(); - } catch (err) { - SystemLogger.error({ err }); - throw new Error('Connection_failed'); - } - - return API.v1.success({ - message: 'LDAP_Connection_successful' as const, - }); + authRequired: true, + permissionsRequired: ['test-admin-options'], + response: { + 200: ajv.compile<{ message: string; success: true }>(messageResponseSchema), + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + if (!this.userId) { + throw new Error('error-invalid-user'); + } + + if (settings.get('LDAP_Enable') !== true) { + throw new Error('LDAP_disabled'); + } + + try { + await LDAP.testConnection(); + } catch (err) { + SystemLogger.error({ err }); + throw new Error('Connection_failed'); + } + + return API.v1.success({ + message: 'LDAP_Connection_successful' as const, + }); + }, ); -API.v1.addRoute( +API.v1.post( 'ldap.testSearch', - { authRequired: true, permissionsRequired: ['test-admin-options'] }, { - async post() { - check( - this.bodyParams, - Match.ObjectIncluding({ - username: String, - }), - ); - - if (!this.userId) { - throw new Error('error-invalid-user'); - } - - if (settings.get('LDAP_Enable') !== true) { - throw new Error('LDAP_disabled'); - } + authRequired: true, + permissionsRequired: ['test-admin-options'], + body: isLdapTestSearch, + response: { + 200: ajv.compile<{ message: string; success: true }>(messageResponseSchema), + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + if (!this.userId) { + throw new Error('error-invalid-user'); + } + if (settings.get('LDAP_Enable') !== true) { + throw new Error('LDAP_disabled'); + } + + try { await LDAP.testSearch(this.bodyParams.username); + } catch (err) { + SystemLogger.error({ err }); + throw new Error('LDAP_search_failed'); + } - return API.v1.success({ - message: 'LDAP_User_Found' as const, - }); - }, + return API.v1.success({ + message: 'LDAP_User_Found' as const, + }); }, ); diff --git a/apps/meteor/app/api/server/v1/misc.ts b/apps/meteor/app/api/server/v1/misc.ts index c679a42582924..852351a3c5ded 100644 --- a/apps/meteor/app/api/server/v1/misc.ts +++ b/apps/meteor/app/api/server/v1/misc.ts @@ -29,7 +29,6 @@ import { getBaseUserFields } from '../../../utils/server/functions/getBaseUserFi import { isSMTPConfigured } from '../../../utils/server/functions/isSMTPConfigured'; import { getURL } from '../../../utils/server/getURL'; import { API } from '../api'; -import { getLoggedInUser } from '../helpers/getLoggedInUser'; import { getPaginationItems } from '../helpers/getPaginationItems'; import { getUserFromParams } from '../helpers/getUserFromParams'; import { getUserInfo } from '../helpers/getUserInfo'; @@ -244,7 +243,7 @@ API.v1.addRoute( text = `#${channel}`; break; case 'user': - if (settings.get('API_Shield_user_require_auth') && !(await getLoggedInUser(this.request))) { + if (settings.get('API_Shield_user_require_auth') && !this.user) { return API.v1.failure('You must be logged in to do this.'); } const user = await getUserFromParams(this.queryParams); diff --git a/apps/meteor/app/api/server/v1/presence.ts b/apps/meteor/app/api/server/v1/presence.ts index 019137569f610..28e83ecb68f80 100644 --- a/apps/meteor/app/api/server/v1/presence.ts +++ b/apps/meteor/app/api/server/v1/presence.ts @@ -1,27 +1,63 @@ import { Presence } from '@rocket.chat/core-services'; +import { ajv, validateUnauthorizedErrorResponse, validateForbiddenErrorResponse } from '@rocket.chat/rest-typings'; import { API } from '../api'; -API.v1.addRoute( +API.v1.get( 'presence.getConnections', - { authRequired: true, permissionsRequired: ['manage-user-status'] }, { - async get() { - const result = await Presence.getConnectionCount(); - - return API.v1.success(result); + authRequired: true, + permissionsRequired: ['manage-user-status'], + response: { + 200: ajv.compile<{ current: number; max: number; success: true }>({ + type: 'object', + properties: { + current: { type: 'number' }, + max: { type: 'number' }, + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['current', 'max', 'success'], + additionalProperties: false, + }), + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + const result = await Presence.getConnectionCount(); + + return API.v1.success(result); + }, ); -API.v1.addRoute( +API.v1.post( 'presence.enableBroadcast', - { authRequired: true, permissionsRequired: ['manage-user-status'], twoFactorRequired: true }, { - async post() { - await Presence.toggleBroadcast(true); - - return API.v1.success(); + authRequired: true, + permissionsRequired: ['manage-user-status'], + twoFactorRequired: true, + response: { + 200: ajv.compile<{ success: true }>({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['success'], + additionalProperties: false, + }), + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + await Presence.toggleBroadcast(true); + + return API.v1.success({}); + }, ); diff --git a/apps/meteor/app/api/server/v1/push.ts b/apps/meteor/app/api/server/v1/push.ts index f84076d83d51e..0d5e21e3f97eb 100644 --- a/apps/meteor/app/api/server/v1/push.ts +++ b/apps/meteor/app/api/server/v1/push.ts @@ -1,85 +1,211 @@ -import type { IAppsTokens } from '@rocket.chat/core-typings'; -import { Messages, AppsTokens, Users, Rooms, Settings } from '@rocket.chat/models'; -import { Random } from '@rocket.chat/random'; +import { Push } from '@rocket.chat/core-services'; +import type { IPushToken } from '@rocket.chat/core-typings'; +import { Messages, PushToken, Users, Rooms, Settings } from '@rocket.chat/models'; +import { + ajv, + validateNotFoundErrorResponse, + validateBadRequestErrorResponse, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, +} from '@rocket.chat/rest-typings'; +import type { JSONSchemaType } from 'ajv'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { executePushTest } from '../../../../server/lib/pushConfig'; import { canAccessRoomAsync } from '../../../authorization/server/functions/canAccessRoom'; -import { pushUpdate } from '../../../push/server/methods'; import PushNotification from '../../../push-notifications/server/lib/PushNotification'; import { settings } from '../../../settings/server'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; import { API } from '../api'; +import type { SuccessResult } from '../definition'; -API.v1.addRoute( - 'push.token', - { authRequired: true }, - { - async post() { - const { id, type, value, appName } = this.bodyParams; +type PushTokenPOST = { + id?: string; + type: 'apn' | 'gcm'; + value: string; + appName: string; +}; - if (id && typeof id !== 'string') { - throw new Meteor.Error('error-id-param-not-valid', 'The required "id" body param is invalid.'); - } +const PushTokenPOSTSchema: JSONSchemaType = { + type: 'object', + properties: { + id: { + type: 'string', + nullable: true, + }, + type: { + type: 'string', + enum: ['apn', 'gcm'], + }, + value: { + type: 'string', + minLength: 1, + }, + appName: { + type: 'string', + minLength: 1, + }, + }, + required: ['type', 'value', 'appName'], + additionalProperties: false, +}; - const deviceId = id || Random.id(); +export const isPushTokenPOSTProps = ajv.compile(PushTokenPOSTSchema); - if (!type || (type !== 'apn' && type !== 'gcm')) { - throw new Meteor.Error('error-type-param-not-valid', 'The required "type" body param is missing or invalid.'); - } +type PushTokenDELETE = { + token: string; +}; - if (!value || typeof value !== 'string') { - throw new Meteor.Error('error-token-param-not-valid', 'The required "value" body param is missing or invalid.'); - } +const PushTokenDELETESchema: JSONSchemaType = { + type: 'object', + properties: { + token: { + type: 'string', + minLength: 1, + }, + }, + required: ['token'], + additionalProperties: false, +}; - if (!appName || typeof appName !== 'string') { - throw new Meteor.Error('error-appName-param-not-valid', 'The required "appName" body param is missing or invalid.'); - } +export const isPushTokenDELETEProps = ajv.compile(PushTokenDELETESchema); + +type PushTokenResult = Pick; + +/** + * Pick only the attributes we actually want to return on the endpoint, ensuring nothing from older schemas get mixed in + */ +function cleanTokenResult(result: Omit): PushTokenResult { + const { _id, token, appName, userId, enabled, createdAt, _updatedAt } = result; + + return { + _id, + token, + appName, + userId, + enabled, + createdAt, + _updatedAt, + }; +} + +const pushTokenEndpoints = API.v1 + .post( + 'push.token', + { + response: { + 200: ajv.compile['body']>({ + additionalProperties: false, + type: 'object', + properties: { + success: { + type: 'boolean', + description: 'Indicates if the request was successful.', + }, + result: { + type: 'object', + description: 'The updated token data for this device', + properties: { + _id: { + type: 'string', + }, + token: { + type: 'object', + properties: { + apn: { + type: 'string', + }, + gcm: { + type: 'string', + }, + }, + required: [], + additionalProperties: false, + }, + appName: { + type: 'string', + }, + userId: { + type: 'string', + nullable: true, + }, + enabled: { + type: 'boolean', + }, + createdAt: { + type: 'string', + }, + _updatedAt: { + type: 'string', + }, + }, + additionalProperties: false, + }, + }, + required: ['success', 'result'], + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + body: isPushTokenPOSTProps, + authRequired: true, + }, + async function action() { + const { id, type, value, appName } = this.bodyParams; - const authToken = this.request.headers.get('x-auth-token'); - if (!authToken) { + const rawToken = this.request.headers.get('x-auth-token'); + if (!rawToken) { throw new Meteor.Error('error-authToken-param-not-valid', 'The required "authToken" header param is missing or invalid.'); } + const authToken = Accounts._hashLoginToken(rawToken); - const result = await pushUpdate({ - id: deviceId, - token: { [type]: value } as IAppsTokens['token'], + const result = await Push.registerPushToken({ + ...(id && { _id: id }), + token: { [type]: value } as IPushToken['token'], authToken, appName, userId: this.userId, }); - return API.v1.success({ result }); + return API.v1.success({ result: cleanTokenResult(result) }); + }, + ) + .delete( + 'push.token', + { + response: { + 200: ajv.compile({ + additionalProperties: false, + type: 'object', + properties: { + success: { + type: 'boolean', + }, + }, + required: ['success'], + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 404: validateNotFoundErrorResponse, + }, + body: isPushTokenDELETEProps, + authRequired: true, }, - async delete() { + async function action() { const { token } = this.bodyParams; - if (!token || typeof token !== 'string') { - throw new Meteor.Error('error-token-param-not-valid', 'The required "token" body param is missing or invalid.'); - } + const removeResult = await PushToken.removeAllByTokenStringAndUserId(token, this.userId); - const affectedRecords = ( - await AppsTokens.deleteMany({ - $or: [ - { - 'token.apn': token, - }, - { - 'token.gcm': token, - }, - ], - userId: this.userId, - }) - ).deletedCount; - - if (affectedRecords === 0) { + if (removeResult.deletedCount === 0) { return API.v1.notFound(); } return API.v1.success(); }, - }, -); + ); API.v1.addRoute( 'push.get', @@ -135,7 +261,7 @@ API.v1.addRoute( }, ); -API.v1.addRoute( +const pushTestEndpoints = API.v1.post( 'push.test', { authRequired: true, @@ -144,17 +270,44 @@ API.v1.addRoute( intervalTimeInMS: 1000, }, permissionsRequired: ['test-push-notifications'], + body: ajv.compile({ type: 'object', additionalProperties: false }), + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile<{ tokensCount: number }>({ + type: 'object', + properties: { + tokensCount: { type: 'integer' }, + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['tokensCount', 'success'], + additionalProperties: false, + }), + }, }, - { - async post() { - if (settings.get('Push_enable') !== true) { - throw new Meteor.Error('error-push-disabled', 'Push is disabled', { - method: 'push_test', - }); - } - const tokensCount = await executePushTest(this.userId, this.user.username); - return API.v1.success({ tokensCount }); - }, + async function action() { + if (settings.get('Push_enable') !== true) { + throw new Meteor.Error('error-push-disabled', 'Push is disabled', { + method: 'push_test', + }); + } + + const tokensCount = await executePushTest(this.userId, this.user.username); + return API.v1.success({ tokensCount }); }, ); + +type PushTestEndpoints = ExtractRoutesFromAPI; + +type PushTokenEndpoints = ExtractRoutesFromAPI; + +type PushEndpoints = PushTestEndpoints & PushTokenEndpoints; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends PushEndpoints {} +} diff --git a/apps/meteor/app/api/server/v1/rooms.ts b/apps/meteor/app/api/server/v1/rooms.ts index 686f76a7476c6..835c6cc5fd65b 100644 --- a/apps/meteor/app/api/server/v1/rooms.ts +++ b/apps/meteor/app/api/server/v1/rooms.ts @@ -121,37 +121,58 @@ API.v1.addRoute( }, ); -API.v1.addRoute( +const roomDeleteEndpoint = API.v1.post( 'rooms.delete', { authRequired: true, + body: ajv.compile<{ roomId: string }>({ + type: 'object', + properties: { + roomId: { + type: 'string', + description: 'The ID of the room to delete.', + }, + }, + required: ['roomId'], + additionalProperties: false, + }), + response: { + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + description: 'Indicates if the request was successful.', + }, + }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async post() { - const { roomId } = this.bodyParams; - - if (!roomId) { - return API.v1.failure("The 'roomId' param is required"); - } + async function action() { + const { roomId } = this.bodyParams; - const room = await Rooms.findOneById(roomId); + const room = await Rooms.findOneById(roomId); - if (!room) { - throw new MeteorError('error-invalid-room', 'Invalid room', { - method: 'eraseRoom', - }); - } + if (!room) { + throw new MeteorError('error-invalid-room', 'Invalid room', { + method: 'eraseRoom', + }); + } - if (room.teamMain) { - throw new Meteor.Error('error-cannot-delete-team-channel', 'Cannot delete a team channel', { - method: 'eraseRoom', - }); - } + if (room.teamMain) { + throw new Meteor.Error('error-cannot-delete-team-channel', 'Cannot delete a team channel', { + method: 'eraseRoom', + }); + } - await eraseRoom(room, this.user); + await eraseRoom(room, this.user); - return API.v1.success(); - }, + return API.v1.success(); }, ); @@ -257,7 +278,7 @@ API.v1.addRoute( return API.v1.forbidden(); } - const file = await Uploads.findOneById(this.urlParams.fileId); + const file = await Uploads.findOneByIdAndUserIdAndRoomId(this.urlParams.fileId, this.userId, this.urlParams.rid); if (!file) { throw new Meteor.Error('invalid-file'); @@ -285,49 +306,53 @@ API.v1.addRoute( }, ); -API.v1.addRoute( - 'rooms.saveNotification', - { authRequired: true }, - { - async post() { - const { roomId, notifications } = this.bodyParams; - - if (!roomId) { - return API.v1.failure("The 'roomId' param is required"); - } - - if (!notifications || Object.keys(notifications).length === 0) { - return API.v1.failure("The 'notifications' param is required"); - } - - await Promise.all( - Object.entries(notifications as Notifications).map(async ([notificationKey, notificationValue]) => - saveNotificationSettingsMethod(this.userId, roomId, notificationKey as NotificationFieldType, notificationValue), - ), - ); - - return API.v1.success(); +const saveNotificationBodySchema = ajv.compile<{ + roomId: string; + notifications: Record; +}>({ + type: 'object', + properties: { + roomId: { type: 'string', minLength: 1 }, + notifications: { + type: 'object', + minProperties: 1, + additionalProperties: { type: 'string' }, }, }, -); - -API.v1.addRoute( - 'rooms.favorite', - { authRequired: true }, - { - async post() { - const { favorite } = this.bodyParams; + required: ['roomId', 'notifications'], + additionalProperties: false, +}); - if (!this.bodyParams.hasOwnProperty('favorite')) { - return API.v1.failure("The 'favorite' param is required"); - } +const saveNotificationResponseSchema = ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, +}); - const room = await findRoomByIdOrName({ params: this.bodyParams }); +const roomsSaveNotificationEndpoint = API.v1.post( + 'rooms.saveNotification', + { + authRequired: true, + body: saveNotificationBodySchema, + response: { + 200: saveNotificationResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { roomId, notifications } = this.bodyParams; - await toggleFavoriteMethod(this.userId, room._id, favorite); + await Promise.all( + Object.entries(notifications as Notifications).map(async ([notificationKey, notificationValue]) => + saveNotificationSettingsMethod(this.userId, roomId, notificationKey as NotificationFieldType, notificationValue), + ), + ); - return API.v1.success(); - }, + return API.v1.success({ success: true }); }, ); @@ -410,23 +435,6 @@ API.v1.addRoute( }, ); -API.v1.addRoute( - 'rooms.leave', - { authRequired: true }, - { - async post() { - const room = await findRoomByIdOrName({ params: this.bodyParams }); - const user = await Users.findOneById(this.userId); - if (!user) { - return API.v1.failure('Invalid user'); - } - await leaveRoomMethod(user, room._id); - - return API.v1.success(); - }, - }, -); - /* TO-DO: 8.0.0 should use the ajv validation which will change this endpoint's @@ -945,6 +953,24 @@ API.v1.addRoute( }, ); +type RoomsFavorite = + | { + roomId: string; + favorite: boolean; + } + | { + roomName: string; + favorite: boolean; + }; + +type RoomsLeave = + | { + roomId: string; + } + | { + roomName: string; + }; + const isRoomGetRolesPropsSchema = { type: 'object', properties: { @@ -953,6 +979,54 @@ const isRoomGetRolesPropsSchema = { additionalProperties: false, required: ['rid'], }; + +const RoomsFavoriteSchema = { + anyOf: [ + { + type: 'object', + properties: { + favorite: { type: 'boolean' }, + roomName: { type: 'string' }, + }, + required: ['roomName', 'favorite'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + favorite: { type: 'boolean' }, + roomId: { type: 'string' }, + }, + required: ['roomId', 'favorite'], + additionalProperties: false, + }, + ], +}; + +const isRoomsLeavePropsSchema = { + anyOf: [ + { + type: 'object', + properties: { + roomId: { type: 'string' }, + }, + required: ['roomId'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + roomName: { type: 'string' }, + }, + required: ['roomName'], + additionalProperties: false, + }, + ], +}; + +const isRoomsFavoriteProps = ajv.compile(RoomsFavoriteSchema); +const isRoomsLeaveProps = ajv.compile(isRoomsLeavePropsSchema); + export const roomEndpoints = API.v1 .get( 'rooms.roles', @@ -1066,39 +1140,105 @@ export const roomEndpoints = API.v1 total, }); }, - ); + ) + .post( + 'rooms.invite', + { + authRequired: true, + body: isRoomsInviteProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, + }), + }, + }, + async function action() { + const { roomId, action } = this.bodyParams; -const roomInviteEndpoints = API.v1.post( - 'rooms.invite', - { - authRequired: true, - body: isRoomsInviteProps, - response: { - 400: validateBadRequestErrorResponse, - 401: validateUnauthorizedErrorResponse, - 200: ajv.compile({ - type: 'object', - properties: { - success: { type: 'boolean', enum: [true] }, - }, - required: ['success'], - additionalProperties: false, - }), + try { + await FederationMatrix.handleInvite(roomId, this.userId, action); + return API.v1.success(); + } catch (error) { + return API.v1.failure({ error: `Failed to handle invite: ${error instanceof Error ? error.message : String(error)}` }); + } }, - }, - async function action() { - const { roomId, action } = this.bodyParams; + ) + .post( + 'rooms.favorite', + { + authRequired: true, + body: isRoomsFavoriteProps, + response: { + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + description: 'Indicates if the request was successful.', + }, + }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { favorite } = this.bodyParams; + + const room = await findRoomByIdOrName({ params: this.bodyParams }); + + await toggleFavoriteMethod(this.userId, room._id, favorite); - try { - await FederationMatrix.handleInvite(roomId, this.userId, action); return API.v1.success(); - } catch (error) { - return API.v1.failure({ error: `Failed to handle invite: ${error instanceof Error ? error.message : String(error)}` }); - } - }, -); + }, + ) + .post( + 'rooms.leave', + { + authRequired: true, + body: isRoomsLeaveProps, + response: { + 200: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const room = await findRoomByIdOrName({ params: this.bodyParams }); + + const user = await Users.findOneById(this.userId); + + if (!user) { + return API.v1.failure('error-invalid-user'); + } + + await leaveRoomMethod(user, room._id); + + return API.v1.success(); + }, + ); -type RoomEndpoints = ExtractRoutesFromAPI & ExtractRoutesFromAPI; +type RoomEndpoints = ExtractRoutesFromAPI & + ExtractRoutesFromAPI & + ExtractRoutesFromAPI & + ExtractRoutesFromAPI; declare module '@rocket.chat/rest-typings' { // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index 88b6b67e3b442..d9e37b1f7b8b2 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -19,6 +19,8 @@ import { isUsersCheckUsernameAvailabilityParamsGET, isUsersSendConfirmationEmailParamsPOST, ajv, + validateBadRequestErrorResponse, + validateUnauthorizedErrorResponse, } from '@rocket.chat/rest-typings'; import { getLoginExpirationInMs, wrapExceptions } from '@rocket.chat/tools'; import { Accounts } from 'meteor/accounts-base'; @@ -97,20 +99,6 @@ API.v1.addRoute( }, ); -API.v1.addRoute( - 'users.getAvatarSuggestion', - { - authRequired: true, - }, - { - async get() { - const suggestions = await getAvatarSuggestionForUser(this.user); - - return API.v1.success({ suggestions }); - }, - }, -); - API.v1.addRoute( 'users.update', { authRequired: true, twoFactorRequired: true, validateParams: isUsersUpdateParamsPOST }, @@ -764,72 +752,132 @@ API.v1.addRoute( }, ); -const usersEndpoints = API.v1.post( - 'users.createToken', - { - authRequired: true, - body: ajv.compile<{ userId: string; secret: string }>({ - type: 'object', - properties: { - userId: { - type: 'string', - minLength: 1, - }, - secret: { - type: 'string', - minLength: 1, - }, - }, - required: ['userId', 'secret'], - additionalProperties: false, - }), - response: { - 200: ajv.compile<{ data: { userId: string; authToken: string } }>({ +const usersEndpoints = API.v1 + .post( + 'users.createToken', + { + authRequired: true, + body: ajv.compile<{ userId: string; secret: string }>({ type: 'object', properties: { - data: { - type: 'object', - properties: { - userId: { - type: 'string', - minLength: 1, - }, - authToken: { - type: 'string', - minLength: 1, - }, - }, - required: ['userId'], - additionalProperties: false, + userId: { + type: 'string', + minLength: 1, }, - success: { - type: 'boolean', - enum: [true], + secret: { + type: 'string', + minLength: 1, }, }, - required: ['data', 'success'], - additionalProperties: false, - }), - 400: ajv.compile({ - type: 'object', - properties: { - success: { type: 'boolean', enum: [false] }, - error: { type: 'string' }, - errorType: { type: 'string' }, - }, - required: ['success'], + required: ['userId', 'secret'], additionalProperties: false, }), + response: { + 200: ajv.compile<{ data: { userId: string; authToken: string } }>({ + type: 'object', + properties: { + data: { + type: 'object', + properties: { + userId: { + type: 'string', + minLength: 1, + }, + authToken: { + type: 'string', + minLength: 1, + }, + }, + required: ['userId'], + additionalProperties: false, + }, + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['data', 'success'], + additionalProperties: false, + }), + 400: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [false] }, + error: { type: 'string' }, + errorType: { type: 'string' }, + }, + required: ['success'], + additionalProperties: false, + }), + }, }, - }, - async function action() { - const user = await getUserFromParams(this.bodyParams); + async function action() { + const user = await getUserFromParams(this.bodyParams); - const data = await generateAccessToken(user._id, this.bodyParams.secret); + const data = await generateAccessToken(user._id, this.bodyParams.secret); - return API.v1.success({ data }); - }, -); + return API.v1.success({ data }); + }, + ) + .get( + 'users.getAvatarSuggestion', + { + authRequired: true, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile<{ + suggestions: Record< + string, + { + blob: string; + contentType: string; + service: string; + url: string; + } + >; + }>({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + suggestions: { + type: 'object', + additionalProperties: { + type: 'object', + properties: { + blob: { + type: 'string', + }, + contentType: { + type: 'string', + }, + service: { + type: 'string', + }, + url: { + type: 'string', + format: 'uri', + }, + }, + required: ['blob', 'contentType', 'service', 'url'], + additionalProperties: false, + }, + }, + }, + required: ['success', 'suggestions'], + additionalProperties: false, + }), + }, + }, + async function action() { + const suggestions = await getAvatarSuggestionForUser(this.user); + + return API.v1.success({ suggestions }); + }, + ); API.v1.addRoute( 'users.getPreferences', diff --git a/apps/meteor/app/authorization/server/functions/canSendMessage.ts b/apps/meteor/app/authorization/server/functions/canSendMessage.ts index b9d6b740c2ddd..5f5c97fda453d 100644 --- a/apps/meteor/app/authorization/server/functions/canSendMessage.ts +++ b/apps/meteor/app/authorization/server/functions/canSendMessage.ts @@ -13,9 +13,10 @@ const subscriptionOptions = { }, }; +// TODO: remove option uid and username and type export async function validateRoomMessagePermissionsAsync( room: IRoom | null, - { uid, username, type }: { uid: IUser['_id']; username: IUser['username']; type: IUser['type'] }, + args: { uid: IUser['_id']; username: IUser['username']; type: IUser['type'] } | IUser, extraData?: Record, ): Promise { if (!room) { @@ -25,33 +26,34 @@ export async function validateRoomMessagePermissionsAsync( if (room.archived) { throw new Error('room_is_archived'); } - - if (type !== 'app' && !(await canAccessRoomAsync(room, { _id: uid }, extraData))) { + if (args.type !== 'app' && !(await canAccessRoomAsync(room, 'uid' in args ? { _id: args.uid } : args, extraData))) { throw new Error('error-not-allowed'); } - if (await roomCoordinator.getRoomDirectives(room.t).allowMemberAction(room, RoomMemberActions.BLOCK, uid)) { - const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, uid, subscriptionOptions); + if ( + await roomCoordinator.getRoomDirectives(room.t).allowMemberAction(room, RoomMemberActions.BLOCK, 'uid' in args ? args.uid : args._id) + ) { + const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, 'uid' in args ? args.uid : args._id, subscriptionOptions); if (subscription && (subscription.blocked || subscription.blocker)) { throw new Error('room_is_blocked'); } } - if (room.ro === true && !(await hasPermissionAsync(uid, 'post-readonly', room._id))) { + if (room.ro === true && !(await hasPermissionAsync('uid' in args ? args.uid : args._id, 'post-readonly', room._id))) { // Unless the user was manually unmuted - if (username && !(room.unmuted || []).includes(username)) { + if (args.username && !(room.unmuted || []).includes(args.username)) { throw new Error("You can't send messages because the room is readonly."); } } - if (username && room?.muted?.includes(username)) { + if (args.username && room?.muted?.includes(args.username)) { throw new Error('You_have_been_muted'); } } - +// TODO: remove option uid and username and type export async function canSendMessageAsync( rid: IRoom['_id'], - { uid, username, type }: { uid: IUser['_id']; username: IUser['username']; type: IUser['type'] }, + user: { uid: IUser['_id']; username: IUser['username']; type: IUser['type'] } | IUser, extraData?: Record, ): Promise { const room = await Rooms.findOneById(rid); @@ -59,6 +61,6 @@ export async function canSendMessageAsync( throw new Error('error-invalid-room'); } - await validateRoomMessagePermissionsAsync(room, { uid, username, type }, extraData); + await validateRoomMessagePermissionsAsync(room, user, extraData); return room; } diff --git a/apps/meteor/app/autotranslate/server/functions/translateMessage.ts b/apps/meteor/app/autotranslate/server/functions/translateMessage.ts index c3c9b0d8709df..8c0545369dcca 100644 --- a/apps/meteor/app/autotranslate/server/functions/translateMessage.ts +++ b/apps/meteor/app/autotranslate/server/functions/translateMessage.ts @@ -12,7 +12,15 @@ export const translateMessage = async (targetLanguage?: string, message?: IMessa } const room = await Rooms.findOneById(message?.rid); + let translatedMessage; + if (message && room) { - await TranslationProviderRegistry.translateMessage(message, room, targetLanguage); + translatedMessage = await TranslationProviderRegistry.translateMessage(message, room, targetLanguage); } + + if (!translatedMessage) { + return; + } + + return translatedMessage; }; diff --git a/apps/meteor/app/autotranslate/server/methods/translateMessage.ts b/apps/meteor/app/autotranslate/server/methods/translateMessage.ts index 7c8741782647c..c9d2cc43996be 100644 --- a/apps/meteor/app/autotranslate/server/methods/translateMessage.ts +++ b/apps/meteor/app/autotranslate/server/methods/translateMessage.ts @@ -7,7 +7,7 @@ import { translateMessage } from '../functions/translateMessage'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { - 'autoTranslate.translateMessage'(message: IMessage | undefined, targetLanguage: string): Promise; + 'autoTranslate.translateMessage'(message: IMessage | undefined, targetLanguage: string): Promise; } } diff --git a/apps/meteor/app/custom-sounds/server/startup/custom-sounds.js b/apps/meteor/app/custom-sounds/server/startup/custom-sounds.js index 117a7d3c9e759..601157a884de6 100644 --- a/apps/meteor/app/custom-sounds/server/startup/custom-sounds.js +++ b/apps/meteor/app/custom-sounds/server/startup/custom-sounds.js @@ -1,3 +1,4 @@ +import { CustomSounds } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import { WebApp } from 'meteor/webapp'; @@ -7,7 +8,7 @@ import { settings } from '../../../settings/server'; export let RocketChatFileCustomSoundsInstance; -Meteor.startup(() => { +const initializeCustomSoundsStorage = () => { let storeType = 'GridFS'; if (settings.get('CustomSounds_Storage_Type')) { @@ -36,7 +37,10 @@ Meteor.startup(() => { name: 'custom_sounds', absolutePath: path, }); +}; +Meteor.startup(() => { + initializeCustomSoundsStorage(); return WebApp.connectHandlers.use('/custom-sounds/', async (req, res /* , next*/) => { const fileId = decodeURIComponent(req.url.replace(/^\//, '').replace(/\?.*$/, '')); @@ -47,7 +51,17 @@ Meteor.startup(() => { return; } + const sound = await CustomSounds.findOneById(fileId.split('.')[0], { projection: { _id: 1 } }); + + if (!sound) { + res.writeHead(404); + res.write('Not found'); + res.end(); + return; + } + const file = await RocketChatFileCustomSoundsInstance.getFileWithReadStream(fileId); + if (!file) { res.writeHead(404); res.write('Not found'); @@ -86,3 +100,5 @@ Meteor.startup(() => { file.readStream.pipe(res); }); }); + +settings.watchMultiple(['CustomSounds_Storage_Type', 'CustomSounds_FileSystemPath'], initializeCustomSoundsStorage); diff --git a/apps/meteor/app/emoji-custom/server/startup/emoji-custom.js b/apps/meteor/app/emoji-custom/server/startup/emoji-custom.js index fed5123b115f4..d784630013a57 100644 --- a/apps/meteor/app/emoji-custom/server/startup/emoji-custom.js +++ b/apps/meteor/app/emoji-custom/server/startup/emoji-custom.js @@ -1,3 +1,4 @@ +import { EmojiCustom } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import { WebApp } from 'meteor/webapp'; import _ from 'underscore'; @@ -8,7 +9,36 @@ import { settings } from '../../../settings/server'; export let RocketChatFileEmojiCustomInstance; -Meteor.startup(() => { +const writeSvgFallback = (res, req) => { + res.setHeader('Content-Type', 'image/svg+xml'); + res.setHeader('Cache-Control', 'public, max-age=0'); + res.setHeader('Expires', '-1'); + res.setHeader('Last-Modified', 'Thu, 01 Jan 2015 00:00:00 GMT'); + + const reqModifiedHeader = req.headers['if-modified-since']; + if (reqModifiedHeader != null) { + if (reqModifiedHeader === 'Thu, 01 Jan 2015 00:00:00 GMT') { + res.writeHead(304); + res.end(); + return; + } + } + + const color = '#000'; + const initials = '?'; + + const svg = ` + + + ${initials} + +`; + + res.write(svg); + res.end(); +}; + +const initializeEmojiCustomStorage = () => { let storeType = 'GridFS'; if (settings.get('EmojiUpload_Storage_Type')) { @@ -37,7 +67,10 @@ Meteor.startup(() => { name: 'custom_emoji', absolutePath: path, }); +}; +Meteor.startup(() => { + initializeEmojiCustomStorage(); return WebApp.connectHandlers.use('/emoji-custom/', async (req, res /* , next*/) => { const params = { emoji: decodeURIComponent(req.url.replace(/^\//, '').replace(/\?.*$/, '')) }; @@ -48,39 +81,19 @@ Meteor.startup(() => { return; } - const file = await RocketChatFileEmojiCustomInstance.getFileWithReadStream(encodeURIComponent(params.emoji)); - res.setHeader('Content-Disposition', 'inline'); + const emoji = await EmojiCustom.findOneByName(params.emoji.split('.')[0], { projection: { _id: 1 } }); + + if (!emoji) { + return writeSvgFallback(res, req); + } + + const file = await RocketChatFileEmojiCustomInstance.getFileWithReadStream(encodeURIComponent(params.emoji)); + if (!file) { // use code from username initials renderer until file upload is complete - res.setHeader('Content-Type', 'image/svg+xml'); - res.setHeader('Cache-Control', 'public, max-age=0'); - res.setHeader('Expires', '-1'); - res.setHeader('Last-Modified', 'Thu, 01 Jan 2015 00:00:00 GMT'); - - const reqModifiedHeader = req.headers['if-modified-since']; - if (reqModifiedHeader != null) { - if (reqModifiedHeader === 'Thu, 01 Jan 2015 00:00:00 GMT') { - res.writeHead(304); - res.end(); - return; - } - } - - const color = '#000'; - const initials = '?'; - - const svg = ` - - - ${initials} - -`; - - res.write(svg); - res.end(); - return; + return writeSvgFallback(res, req); } const fileUploadDate = file.uploadDate != null ? file.uploadDate.toUTCString() : undefined; @@ -108,3 +121,5 @@ Meteor.startup(() => { file.readStream.pipe(res); }); }); + +settings.watchMultiple(['EmojiUpload_Storage_Type', 'EmojiUpload_FileSystemPath'], initializeEmojiCustomStorage); diff --git a/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts b/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts index 15fcba1875388..e105d1962d895 100644 --- a/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts +++ b/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts @@ -38,6 +38,13 @@ export const parseFileIntoMessageAttachments = async ( ): Promise => { validateFileRequiredFields(file); + const upload = await Uploads.findOneByIdAndUserIdAndRoomId(file._id, user._id, roomId, { projection: { _id: 1 } }); + if (!upload) { + throw new Meteor.Error('error-invalid-file', 'Invalid file', { + method: 'sendFileMessage', + }); + } + await Uploads.updateFileComplete(file._id, user._id, omit(file, '_id')); const fileUrl = FileUpload.getPath(`${file._id}/${encodeURI(file.name || '')}`); diff --git a/apps/meteor/app/integrations/server/api/api.ts b/apps/meteor/app/integrations/server/api/api.ts index 7e72862f70588..511b22f3f97d0 100644 --- a/apps/meteor/app/integrations/server/api/api.ts +++ b/apps/meteor/app/integrations/server/api/api.ts @@ -3,7 +3,6 @@ import { Integrations, Users } from '@rocket.chat/models'; import { Random } from '@rocket.chat/random'; import { isIntegrationsHooksAddSchema, isIntegrationsHooksRemoveSchema } from '@rocket.chat/rest-typings'; import type express from 'express'; -import type { Context, Next } from 'hono'; import { Meteor } from 'meteor/meteor'; import type { RateLimiterOptionsToCheck } from 'meteor/rate-limit'; import { WebApp } from 'meteor/webapp'; @@ -48,7 +47,7 @@ type IntegrationThis = GenericRouteExecutionContext & { request: Request & { integration: IIncomingIntegration; }; - user: IUser & { username: RequiredField }; + user: RequiredField; }; async function createIntegration(options: IntegrationOptions, user: IUser): Promise { @@ -118,6 +117,42 @@ async function removeIntegration(options: { target_url: string }, user: IUser): return API.v1.success(); } +/** + * Slack/GitHub-style webhooks send JSON wrapped in a `payload` field + * with Content-Type: application/x-www-form-urlencoded (e.g. `payload={"text":"hello"}`). + * This function unwraps it so integrations receive the parsed JSON directly. + */ +function getBodyParams(bodyParams: unknown, request: Request): Record { + if (!isPlainObject(bodyParams)) { + return {}; + } + + if ( + request.headers.get('content-type')?.startsWith('application/x-www-form-urlencoded') && + Object.keys(bodyParams).length === 1 && + typeof bodyParams.payload === 'string' + ) { + try { + const parsed = JSON.parse(bodyParams.payload); + + // Valid JSON must be an object, not an array or primitive + if (!isPlainObject(parsed)) { + throw new Error('Integration payload must be a JSON object, not an array or primitive'); + } + + return parsed; + } catch (err) { + // Invalid JSON -> return original bodyParams (backward compatibility) + if (err instanceof SyntaxError) { + return bodyParams; + } + throw err; + } + } + + return bodyParams; +} + async function executeIntegrationRest( this: IntegrationThis, ): Promise< @@ -142,7 +177,13 @@ async function executeIntegrationRest( const scriptEngine = getEngine(this.request.integration); - let bodyParams = isPlainObject(this.bodyParams) ? this.bodyParams : {}; + let bodyParams: Record; + try { + bodyParams = getBodyParams(this.bodyParams, this.request); + } catch (err) { + return API.v1.failure(err instanceof Error ? err.message : String(err)); + } + const separateResponse = bodyParams.separateResponse === true; let scriptResponse: Record | undefined; @@ -328,6 +369,8 @@ class WebHookAPI extends APIClass<'/hooks'> { throw new Error('Invalid integration id or token provided.'); } + routeContext.request.headers.set('x-auth-token', token); + routeContext.request.integration = integration; return Users.findOneById(routeContext.request.integration.userId); @@ -388,51 +431,6 @@ Api.router .use(metricsMiddleware({ basePathRegex: new RegExp(/^\/hooks\//), api: Api, settings, summary: metrics.rocketchatRestApi })) .use(tracerSpanMiddleware); -const middleware = async (c: Context, next: Next): Promise => { - const { req } = c; - if (req.raw.headers.get('content-type') !== 'application/x-www-form-urlencoded') { - return next(); - } - - try { - const content = await req.raw.clone().text(); - const body = Object.fromEntries(new URLSearchParams(content)); - if (!body || typeof body !== 'object' || Object.keys(body).length !== 1) { - return next(); - } - - /** - * Slack/GitHub-style webhooks send JSON wrapped in a `payload` field with - * Content-Type: application/x-www-form-urlencoded (e.g. `payload={"text":"hello"}`). - * We unwrap it here so integrations receive the parsed JSON directly. - * - * Note: These webhooks only send the `payload` field with no additional form - * parameters, so we simply replace bodyParams with the parsed JSON. - */ - if (body.payload) { - if (typeof body.payload === 'string') { - try { - c.set('bodyParams-override', JSON.parse(body.payload)); - } catch { - // Keep original without unwrapping - } - } - return next(); - } - - incomingLogger.debug({ - msg: 'Body received as application/x-www-form-urlencoded without the "payload" key, parsed as string', - content, - }); - } catch (e: any) { - c.body(JSON.stringify({ success: false, error: e.message }), 400); - } - - return next(); -}; - -Api.router.use(middleware); - Api.addRoute( ':integrationId/:userId/:token', { authRequired: true }, diff --git a/apps/meteor/app/integrations/server/lib/triggerHandler.ts b/apps/meteor/app/integrations/server/lib/triggerHandler.ts index 192419d6c2136..135c88bdde228 100644 --- a/apps/meteor/app/integrations/server/lib/triggerHandler.ts +++ b/apps/meteor/app/integrations/server/lib/triggerHandler.ts @@ -181,7 +181,7 @@ class RocketChatIntegrationHandler { channel: tmpRoom.t === 'd' ? `@${tmpRoom._id}` : `#${tmpRoom._id}`, }; - return processWebhookMessage(message, user as IUser & { username: RequiredField }, defaultValues); + return processWebhookMessage(message, user as RequiredField, defaultValues); } eventNameArgumentsToObject(...args: unknown[]) { @@ -621,6 +621,7 @@ class RocketChatIntegrationHandler { ...(opts.data && { body: opts.data }), // SECURITY: Integrations can only be configured by users with enough privileges. It's ok to disable this check here. ignoreSsrfValidation: true, + size: 10 * 1024 * 1024, }, settings.get('Allow_Invalid_SelfSigned_Certs'), ) diff --git a/apps/meteor/app/lib/client/methods/sendMessage.ts b/apps/meteor/app/lib/client/methods/sendMessage.ts index ee67688a0b6ae..0969ea8d9cf36 100644 --- a/apps/meteor/app/lib/client/methods/sendMessage.ts +++ b/apps/meteor/app/lib/client/methods/sendMessage.ts @@ -44,13 +44,7 @@ Meteor.methods({ await onClientMessageReceived(message as IMessage).then((message) => { Messages.state.store(message); - void clientCallbacks.run('afterSaveMessage', message, { room, user }); - - // Now that the message is stored, we can go ahead and mark as sent - Messages.state.update( - (record) => record._id === message._id && record.temp === true, - ({ temp: _, ...record }) => record, - ); + return clientCallbacks.run('afterSaveMessage', message, { room, user }); }); }, }); diff --git a/apps/meteor/app/lib/server/functions/createRoom.ts b/apps/meteor/app/lib/server/functions/createRoom.ts index bbd971e667e66..a9cd1330d6148 100644 --- a/apps/meteor/app/lib/server/functions/createRoom.ts +++ b/apps/meteor/app/lib/server/functions/createRoom.ts @@ -193,7 +193,9 @@ export const createRoom = async ( return createDirectRoom(members as IUser[], extraData, { ...options, creator: options?.creator || owner?._id }); } - if (!onlyUsernames(members)) { + const memberList = [...members]; + + if (!onlyUsernames(memberList)) { throw new Meteor.Error( 'error-invalid-members', 'members should be an array of usernames if provided for rooms other than direct messages', @@ -218,8 +220,8 @@ export const createRoom = async ( }); } - if (!excludeSelf && owner.username && !members.includes(owner.username)) { - members.push(owner.username); + if (!excludeSelf && owner.username && !memberList.includes(owner.username)) { + memberList.push(owner.username); } if (extraData.broadcast) { @@ -258,7 +260,7 @@ export const createRoom = async ( const tmp = { ...roomProps, - _USERNAMES: members, + _USERNAMES: memberList, }; const prevent = await Apps.self?.triggerEvent(AppEvents.IPreRoomCreatePrevent, tmp).catch((error) => { @@ -299,10 +301,10 @@ export const createRoom = async ( if (shouldBeHandledByFederation) { // Reusing unused callback to create Matrix room. // We should discuss the opportunity to rename it to something with "before" prefix. - await callbacks.run('federation.afterCreateFederatedRoom', room, { owner, originalMemberList: members, options }); + await callbacks.run('federation.afterCreateFederatedRoom', room, { owner, originalMemberList: memberList, options }); } - await createUsersSubscriptions({ room, members, now, owner, options, shouldBeHandledByFederation }); + await createUsersSubscriptions({ room, members: memberList, now, owner, options, shouldBeHandledByFederation }); if (type === 'c') { if (room.teamId) { diff --git a/apps/meteor/app/lib/server/functions/extractMentionsFromMessageAST.ts b/apps/meteor/app/lib/server/functions/extractMentionsFromMessageAST.ts new file mode 100644 index 0000000000000..441aa8147f5d0 --- /dev/null +++ b/apps/meteor/app/lib/server/functions/extractMentionsFromMessageAST.ts @@ -0,0 +1,63 @@ +import type { Root, Paragraph, Blocks, Inlines, UserMention, ChannelMention, Task, ListItem, BigEmoji } from '@rocket.chat/message-parser'; + +type ExtractedMentions = { + mentions: string[]; + channels: string[]; +}; + +type MessageNode = Paragraph | Blocks | Inlines | Task | ListItem | BigEmoji; + +function isUserMention(node: MessageNode): node is UserMention { + return node.type === 'MENTION_USER'; +} + +function isChannelMention(node: MessageNode): node is ChannelMention { + return node.type === 'MENTION_CHANNEL'; +} + +function hasArrayValue(node: MessageNode): node is MessageNode & { value: MessageNode[] } { + return Array.isArray(node.value); +} + +function hasObjectValue(node: MessageNode): node is MessageNode & { value: Record } { + return typeof node.value === 'object' && node.value !== null && !Array.isArray(node.value); +} + +function traverse(node: MessageNode, mentions: Set, channels: Set): void { + if (isUserMention(node)) { + mentions.add(node.value.value); + return; + } + + if (isChannelMention(node)) { + channels.add(node.value.value); + return; + } + + if (hasArrayValue(node)) { + for (const child of node.value) { + traverse(child, mentions, channels); + } + return; + } + + if (hasObjectValue(node)) { + for (const key of Object.keys(node.value)) { + traverse(node.value[key], mentions, channels); + } + } +} + +export function extractMentionsFromMessageAST(ast: Root): ExtractedMentions { + const mentions = new Set(); + const channels = new Set(); + + for (const node of ast) { + traverse(node, mentions, channels); + } + + return { + mentions: Array.from(mentions), + channels: Array.from(channels), + }; +} diff --git a/apps/meteor/app/lib/server/functions/processWebhookMessage.ts b/apps/meteor/app/lib/server/functions/processWebhookMessage.ts index 374cc091e0634..e3e9903a6527e 100644 --- a/apps/meteor/app/lib/server/functions/processWebhookMessage.ts +++ b/apps/meteor/app/lib/server/functions/processWebhookMessage.ts @@ -131,13 +131,13 @@ const buildMessage = (messageObj: Payload, defaultValues: DefaultValues) => { export function processWebhookMessage( messageObj: Payload & { separateResponse: true }, - user: IUser & { username: RequiredField }, + user: RequiredField, defaultValues?: DefaultValues, ): Promise; export function processWebhookMessage( messageObj: Payload & { separateResponse?: false | undefined }, - user: IUser & { username: RequiredField }, + user: RequiredField, defaultValues?: DefaultValues, ): Promise; @@ -148,7 +148,7 @@ export async function processWebhookMessage( */ separateResponse?: boolean; }, - user: IUser & { username: RequiredField }, + user: RequiredField, defaultValues: DefaultValues = { channel: '', alias: '', avatar: '', emoji: '' }, ) { const rooms: ({ channel: string } & ({ room: IRoom } | { room: IRoom | null; error?: any }))[] = []; diff --git a/apps/meteor/app/lib/server/methods/sendMessage.ts b/apps/meteor/app/lib/server/methods/sendMessage.ts index 895ccc27a5b87..a1208543d6841 100644 --- a/apps/meteor/app/lib/server/methods/sendMessage.ts +++ b/apps/meteor/app/lib/server/methods/sendMessage.ts @@ -30,7 +30,7 @@ import { RateLimiter } from '../lib'; * @returns */ export async function executeSendMessage( - uid: IUser['_id'], + uid: IUser['_id'] | IUser, message: AtLeast, extraInfo?: { ts?: Date; previewUrls?: string[] }, ) { @@ -71,7 +71,7 @@ export async function executeSendMessage( } } - const user = await Users.findOneById(uid); + const user = typeof uid === 'string' ? await Users.findOneById(uid) : uid; if (!user?.username) { throw new Meteor.Error('error-invalid-user', 'Invalid user'); } @@ -95,7 +95,7 @@ export async function executeSendMessage( check(rid, String); try { - const room = await canSendMessageAsync(rid, { uid, username: user.username, type: user.type }); + const room = await canSendMessageAsync(rid, user); if (room.encrypted && settings.get('E2E_Enable') && !settings.get('E2E_Allow_Unencrypted_Messages')) { if (message.t !== 'e2e') { @@ -112,7 +112,7 @@ export async function executeSendMessage( const errorMessage: RocketchatI18nKeys = typeof err === 'string' ? err : err.error || err.message; const errorContext: TOptions = err.details ?? {}; - void api.broadcast('notify.ephemeralMessage', uid, message.rid, { + void api.broadcast('notify.ephemeralMessage', user._id, message.rid, { msg: i18n.t(errorMessage, { ...errorContext, lng: user.language }), }); @@ -151,8 +151,8 @@ Meteor.methods({ sentByEmail: Match.Maybe(Boolean), }); - const uid = Meteor.userId(); - if (!uid) { + const user = (await Meteor.userAsync()) as IUser; + if (!user) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'sendMessage', }); @@ -163,7 +163,7 @@ Meteor.methods({ } try { - return await applyAirGappedRestrictionsValidation(() => executeSendMessage(uid, message, { previewUrls })); + return await applyAirGappedRestrictionsValidation(() => executeSendMessage(user, message, { previewUrls })); } catch (error: any) { if (['error-not-allowed', 'restricted-workspace'].includes(error.error || error.message)) { throw new Meteor.Error(error.error || error.message, error.reason, { diff --git a/apps/meteor/app/livechat/imports/server/rest/inquiries.ts b/apps/meteor/app/livechat/imports/server/rest/inquiries.ts index a69604bd083ec..04af97752d22b 100644 --- a/apps/meteor/app/livechat/imports/server/rest/inquiries.ts +++ b/apps/meteor/app/livechat/imports/server/rest/inquiries.ts @@ -69,7 +69,7 @@ API.v1.addRoute( return API.v1.failure('The user is invalid'); } return API.v1.success({ - inquiry: await takeInquiry(this.bodyParams.userId || this.userId, this.bodyParams.inquiryId), + inquiry: await takeInquiry(this.bodyParams.userId || this.userId, this.bodyParams.inquiryId, this.bodyParams.options), }); }, }, diff --git a/apps/meteor/app/livechat/imports/server/rest/rooms.ts b/apps/meteor/app/livechat/imports/server/rest/rooms.ts index 917647da06334..f7462495013bf 100644 --- a/apps/meteor/app/livechat/imports/server/rest/rooms.ts +++ b/apps/meteor/app/livechat/imports/server/rest/rooms.ts @@ -29,7 +29,7 @@ API.v1.addRoute( { async get() { const { offset, count } = await getPaginationItems(this.queryParams); - const { sort, fields } = await this.parseJsonQuery(); + const { sort, fields, query } = await this.parseJsonQuery(); const { agents, departmentId, open, tags, roomName, onhold, queued, units } = this.queryParams; const { createdAt, customFields, closedAt } = this.queryParams; @@ -71,6 +71,7 @@ API.v1.addRoute( onhold, queued, units, + query, options: { offset, count, sort, fields }, callerId: this.userId, }), diff --git a/apps/meteor/app/livechat/server/api/lib/rooms.ts b/apps/meteor/app/livechat/server/api/lib/rooms.ts index f8b4a5d4acd10..5632a56d6411d 100644 --- a/apps/meteor/app/livechat/server/api/lib/rooms.ts +++ b/apps/meteor/app/livechat/server/api/lib/rooms.ts @@ -16,6 +16,7 @@ export async function findRooms({ onhold, queued, units, + query, options: { offset, count, fields, sort }, callerId, }: { @@ -36,10 +37,12 @@ export async function findRooms({ onhold?: string | boolean; queued?: string | boolean; units?: Array; + query?: Record; options: { offset: number; count: number; fields: Record; sort: Record }; callerId: string; }): Promise }>> { - const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}, { unitsFilter: units, userId: callerId }); + const extraQueryBase = await callbacks.run('livechat.applyRoomRestrictions', {}, { unitsFilter: units, userId: callerId }); + const extraQuery = { ...extraQueryBase, ...(query || {}) }; const { cursor, totalCount } = LivechatRooms.findRoomsWithCriteria({ agents, roomName, diff --git a/apps/meteor/app/livechat/server/api/v1/webhooks.ts b/apps/meteor/app/livechat/server/api/v1/webhooks.ts index 4a5fdb50f7e44..276a910502d69 100644 --- a/apps/meteor/app/livechat/server/api/v1/webhooks.ts +++ b/apps/meteor/app/livechat/server/api/v1/webhooks.ts @@ -66,6 +66,7 @@ API.v1.addRoute( body: sampleData, // SECURITY: Webhooks can only be configured by users with enough privileges. It's ok to disable this check here. ignoreSsrfValidation: true, + size: 10 * 1024 * 1024, } as ExtendedFetchOptions; const webhookUrl = settings.get('Livechat_webhookUrl'); diff --git a/apps/meteor/app/livechat/server/lib/webhooks.ts b/apps/meteor/app/livechat/server/lib/webhooks.ts index b0d2cd94f80e2..661b428cc7cb8 100644 --- a/apps/meteor/app/livechat/server/lib/webhooks.ts +++ b/apps/meteor/app/livechat/server/lib/webhooks.ts @@ -29,6 +29,7 @@ export async function sendRequest( timeout, // SECURITY: Webhooks can only be configured by users with enough privileges. It's ok to disable this check here. ignoreSsrfValidation: true, + size: 10 * 1024 * 1024, }); if (result.status === 200) { diff --git a/apps/meteor/app/markdown/lib/parser/filtered/filtered.js b/apps/meteor/app/markdown/lib/parser/filtered/filtered.js index 260fc835d8a0a..868baaee80903 100644 --- a/apps/meteor/app/markdown/lib/parser/filtered/filtered.js +++ b/apps/meteor/app/markdown/lib/parser/filtered/filtered.js @@ -9,7 +9,7 @@ export const filtered = ( supportSchemesForLink: 'http,https', }, ) => { - const schemes = options.supportSchemesForLink.split(',').join('|'); + const schemes = (options.supportSchemesForLink || 'http,https').split(',').join('|'); // Remove block code backticks message = message.replace(/```/g, ''); diff --git a/apps/meteor/app/mentions/server/Mentions.ts b/apps/meteor/app/mentions/server/Mentions.ts index f6d40840b6488..15213c8318a3b 100644 --- a/apps/meteor/app/mentions/server/Mentions.ts +++ b/apps/meteor/app/mentions/server/Mentions.ts @@ -4,6 +4,7 @@ */ import { isE2EEMessage, type IMessage, type IRoom, type IUser } from '@rocket.chat/core-typings'; +import { extractMentionsFromMessageAST } from '../../lib/server/functions/extractMentionsFromMessageAST'; import { type MentionsParserArgs, MentionsParser } from '../lib/MentionsParser'; type MentionsServerArgs = MentionsParserArgs & { @@ -50,13 +51,25 @@ export class MentionsServer extends MentionsParser { isE2EEMessage(message) && e2eMentions?.e2eUserMentions && e2eMentions?.e2eUserMentions.length > 0 ? e2eMentions?.e2eUserMentions : this.getUserMentions(msg); + + return this.convertMentionsToUsers(mentions, rid, sender); + } + + async convertMentionsToUsers(mentions: string[], rid: string, sender: IMessage['u']): Promise { const mentionsAll: { _id: string; username: string }[] = []; - const userMentions = []; + const userMentions = new Set(); for await (const m of mentions) { - const mention = m.includes(':') ? m.trim() : m.trim().substring(1); + let mention: string; + if (m.includes(':')) { + mention = m.trim(); + } else if (m.startsWith('@')) { + mention = m.substring(1); + } else { + mention = m; + } if (mention !== 'all' && mention !== 'here') { - userMentions.push(mention); + userMentions.add(mention); continue; } if (this.messageMaxAll() > 0 && (await this.getTotalChannelMembers(rid)) > this.messageMaxAll()) { @@ -69,7 +82,7 @@ export class MentionsServer extends MentionsParser { }); } - return [...mentionsAll, ...(userMentions.length ? await this.getUsers(userMentions) : [])]; + return [...mentionsAll, ...(userMentions.size ? await this.getUsers(Array.from(userMentions)) : [])]; } async getChannelbyMentions(message: IMessage) { @@ -79,15 +92,23 @@ export class MentionsServer extends MentionsParser { isE2EEMessage(message) && e2eMentions?.e2eChannelMentions && e2eMentions?.e2eChannelMentions.length > 0 ? e2eMentions?.e2eChannelMentions : this.getChannelMentions(msg); - return this.getChannels(channels.map((c) => c.trim().substring(1))); + return this.convertMentionsToChannels(channels); + } + + async convertMentionsToChannels(channels: string[]): Promise[]> { + return this.getChannels(channels.map((c) => (c.startsWith('#') ? c.substring(1) : c))); } async execute(message: IMessage) { - const mentionsAll = await this.getUsersByMentions(message); - const channels = await this.getChannelbyMentions(message); + if (message.md) { + const { mentions, channels } = extractMentionsFromMessageAST(message.md); + message.mentions = await this.convertMentionsToUsers(mentions, message.rid, message.u); + message.channels = await this.convertMentionsToChannels(channels); + return message; + } - message.mentions = mentionsAll; - message.channels = channels; + message.mentions = await this.getUsersByMentions(message); + message.channels = await this.getChannelbyMentions(message); return message; } diff --git a/apps/meteor/app/push/server/apn.spec.ts b/apps/meteor/app/push/server/apn.spec.ts new file mode 100644 index 0000000000000..583eaca2ffd69 --- /dev/null +++ b/apps/meteor/app/push/server/apn.spec.ts @@ -0,0 +1,131 @@ +import { expect } from 'chai'; +import { describe, it, beforeEach } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const sandbox = sinon.createSandbox(); + +const mocks = { + logger: { + debug: sandbox.stub(), + warn: sandbox.stub(), + error: sandbox.stub(), + }, + ApnProvider: sandbox.stub(), +}; + +const apnMock = { + 'Provider': mocks.ApnProvider, + 'Notification': sandbox.stub(), + '@noCallThru': true, +}; + +const { initAPN } = proxyquire.noCallThru().load('./apn', { + '@parse/node-apn': { + default: apnMock, + ...apnMock, + }, + './logger': { logger: mocks.logger }, +}); + +const baseOptions = { + apn: { + cert: 'cert-data', + key: 'key-data', + gateway: undefined as string | undefined, + }, + production: false, +}; + +const buildOptions = (overrides: Record = {}, apnOverrides: Record = {}) => ({ + ...baseOptions, + ...overrides, + apn: { + ...baseOptions.apn, + ...apnOverrides, + }, +}); + +describe('initAPN', () => { + beforeEach(() => { + sandbox.resetHistory(); + mocks.ApnProvider.reset(); + }); + + describe('APN provider initialization', () => { + it('should create apn.Provider with correct options', () => { + const options = buildOptions({ production: true }, { gateway: 'gateway.push.apple.com' }); + + initAPN({ + options, + absoluteUrl: 'https://example.com', + }); + + expect(mocks.ApnProvider.calledWithNew()).to.be.true; + }); + + it('should pass production flag from options to Provider', () => { + initAPN({ + options: buildOptions({ production: true }), + absoluteUrl: 'https://example.com', + }); + + expect(mocks.ApnProvider.firstCall.args[0]).to.have.property('production', true); + }); + + it('should pass production false when options.production is false', () => { + initAPN({ + options: buildOptions({ production: false }), + absoluteUrl: 'https://example.com', + }); + + expect(mocks.ApnProvider.firstCall.args[0]).to.have.property('production', false); + }); + + it('should pass cert and key to Provider', () => { + initAPN({ + options: buildOptions({}, { cert: 'my-cert', key: 'my-key' }), + absoluteUrl: 'https://example.com', + }); + + const providerArgs = mocks.ApnProvider.firstCall.args[0]; + expect(providerArgs).to.have.property('cert', 'my-cert'); + expect(providerArgs).to.have.property('key', 'my-key'); + }); + + it('should pass gateway to Provider when specified', () => { + initAPN({ + options: buildOptions({}, { gateway: 'gateway.push.apple.com' }), + absoluteUrl: 'https://example.com', + }); + + expect(mocks.ApnProvider.firstCall.args[0]).to.have.property('gateway', 'gateway.push.apple.com'); + }); + + it('should spread all apn options to Provider', () => { + initAPN({ + options: buildOptions({ production: true }, { cert: 'c', key: 'k', gateway: 'gateway.sandbox.push.apple.com' }), + absoluteUrl: 'https://example.com', + }); + + const providerArgs = mocks.ApnProvider.firstCall.args[0]; + expect(providerArgs).to.deep.equal({ + cert: 'c', + key: 'k', + gateway: 'gateway.sandbox.push.apple.com', + production: true, + }); + }); + + it('should not throw when Provider constructor throws', () => { + mocks.ApnProvider.throws(new Error('APN init failed')); + + expect(() => + initAPN({ + options: buildOptions(), + absoluteUrl: 'https://example.com', + }), + ).to.not.throw(); + }); + }); +}); diff --git a/apps/meteor/app/push/server/apn.ts b/apps/meteor/app/push/server/apn.ts index 634665cad9403..e8732a9daae5f 100644 --- a/apps/meteor/app/push/server/apn.ts +++ b/apps/meteor/app/push/server/apn.ts @@ -1,5 +1,5 @@ import apn from '@parse/node-apn'; -import type { IAppsTokens, RequiredField } from '@rocket.chat/core-typings'; +import type { IPushToken, RequiredField } from '@rocket.chat/core-typings'; import EJSON from 'ejson'; import type { PushOptions, PendingPushNotification } from './definition'; @@ -24,7 +24,7 @@ export const sendAPN = ({ }: { userToken: string; notification: PendingPushNotification & { topic: string }; - _removeToken: (token: IAppsTokens['token']) => void; + _removeToken: (token: IPushToken['token']) => void; }) => { if (!apnConnection) { throw new Error('Apn Connection not initialized.'); @@ -137,7 +137,10 @@ export const initAPN = ({ options, absoluteUrl }: { options: RequiredField>; + 'raix:push-update'(options: PushUpdateOptions): Promise>; 'raix:push-setuser'(options: { id: string; userId: string }): Promise; } } -export const pushUpdate = async (options: PushUpdateOptions): Promise> => { - // we always store the hashed token to protect users - const hashedToken = Accounts._hashLoginToken(options.authToken); - - let doc; - - // lookup app by id if one was included - if (options.id) { - doc = await AppsTokens.findOne({ _id: options.id }); - } else if (options.userId) { - doc = await AppsTokens.findOne({ userId: options.userId }); - } - - // No doc was found - we check the database to see if - // we can find a match for the app via token and appName - if (!doc) { - doc = await AppsTokens.findOne({ - $and: [ - { token: options.token }, // Match token - { appName: options.appName }, // Match appName - { token: { $exists: true } }, // Make sure token exists - ], - }); - } - - // if we could not find the id or token then create it - if (!doc) { - // Rig default doc - doc = { - token: options.token, - authToken: hashedToken, - appName: options.appName, - userId: options.userId, - enabled: true, - createdAt: new Date(), - updatedAt: new Date(), - metadata: options.metadata || {}, - - // XXX: We might want to check the id - Why isnt there a match for id - // in the Meteor check... Normal length 17 (could be larger), and - // numbers+letters are used in Random.id() with exception of 0 and 1 - _id: options.id || Random.id(), - // The user wanted us to use a specific id, we didn't find this while - // searching. The client could depend on the id eg. as reference so - // we respect this and try to create a document with the selected id; - }; - - await AppsTokens.insertOne(doc); - } else { - // We found the app so update the updatedAt and set the token - await AppsTokens.updateOne( - { _id: doc._id }, - { - $set: { - updatedAt: new Date(), - token: options.token, - authToken: hashedToken, - }, - }, - ); - } - - if (doc.token) { - const removed = ( - await AppsTokens.deleteMany({ - $and: [ - { _id: { $ne: doc._id } }, - { token: doc.token }, // Match token - { appName: doc.appName }, // Match appName - { token: { $exists: true } }, // Make sure token exists - ], - }) - ).deletedCount; - - if (removed) { - logger.debug({ msg: 'Removed existing app items', removed }); - } - } - - logger.debug({ msg: 'Push token updated', doc }); - - // Return the doc we want to use - return doc; -}; - Meteor.methods({ async 'raix:push-update'(options) { logger.debug({ msg: 'Got push token from app', options }); @@ -124,11 +39,28 @@ Meteor.methods({ }); // The if user id is set then user id should match on client and connection - if (options.userId && options.userId !== this.userId) { + if (!this.userId || (options.userId && options.userId !== this.userId)) { throw new Meteor.Error(403, 'Forbidden access'); } - return pushUpdate(options); + // Retain old behavior: if id is not specified but userId is explicitly set, then update the user's first token + if (!options.id && options.userId) { + const firstDoc = await PushToken.findFirstByUserId(options.userId, { projection: { _id: 1 } }); + if (firstDoc) { + options.id = firstDoc._id; + } + } + + const authToken = Accounts._hashLoginToken(options.authToken); + + return Push.registerPushToken({ + ...(options.id && { _id: options.id }), + token: options.token, + appName: options.appName, + authToken, + userId: this.userId, + ...(options.metadata && { metadata: options.metadata }), + }); }, // Deprecated async 'raix:push-setuser'(id) { @@ -138,7 +70,7 @@ Meteor.methods({ } logger.debug({ msg: 'Setting userId for app', userId: this.userId, appId: id }); - const found = await AppsTokens.updateOne({ _id: id }, { $set: { userId: this.userId } }); + const found = await PushToken.updateOne({ _id: id }, { $set: { userId: this.userId } }); return !!found; }, diff --git a/apps/meteor/app/push/server/push.ts b/apps/meteor/app/push/server/push.ts index 04e217822156a..860900a92471c 100644 --- a/apps/meteor/app/push/server/push.ts +++ b/apps/meteor/app/push/server/push.ts @@ -1,5 +1,5 @@ -import type { IAppsTokens, RequiredField, Optional, IPushNotificationConfig } from '@rocket.chat/core-typings'; -import { AppsTokens } from '@rocket.chat/models'; +import type { IPushToken, RequiredField, Optional, IPushNotificationConfig } from '@rocket.chat/core-typings'; +import { PushToken } from '@rocket.chat/models'; import { ajv } from '@rocket.chat/rest-typings'; import type { ExtendedFetchOptions } from '@rocket.chat/server-fetch'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; @@ -112,8 +112,8 @@ type GatewayNotification = { query?: { userId: any; }; - token?: IAppsTokens['token']; - tokens?: IAppsTokens['token'][]; + token?: IPushToken['token']; + tokens?: IPushToken['token'][]; payload?: Record; delayUntil?: Date; createdAt: Date; @@ -123,8 +123,8 @@ type GatewayNotification = { export type NativeNotificationParameters = { userTokens: string | string[]; notification: PendingPushNotification; - _replaceToken: (currentToken: IAppsTokens['token'], newToken: IAppsTokens['token']) => void; - _removeToken: (token: IAppsTokens['token']) => void; + _replaceToken: (currentToken: IPushToken['token'], newToken: IPushToken['token']) => void; + _removeToken: (token: IPushToken['token']) => void; options: RequiredField; }; @@ -167,12 +167,12 @@ class PushClass { } } - private replaceToken(currentToken: IAppsTokens['token'], newToken: IAppsTokens['token']): void { - void AppsTokens.updateMany({ token: currentToken }, { $set: { token: newToken } }); + private replaceToken(currentToken: IPushToken['token'], newToken: IPushToken['token']): void { + void PushToken.updateMany({ token: currentToken }, { $set: { token: newToken } }); } - private removeToken(token: IAppsTokens['token']): void { - void AppsTokens.deleteOne({ token }); + private removeToken(token: IPushToken['token']): void { + void PushToken.deleteOne({ token }); } private shouldUseGateway(): boolean { @@ -180,7 +180,7 @@ class PushClass { } private async sendNotificationNative( - app: IAppsTokens, + app: IPushToken, notification: PendingPushNotification, countApn: string[], countGcm: string[], @@ -275,7 +275,7 @@ class PushClass { if (result.status === 406) { logger.info({ msg: 'removing push token', token }); - await AppsTokens.deleteMany({ + await PushToken.deleteMany({ $or: [ { 'token.apn': token, @@ -325,7 +325,7 @@ class PushClass { } private async sendNotificationGateway( - app: IAppsTokens, + app: IPushToken, notification: PendingPushNotification, countApn: string[], countGcm: string[], @@ -378,7 +378,7 @@ class PushClass { $or: [{ 'token.apn': { $exists: true } }, { 'token.gcm': { $exists: true } }], }; - const appTokens = AppsTokens.find(query); + const appTokens = PushToken.find(query); for await (const app of appTokens) { logger.debug({ msg: 'send to token', token: app.token }); @@ -402,15 +402,15 @@ class PushClass { // Add some verbosity about the send result, making sure the developer // understands what just happened. if (!countApn.length && !countGcm.length) { - if ((await AppsTokens.estimatedDocumentCount()) === 0) { + if ((await PushToken.estimatedDocumentCount()) === 0) { logger.debug('GUIDE: The "AppsTokens" is empty - No clients have registered on the server yet...'); } } else if (!countApn.length) { - if ((await AppsTokens.countApnTokens()) === 0) { + if ((await PushToken.countApnTokens()) === 0) { logger.debug('GUIDE: The "AppsTokens" - No APN clients have registered on the server yet...'); } } else if (!countGcm.length) { - if ((await AppsTokens.countGcmTokens()) === 0) { + if ((await PushToken.countGcmTokens()) === 0) { logger.debug('GUIDE: The "AppsTokens" - No GCM clients have registered on the server yet...'); } } diff --git a/apps/meteor/app/settings/server/CachedSettings.ts b/apps/meteor/app/settings/server/CachedSettings.ts index 3c46dd05a6806..8332f45c5c497 100644 --- a/apps/meteor/app/settings/server/CachedSettings.ts +++ b/apps/meteor/app/settings/server/CachedSettings.ts @@ -181,9 +181,15 @@ export class CachedSettings const settings = _id.map((id) => this.store.get(id)?.value); callback(settings as T[]); } - const mergeFunction = _.debounce((): void => { - callback(_id.map((id) => this.store.get(id)?.value) as T[]); - }, 100); + + const mergeFunction = + process.env.TEST_MODE !== 'true' + ? _.debounce((): void => { + callback(_id.map((id) => this.store.get(id)?.value) as T[]); + }, 100) + : (): void => { + callback(_id.map((id) => this.store.get(id)?.value) as T[]); + }; const fns = _id.map((id) => this.on(id, mergeFunction)); return (): void => { diff --git a/apps/meteor/app/ui-message/client/messageBox/AddLinkComposerActionModal.tsx b/apps/meteor/app/ui-message/client/messageBox/AddLinkComposerActionModal.tsx index ce75e12ee68df..58edd971d1e1c 100644 --- a/apps/meteor/app/ui-message/client/messageBox/AddLinkComposerActionModal.tsx +++ b/apps/meteor/app/ui-message/client/messageBox/AddLinkComposerActionModal.tsx @@ -1,9 +1,11 @@ -import { Field, FieldGroup, TextInput, FieldLabel, FieldRow, Box } from '@rocket.chat/fuselage'; +import { Field, FieldGroup, TextInput, FieldLabel, FieldRow, Box, FieldError } from '@rocket.chat/fuselage'; import { GenericModal } from '@rocket.chat/ui-client'; import { useEffect, useId } from 'react'; import { useForm, Controller } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; +import { isValidLink } from '../../../../client/views/room/MessageList/lib/isValidLink'; + type AddLinkComposerActionModalProps = { selectedText?: string; onConfirm: (url: string, text: string) => void; @@ -15,8 +17,8 @@ const AddLinkComposerActionModal = ({ selectedText, onClose, onConfirm }: AddLin const textField = useId(); const urlField = useId(); - const { handleSubmit, setFocus, control } = useForm({ - mode: 'onBlur', + const { handleSubmit, setFocus, control, formState } = useForm({ + mode: 'onChange', defaultValues: { text: selectedText || '', url: '', @@ -40,6 +42,7 @@ const AddLinkComposerActionModal = ({ selectedText, onClose, onConfirm }: AddLin confirmText={t('Add')} onCancel={onClose} wrapperFunction={(props) => void submit(e)} {...props} />} + confirmDisabled={!formState.isValid} title={t('Add_link')} > @@ -52,8 +55,20 @@ const AddLinkComposerActionModal = ({ selectedText, onClose, onConfirm }: AddLin {t('URL')} - } /> + isValidLink(value) || t('Invalid_URL'), + required: { + value: true, + message: t(`URL_is_required`), + }, + }} + render={({ field }) => } + /> + {formState.errors.url?.message} diff --git a/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts b/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts index a7d92ed3f02f3..ed282ce3c75aa 100644 --- a/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts +++ b/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts @@ -230,11 +230,11 @@ export const createComposerAPI = ( focus(); const startPattern = pattern.slice(0, pattern.indexOf('{{text}}')); - const startPatternFound = [...startPattern].reverse().every((char, index) => input.value.slice(selectionStart - index - 1, 1) === char); + const startPatternFound = input.value.slice(selectionStart - startPattern.length, selectionStart) === startPattern; if (startPatternFound) { const endPattern = pattern.slice(pattern.indexOf('{{text}}') + '{{text}}'.length); - const endPatternFound = [...endPattern].every((char, index) => input.value.slice(selectionEnd + index, 1) === char); + const endPatternFound = input.value.slice(selectionEnd, selectionEnd + endPattern.length) === endPattern; if (endPatternFound) { insertText(selectedText); diff --git a/apps/meteor/app/utils/rocketchat.info b/apps/meteor/app/utils/rocketchat.info index d858a90fa9d98..dc93b342bf43e 100644 --- a/apps/meteor/app/utils/rocketchat.info +++ b/apps/meteor/app/utils/rocketchat.info @@ -1,3 +1,3 @@ { - "version": "8.2.0" + "version": "8.3.0-rc.0" } diff --git a/apps/meteor/app/utils/server/getUserNotificationPreference.ts b/apps/meteor/app/utils/server/getUserNotificationPreference.ts index 2c70a7a11a18c..c93eb199294b3 100644 --- a/apps/meteor/app/utils/server/getUserNotificationPreference.ts +++ b/apps/meteor/app/utils/server/getUserNotificationPreference.ts @@ -40,7 +40,7 @@ export const getUserNotificationPreference = async (user: IUser | string, pref: }; } const serverValue = settings.get(`Accounts_Default_User_Preferences_${preferenceKey}`); - if (serverValue) { + if (typeof serverValue !== 'undefined') { return { value: serverValue, origin: 'server', diff --git a/apps/meteor/app/version-check/server/functions/buildVersionUpdateMessage.spec.ts b/apps/meteor/app/version-check/server/functions/buildVersionUpdateMessage.spec.ts new file mode 100644 index 0000000000000..0f8f8198e2d5f --- /dev/null +++ b/apps/meteor/app/version-check/server/functions/buildVersionUpdateMessage.spec.ts @@ -0,0 +1,123 @@ +import { buildVersionUpdateMessage } from './buildVersionUpdateMessage'; +import { sendMessagesToAdmins } from '../../../../server/lib/sendMessagesToAdmins'; + +const originalTestMode = process.env.TEST_MODE; + +const mockInfoVersion = jest.fn(() => '7.5.0'); + +jest.mock('../../../utils/rocketchat.info', () => ({ + Info: { + get version() { + return mockInfoVersion(); + }, + }, +})); + +const mockSetBannersInBulk = jest.fn(); +const mockFindUsersInRolesWithQuery = jest.fn(); + +jest.mock('@rocket.chat/models', () => ({ + Settings: { + updateValueById: jest.fn().mockResolvedValue({ modifiedCount: 0 }), + }, + Users: { + findUsersInRolesWithQuery: () => mockFindUsersInRolesWithQuery(), + setBannersInBulk: (updates: unknown) => mockSetBannersInBulk(updates), + }, +})); + +const mockSettingsGet = jest.fn(); + +jest.mock('../../../settings/server', () => ({ + settings: { + get: (key: string) => mockSettingsGet(key), + }, +})); + +jest.mock('../../../../server/lib/i18n', () => ({ + i18n: { + t: jest.fn((key) => key), + }, +})); + +jest.mock('../../../../server/lib/sendMessagesToAdmins', () => ({ + sendMessagesToAdmins: jest.fn(), +})); + +jest.mock('../../../../server/settings/lib/auditedSettingUpdates', () => ({ + updateAuditedBySystem: jest.fn(() => () => Promise.resolve({ modifiedCount: 0 })), +})); + +jest.mock('../../../lib/server/lib/notifyListener', () => ({ + notifyOnSettingChangedById: jest.fn(), +})); + +describe('buildVersionUpdateMessage', () => { + // Delete the TEST_MODE environment variable so buildVersionUpdateMessage() + // doesn't return early (see line 40 in buildVersionUpdateMessage.ts) + beforeAll(() => { + delete process.env.TEST_MODE; + }); + + afterAll(() => { + process.env.TEST_MODE = originalTestMode; + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockInfoVersion.mockReturnValue('7.5.0'); + mockSettingsGet.mockReturnValue('7.0.0'); + }); + + describe('cleanupOutdatedVersionUpdateBanners', () => { + it('should remove outdated version banners (<= current installed)', async () => { + const admin = { _id: 'admin1', banners: { 'versionUpdate-6_2_0': { id: 'versionUpdate-6_2_0' } } }; + mockFindUsersInRolesWithQuery.mockReturnValue([admin]); + + await buildVersionUpdateMessage([]); + + expect(mockSetBannersInBulk).toHaveBeenCalledWith([{ userId: 'admin1', banners: {} }]); + }); + + it('should keep version banners > current installed', async () => { + const admin = { _id: 'admin1', banners: { 'versionUpdate-8_0_0': { id: 'versionUpdate-8_0_0' } } }; + mockFindUsersInRolesWithQuery.mockReturnValue([admin]); + + await buildVersionUpdateMessage([]); + + expect(mockSetBannersInBulk).not.toHaveBeenCalled(); + }); + + it('should remove banners with invalid semver version IDs', async () => { + const admin = { _id: 'admin1', banners: { 'versionUpdate-invalid_version': { id: 'versionUpdate-invalid_version' } } }; + mockFindUsersInRolesWithQuery.mockReturnValue([admin]); + + await buildVersionUpdateMessage([]); + + expect(mockSetBannersInBulk).toHaveBeenCalledWith([{ userId: 'admin1', banners: {} }]); + }); + }); + + describe('version sorting', () => { + it('should process versions in descending order (highest first)', async () => { + mockFindUsersInRolesWithQuery.mockReturnValue([]); + + await buildVersionUpdateMessage([ + { version: '7.6.0', security: false, infoUrl: 'https://example.com/7.6.0' }, + { version: '8.0.0', security: false, infoUrl: 'https://example.com/8.0.0' }, + { version: '7.8.0', security: false, infoUrl: 'https://example.com/7.8.0' }, + ]); + + expect(sendMessagesToAdmins).toHaveBeenCalledTimes(1); + expect(sendMessagesToAdmins).toHaveBeenCalledWith( + expect.objectContaining({ + banners: expect.arrayContaining([ + expect.objectContaining({ + id: 'versionUpdate-8_0_0', + }), + ]), + }), + ); + }); + }); +}); diff --git a/apps/meteor/app/version-check/server/functions/buildVersionUpdateMessage.ts b/apps/meteor/app/version-check/server/functions/buildVersionUpdateMessage.ts index be53cee5959af..9811ae1ec94a6 100644 --- a/apps/meteor/app/version-check/server/functions/buildVersionUpdateMessage.ts +++ b/apps/meteor/app/version-check/server/functions/buildVersionUpdateMessage.ts @@ -1,4 +1,5 @@ -import { Settings } from '@rocket.chat/models'; +import type { IUser } from '@rocket.chat/core-typings'; +import { Settings, Users } from '@rocket.chat/models'; import semver from 'semver'; import { i18n } from '../../../../server/lib/i18n'; @@ -8,6 +9,39 @@ import { notifyOnSettingChangedById } from '../../../lib/server/lib/notifyListen import { settings } from '../../../settings/server'; import { Info } from '../../../utils/rocketchat.info'; +const cleanupOutdatedVersionUpdateBanners = async (): Promise => { + const admins = Users.findUsersInRolesWithQuery('admin', { banners: { $exists: true } }, { projection: { _id: 1, banners: 1 } }); + + const updates: { userId: IUser['_id']; banners: NonNullable }[] = []; + + for await (const admin of admins) { + if (!admin.banners) { + continue; + } + + const filteredBanners = Object.fromEntries( + Object.entries(admin.banners).filter(([bannerId]) => { + if (!bannerId.startsWith('versionUpdate-')) { + return true; + } + const version = bannerId.replace('versionUpdate-', '').replace(/_/g, '.'); + if (!semver.valid(version) || semver.lte(version, Info.version)) { + return false; + } + return true; + }), + ); + + if (Object.keys(filteredBanners).length !== Object.keys(admin.banners).length) { + updates.push({ userId: admin._id, banners: filteredBanners }); + } + } + + if (updates.length > 0) { + await Users.setBannersInBulk(updates); + } +}; + export const buildVersionUpdateMessage = async ( versions: { version: string; @@ -25,8 +59,11 @@ export const buildVersionUpdateMessage = async ( return; } - for await (const version of versions) { - // Ignore prerelease versions + const sortedVersions = [...versions].sort((a, b) => semver.rcompare(a.version, b.version)); + + await cleanupOutdatedVersionUpdateBanners(); + + for await (const version of sortedVersions) { if (semver.prerelease(version.version)) { continue; } diff --git a/apps/meteor/client/components/MarkdownText.spec.tsx b/apps/meteor/client/components/MarkdownText.spec.tsx index a7b9da94b84e3..a59c53cc14012 100644 --- a/apps/meteor/client/components/MarkdownText.spec.tsx +++ b/apps/meteor/client/components/MarkdownText.spec.tsx @@ -1,5 +1,6 @@ import { mockAppRoot } from '@rocket.chat/mock-providers'; import { render, screen } from '@testing-library/react'; +import dompurify from 'dompurify'; import MarkdownText, { supportedURISchemes } from './MarkdownText'; @@ -432,3 +433,69 @@ describe('code handling', () => { expect(screen.getByRole('code').outerHTML).toEqual(expected); }); }); + +describe('line breaks handling', () => { + it('should convert newlines to
in document variant', () => { + const content = 'First Line\nSecond Line\nThird Line'; + const { container } = render(, { + wrapper: mockAppRoot().build(), + }); + + const html = container.innerHTML; + expect(html).toContain('First Line
Second Line
Third Line'); + }); + + it('should convert newlines to
in inline variant', () => { + const content = 'First Line\nSecond Line\nThird Line'; + const { container } = render(, { + wrapper: mockAppRoot().build(), + }); + + const html = container.innerHTML; + expect(html).not.toContain('
'); + }); + + it('should not convert newlines to
in inlineWithoutBreaks variant', () => { + const content = 'First Line\nSecond Line\nThird Line'; + const { container } = render(, { + wrapper: mockAppRoot().build(), + }); + + const html = container.innerHTML; + expect(html).not.toContain('
'); + }); +}); + +describe('DOMPurify hook registration', () => { + it('should register hook only once at module level', () => { + // Import the module to trigger hook registration + + const addHookSpy = jest.spyOn(dompurify, 'addHook'); + + // Clear any previous calls from module initialization + addHookSpy.mockClear(); + + const { rerender, unmount } = render(, { + wrapper: mockAppRoot().build(), + }); + + // Hook should NOT be registered during component render (it's registered at module level) + expect(addHookSpy).toHaveBeenCalledTimes(0); + + // Re-rendering with different props should not register hook again + rerender(); + expect(addHookSpy).toHaveBeenCalledTimes(0); + + // Rendering another instance should not register hook again + render(, { + wrapper: mockAppRoot().build(), + }); + expect(addHookSpy).toHaveBeenCalledTimes(0); + + // Unmounting should not affect the module-level hook + unmount(); + expect(addHookSpy).toHaveBeenCalledTimes(0); + + addHookSpy.mockRestore(); + }); +}); diff --git a/apps/meteor/client/components/MarkdownText.tsx b/apps/meteor/client/components/MarkdownText.tsx index 8503d5ee7ed5f..faefcf806d54b 100644 --- a/apps/meteor/client/components/MarkdownText.tsx +++ b/apps/meteor/client/components/MarkdownText.tsx @@ -78,6 +78,7 @@ const defaultOptions = { const options = { ...defaultOptions, + breaks: true, renderer: documentRenderer, }; @@ -101,6 +102,49 @@ type MarkdownTextProps = Partial; export const supportedURISchemes = ['http', 'https', 'notes', 'ftp', 'ftps', 'tel', 'mailto', 'sms', 'cid']; +const isElement = (node: Node): node is Element => node.nodeType === Node.ELEMENT_NODE; +const isLinkElement = (node: Node): node is HTMLAnchorElement => isElement(node) && node.tagName.toLowerCase() === 'a'; + +// Generate a unique token at runtime to prevent enumeration attacks +// This token marks internal links that need translation +const INTERNAL_LINK_TOKEN = `__INTERNAL_LINK_TITLE_${Math.random().toString(36).substring(2, 15)}__`; + +// Register the DOMPurify hook once at module level to prevent memory leaks +// This hook will be shared by all MarkdownText component instances +dompurify.addHook('afterSanitizeAttributes', (node) => { + if (!isLinkElement(node)) { + return; + } + + const href = node.getAttribute('href') || ''; + const isExternalLink = isExternal(href); + const isMailto = href.startsWith('mailto:'); + + // Set appropriate attributes based on link type + if (isExternalLink || isMailto) { + node.setAttribute('rel', 'nofollow noopener noreferrer'); + // Enforcing external links to open in new tabs is critical to assure users never navigate away from the chat + // This attribute must be preserved to guarantee users maintain their chat context + node.setAttribute('target', '_blank'); + } + + // Set appropriate title based on link type + if (isMailto) { + // For mailto links, use the email address as the title for better user experience + // Example: for href "mailto:user@example.com" the title would be "mailto:user@example.com" + node.setAttribute('title', href); + } else if (isExternalLink) { + // For external links, set an empty title to prevent tooltips + // This reduces visual clutter and lets users see the URL in the browser's status bar instead + node.setAttribute('title', ''); + } else { + // For internal links, use a token that will be replaced with translated text in the component + // This allows us to use the contextualized translation function + const relativePath = href.replace(getBaseURI(), ''); + node.setAttribute('title', `${INTERNAL_LINK_TOKEN}${relativePath}`); + } +}); + const MarkdownText = ({ content, variant = 'document', @@ -143,41 +187,16 @@ const MarkdownText = ({ } })(); - // Add a hook to make all external links open a new window - dompurify.addHook('afterSanitizeAttributes', (node) => { - if (!isLinkElement(node)) { - return; - } + const sanitizedHtml = preserveHtml + ? html + : html && sanitizer(html, { ADD_ATTR: ['target'], ALLOWED_URI_REGEXP: getRegexp(supportedURISchemes) }); - const href = node.getAttribute('href') || ''; - const isExternalLink = isExternal(href); - const isMailto = href.startsWith('mailto:'); + // Replace internal link tokens with contextualized translations + if (sanitizedHtml && typeof sanitizedHtml === 'string') { + return sanitizedHtml.replace(new RegExp(`${INTERNAL_LINK_TOKEN}([^"]*)`, 'g'), (_, href) => t('Go_to_href', { href })); + } - // Set appropriate attributes based on link type - if (isExternalLink || isMailto) { - node.setAttribute('rel', 'nofollow noopener noreferrer'); - // Enforcing external links to open in new tabs is critical to assure users never navigate away from the chat - // This attribute must be preserved to guarantee users maintain their chat context - node.setAttribute('target', '_blank'); - } - - // Set appropriate title based on link type - if (isMailto) { - // For mailto links, use the email address as the title for better user experience - // Example: for href "mailto:user@example.com" the title would be "mailto:user@example.com" - node.setAttribute('title', href); - } else if (isExternalLink) { - // For external links, set an empty title to prevent tooltips - // This reduces visual clutter and lets users see the URL in the browser's status bar instead - node.setAttribute('title', ''); - } else { - // For internal links, add a translated title with the relative path - // Example: for href "https://my-server.rocket.chat/channel/general" the title would be "Go to #general" - node.setAttribute('title', `${t('Go_to_href', { href: href.replace(getBaseURI(), '') })}`); - } - }); - - return preserveHtml ? html : html && sanitizer(html, { ADD_ATTR: ['target'], ALLOWED_URI_REGEXP: getRegexp(supportedURISchemes) }); + return sanitizedHtml; }, [preserveHtml, sanitizer, content, variant, markedOptions, parseEmoji, t]); return __html ? ( @@ -190,7 +209,4 @@ const MarkdownText = ({ ) : null; }; -const isElement = (node: Node): node is Element => node.nodeType === Node.ELEMENT_NODE; -const isLinkElement = (node: Node): node is HTMLAnchorElement => isElement(node) && node.tagName.toLowerCase() === 'a'; - export default MarkdownText; diff --git a/apps/meteor/client/components/UserAutoCompleteMultiple/__snapshots__/UserAvatarChip.spec.tsx.snap b/apps/meteor/client/components/UserAutoCompleteMultiple/__snapshots__/UserAvatarChip.spec.tsx.snap index a55dbb1d84acd..6b7320236d10d 100644 --- a/apps/meteor/client/components/UserAutoCompleteMultiple/__snapshots__/UserAvatarChip.spec.tsx.snap +++ b/apps/meteor/client/components/UserAutoCompleteMultiple/__snapshots__/UserAvatarChip.spec.tsx.snap @@ -14,7 +14,7 @@ exports[`UserAvatarChip renders Default without crashing 1`] = ` class="rcx-box rcx-box--full rcx-avatar rcx-avatar--x20" > + ; -const MessageContentBody = ({ mentions, channels, md, searchText, ...props }: MessageContentBodyProps) => ( - - }> - - - - - -); +const MessageContentBody = ({ mentions, channels, md, searchText, ...props }: MessageContentBodyProps) => { + const { t } = useTranslation(); + + return ( + + }> + + + + + + ); +}; export default MessageContentBody; diff --git a/apps/meteor/client/components/message/MessageHeader.tsx b/apps/meteor/client/components/message/MessageHeader.tsx index 427f61803b584..23196a6c52cec 100644 --- a/apps/meteor/client/components/message/MessageHeader.tsx +++ b/apps/meteor/client/components/message/MessageHeader.tsx @@ -56,19 +56,13 @@ const MessageHeader = ({ message }: MessageHeaderProps): ReactElement => { {...buttonProps} {...triggerProps} > - + {message.alias || displayName} {showUsername && ( <> {' '} - - @{user.username} - + @{user.username} )} diff --git a/apps/meteor/client/components/message/content/attachments/file/GenericFileAttachment.tsx b/apps/meteor/client/components/message/content/attachments/file/GenericFileAttachment.tsx index 6d4955fb73424..d2dbd4f888257 100644 --- a/apps/meteor/client/components/message/content/attachments/file/GenericFileAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/file/GenericFileAttachment.tsx @@ -78,12 +78,7 @@ const GenericFileAttachment = ({ } > - + {title} {size && ( diff --git a/apps/meteor/client/components/message/content/attachments/structure/AttachmentText.tsx b/apps/meteor/client/components/message/content/attachments/structure/AttachmentText.tsx index 75153d1d57eb5..05a694df24997 100644 --- a/apps/meteor/client/components/message/content/attachments/structure/AttachmentText.tsx +++ b/apps/meteor/client/components/message/content/attachments/structure/AttachmentText.tsx @@ -1,10 +1,12 @@ import { Box } from '@rocket.chat/fuselage'; import type { ComponentPropsWithoutRef } from 'react'; +import { useTranslation } from 'react-i18next'; type AttachmentTextProps = ComponentPropsWithoutRef; -const AttachmentText = (props: AttachmentTextProps) => ( - -); +const AttachmentText = (props: AttachmentTextProps) => { + const { t } = useTranslation(); + return ; +}; export default AttachmentText; diff --git a/apps/meteor/client/components/message/toolbar/MessageToolbarItem.tsx b/apps/meteor/client/components/message/toolbar/MessageToolbarItem.tsx index 633f3fc72d679..6cb9f2d351d0b 100644 --- a/apps/meteor/client/components/message/toolbar/MessageToolbarItem.tsx +++ b/apps/meteor/client/components/message/toolbar/MessageToolbarItem.tsx @@ -8,27 +8,17 @@ type MessageToolbarItemProps = { icon: IconName; title: string; disabled?: boolean; - qa: string; onClick: MouseEventHandler; }; -const MessageToolbarItem = ({ id, icon, title, disabled, qa, onClick }: MessageToolbarItemProps) => { +const MessageToolbarItem = ({ id, icon, title, disabled, onClick }: MessageToolbarItemProps) => { const hiddenActions = useLayoutHiddenActions().messageToolbox; if (hiddenActions.includes(id)) { return null; } - return ( - - ); + return ; }; export default MessageToolbarItem; diff --git a/apps/meteor/client/components/message/toolbar/items/actions/ForwardMessageAction.tsx b/apps/meteor/client/components/message/toolbar/items/actions/ForwardMessageAction.tsx index 64e11d578272c..6c5720990b128 100644 --- a/apps/meteor/client/components/message/toolbar/items/actions/ForwardMessageAction.tsx +++ b/apps/meteor/client/components/message/toolbar/items/actions/ForwardMessageAction.tsx @@ -35,7 +35,6 @@ const ForwardMessageAction = ({ message, room }: ForwardMessageActionProps) => { id='forward-message' icon='arrow-forward' title={getTitle} - qa='Forward_message' disabled={encrypted || isABACEnabled} onClick={async () => { const permalink = await getPermaLink(message._id); diff --git a/apps/meteor/client/components/message/toolbar/items/actions/JumpToMessageAction.tsx b/apps/meteor/client/components/message/toolbar/items/actions/JumpToMessageAction.tsx index 1f1d757287f3d..802b505a55045 100644 --- a/apps/meteor/client/components/message/toolbar/items/actions/JumpToMessageAction.tsx +++ b/apps/meteor/client/components/message/toolbar/items/actions/JumpToMessageAction.tsx @@ -17,7 +17,6 @@ const JumpToMessageAction = ({ id, message }: JumpToMessageActionProps) => { id={id} icon='jump' title={t('Jump_to_message')} - qa='Jump_to_message' onClick={() => { setMessageJumpQueryStringParameter(message._id); }} diff --git a/apps/meteor/client/components/message/toolbar/items/actions/QuoteMessageAction.tsx b/apps/meteor/client/components/message/toolbar/items/actions/QuoteMessageAction.tsx index d73f193cbb1d5..088fb1f61028b 100644 --- a/apps/meteor/client/components/message/toolbar/items/actions/QuoteMessageAction.tsx +++ b/apps/meteor/client/components/message/toolbar/items/actions/QuoteMessageAction.tsx @@ -40,7 +40,6 @@ const QuoteMessageAction = ({ message, subscription }: QuoteMessageActionProps) id='quote-message' icon='quote' title={t('Quote')} - qa='Quote' onClick={() => { if (message && autoTranslateOptions?.autoTranslateEnabled && autoTranslateOptions.showAutoTranslate(message)) { message.msg = diff --git a/apps/meteor/client/components/message/toolbar/items/actions/ReactionMessageAction.tsx b/apps/meteor/client/components/message/toolbar/items/actions/ReactionMessageAction.tsx index 8772672c707ec..6ce3dc1be51d6 100644 --- a/apps/meteor/client/components/message/toolbar/items/actions/ReactionMessageAction.tsx +++ b/apps/meteor/client/components/message/toolbar/items/actions/ReactionMessageAction.tsx @@ -72,7 +72,6 @@ const ReactionMessageAction = ({ message, room, subscription }: ReactionMessageA id='reaction-message' icon='add-reaction' title={t('Add_Reaction')} - qa='Add_Reaction' onClick={(event) => { event.stopPropagation(); chat?.emojiPicker.open(event.currentTarget, (emoji) => { diff --git a/apps/meteor/client/components/message/toolbar/items/actions/ReplyInThreadMessageAction.tsx b/apps/meteor/client/components/message/toolbar/items/actions/ReplyInThreadMessageAction.tsx index d46f01935d582..d962cceffbe99 100644 --- a/apps/meteor/client/components/message/toolbar/items/actions/ReplyInThreadMessageAction.tsx +++ b/apps/meteor/client/components/message/toolbar/items/actions/ReplyInThreadMessageAction.tsx @@ -37,7 +37,6 @@ const ReplyInThreadMessageAction = ({ message, room, subscription }: ReplyInThre id='reply-in-thread' icon='thread' title={t('Reply_in_thread')} - qa='Reply_in_thread' onClick={(event) => { event.stopPropagation(); const routeName = router.getRouteName(); diff --git a/apps/meteor/client/components/message/variants/RoomMessage.tsx b/apps/meteor/client/components/message/variants/RoomMessage.tsx index 43d885f6839f6..ec96ff8fc1fc1 100644 --- a/apps/meteor/client/components/message/variants/RoomMessage.tsx +++ b/apps/meteor/client/components/message/variants/RoomMessage.tsx @@ -74,14 +74,11 @@ const RoomMessage = ({ isEditing={editing} isPending={message.temp} sequential={sequential} - data-qa-editing={editing} - data-qa-selected={selected} data-id={message._id} data-mid={message._id} data-unread={unread} data-sequential={sequential} data-own={message.u._id === uid} - data-qa-type='message' aria-busy={message.temp} {...props} > diff --git a/apps/meteor/client/components/message/variants/SystemMessage.tsx b/apps/meteor/client/components/message/variants/SystemMessage.tsx index 1577007c06097..ff19c38a5003b 100644 --- a/apps/meteor/client/components/message/variants/SystemMessage.tsx +++ b/apps/meteor/client/components/message/variants/SystemMessage.tsx @@ -68,8 +68,6 @@ const SystemMessage = ({ message, showUserAvatar, ...props }: SystemMessageProps tabIndex={0} onClick={isSelecting ? toggleSelected : undefined} isSelected={isSelected} - data-qa-selected={isSelected} - data-qa='system-message' data-system-message-type={message.t} {...props} > @@ -88,7 +86,11 @@ const SystemMessage = ({ message, showUserAvatar, ...props }: SystemMessageProps )} - {messageType && {messageType.text(t, message)}} + {messageType && ( + + {messageType.text(t, message)} + + )} {formatTime(message.ts)} {message.attachments && ( diff --git a/apps/meteor/client/components/message/variants/ThreadMessage.tsx b/apps/meteor/client/components/message/variants/ThreadMessage.tsx index 86a0442a69948..3acb7653ab625 100644 --- a/apps/meteor/client/components/message/variants/ThreadMessage.tsx +++ b/apps/meteor/client/components/message/variants/ThreadMessage.tsx @@ -45,13 +45,11 @@ const ThreadMessage = ({ message, sequential, unread, showUserAvatar }: ThreadMe isEditing={editing} isPending={message.temp} sequential={sequential} - data-qa-editing={editing} data-id={message._id} data-mid={message._id} data-unread={unread} data-sequential={sequential} data-own={message.u._id === uid} - data-qa-type='message' > {!sequential && message.u.username && showUserAvatar && ( diff --git a/apps/meteor/client/components/message/variants/ThreadMessagePreview.tsx b/apps/meteor/client/components/message/variants/ThreadMessagePreview.tsx index 393f8b16e01a5..8822dcbfab712 100644 --- a/apps/meteor/client/components/message/variants/ThreadMessagePreview.tsx +++ b/apps/meteor/client/components/message/variants/ThreadMessagePreview.tsx @@ -77,7 +77,6 @@ const ThreadMessagePreview = ({ message, showUserAvatar, sequential, ...props }: onClick={handleThreadClick} onKeyDown={(e) => e.code === 'Enter' && handleThreadClick()} isSelected={isSelected} - data-qa-selected={isSelected} {...props} > {!sequential && ( diff --git a/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx b/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx index a8fbfb2e345e4..813d63e9f9130 100644 --- a/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx +++ b/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx @@ -2,9 +2,10 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { isDiscussionMessage, isThreadMainMessage, isE2EEMessage, isQuoteAttachment } from '@rocket.chat/core-typings'; import { MessageBody } from '@rocket.chat/fuselage'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; -import { useTranslation, useUserId, useUserPresence } from '@rocket.chat/ui-contexts'; +import { useUserId, useUserPresence } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; import { useChat } from '../../../../views/room/contexts/ChatContext'; import MessageContentBody from '../../MessageContentBody'; @@ -40,7 +41,7 @@ const RoomMessageContent = ({ message, unread, all, mention, searchText }: RoomM const { enabled: readReceiptEnabled } = useMessageListReadReceipts(); const messageUser = { ...message.u, roles: [], ...useUserPresence(message.u._id) }; const chat = useChat(); - const t = useTranslation(); + const { t } = useTranslation(); const normalizedMessage = useNormalizedMessage(message); const isMessageEncrypted = encrypted && normalizedMessage?.e2e === 'pending'; @@ -51,7 +52,11 @@ const RoomMessageContent = ({ message, unread, all, mention, searchText }: RoomM return ( <> - {isMessageEncrypted && {t('E2E_message_encrypted_placeholder')}} + {isMessageEncrypted && ( + + {t('E2E_message_encrypted_placeholder')} + + )} {!!quotes?.length && } diff --git a/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx b/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx index bb422e1f20b90..d137162d0b06d 100644 --- a/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx +++ b/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx @@ -46,7 +46,11 @@ const ThreadMessageContent = ({ message }: ThreadMessageContentProps): ReactElem return ( <> - {isMessageEncrypted && {t('E2E_message_encrypted_placeholder')}} + {isMessageEncrypted && ( + + {t('E2E_message_encrypted_placeholder')} + + )} {!!quotes?.length && } diff --git a/apps/meteor/client/lib/chats/flows/sendMessage.ts b/apps/meteor/client/lib/chats/flows/sendMessage.ts index 64ef5ecf37a16..dd4fae1deb952 100644 --- a/apps/meteor/client/lib/chats/flows/sendMessage.ts +++ b/apps/meteor/client/lib/chats/flows/sendMessage.ts @@ -1,15 +1,16 @@ import type { IMessage } from '@rocket.chat/core-typings'; -import { sdk } from '../../../../app/utils/client/lib/SDKClient'; -import { t } from '../../../../app/utils/lib/i18n'; -import { onClientBeforeSendMessage } from '../../onClientBeforeSendMessage'; -import { dispatchToastMessage } from '../../toast'; -import type { ChatAPI } from '../ChatAPI'; import { processMessageEditing } from './processMessageEditing'; import { processSetReaction } from './processSetReaction'; import { processSlashCommand } from './processSlashCommand'; import { processTooLongMessage } from './processTooLongMessage'; +import { sdk } from '../../../../app/utils/client/lib/SDKClient'; +import { t } from '../../../../app/utils/lib/i18n'; import { closeUnclosedCodeBlock } from '../../../../lib/utils/closeUnclosedCodeBlock'; +import { Messages } from '../../../stores'; +import { onClientBeforeSendMessage } from '../../onClientBeforeSendMessage'; +import { dispatchToastMessage } from '../../toast'; +import type { ChatAPI } from '../ChatAPI'; const process = async (chat: ChatAPI, message: IMessage, previewUrls?: string[], isSlashCommandAllowed?: boolean): Promise => { const mid = chat.currentEditingMessage.getMID(); @@ -37,6 +38,12 @@ const process = async (chat: ChatAPI, message: IMessage, previewUrls?: string[], } await sdk.call('sendMessage', message, previewUrls); + + // after the request is complete we can go ahead and mark as sent + Messages.state.update( + (record) => record._id === message._id && record.temp === true, + ({ temp: _, ...record }) => record, + ); }; export const sendMessage = async ( diff --git a/apps/meteor/client/providers/MediaCallProvider.tsx b/apps/meteor/client/providers/MediaCallProvider.tsx index 006b2bb067f22..ece301ca46f32 100644 --- a/apps/meteor/client/providers/MediaCallProvider.tsx +++ b/apps/meteor/client/providers/MediaCallProvider.tsx @@ -1,5 +1,6 @@ +import { Emitter } from '@rocket.chat/emitter'; import { usePermission } from '@rocket.chat/ui-contexts'; -import { MediaCallProvider as MediaCallProviderBase, MediaCallContext } from '@rocket.chat/ui-voip'; +import { MediaCallProvider as MediaCallProviderBase, MediaCallInstanceContext } from '@rocket.chat/ui-voip'; import type { ReactNode } from 'react'; import { useMemo } from 'react'; @@ -13,17 +14,18 @@ const MediaCallProvider = ({ children }: { children: ReactNode }) => { const unauthorizedContextValue = useMemo( () => ({ - state: 'unauthorized' as const, - onToggleWidget: undefined, - onEndCall: undefined, - peerInfo: undefined, - setOpenRoomId: undefined, + instance: undefined, + signalEmitter: new Emitter(), + audioElement: undefined, + openRoomId: undefined, + setOpenRoomId: () => undefined, + getAutocompleteOptions: () => Promise.resolve([]), }), [], ); if (!hasModule || (!canMakeInternalCall && !canMakeExternalCall)) { - return {children}; + return {children}; } return {children}; diff --git a/apps/meteor/client/sidebar/Item/Condensed.tsx b/apps/meteor/client/sidebar/Item/Condensed.tsx index 3c737cc30e7c9..1f9f522d2844e 100644 --- a/apps/meteor/client/sidebar/Item/Condensed.tsx +++ b/apps/meteor/client/sidebar/Item/Condensed.tsx @@ -24,7 +24,7 @@ const Condensed = ({ icon, title, avatar, actions, unread, menu, badges, ...prop const handlePointerEnter = () => setMenuVisibility(true); return ( - + {avatar && {avatar}} {icon} {title} diff --git a/apps/meteor/client/sidebar/Item/Extended.tsx b/apps/meteor/client/sidebar/Item/Extended.tsx index ce9faea597849..fde211d74f0d0 100644 --- a/apps/meteor/client/sidebar/Item/Extended.tsx +++ b/apps/meteor/client/sidebar/Item/Extended.tsx @@ -55,7 +55,7 @@ const Extended = ({ const handlePointerEnter = () => setMenuVisibility(true); return ( - + {avatar && {avatar}} diff --git a/apps/meteor/client/sidebar/Item/Medium.tsx b/apps/meteor/client/sidebar/Item/Medium.tsx index f26d64b983891..3492d4e55de34 100644 --- a/apps/meteor/client/sidebar/Item/Medium.tsx +++ b/apps/meteor/client/sidebar/Item/Medium.tsx @@ -23,7 +23,7 @@ const Medium = ({ icon, title, avatar, actions, badges, unread, menu, ...props } const handlePointerEnter = () => setMenuVisibility(true); return ( - + {avatar} {icon} {title} diff --git a/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx b/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx index 3bc46c8742f1d..b3a75763c0932 100644 --- a/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx +++ b/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx @@ -63,6 +63,7 @@ const AddCustomSound = ({ goToNew, close, onChange, ...props }: AddCustomSoundPr (typeof error === 'string' || error instanceof Error) && dispatchToastMessage({ type: 'error', message: error }); } }; + close(); return soundId; } catch (error) { (typeof error === 'string' || error instanceof Error) && dispatchToastMessage({ type: 'error', message: error }); diff --git a/apps/meteor/client/views/admin/customSounds/EditCustomSound.tsx b/apps/meteor/client/views/admin/customSounds/EditCustomSound.tsx index a16c6e476505f..0319ab6db8346 100644 --- a/apps/meteor/client/views/admin/customSounds/EditCustomSound.tsx +++ b/apps/meteor/client/views/admin/customSounds/EditCustomSound.tsx @@ -1,3 +1,4 @@ +import { ContextualbarEmptyContent } from '@rocket.chat/ui-client'; import { useEndpoint } from '@rocket.chat/ui-contexts'; import { useQuery } from '@tanstack/react-query'; import type { ReactElement } from 'react'; @@ -9,41 +10,37 @@ import { FormSkeleton } from '../../../components/Skeleton'; type EditCustomSoundProps = { _id: string | undefined; onChange?: () => void; - close?: () => void; + close: () => void; }; -function EditCustomSound({ _id, onChange, ...props }: EditCustomSoundProps): ReactElement | null { +function EditCustomSound({ _id, onChange, close, ...props }: EditCustomSoundProps): ReactElement | null { + const getSound = useEndpoint('GET', '/v1/custom-sounds.getOne'); const { t } = useTranslation(); - const getSounds = useEndpoint('GET', '/v1/custom-sounds.list'); - const { data, isPending, refetch } = useQuery({ - queryKey: ['custom-sounds', _id], - - queryFn: async () => { - const { sounds } = await getSounds({ query: JSON.stringify({ _id }) }); - - if (sounds.length === 0) { - throw new Error(t('No_results_found')); + const { data, isLoading } = useQuery({ + queryKey: ['custom-sound', _id], + queryFn: () => { + if (!_id) { + throw new Error('Cannot fetch custom sound: missing _id in query.'); } - return sounds[0]; + return getSound({ _id }); }, - meta: { apiErrorToastMessage: true }, + enabled: !!_id, }); - if (isPending) { + if (isLoading) { return ; } if (!data) { - return null; + return ; } const handleChange: () => void = () => { onChange?.(); - refetch?.(); }; - return ; + return ; } export default EditCustomSound; diff --git a/apps/meteor/client/views/admin/customSounds/EditSound.tsx b/apps/meteor/client/views/admin/customSounds/EditSound.tsx index 9f72df02ca7bd..f46ce0e175b61 100644 --- a/apps/meteor/client/views/admin/customSounds/EditSound.tsx +++ b/apps/meteor/client/views/admin/customSounds/EditSound.tsx @@ -9,7 +9,7 @@ import { validate, createSoundData } from './lib'; import { useSingleFileInput } from '../../../hooks/useSingleFileInput'; type EditSoundProps = { - close?: () => void; + close: () => void; onChange: () => void; data: { _id: string; @@ -82,6 +82,7 @@ function EditSound({ close, onChange, data, ...props }: EditSoundProps): ReactEl } }; } + close(); } validation.forEach((invalidFieldName) => @@ -95,7 +96,7 @@ function EditSound({ close, onChange, data, ...props }: EditSoundProps): ReactEl ); const handleSave = useCallback(async () => { - saveAction(sound); + await saveAction(sound); onChange(); }, [saveAction, sound, onChange]); diff --git a/apps/meteor/client/views/admin/integrations/incoming/IncomingWebhookForm.tsx b/apps/meteor/client/views/admin/integrations/incoming/IncomingWebhookForm.tsx index aa8e77f5b2746..3fe9c96326cd3 100644 --- a/apps/meteor/client/views/admin/integrations/incoming/IncomingWebhookForm.tsx +++ b/apps/meteor/client/views/admin/integrations/incoming/IncomingWebhookForm.tsx @@ -202,7 +202,7 @@ const IncomingWebhookForm = ({ webhookData }: { webhookData?: Serialized ( { {isLoading && ( {headers} - {isLoading && } + {isLoading && } )} {isSuccess && data.reports.length > 0 && ( diff --git a/apps/meteor/client/views/admin/permissions/UsersInRole/UsersInRoleTable/__snapshots__/UsersInRoleTable.spec.tsx.snap b/apps/meteor/client/views/admin/permissions/UsersInRole/UsersInRoleTable/__snapshots__/UsersInRoleTable.spec.tsx.snap index e1a1e54446fca..6a47d55c4a414 100644 --- a/apps/meteor/client/views/admin/permissions/UsersInRole/UsersInRoleTable/__snapshots__/UsersInRoleTable.spec.tsx.snap +++ b/apps/meteor/client/views/admin/permissions/UsersInRole/UsersInRoleTable/__snapshots__/UsersInRoleTable.spec.tsx.snap @@ -88,7 +88,7 @@ exports[`renders Default without crashing 1`] = ` class="rcx-box rcx-box--full rcx-avatar rcx-avatar--x40" > - + Email sent to{' '} diff --git a/apps/meteor/client/views/audit/components/__snapshots__/SecurityLogDisplayModal.spec.tsx.snap b/apps/meteor/client/views/audit/components/__snapshots__/SecurityLogDisplayModal.spec.tsx.snap index 8f1183e87a237..caeb092cd80ce 100644 --- a/apps/meteor/client/views/audit/components/__snapshots__/SecurityLogDisplayModal.spec.tsx.snap +++ b/apps/meteor/client/views/audit/components/__snapshots__/SecurityLogDisplayModal.spec.tsx.snap @@ -65,7 +65,7 @@ exports[`renders Default without crashing 1`] = ` class="rcx-box rcx-box--full rcx-avatar rcx-avatar--x24" > toggleCollapsed()} selectedCategoriesCount={selectedCategories.length} {...props} /> diff --git a/apps/meteor/client/views/marketplace/components/RadioDropDown/RadioDownAnchor.tsx b/apps/meteor/client/views/marketplace/components/RadioDropDown/RadioDownAnchor.tsx index 6b3bb5e4c28fe..a12f40bd16126 100644 --- a/apps/meteor/client/views/marketplace/components/RadioDropDown/RadioDownAnchor.tsx +++ b/apps/meteor/client/views/marketplace/components/RadioDropDown/RadioDownAnchor.tsx @@ -1,12 +1,12 @@ import type { Button } from '@rocket.chat/fuselage'; import { Box, Icon } from '@rocket.chat/fuselage'; -import type { ComponentProps, SetStateAction } from 'react'; +import type { ComponentProps } from 'react'; import { forwardRef } from 'react'; import type { RadioDropDownGroup } from '../../definitions/RadioDropDownDefinitions'; type RadioDropdownAnchorProps = { - onClick: (forcedValue?: SetStateAction | undefined) => void; + onClick: (event: React.MouseEvent) => void; group: RadioDropDownGroup; } & Omit, 'onClick'>; @@ -17,7 +17,7 @@ const RadioDownAnchor = forwardRef(functi - + toggleCollapsed()} {...props} /> {collapsed && ( diff --git a/apps/meteor/client/views/mediaCallHistory/CallHistoryRowExternalUser.tsx b/apps/meteor/client/views/mediaCallHistory/CallHistoryRowExternalUser.tsx index b26b3621b632b..8033f746a54a0 100644 --- a/apps/meteor/client/views/mediaCallHistory/CallHistoryRowExternalUser.tsx +++ b/apps/meteor/client/views/mediaCallHistory/CallHistoryRowExternalUser.tsx @@ -1,6 +1,6 @@ import { GenericMenu } from '@rocket.chat/ui-client'; import type { CallHistoryTableExternalContact, CallHistoryTableRowProps } from '@rocket.chat/ui-voip'; -import { CallHistoryTableRow, useMediaCallContext, isCallingBlocked } from '@rocket.chat/ui-voip'; +import { CallHistoryTableRow, usePeekMediaSessionState, useWidgetExternalControls } from '@rocket.chat/ui-voip'; import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -11,27 +11,29 @@ type CallHistoryRowExternalUserProps = Omit { const { t } = useTranslation(); - const { onToggleWidget, state } = useMediaCallContext(); + const state = usePeekMediaSessionState(); + const { toggleWidget } = useWidgetExternalControls(); const handleClick = useCallback(() => { onClick(_id); }, [onClick, _id]); const actions = useMemo(() => { - if (state === 'unauthorized' || state === 'unlicensed' || !onToggleWidget) { + if (state === 'unavailable') { return []; } + const disabled = state !== 'available'; return [ { id: 'voiceCall', icon: 'phone', content: t('Voice_call'), - disabled: isCallingBlocked(state), - tooltip: isCallingBlocked(state) ? t('Call_in_progress') : undefined, - onClick: () => onToggleWidget({ number: contact.number }), + disabled, + tooltip: disabled ? t('Call_in_progress') : undefined, + onClick: () => toggleWidget({ number: contact.number }), } as const, ]; - }, [contact, onToggleWidget, t, state]); + }, [contact, toggleWidget, t, state]); return ( = { userInfo: 'User_info', } as const; -const getItems = (actions: HistoryActionCallbacks, t: TFunction, state: MediaCallState) => { +const getItems = (actions: HistoryActionCallbacks, t: TFunction, state: PeekMediaSessionStateReturn) => { return (Object.entries(actions) as [HistoryActions, () => void][]) .filter(([_, callback]) => callback) .map(([action, callback]) => { - const disabled = action === 'voiceCall' && isCallingBlocked(state); + const disabled = action === 'voiceCall' && state !== 'available'; return { id: action, icon: iconDictionary[action], @@ -66,7 +66,7 @@ const CallHistoryRowInternalUser = ({ onClick, }: CallHistoryRowInternalUserProps) => { const { t } = useTranslation(); - const { state } = useMediaCallContext(); + const state = usePeekMediaSessionState(); const actions = useMediaCallInternalHistoryActions({ contact: { _id: contact._id, diff --git a/apps/meteor/client/views/mediaCallHistory/MediaCallHistoryContextualbar.tsx b/apps/meteor/client/views/mediaCallHistory/MediaCallHistoryContextualbar.tsx index ab849910ad7d8..fd5e5b1985012 100644 --- a/apps/meteor/client/views/mediaCallHistory/MediaCallHistoryContextualbar.tsx +++ b/apps/meteor/client/views/mediaCallHistory/MediaCallHistoryContextualbar.tsx @@ -39,10 +39,10 @@ const MediaCallHistoryContextualbar = ({ queryKey: callHistoryQueryKeys.info(callId || historyId), queryFn: async () => { if (callId) { - return getCallHistory({ callId } as any); // TODO fix this type + return getCallHistory({ callId }); } if (historyId) { - return getCallHistory({ historyId } as any); // TODO fix this type + return getCallHistory({ historyId }); } throw new Error('Call ID or history ID is required'); }, diff --git a/apps/meteor/client/views/mediaCallHistory/MediaCallHistoryExternal.tsx b/apps/meteor/client/views/mediaCallHistory/MediaCallHistoryExternal.tsx index a9af771979f02..94deaf3302eb7 100644 --- a/apps/meteor/client/views/mediaCallHistory/MediaCallHistoryExternal.tsx +++ b/apps/meteor/client/views/mediaCallHistory/MediaCallHistoryExternal.tsx @@ -1,5 +1,5 @@ import type { CallHistoryItem, IExternalMediaCallHistoryItem, IMediaCall, Serialized } from '@rocket.chat/core-typings'; -import { CallHistoryContextualBar, useMediaCallContext } from '@rocket.chat/ui-voip'; +import { CallHistoryContextualBar, useWidgetExternalControls, usePeekMediaSessionState } from '@rocket.chat/ui-voip'; import { useMemo } from 'react'; type ExternalCallEndpointData = Serialized<{ @@ -33,16 +33,17 @@ const MediaCallHistoryExternal = ({ data, onClose }: MediaCallHistoryExternalPro state: data.item.state, }; }, [data]); - const { onToggleWidget } = useMediaCallContext(); + const state = usePeekMediaSessionState(); + const { toggleWidget } = useWidgetExternalControls(); const actions = useMemo(() => { - if (!onToggleWidget) { + if (state !== 'available') { return {}; } return { - voiceCall: () => onToggleWidget(contact), + voiceCall: () => toggleWidget(contact), }; - }, [contact, onToggleWidget]); + }, [contact, state, toggleWidget]); return ; }; diff --git a/apps/meteor/client/views/mediaCallHistory/useMediaCallInternalHistoryActions.ts b/apps/meteor/client/views/mediaCallHistory/useMediaCallInternalHistoryActions.ts index 5ae12a636ebea..ec1a9b77ca1a6 100644 --- a/apps/meteor/client/views/mediaCallHistory/useMediaCallInternalHistoryActions.ts +++ b/apps/meteor/client/views/mediaCallHistory/useMediaCallInternalHistoryActions.ts @@ -1,7 +1,7 @@ import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; import { useGoToDirectMessage } from '@rocket.chat/ui-client'; import { useRouter, useUserAvatarPath } from '@rocket.chat/ui-contexts'; -import { useMediaCallContext } from '@rocket.chat/ui-voip'; +import { useWidgetExternalControls, usePeekMediaSessionState } from '@rocket.chat/ui-voip'; import { useMemo } from 'react'; export type InternalCallHistoryContact = { @@ -28,17 +28,18 @@ export const useMediaCallInternalHistoryActions = ({ messageRoomId, openUserInfo, }: UseMediaCallInternalHistoryActionsBaseOptions) => { - const { onToggleWidget, state } = useMediaCallContext(); + const state = usePeekMediaSessionState(); + const { toggleWidget } = useWidgetExternalControls(); const router = useRouter(); const getAvatarUrl = useUserAvatarPath(); const voiceCall = useEffectEvent(() => { - if (state === 'unauthorized' || state === 'unlicensed' || !onToggleWidget) { + if (state !== 'available') { return; } - onToggleWidget({ + toggleWidget({ userId: contact._id, displayName: contact.displayName ?? '', username: contact.username, diff --git a/apps/meteor/client/views/omnichannel/additionalForms/CurrentChatTags.tsx b/apps/meteor/client/views/omnichannel/additionalForms/CurrentChatTags.tsx index e298f3cb3dbc4..0df8f5d14a2ec 100644 --- a/apps/meteor/client/views/omnichannel/additionalForms/CurrentChatTags.tsx +++ b/apps/meteor/client/views/omnichannel/additionalForms/CurrentChatTags.tsx @@ -4,8 +4,8 @@ import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule'; import AutoCompleteTagsMultiple from '../tags/AutoCompleteTagsMultiple'; type CurrentChatTagsProps = Pick, 'id' | 'aria-labelledby'> & { - value: Array<{ value: string; label: string }>; - handler: (value: { label: string; value: string }[]) => void; + value: NonNullable['value']>; + handler: NonNullable['onChange']>; department?: string; viewAll?: boolean; }; @@ -17,15 +17,7 @@ const CurrentChatTags = ({ value, handler, department, viewAll, ...props }: Curr return null; } - return ( - - ); + return ; }; export default CurrentChatTags; diff --git a/apps/meteor/client/views/omnichannel/cannedResponses/components/CannedResponsesComposer/CannedResponsesComposer.tsx b/apps/meteor/client/views/omnichannel/cannedResponses/components/CannedResponsesComposer/CannedResponsesComposer.tsx index b2abf4a612a3f..db3e855cebce9 100644 --- a/apps/meteor/client/views/omnichannel/cannedResponses/components/CannedResponsesComposer/CannedResponsesComposer.tsx +++ b/apps/meteor/client/views/omnichannel/cannedResponses/components/CannedResponsesComposer/CannedResponsesComposer.tsx @@ -8,7 +8,7 @@ import { MessageComposerActionsDivider, } from '@rocket.chat/ui-composer'; import { useUserPreference } from '@rocket.chat/ui-contexts'; -import type { ComponentProps } from 'react'; +import type { ComponentProps, ChangeEvent } from 'react'; import { memo, useCallback, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -16,7 +16,11 @@ import InsertPlaceholderDropdown from './InsertPlaceholderDropdown'; import { Backdrop } from '../../../../../components/Backdrop'; import { useEmojiPicker } from '../../../../../contexts/EmojiPickerContext'; -const CannedResponsesComposer = ({ onChange, ...props }: ComponentProps) => { +type CannedResponsesComposerProps = Omit, 'onChange'> & { + onChange: (value: string) => void; +}; + +const CannedResponsesComposer = ({ onChange, ...props }: CannedResponsesComposerProps) => { const { t } = useTranslation(); const useEmojisPreference = useUserPreference('useEmojis'); @@ -53,7 +57,7 @@ const CannedResponsesComposer = ({ onChange, ...props }: ComponentProps - + ): void => onChange(e.target.value)} + {...props} + /> diff --git a/apps/meteor/client/views/omnichannel/cannedResponses/components/CannedResponsesComposer/InsertPlaceholderDropdown.tsx b/apps/meteor/client/views/omnichannel/cannedResponses/components/CannedResponsesComposer/InsertPlaceholderDropdown.tsx index c8972618745d8..0bff09fa387c2 100644 --- a/apps/meteor/client/views/omnichannel/cannedResponses/components/CannedResponsesComposer/InsertPlaceholderDropdown.tsx +++ b/apps/meteor/client/views/omnichannel/cannedResponses/components/CannedResponsesComposer/InsertPlaceholderDropdown.tsx @@ -5,7 +5,7 @@ import { memo } from 'react'; import { useTranslation } from 'react-i18next'; type InsertPlaceholderDropdownProps = { - onChange: any; + onChange: (value: string) => void; textAreaRef: RefObject; setVisible: Dispatch>; }; @@ -17,7 +17,7 @@ const InsertPlaceholderDropdown = ({ onChange, textAreaRef, setVisible }: Insert cursor: pointer; `; - const setPlaceholder = (name: any): void => { + const setPlaceholder = (name: string): void => { if (textAreaRef?.current) { const text = textAreaRef.current.value; const startPos = textAreaRef.current.selectionStart; diff --git a/apps/meteor/client/views/omnichannel/components/Tags.tsx b/apps/meteor/client/views/omnichannel/components/Tags.tsx index c85d8d9994181..f5a8e96ee6455 100644 --- a/apps/meteor/client/views/omnichannel/components/Tags.tsx +++ b/apps/meteor/client/views/omnichannel/components/Tags.tsx @@ -76,7 +76,7 @@ const Tags = ({ tags = [], handler, error, tagRequired, department }: TagsProps) { + handler={(tags): void => { handler(tags.map((tag) => tag.label)); }} department={department} diff --git a/apps/meteor/client/views/room/Header/Omnichannel/QuickActions/QuickActionOptions.tsx b/apps/meteor/client/views/room/Header/Omnichannel/QuickActions/QuickActionOptions.tsx index 0e86696253cdb..2c4bb57ca5026 100644 --- a/apps/meteor/client/views/room/Header/Omnichannel/QuickActions/QuickActionOptions.tsx +++ b/apps/meteor/client/views/room/Header/Omnichannel/QuickActions/QuickActionOptions.tsx @@ -5,7 +5,7 @@ import { HeaderToolbarAction } from '@rocket.chat/ui-client'; import { memo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { useDropdownVisibility } from './hooks/useDrowdownVisibility'; +import { useDropdownVisibility } from './hooks/useDropdownVisibility'; import type { QuickActionsActionOptions } from '../../../lib/quickActions'; type QuickActionOptionsProps = { diff --git a/apps/meteor/client/views/room/Header/Omnichannel/QuickActions/hooks/useDrowdownVisibility.ts b/apps/meteor/client/views/room/Header/Omnichannel/QuickActions/hooks/useDropdownVisibility.ts similarity index 100% rename from apps/meteor/client/views/room/Header/Omnichannel/QuickActions/hooks/useDrowdownVisibility.ts rename to apps/meteor/client/views/room/Header/Omnichannel/QuickActions/hooks/useDropdownVisibility.ts diff --git a/apps/meteor/client/views/room/Header/ParentRoom/ParentRoom.tsx b/apps/meteor/client/views/room/Header/ParentRoom/ParentRoom.tsx index 38c6cc5f86597..2fbc31cd9f01f 100644 --- a/apps/meteor/client/views/room/Header/ParentRoom/ParentRoom.tsx +++ b/apps/meteor/client/views/room/Header/ParentRoom/ParentRoom.tsx @@ -4,12 +4,6 @@ import ParentDiscussion from './ParentDiscussion'; import ParentTeam from './ParentTeam'; const ParentRoom = ({ room }: { room: IRoom }) => { - const parentRoomId = Boolean(room.prid || (room.teamId && !room.teamMain)); - - if (!parentRoomId) { - return null; - } - if (room.prid) { return ; } @@ -17,6 +11,8 @@ const ParentRoom = ({ room }: { room: IRoom }) => { if (room.teamId && !room.teamMain) { return ; } + + return null; }; export default ParentRoom; diff --git a/apps/meteor/client/views/room/Header/icons/Encrypted.tsx b/apps/meteor/client/views/room/Header/icons/Encrypted.tsx index 65ffd3ecac502..5386c4beb90fe 100644 --- a/apps/meteor/client/views/room/Header/icons/Encrypted.tsx +++ b/apps/meteor/client/views/room/Header/icons/Encrypted.tsx @@ -1,5 +1,4 @@ import type { IRoom } from '@rocket.chat/core-typings'; -import colors from '@rocket.chat/fuselage-tokens/colors.json'; import { HeaderState } from '@rocket.chat/ui-client'; import { useSetting } from '@rocket.chat/ui-contexts'; import { memo } from 'react'; @@ -8,7 +7,7 @@ import { useTranslation } from 'react-i18next'; const Encrypted = ({ room }: { room: IRoom }) => { const { t } = useTranslation(); const e2eEnabled = useSetting('E2E_Enable'); - return e2eEnabled && room?.encrypted ? : null; + return e2eEnabled && room?.encrypted ? : null; }; export default memo(Encrypted); diff --git a/apps/meteor/client/views/room/body/__snapshots__/RoomInviteBody.spec.tsx.snap b/apps/meteor/client/views/room/body/__snapshots__/RoomInviteBody.spec.tsx.snap index 8e9de74d07856..a5c317118b8ff 100644 --- a/apps/meteor/client/views/room/body/__snapshots__/RoomInviteBody.spec.tsx.snap +++ b/apps/meteor/client/views/room/body/__snapshots__/RoomInviteBody.spec.tsx.snap @@ -40,7 +40,7 @@ exports[`RoomInvite renders Default without crashing 1`] = ` class="rcx-box rcx-box--full rcx-avatar rcx-avatar--x16" > = suspended: boolean; filter: unknown; clear: () => void; + update: () => void; } | { option: undefined; @@ -39,6 +40,7 @@ type ComposerBoxPopupResult = suspended: undefined; filter: unknown; clear: () => void; + update: () => void; }; const keys = { @@ -269,6 +271,7 @@ export const useComposerBoxPopup = ( suspended: undefined, filter: undefined, clear, + update: setOptionByInput, }; } @@ -282,5 +285,6 @@ export const useComposerBoxPopup = ( suspended, filter, clear, + update: setOptionByInput, }; }; diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx index dd804ab3f6dd8..8ad2997ef917a 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx @@ -178,10 +178,15 @@ const MessageBox = ({ event.stopPropagation(); chat.currentEditingMessage.reset().then((reset) => { - if (!reset) { - chat.currentEditingMessage.cancel(); - chat.currentEditingMessage.stop(); + // NOTE: if the message was reset (i.e. content changed), we just update the popup (to re-apply/remove the preview) + if (reset) { + popup.update(); + return; } + + chat.currentEditingMessage.cancel(); + chat.currentEditingMessage.stop(); + popup.clear(); }); } }; @@ -378,7 +383,6 @@ const MessageBox = ({ ); const shouldPopupPreview = useEnablePopupPreview(popup.filter, popup.option); - return ( <> {chat.composer?.quotedMessages && } diff --git a/apps/meteor/client/views/room/contextualBar/UserInfo/UserInfoActions.tsx b/apps/meteor/client/views/room/contextualBar/UserInfo/UserInfoActions.tsx index 929c1d9c7570c..082e5c867306d 100644 --- a/apps/meteor/client/views/room/contextualBar/UserInfo/UserInfoActions.tsx +++ b/apps/meteor/client/views/room/contextualBar/UserInfo/UserInfoActions.tsx @@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next'; import { UserInfoAction } from '../../../../components/UserInfo'; import { useMemberExists } from '../../../hooks/useMemberExists'; +import type { UserInfoAction as UserInfoActionType } from '../../hooks/useUserInfoActions'; import { useUserInfoActions } from '../../hooks/useUserInfoActions'; type UserInfoActionsProps = { @@ -58,10 +59,9 @@ const UserInfoActions = ({ user, rid, isInvited, backToList }: UserInfoActionsPr ); }, [menuOptions, t]); - // TODO: sanitize Action type to avoid any const actions = useMemo(() => { - const mapAction = ([key, { content, title, icon, onClick }]: any): ReactElement => ( - + const mapAction = ([key, action]: [string, UserInfoActionType]): ReactElement => ( + ); return [...actionsDefinition.map(mapAction), menu].filter(Boolean); diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useUserMediaCallAction.spec.tsx b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useUserMediaCallAction.spec.tsx index 0f7f038df95bf..c3d2c8c56faeb 100644 --- a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useUserMediaCallAction.spec.tsx +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useUserMediaCallAction.spec.tsx @@ -1,10 +1,12 @@ import { mockAppRoot } from '@rocket.chat/mock-providers'; -import { useMediaCallContext } from '@rocket.chat/ui-voip'; import { act, renderHook } from '@testing-library/react'; import { useUserMediaCallAction } from './useUserMediaCallAction'; import { createFakeRoom, createFakeSubscription, createFakeUser } from '../../../../../../tests/mocks/data'; +const usePeekMediaSessionStateMock = jest.fn().mockReturnValue('available'); +const toggleWidgetMock = jest.fn(); + jest.mock('@rocket.chat/ui-contexts', () => ({ ...jest.requireActual('@rocket.chat/ui-contexts'), useUserAvatarPath: jest.fn().mockReturnValue((_args: any) => 'avatar-url'), @@ -13,14 +15,10 @@ jest.mock('@rocket.chat/ui-contexts', () => ({ jest.mock('@rocket.chat/ui-voip', () => ({ ...jest.requireActual('@rocket.chat/ui-voip'), - useMediaCallContext: jest.fn().mockImplementation(() => ({ - state: 'closed', - onToggleWidget: jest.fn(), - })), + useWidgetExternalControls: jest.fn().mockReturnValue({ toggleWidget: (...args: any[]) => toggleWidgetMock(...args) }), + usePeekMediaSessionState: () => usePeekMediaSessionStateMock(), })); -const useMediaCallContextMocked = jest.mocked(useMediaCallContext); - describe('useUserMediaCallAction', () => { const fakeUser = createFakeUser({ _id: 'own-uid' }); const mockRid = 'room-id'; @@ -29,6 +27,10 @@ describe('useUserMediaCallAction', () => { jest.clearAllMocks(); }); + beforeEach(() => { + usePeekMediaSessionStateMock.mockReturnValue('available'); + }); + it('should return undefined if room is federated', () => { const { result } = renderHook(() => useUserMediaCallAction(fakeUser, mockRid), { wrapper: mockAppRoot() @@ -41,13 +43,7 @@ describe('useUserMediaCallAction', () => { }); it('should return undefined if state is unauthorized', () => { - useMediaCallContextMocked.mockReturnValueOnce({ - state: 'unauthorized', - onToggleWidget: undefined, - onEndCall: undefined, - peerInfo: undefined, - setOpenRoomId: undefined, - }); + usePeekMediaSessionStateMock.mockReturnValueOnce('unavailable'); const { result } = renderHook(() => useUserMediaCallAction(fakeUser, mockRid), { wrapper: mockAppRoot().build() }); expect(result.current).toBeUndefined(); @@ -109,20 +105,13 @@ describe('useUserMediaCallAction', () => { }); it('should call onClick handler correctly', () => { - const mockOnToggleWidget = jest.fn(); - useMediaCallContextMocked.mockReturnValueOnce({ - state: 'closed', - onToggleWidget: mockOnToggleWidget, - peerInfo: undefined, - onEndCall: () => undefined, - setOpenRoomId: () => undefined, - }); + usePeekMediaSessionStateMock.mockReturnValueOnce('available'); const { result } = renderHook(() => useUserMediaCallAction(fakeUser, mockRid)); act(() => result.current?.onClick()); - expect(mockOnToggleWidget).toHaveBeenCalledWith({ + expect(toggleWidgetMock).toHaveBeenCalledWith({ userId: fakeUser._id, displayName: fakeUser.name, avatarUrl: 'avatar-url', @@ -130,13 +119,7 @@ describe('useUserMediaCallAction', () => { }); it('should be disabled if state is not closed, new, or unlicensed', () => { - useMediaCallContextMocked.mockReturnValueOnce({ - state: 'calling', - onToggleWidget: jest.fn(), - peerInfo: undefined, - onEndCall: () => undefined, - setOpenRoomId: () => undefined, - }); + usePeekMediaSessionStateMock.mockReturnValueOnce('calling'); const { result } = renderHook(() => useUserMediaCallAction(fakeUser, mockRid)); diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useUserMediaCallAction.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useUserMediaCallAction.ts index dfcb2a7a02e2b..8672f5c66803a 100644 --- a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useUserMediaCallAction.ts +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useUserMediaCallAction.ts @@ -1,7 +1,7 @@ import { isRoomFederated } from '@rocket.chat/core-typings'; import type { IRoom, IUser } from '@rocket.chat/core-typings'; import { useUserAvatarPath, useUserId, useUserSubscription, useUserCard, useUserRoom } from '@rocket.chat/ui-contexts'; -import { useMediaCallContext } from '@rocket.chat/ui-voip'; +import { usePeekMediaSessionState, useWidgetExternalControls } from '@rocket.chat/ui-voip'; import { useTranslation } from 'react-i18next'; import type { UserInfoAction } from '../useUserInfoActions'; @@ -10,7 +10,8 @@ export const useUserMediaCallAction = (user: Pick { closeUserCard(); - onToggleWidget({ + toggleWidget({ userId: user._id, displayName: user.name || user.username || '', avatarUrl, diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/index.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/index.ts index 44ce5ef6c2da5..931c3ee9dd51a 100644 --- a/apps/meteor/client/views/room/hooks/useUserInfoActions/index.ts +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/index.ts @@ -1 +1,2 @@ export { useUserInfoActions } from './useUserInfoActions'; +export type { UserInfoAction, UserMenuAction } from './useUserInfoActions'; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts index f9baa1f736680..ca0c8b753ddbd 100644 --- a/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts @@ -44,7 +44,7 @@ type UserInfoActionWithContent = { export type UserInfoAction = UserInfoActionWithContent | UserInfoActionWithOnlyIcon; -type UserMenuAction = { +export type UserMenuAction = { id: string; title: string; items: GenericMenuItemProps[]; @@ -66,7 +66,7 @@ export const useUserInfoActions = ({ size = 2, isMember, isInvited, -}: UserInfoActionsParams): { actions: [string, UserInfoAction][]; menuActions: any | undefined } => { +}: UserInfoActionsParams): { actions: [string, UserInfoAction][]; menuActions: UserMenuAction | undefined } => { const addUser = useAddUserAction(user, rid, reload); const blockUser = useBlockUserAction(user, rid); const changeLeader = useChangeLeaderAction(user, rid); diff --git a/apps/meteor/ee/server/apps/communication/uikit.ts b/apps/meteor/ee/server/apps/communication/uikit.ts index e5e851ef8f2b1..1dcd1c169d578 100644 --- a/apps/meteor/ee/server/apps/communication/uikit.ts +++ b/apps/meteor/ee/server/apps/communication/uikit.ts @@ -1,7 +1,6 @@ import { AppEvents, type IAppServerOrchestrator } from '@rocket.chat/apps'; -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 type { OperationParams, OperationResult, UrlParams } from '@rocket.chat/rest-typings'; import bodyParser from 'body-parser'; import cors from 'cors'; import type { Request, Response } from 'express'; @@ -94,7 +93,7 @@ apiServer.use('/api/apps/ui.interaction/', bodyParser.json(), cors(corsOptions), type UiKitUserInteractionRequest = Request< UrlParams<'/apps/ui.interaction/:id'>, - any, + OperationResult<'POST', '/apps/ui.interaction/:id'> | { error: unknown }, OperationParams<'POST', '/apps/ui.interaction/:id'> & { visitor?: { id: string; @@ -111,81 +110,87 @@ type UiKitUserInteractionRequest = Request< } >; -const getCoreAppPayload = (req: UiKitUserInteractionRequest): UiKitCoreAppPayload => { +router.post('/:id', async (req: UiKitUserInteractionRequest, res, next) => { const { id: appId } = req.params; - 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, - actionId, - triggerId, - container, - message, - payload, - user, - visitor, - room, - }; + const isCoreApp = await UiKitCoreApp.isRegistered(appId); + if (!isCoreApp) { + return next(); } - if (req.body.type === 'viewClosed') { + try { const { user } = req; - const { - type, - payload: { view, isCleared }, - triggerId, - } = req.body; - - return { - appId, - triggerId, - type, - user, - payload: { - view, - isCleared, - }, - }; - } - if (req.body.type === 'viewSubmit') { - const { user } = req; - const { type, actionId, triggerId, payload } = req.body; - - return { - appId, - type, - actionId, - triggerId, - payload, - user, - }; - } + switch (req.body.type) { + case 'blockAction': { + 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; - throw new Error('Type not supported'); -}; + const result = await UiKitCoreApp.blockAction({ + appId, + type, + actionId, + triggerId, + container, + message, + payload, + user, + visitor, + room, + }); -router.post('/:id', async (req: UiKitUserInteractionRequest, res, next) => { - const { id: appId } = req.params; + // Using ?? to always send something in the response, even if the app had no result. + res.send(result ?? {}); - const isCoreApp = await UiKitCoreApp.isRegistered(appId); - if (!isCoreApp) { - return next(); - } + return; + } - try { - const payload = getCoreAppPayload(req); + case 'viewSubmit': { + const { type, actionId, triggerId, payload } = req.body; + + const result = await UiKitCoreApp.viewSubmit({ + appId, + type, + actionId, + triggerId, + payload, + user, + }); + + // Using ?? to always send something in the response, even if the app had no result. + res.send(result ?? {}); + + return; + } + + case 'viewClosed': { + const { + type, + payload: { view, isCleared }, + triggerId, + } = req.body; + + const result = await UiKitCoreApp.viewClosed({ + appId, + triggerId, + type, + user, + payload: { + view, + isCleared, + }, + }); + + // Using ?? to always send something in the response, even if the app had no result. + res.send(result ?? {}); - const result = await UiKitCoreApp[payload.type](payload); + return; + } - // Using ?? to always send something in the response, even if the app had no result. - res.send(result ?? {}); + default: + throw new Error('Type not supported'); + } } catch (e) { const error = e instanceof Error ? e.message : e; res.status(500).send({ error }); @@ -201,7 +206,10 @@ export class AppUIKitInteractionApi { router.post('/:id', this.routeHandler); } - private routeHandler = async (req: UiKitUserInteractionRequest, res: Response): Promise => { + private routeHandler = async ( + req: UiKitUserInteractionRequest, + res: Response | { error: unknown }>, + ): Promise => { const { orch } = this; const { id: appId } = req.params; diff --git a/apps/meteor/ee/server/services/CHANGELOG.md b/apps/meteor/ee/server/services/CHANGELOG.md index 1fc4521bc7488..09a39d655f783 100644 --- a/apps/meteor/ee/server/services/CHANGELOG.md +++ b/apps/meteor/ee/server/services/CHANGELOG.md @@ -1,5 +1,22 @@ # rocketchat-services +## 2.0.43-rc.0 + +### Patch Changes + +-
Updated dependencies [602b20a8c570b895eb296ecfe39c9b7fcb12fabd, d1bf2cc675e80403659d388a1fbbdc6f73889dad, 02b1e6e6a184850d21e335077ca30382a1c7a66b, 9a70095296dbf516b0113a9a65e09f25137b2eaf, a4e3c1635d55ec4ce04cbde741426770e43581fb, 539659af22bc19880eda047dfc0b152472ccb65c, b1b1d6ccd81c90d231a7e594f834965c6e5f4fae, 5518503736b72674753e711ba4089d177ab988a5, a4341ec67d1f0413f30bbabfd292d1b0a41728b2, 40253146de8d8f83737e71b0ade7c67e0c295a28, 803b8075514de54c9ff34ba0c9aa3ee5fc3bbe61, 1361a1f4f1e3c0cc3f2a191cef8eccc12a714cde, 2a2701098536b32143003be8d267891978c708c9, 37acece030bc9f39bdaa86ab0130eb818332033e, d8baf395181b70fef9ce448eb509f65b66049615, ddc0ed34b03072362d166f1160104a9332b362e8, 722df6f60bc86c51b204e28a39acb3dc8710bdeb, 78b3fe3ef20e3a545b84551ba3f85cb40e862ba7, 98a6c58a38c053c60db2b4d53a9df0e94fecf0ba, 29b453e1def8092a8d78c28736e2bfb24229717b, 39f2e87e1caa6842e69155f033205cfdc4767b9e, c117492ad90d291a361eedc929506f557495caf7, 7c7324184589a15bf3e67b4f0c1cc222f8d48db3]: + + - @rocket.chat/model-typings@2.1.1-rc.0 + - @rocket.chat/models@2.1.1-rc.0 + - @rocket.chat/message-parser@0.31.35-rc.0 + - @rocket.chat/rest-typings@8.3.0-rc.0 + - @rocket.chat/network-broker@0.2.31-rc.0 + - @rocket.chat/core-services@0.13.1-rc.0 + - @rocket.chat/core-typings@8.3.0-rc.0 + - @rocket.chat/apps-engine@1.60.1-rc.0 + - @rocket.chat/ui-kit@0.39.1-rc.0 +
+ ## 2.0.42 ### Patch Changes diff --git a/apps/meteor/ee/server/services/package.json b/apps/meteor/ee/server/services/package.json index 3ac23d35e86ff..24f9331138d1a 100644 --- a/apps/meteor/ee/server/services/package.json +++ b/apps/meteor/ee/server/services/package.json @@ -1,7 +1,7 @@ { "name": "rocketchat-services", "private": true, - "version": "2.0.42", + "version": "2.0.43-rc.0", "description": "Rocket.Chat Authorization service", "main": "index.js", "scripts": { diff --git a/apps/meteor/jest.config.ts b/apps/meteor/jest.config.ts index e7b2746d65fae..cb6261662258a 100644 --- a/apps/meteor/jest.config.ts +++ b/apps/meteor/jest.config.ts @@ -48,6 +48,7 @@ export default { '/app/api/server/**.spec.ts', '/app/api/server/helpers/**.spec.ts', '/app/api/server/middlewares/**.spec.ts', + '/app/version-check/server/**/*.spec.ts', ], coveragePathIgnorePatterns: ['/node_modules/'], }, diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 08de86fec68a3..44b56db62cfb5 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -1,6 +1,6 @@ { "name": "@rocket.chat/meteor", - "version": "8.2.0", + "version": "8.3.0-rc.0", "private": true, "description": "The Ultimate Open Source WebChat Platform", "keywords": [ @@ -34,12 +34,12 @@ "dev": "NODE_OPTIONS=\"--trace-warnings\" meteor --exclude-archs \"web.browser.legacy, web.cordova\"", "docker:start": "docker-compose up", "dsv": "meteor npm run dev", - "eslint": "NODE_OPTIONS=\"--max-old-space-size=8192\" eslint --ext .js,.jsx,.ts,.tsx . --cache", + "eslint": "NODE_OPTIONS=\"--max-old-space-size=8192\" eslint --cache", "eslint:fix": "yarn eslint --fix", "ha": "meteor npm run ha:start", "ha:add": "TS_NODE_COMPILER_OPTIONS='{\"module\": \"commonjs\"}' ts-node .scripts/run-ha.ts instance", "ha:start": "TS_NODE_COMPILER_OPTIONS='{\"module\": \"commonjs\"}' ts-node .scripts/run-ha.ts main", - "lint": "yarn stylelint && yarn eslint", + "lint": "yarn stylelint && meteor lint && yarn eslint .", "migration:add": "ts-node-transpile-only --skip-project .scripts/make-migration.ts", "ms": "TRANSPORTER=${TRANSPORTER:-TCP} meteor npm run dev", "obj:dev": "TEST_MODE=true yarn dev", @@ -55,6 +55,7 @@ "test:e2e:federation": "playwright test --config=playwright-federation.config.ts", "test:e2e:nyc": "nyc report --reporter=lcovonly", "testapi": "TS_NODE_COMPILER_OPTIONS='{\"module\": \"commonjs\"}' mocha --config ./.mocharc.api.js", + "testapi:livechat": "TS_NODE_COMPILER_OPTIONS='{\"module\": \"commonjs\"}' mocha --config ./.mocharc.api.livechat.js", "testunit": "yarn .testunit:definition && yarn .testunit:jest && yarn .testunit:server:cov", "testunit-watch": "TS_NODE_COMPILER_OPTIONS='{\"module\": \"commonjs\"}' mocha --watch --config ./.mocharc.js", "typecheck": "meteor lint && cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" tsc --noEmit --skipLibCheck", @@ -309,25 +310,23 @@ "devDependencies": { "@axe-core/playwright": "^4.10.2", "@babel/core": "~7.28.6", - "@babel/eslint-parser": "~7.28.6", "@babel/preset-env": "~7.28.6", "@babel/preset-react": "~7.27.1", "@babel/register": "~7.28.6", "@faker-js/faker": "~8.0.2", "@playwright/test": "^1.52.0", "@rocket.chat/desktop-api": "workspace:~", - "@rocket.chat/eslint-config": "workspace:^", "@rocket.chat/jest-presets": "workspace:~", "@rocket.chat/livechat": "workspace:^", "@rocket.chat/mock-providers": "workspace:^", "@rocket.chat/tsconfig": "workspace:*", - "@storybook/addon-a11y": "^8.6.15", - "@storybook/addon-essentials": "^8.6.15", - "@storybook/addon-interactions": "^8.6.15", + "@storybook/addon-a11y": "^8.6.17", + "@storybook/addon-essentials": "^8.6.17", + "@storybook/addon-interactions": "^8.6.17", "@storybook/addon-styling-webpack": "^1.0.1", "@storybook/addon-webpack5-compiler-swc": "~3.0.0", - "@storybook/react": "^8.6.15", - "@storybook/react-webpack5": "^8.6.15", + "@storybook/react": "^8.6.17", + "@storybook/react-webpack5": "^8.6.17", "@testing-library/dom": "~10.4.1", "@testing-library/react": "~16.3.2", "@testing-library/user-event": "~14.6.1", @@ -400,8 +399,6 @@ "@types/underscore": "^1.13.0", "@types/xml-crypto": "~1.4.6", "@types/xml-encryption": "~1.2.4", - "@typescript-eslint/eslint-plugin": "~5.60.1", - "@typescript-eslint/parser": "~5.60.1", "autoprefixer": "^9.8.8", "babel-loader": "~10.0.0", "babel-plugin-array-includes": "^2.0.3", @@ -414,18 +411,7 @@ "cross-env": "^7.0.3", "docker-compose": "^0.24.8", "emojione-assets": "^4.5.0", - "eslint": "~8.45.0", - "eslint-config-prettier": "~9.1.2", - "eslint-plugin-anti-trojan-source": "~1.1.2", - "eslint-plugin-import": "~2.31.0", - "eslint-plugin-no-floating-promise": "~2.0.0", - "eslint-plugin-playwright": "~2.2.2", - "eslint-plugin-prettier": "~5.2.6", - "eslint-plugin-react": "~7.37.5", - "eslint-plugin-react-hooks": "~5.0.0", - "eslint-plugin-storybook": "~0.11.6", - "eslint-plugin-testing-library": "~6.4.0", - "eslint-plugin-you-dont-need-lodash-underscore": "~6.14.0", + "eslint": "~9.39.3", "fast-glob": "^3.3.3", "i18next": "~23.4.9", "jest": "~30.2.0", @@ -450,7 +436,7 @@ "react-docgen-typescript-plugin": "^1.0.8", "sinon": "^19.0.5", "source-map": "~0.7.6", - "storybook": "^8.6.15", + "storybook": "^8.6.17", "stylelint": "^16.10.0", "stylelint-config-standard": "^36.0.1", "stylelint-order": "^6.0.4", diff --git a/apps/meteor/packages/.eslintrc.json b/apps/meteor/packages/.eslintrc.json deleted file mode 100644 index 0e3406639dc1c..0000000000000 --- a/apps/meteor/packages/.eslintrc.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "globals": { - "Package": false, - "Npm": false - } -} diff --git a/apps/meteor/packages/rocketchat-i18n/.eslintrc.json b/apps/meteor/packages/rocketchat-i18n/.eslintrc.json deleted file mode 100644 index e891ae3e268d1..0000000000000 --- a/apps/meteor/packages/rocketchat-i18n/.eslintrc.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "globals": { - "Npm" : false - } -} diff --git a/apps/meteor/server/cron/userDataDownloads.ts b/apps/meteor/server/cron/userDataDownloads.ts index 47b1f517ee9f5..d996d11c1cef8 100644 --- a/apps/meteor/server/cron/userDataDownloads.ts +++ b/apps/meteor/server/cron/userDataDownloads.ts @@ -5,8 +5,7 @@ import { settings } from '../../app/settings/server'; import * as dataExport from '../lib/dataExport'; export const userDataDownloadsCron = (): void => { - const jobName = 'Generate download files for user data'; - const name = 'UserDataDownload'; + const jobName = 'UserDataDownload'; const plug = async ({ disabled, @@ -19,7 +18,7 @@ export const userDataDownloadsCron = (): void => { return; } - await cronJobs.add(name, `*/${processingFrequency} * * * *`, async () => dataExport.processDataDownloads()); + await cronJobs.add(jobName, `*/${processingFrequency} * * * *`, async () => dataExport.processDataDownloads()); return async () => { await cronJobs.remove(jobName); diff --git a/apps/meteor/server/lib/pushConfig.ts b/apps/meteor/server/lib/pushConfig.ts index 62cd6a4bbc68c..95719d2e9c415 100644 --- a/apps/meteor/server/lib/pushConfig.ts +++ b/apps/meteor/server/lib/pushConfig.ts @@ -1,6 +1,6 @@ import type { IUser } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; -import { AppsTokens } from '@rocket.chat/models'; +import { PushToken } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import { i18n } from './i18n'; @@ -10,7 +10,7 @@ import { Push } from '../../app/push/server'; import { settings } from '../../app/settings/server'; export const executePushTest = async (userId: IUser['_id'], username: IUser['username']): Promise => { - const tokens = await AppsTokens.countTokensByUserId(userId); + const tokens = await PushToken.countTokensByUserId(userId); if (tokens === 0) { throw new Meteor.Error('error-no-tokens-for-this-user', 'There are no tokens for this user', { diff --git a/apps/meteor/server/models.ts b/apps/meteor/server/models.ts index 9549477407ad7..49b5b98b19f08 100644 --- a/apps/meteor/server/models.ts +++ b/apps/meteor/server/models.ts @@ -4,7 +4,6 @@ import { AppsLogsModel, AppsModel, AppsPersistenceModel, - AppsTokensRaw, AvatarsRaw, BannersDismissRaw, BannersRaw, @@ -86,7 +85,6 @@ registerModel('IAnalyticsModel', new AnalyticsRaw(db)); registerModel('IAppLogsModel', new AppsLogsModel(db)); registerModel('IAppsModel', new AppsModel(db)); registerModel('IAppsPersistenceModel', new AppsPersistenceModel(db)); -registerModel('IAppsTokensModel', new AppsTokensRaw(db)); registerModel('IAvatarsModel', new AvatarsRaw(db)); registerModel('IBannersDismissModel', new BannersDismissRaw(db)); registerModel('IBannersModel', new BannersRaw(db)); diff --git a/apps/meteor/server/modules/core-apps/banner.module.ts b/apps/meteor/server/modules/core-apps/banner.module.ts index cf5bdebd50fe2..2af041134d065 100644 --- a/apps/meteor/server/modules/core-apps/banner.module.ts +++ b/apps/meteor/server/modules/core-apps/banner.module.ts @@ -1,12 +1,12 @@ import { Banner } from '@rocket.chat/core-services'; -import type { IUiKitCoreApp, UiKitCoreAppPayload } from '@rocket.chat/core-services'; +import type { IUiKitCoreApp, UiKitCoreAppViewClosedPayload } from '@rocket.chat/core-services'; import type * as UiKit from '@rocket.chat/ui-kit'; export class BannerModule implements IUiKitCoreApp { appId = 'banner-core'; // when banner view is closed we need to dismiss that banner for that user - async viewClosed(payload: UiKitCoreAppPayload): Promise { + async viewClosed(payload: UiKitCoreAppViewClosedPayload): Promise { const { payload: { view: { viewId: bannerId } = {} }, user: { _id: userId } = {}, diff --git a/apps/meteor/server/modules/core-apps/cloudAnnouncements.module.ts b/apps/meteor/server/modules/core-apps/cloudAnnouncements.module.ts index f13957b729813..00ce92c3b6fe1 100644 --- a/apps/meteor/server/modules/core-apps/cloudAnnouncements.module.ts +++ b/apps/meteor/server/modules/core-apps/cloudAnnouncements.module.ts @@ -1,5 +1,10 @@ import { Banner } from '@rocket.chat/core-services'; -import type { IUiKitCoreApp, UiKitCoreAppPayload } from '@rocket.chat/core-services'; +import type { + IUiKitCoreApp, + UiKitCoreAppBlockActionPayload, + UiKitCoreAppViewClosedPayload, + UiKitCoreAppViewSubmitPayload, +} from '@rocket.chat/core-services'; import type { Cloud, IUser } from '@rocket.chat/core-typings'; import { Banners } from '@rocket.chat/models'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; @@ -19,7 +24,7 @@ type CloudAnnouncementInteractant = user: Pick; } | { - visitor: Pick['visitor'], 'id' | 'username' | 'name' | 'department' | 'phone'>; + visitor: Pick, 'id' | 'username' | 'name' | 'department' | 'phone'>; }; type CloudAnnouncementInteractionRequest = UiKit.UserInteraction & CloudAnnouncementInteractant; @@ -35,15 +40,15 @@ export class CloudAnnouncementsModule implements IUiKitCoreApp { return settings.get('Cloud_Url'); } - blockAction(payload: UiKitCoreAppPayload): Promise { + blockAction(payload: UiKitCoreAppBlockActionPayload): Promise { return this.handlePayload(payload); } - viewSubmit(payload: UiKitCoreAppPayload): Promise { + viewSubmit(payload: UiKitCoreAppViewSubmitPayload): Promise { return this.handlePayload(payload); } - async viewClosed(payload: UiKitCoreAppPayload): Promise { + async viewClosed(payload: UiKitCoreAppViewClosedPayload): Promise { const { payload: { view: { viewId, id } = {} }, user: { _id: userId } = {}, @@ -86,7 +91,9 @@ export class CloudAnnouncementsModule implements IUiKitCoreApp { }; } - protected async handlePayload(payload: UiKitCoreAppPayload): Promise { + protected async handlePayload( + payload: UiKitCoreAppBlockActionPayload | UiKitCoreAppViewSubmitPayload | UiKitCoreAppViewClosedPayload, + ): Promise { const interactant = this.getInteractant(payload); const interaction = this.getInteraction(payload); @@ -104,10 +111,13 @@ export class CloudAnnouncementsModule implements IUiKitCoreApp { return serverInteraction; } catch (err) { SystemLogger.error({ err }); + return undefined; } } - protected getInteractant(payload: UiKitCoreAppPayload): CloudAnnouncementInteractant { + protected getInteractant( + payload: UiKitCoreAppBlockActionPayload | UiKitCoreAppViewSubmitPayload | UiKitCoreAppViewClosedPayload, + ): CloudAnnouncementInteractant { if (payload.user) { return { user: { @@ -118,7 +128,7 @@ export class CloudAnnouncementsModule implements IUiKitCoreApp { }; } - if (payload.visitor) { + if ('visitor' in payload && payload.visitor) { return { visitor: { id: payload.visitor.id, @@ -136,7 +146,9 @@ export class CloudAnnouncementsModule implements IUiKitCoreApp { /** * Transform the payload received from the Core App back to the format the UI sends from the client */ - protected getInteraction(payload: UiKitCoreAppPayload): UiKit.UserInteraction { + protected getInteraction( + payload: UiKitCoreAppBlockActionPayload | UiKitCoreAppViewSubmitPayload | UiKitCoreAppViewClosedPayload, + ): UiKit.UserInteraction { if (payload.type === 'blockAction' && payload.container?.type === 'message') { const { actionId, diff --git a/apps/meteor/server/modules/core-apps/cloudSubscriptionCommunication.module.ts b/apps/meteor/server/modules/core-apps/cloudSubscriptionCommunication.module.ts index e4b540416d4cc..37becfb041fab 100644 --- a/apps/meteor/server/modules/core-apps/cloudSubscriptionCommunication.module.ts +++ b/apps/meteor/server/modules/core-apps/cloudSubscriptionCommunication.module.ts @@ -1,4 +1,4 @@ -import type { UiKitCoreAppPayload } from '@rocket.chat/core-services'; +import type { UiKitCoreAppViewClosedPayload } from '@rocket.chat/core-services'; import type * as UiKit from '@rocket.chat/ui-kit'; import { CloudAnnouncementsModule } from './cloudAnnouncements.module'; @@ -6,7 +6,7 @@ import { CloudAnnouncementsModule } from './cloudAnnouncements.module'; export class CloudSubscriptionCommunication extends CloudAnnouncementsModule { override appId = 'cloud-communication-core'; - override async viewClosed(payload: UiKitCoreAppPayload): Promise { + override async viewClosed(payload: UiKitCoreAppViewClosedPayload): Promise { const { payload: { view: { viewId } = {} }, user: { _id: userId } = {}, diff --git a/apps/meteor/server/modules/core-apps/mention.module.ts b/apps/meteor/server/modules/core-apps/mention.module.ts index 8bd5bec4ce090..7d3785b4aaccd 100644 --- a/apps/meteor/server/modules/core-apps/mention.module.ts +++ b/apps/meteor/server/modules/core-apps/mention.module.ts @@ -1,6 +1,6 @@ import { api } from '@rocket.chat/core-services'; -import type { IUiKitCoreApp } from '@rocket.chat/core-services'; -import type { IMessage } from '@rocket.chat/core-typings'; +import type { IUiKitCoreApp, UiKitCoreAppBlockActionPayload } from '@rocket.chat/core-services'; +import type { IMessage, IUser } from '@rocket.chat/core-typings'; import { Subscriptions, Messages } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; @@ -24,13 +24,16 @@ const retrieveMentionsFromPayload = (stringifiedMentions: string): Exclude { + async blockAction(payload: UiKitCoreAppBlockActionPayload): Promise { const { actionId, payload: { value: stringifiedMentions, blockId: referenceMessageId }, } = payload; - const mentions = retrieveMentionsFromPayload(stringifiedMentions); + const user = payload.user!; + const room = payload.room!; + + const mentions = retrieveMentionsFromPayload(stringifiedMentions as string); const usernames = mentions.map(({ username }) => username); @@ -43,34 +46,34 @@ export class MentionModule implements IUiKitCoreApp { const joinedUsernames = `@${usernames.join(', @')}`; if (actionId === 'dismiss') { - void api.broadcast('notify.ephemeralMessage', payload.user._id, payload.room, { + void api.broadcast('notify.ephemeralMessage', user._id, room, { msg: i18n.t('You_mentioned___mentions__but_theyre_not_in_this_room', { mentions: joinedUsernames, - lng: payload.user.language, + lng: user.language, }), _id: payload.message, tmid: message.tmid, mentions, }); - return; + return undefined; } if (actionId === 'add-users') { - void addUsersToRoomMethod(payload.user._id, { rid: payload.room, users: usernames as string[] }, payload.user); - void api.broadcast('notify.ephemeralMessage', payload.user._id, payload.room, { + void addUsersToRoomMethod(user._id, { rid: room, users: usernames as string[] }, user); + void api.broadcast('notify.ephemeralMessage', user._id, room, { msg: i18n.t('You_mentioned___mentions__but_theyre_not_in_this_room', { mentions: joinedUsernames, - lng: payload.user.language, + lng: user.language, }), tmid: message.tmid, _id: payload.message, mentions, }); - return; + return undefined; } if (actionId === 'share-message') { - const sub = await Subscriptions.findOneByRoomIdAndUserId(payload.room, payload.user._id, { projection: { t: 1, rid: 1, name: 1 } }); + const sub = await Subscriptions.findOneByRoomIdAndUserId(room, user._id, { projection: { t: 1, rid: 1, name: 1 } }); // this should exist since the event is fired from withing the room (e.g the user sent a message) if (!sub) { throw new Error('Mention bot - Failed to retrieve room information'); @@ -83,7 +86,7 @@ export class MentionModule implements IUiKitCoreApp { const messageText = i18n.t('Youre_not_a_part_of__channel__and_I_mentioned_you_there', { channel: `#${sub.name}`, - lng: payload.user.language, + lng: user.language, }); const link = new URL(Meteor.absoluteUrl(roomPath)); @@ -97,18 +100,19 @@ export class MentionModule implements IUiKitCoreApp { text, separateResponse: true, // so that messages are sent to other DMs even if one or more fails }, - payload.user, + user as IUser & { username: string }, ); - void api.broadcast('notify.ephemeralMessage', payload.user._id, payload.room, { + void api.broadcast('notify.ephemeralMessage', user._id, room, { msg: i18n.t('You_mentioned___mentions__but_theyre_not_in_this_room_You_let_them_know_via_dm', { mentions: joinedUsernames, - lng: payload.user.language, + lng: user.language, }), tmid: message.tmid, _id: payload.message, mentions, }); + return undefined; } } } diff --git a/apps/meteor/server/modules/core-apps/nps.module.ts b/apps/meteor/server/modules/core-apps/nps.module.ts index 6e8965122df33..16e1763453f68 100644 --- a/apps/meteor/server/modules/core-apps/nps.module.ts +++ b/apps/meteor/server/modules/core-apps/nps.module.ts @@ -1,12 +1,13 @@ -import type { IUiKitCoreApp, UiKitCoreAppPayload } from '@rocket.chat/core-services'; +import type { IUiKitCoreApp, UiKitCoreAppBlockActionPayload, UiKitCoreAppViewSubmitPayload } from '@rocket.chat/core-services'; import { Banner, NPS } from '@rocket.chat/core-services'; +import type * as UiKit from '@rocket.chat/ui-kit'; import { createModal } from './nps/createModal'; export class Nps implements IUiKitCoreApp { appId = 'nps-core'; - async blockAction(payload: UiKitCoreAppPayload) { + async blockAction(payload: UiKitCoreAppBlockActionPayload): Promise { const { triggerId, actionId, @@ -32,7 +33,7 @@ export class Nps implements IUiKitCoreApp { }); } - async viewSubmit(payload: UiKitCoreAppPayload) { + async viewSubmit(payload: UiKitCoreAppViewSubmitPayload): Promise { if (!payload.payload?.view?.state || !payload.payload?.view?.id) { throw new Error('Invalid payload'); } @@ -65,7 +66,5 @@ export class Nps implements IUiKitCoreApp { } 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 694a0fac9b8e3..1f2765be0ce30 100644 --- a/apps/meteor/server/modules/core-apps/videoconf.module.ts +++ b/apps/meteor/server/modules/core-apps/videoconf.module.ts @@ -1,12 +1,13 @@ -import type { IUiKitCoreApp, UiKitCoreAppPayload } from '@rocket.chat/core-services'; +import type { IUiKitCoreApp, UiKitCoreAppBlockActionPayload } from '@rocket.chat/core-services'; import { VideoConf } from '@rocket.chat/core-services'; +import type * as UiKit from '@rocket.chat/ui-kit'; import { i18n } from '../../lib/i18n'; export class VideoConfModule implements IUiKitCoreApp { appId = 'videoconf-core'; - async blockAction(payload: UiKitCoreAppPayload) { + async blockAction(payload: UiKitCoreAppBlockActionPayload): Promise { const { triggerId, actionId, @@ -31,7 +32,6 @@ export class VideoConfModule implements IUiKitCoreApp { appId: this.appId, view: { appId: this.appId, - type: 'modal', id: `${callId}-info`, title: { type: 'plain_text', @@ -40,6 +40,8 @@ export class VideoConfModule implements IUiKitCoreApp { }, close: { type: 'button', + appId: this.appId, + blockId: callId, text: { type: 'plain_text', text: i18n.t('Close'), diff --git a/apps/meteor/server/modules/notifications/notifications.module.ts b/apps/meteor/server/modules/notifications/notifications.module.ts index 90262f95cf01d..cdeeb73cdecb6 100644 --- a/apps/meteor/server/modules/notifications/notifications.module.ts +++ b/apps/meteor/server/modules/notifications/notifications.module.ts @@ -98,7 +98,8 @@ export class NotificationsModule { return false; } - return Authorization.canReadRoom(room, { _id: this.userId || '' }, extraData); + const user = this.userId ? { _id: this.userId } : undefined; + return Authorization.canReadRoom(room, user, extraData); }); this.streamRoomMessage.allowRead('__my_messages__', 'all'); diff --git a/apps/meteor/server/modules/streamer/streamer.module.ts b/apps/meteor/server/modules/streamer/streamer.module.ts index 26996e96bf431..ef35622ea5f2c 100644 --- a/apps/meteor/server/modules/streamer/streamer.module.ts +++ b/apps/meteor/server/modules/streamer/streamer.module.ts @@ -290,19 +290,30 @@ export abstract class Streamer extends EventEmit args: any[], getMsg: string | TransformMessage, ): Promise { - subscriptions.forEach(async (subscription) => { - if (this.retransmitToSelf === false && origin && origin === subscription.subscription.connection) { - return; - } - - const allowed = await this.isEmitAllowed(subscription.subscription, eventName, ...args); - if (allowed) { - const msg = typeof getMsg === 'string' ? getMsg : getMsg(this, subscription, eventName, args, allowed); - if (msg) { - subscription.subscription._session.socket?.send(msg); + await Promise.all( + [...subscriptions].map(async (subscription) => { + try { + if (this.retransmitToSelf === false && origin && origin === subscription.subscription.connection) { + return; + } + + const allowed = await this.isEmitAllowed(subscription.subscription, eventName, ...args); + if (allowed) { + const msg = typeof getMsg === 'string' ? getMsg : getMsg(this, subscription, eventName, args, allowed); + if (msg) { + subscription.subscription._session.socket?.send(msg); + } + } + } catch (err) { + SystemLogger.error({ + msg: 'Error while delivering streamer event', + eventName, + streamName: this.name, + err, + }); } - } - }); + }), + ); } override emit(eventName: string | symbol, ...args: any[]): boolean { diff --git a/apps/meteor/server/services/authorization/canAccessRoom.ts b/apps/meteor/server/services/authorization/canAccessRoom.ts index 675336df84bc5..684d52ac477a6 100644 --- a/apps/meteor/server/services/authorization/canAccessRoom.ts +++ b/apps/meteor/server/services/authorization/canAccessRoom.ts @@ -1,8 +1,8 @@ import { Authorization, License, Abac, Settings } from '@rocket.chat/core-services'; import type { RoomAccessValidator } from '@rocket.chat/core-services'; import { TeamType, AbacAccessOperation, AbacObjectType } from '@rocket.chat/core-typings'; -import type { IUser, ITeam } from '@rocket.chat/core-typings'; -import { Subscriptions, Rooms, TeamMember, Team } from '@rocket.chat/models'; +import type { IUser, ITeam, IRoom } from '@rocket.chat/core-typings'; +import { Subscriptions, Rooms, TeamMember, Team, Users } from '@rocket.chat/models'; import { canAccessRoomLivechat } from './canAccessRoomLivechat'; @@ -15,7 +15,13 @@ async function canAccessPublicRoom(user?: Partial): Promise { return Authorization.hasPermission(user._id, 'view-c-room'); } -const roomAccessValidators: RoomAccessValidator[] = [ +type RoomAccessValidatorConverted = ( + room?: Pick, + user?: IUser, + extraData?: Record, +) => Promise; + +const roomAccessValidators: RoomAccessValidatorConverted[] = [ async function _validateAccessToPublicRoomsInTeams(room, user): Promise { if (!room) { return false; @@ -56,8 +62,8 @@ const roomAccessValidators: RoomAccessValidator[] = [ } const [canViewJoined, canViewT] = await Promise.all([ - Authorization.hasPermission(user._id, 'view-joined-room'), - Authorization.hasPermission(user._id, `view-${room.t}-room`), + Authorization.hasPermission(user, 'view-joined-room'), + Authorization.hasPermission(user, `view-${room.t}-room`), ]); // When there's no ABAC setting, license or values on the room, fallback to previous behavior @@ -89,14 +95,32 @@ const roomAccessValidators: RoomAccessValidator[] = [ canAccessRoomLivechat, ]; +const isPartialUser = (user: IUser | Pick | undefined): user is Pick => { + return Boolean(user && Object.keys(user).length === 1 && '_id' in user); +}; + export const canAccessRoom: RoomAccessValidator = async (room, user, extraData): Promise => { // TODO livechat can send both as null, so they we need to validate nevertheless // if (!room || !user) { // return false; // } + // TODO: remove this after migrations + // if user only contains _id, convert it to a full IUser object + + if (isPartialUser(user)) { + user = (await Users.findOneById(user._id)) || undefined; + if (!user) { + throw new Error('User not found'); + } + + if (process.env.NODE_ENV === 'development') { + console.log('User converted to full IUser object'); + } + } + for await (const roomAccessValidator of roomAccessValidators) { - if (await roomAccessValidator(room, user, extraData)) { + if (await roomAccessValidator(room, user as IUser, extraData)) { return true; } } diff --git a/apps/meteor/server/services/authorization/service.ts b/apps/meteor/server/services/authorization/service.ts index 3c1858b1305e4..a8984dd79c241 100644 --- a/apps/meteor/server/services/authorization/service.ts +++ b/apps/meteor/server/services/authorization/service.ts @@ -56,21 +56,21 @@ export class Authorization extends ServiceClass implements IAuthorization { } } - async hasAllPermission(userId: string, permissions: string[], scope?: string): Promise { + async hasAllPermission(userId: string | IUser, permissions: string[], scope?: string): Promise { if (!userId) { return false; } return this.all(userId, permissions, scope); } - async hasPermission(userId: string, permissionId: string, scope?: string): Promise { + async hasPermission(userId: string | IUser, permissionId: string, scope?: string): Promise { if (!userId) { return false; } return this.all(userId, [permissionId], scope); } - async hasAtLeastOnePermission(userId: string, permissions: string[], scope?: string): Promise { + async hasAtLeastOnePermission(userId: string | IUser, permissions: string[], scope?: string): Promise { if (!userId) { return false; } @@ -85,7 +85,7 @@ export class Authorization extends ServiceClass implements IAuthorization { return canReadRoom(...args); } - async canAccessRoomId(rid: IRoom['_id'], uid: IUser['_id']): Promise { + async canAccessRoomId(rid: IRoom['_id'], user: IUser['_id']): Promise { const room = await Rooms.findOneById>(rid, { projection: { _id: 1, @@ -100,7 +100,7 @@ export class Authorization extends ServiceClass implements IAuthorization { return false; } - return this.canAccessRoom(room, { _id: uid }); + return this.canAccessRoom(room, { _id: user }); } async addRoleRestrictions(role: IRole['_id'], permissions: string[]): Promise { @@ -160,17 +160,20 @@ export class Authorization extends ServiceClass implements IAuthorization { return !!result; } - private async getRoles(uid: string, scope?: IRoom['_id']): Promise { - const { roles: userRoles = [] } = (await Users.findOneById(uid, { projection: { roles: 1 } })) || {}; + private async getRoles(user: string | IUser, scope?: IRoom['_id']): Promise { + const { roles: userRoles = [] } = typeof user === 'string' ? (await Users.findOneById(user, { projection: { roles: 1 } })) || {} : user; const { roles: subscriptionsRoles = [] } = (scope && - (await Subscriptions.findOne>({ 'rid': scope, 'u._id': uid }, { projection: { roles: 1 } }))) || + (await Subscriptions.findOne>( + { 'rid': scope, 'u._id': typeof user === 'string' ? user : user._id }, + { projection: { roles: 1 } }, + ))) || {}; return [...userRoles, ...subscriptionsRoles].sort((a, b) => a.localeCompare(b)); } - private async atLeastOne(uid: string, permissions: string[] = [], scope?: string): Promise { - const sortedRoles = await this.getRolesCached(uid, scope); + private async atLeastOne(user: string | IUser, permissions: string[] = [], scope?: string): Promise { + const sortedRoles = await this.getRolesCached(user, scope); for await (const permission of permissions) { if (await this.rolesHasPermissionCached(permission, sortedRoles)) { return true; @@ -180,8 +183,8 @@ export class Authorization extends ServiceClass implements IAuthorization { return false; } - private async all(uid: string, permissions: string[] = [], scope?: string): Promise { - const sortedRoles = await this.getRolesCached(uid, scope); + private async all(user: string | IUser, permissions: string[] = [], scope?: string): Promise { + const sortedRoles = await this.getRolesCached(user, scope); for await (const permission of permissions) { if (!(await this.rolesHasPermissionCached(permission, sortedRoles))) { return false; diff --git a/apps/meteor/server/services/messages/service.ts b/apps/meteor/server/services/messages/service.ts index 3de674113ed11..7a6fd09f5f04c 100644 --- a/apps/meteor/server/services/messages/service.ts +++ b/apps/meteor/server/services/messages/service.ts @@ -236,7 +236,6 @@ export class MessageService extends ServiceClassInternal implements IMessageServ throw new FederationMatrixInvalidConfigurationError('Unable to send message'); } - message = await mentionServer.execute(message); message = await this.cannedResponse.replacePlaceholders({ message, room, user }); message = await this.badWords.filterBadWords({ message }); // TODO: Auto-close unclosed markdown code blocks for server versions below 9.0.0 @@ -245,6 +244,7 @@ export class MessageService extends ServiceClassInternal implements IMessageServ message = { ...message, msg: closeUnclosedCodeBlock(message.msg) }; } message = await this.markdownParser.parseMarkdown({ message, config: this.getMarkdownConfig() }); + message = await mentionServer.execute(message); if (parseUrls) { message.urls = parseUrlsInMessage(message, previewUrls); } diff --git a/apps/meteor/server/services/push/logger.ts b/apps/meteor/server/services/push/logger.ts new file mode 100644 index 0000000000000..5793949089dd8 --- /dev/null +++ b/apps/meteor/server/services/push/logger.ts @@ -0,0 +1,3 @@ +import { Logger } from '@rocket.chat/logger'; + +export const logger = new Logger('Push'); diff --git a/apps/meteor/server/services/push/service.ts b/apps/meteor/server/services/push/service.ts index 50f977c0d7606..b6ac954f9356f 100644 --- a/apps/meteor/server/services/push/service.ts +++ b/apps/meteor/server/services/push/service.ts @@ -1,7 +1,11 @@ import type { IPushService } from '@rocket.chat/core-services'; import { ServiceClassInternal } from '@rocket.chat/core-services'; +import type { IPushToken, Optional } from '@rocket.chat/core-typings'; import { PushToken } from '@rocket.chat/models'; +import { logger } from './logger'; +import { registerPushToken } from './tokenManagement/registerPushToken'; + export class PushService extends ServiceClassInternal implements IPushService { protected name = 'push'; @@ -26,4 +30,23 @@ export class PushService extends ServiceClassInternal implements IPushService { } }); } + + async registerPushToken( + data: Optional, '_id' | 'metadata'>, + ): Promise> { + const tokenId = await registerPushToken(data); + + const removeResult = await PushToken.removeByTokenAndAppNameExceptId(data.token, data.appName, tokenId); + if (removeResult.deletedCount) { + logger.debug({ msg: 'Removed existing app items', removed: removeResult.deletedCount }); + } + + const updatedDoc = await PushToken.findOneById>(tokenId, { projection: { authToken: 0 } }); + if (!updatedDoc) { + logger.error({ msg: 'Could not find PushToken document on mongo after successful operation', tokenId }); + throw new Error('could-not-find-token-document'); + } + + return updatedDoc; + } } diff --git a/apps/meteor/server/services/push/tokenManagement/findDocumentToUpdate.ts b/apps/meteor/server/services/push/tokenManagement/findDocumentToUpdate.ts new file mode 100644 index 0000000000000..9e7fa5967f183 --- /dev/null +++ b/apps/meteor/server/services/push/tokenManagement/findDocumentToUpdate.ts @@ -0,0 +1,17 @@ +import type { IPushToken } from '@rocket.chat/core-typings'; +import { PushToken } from '@rocket.chat/models'; + +export async function findDocumentToUpdate(data: Partial): Promise { + if (data._id) { + const existingDoc = await PushToken.findOneById(data._id); + if (existingDoc) { + return existingDoc; + } + } + + if (data.token && data.appName) { + return PushToken.findOneByTokenAndAppName(data.token, data.appName); + } + + return null; +} diff --git a/apps/meteor/server/services/push/tokenManagement/registerPushToken.ts b/apps/meteor/server/services/push/tokenManagement/registerPushToken.ts new file mode 100644 index 0000000000000..c5ae10059e4d8 --- /dev/null +++ b/apps/meteor/server/services/push/tokenManagement/registerPushToken.ts @@ -0,0 +1,41 @@ +import type { IPushToken, Optional } from '@rocket.chat/core-typings'; +import { PushToken } from '@rocket.chat/models'; + +import { logger } from '../logger'; +import { findDocumentToUpdate } from './findDocumentToUpdate'; + +export async function registerPushToken( + data: Optional, '_id' | 'metadata'>, +): Promise { + const doc = await findDocumentToUpdate(data); + + if (!doc) { + const insertResult = await PushToken.insertToken({ + ...(data._id && { _id: data._id }), + token: data.token, + authToken: data.authToken, + appName: data.appName, + userId: data.userId, + ...(data.metadata && { metadata: data.metadata }), + }); + + const { authToken: _, ...dataWithNoAuthToken } = data; + logger.debug({ msg: 'Push token added', dataWithNoAuthToken, insertResult }); + + return insertResult.insertedId; + } + + const updateResult = await PushToken.refreshTokenById(doc._id, { + token: data.token, + authToken: data.authToken, + appName: data.appName, + userId: data.userId, + }); + + if (updateResult.modifiedCount) { + const { authToken: _, ...dataWithNoAuthToken } = data; + logger.debug({ msg: 'Push token updated', dataWithNoAuthToken, updateResult }); + } + + return doc._id; +} diff --git a/apps/meteor/server/services/team/service.ts b/apps/meteor/server/services/team/service.ts index add7f3940574f..9d5b3bb0356ab 100644 --- a/apps/meteor/server/services/team/service.ts +++ b/apps/meteor/server/services/team/service.ts @@ -998,13 +998,11 @@ export class TeamService extends ServiceClassInternal implements ITeamService { const defaultRooms = await Rooms.findDefaultRoomsForTeam(teamId).toArray(); const users = await Users.findActiveByIds(members.map((member) => member.userId)).toArray(); - defaultRooms.map(async (room) => { + for (const room of defaultRooms) { // at this point, users are already part of the team so we won't check for membership - for await (const user of users) { - // add each user to the default room - await addUserToRoom(room._id, user, inviter, { skipSystemMessage: false }); - } - }); + // eslint-disable-next-line no-await-in-loop + await Promise.all(users.map((user) => addUserToRoom(room._id, user, inviter, { skipSystemMessage: false }))); + } } async deleteById(teamId: string): Promise { diff --git a/apps/meteor/server/services/uikit-core-app/service.ts b/apps/meteor/server/services/uikit-core-app/service.ts index a842a4854c6a3..7f07eb0a65fa6 100644 --- a/apps/meteor/server/services/uikit-core-app/service.ts +++ b/apps/meteor/server/services/uikit-core-app/service.ts @@ -1,5 +1,11 @@ import { ServiceClassInternal } from '@rocket.chat/core-services'; -import type { IUiKitCoreApp, IUiKitCoreAppService, UiKitCoreAppPayload } from '@rocket.chat/core-services'; +import type { + IUiKitCoreApp, + IUiKitCoreAppService, + UiKitCoreAppBlockActionPayload, + UiKitCoreAppViewClosedPayload, + UiKitCoreAppViewSubmitPayload, +} from '@rocket.chat/core-services'; const registeredApps = new Map(); @@ -24,29 +30,25 @@ export class UiKitCoreAppService extends ServiceClassInternal implements IUiKitC return registeredApps.has(appId); } - async blockAction(payload: UiKitCoreAppPayload) { + async blockAction(payload: UiKitCoreAppBlockActionPayload) { const { appId } = payload; const service = getAppModule(appId); - if (!service) { - return; - } + if (!service) return undefined; return service.blockAction?.(payload); } - async viewClosed(payload: UiKitCoreAppPayload) { + async viewClosed(payload: UiKitCoreAppViewClosedPayload) { const { appId } = payload; const service = getAppModule(appId); - if (!service) { - return; - } + if (!service) return undefined; return service.viewClosed?.(payload); } - async viewSubmit(payload: UiKitCoreAppPayload) { + async viewSubmit(payload: UiKitCoreAppViewSubmitPayload) { 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 1ea8c03f95c18..a5610b690f5de 100644 --- a/apps/meteor/server/services/video-conference/service.ts +++ b/apps/meteor/server/services/video-conference/service.ts @@ -172,7 +172,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf }); } - 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'); @@ -202,7 +202,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf }); if (blocks?.length) { - return blocks as UiKit.LayoutBlock[]; + return blocks as UiKit.ModalSurfaceLayout; } return [ diff --git a/apps/meteor/tests/data/uploads.helper.ts b/apps/meteor/tests/data/uploads.helper.ts index 20e015700cea0..b0c356ef53dd2 100644 --- a/apps/meteor/tests/data/uploads.helper.ts +++ b/apps/meteor/tests/data/uploads.helper.ts @@ -1,4 +1,5 @@ -import type { IRoom } from '@rocket.chat/core-typings'; +import type { Credentials } from '@rocket.chat/api-client'; +import type { IRoom, IUser } from '@rocket.chat/core-typings'; import { expect } from 'chai'; import { after, before, it } from 'mocha'; import type { Response } from 'supertest'; @@ -6,8 +7,9 @@ import type { Response } from 'supertest'; import { api, request, credentials } from './api-data'; import { imgURL, soundURL } from './interactions'; import { updateSetting } from './permissions.helper'; -import { createRoom, deleteRoom } from './rooms.helper'; -import { createUser, deleteUser } from './users.helper'; +import { addUserToRoom, createRoom, deleteRoom } from './rooms.helper'; +import { password } from './user'; +import { createUser, deleteUser, login } from './users.helper'; export async function testFileUploads( filesEndpoint: 'channels.files' | 'groups.files' | 'im.files', @@ -301,4 +303,77 @@ export async function testFileUploads( await Promise.all([nameFilterTest, typeGroupFilterTest]); }); + + describe('with another user', () => { + let anotherUserCreds: Credentials; + let anotherUser: IUser; + let extraRoom: IRoom; + + before(async () => { + anotherUser = await createUser(); + anotherUserCreds = await login(anotherUser.username, password); + + extraRoom = ( + await createRoom({ + type: roomType, + ...(roomType === 'd' ? { username: user.username } : { name: `channel-files-${Date.now()}` }), + credentials: anotherUserCreds, + } as any) + ).body[propertyMap[roomType]]; + + if (roomType === 'p') { + await addUserToRoom({ + rid: testRoom._id, + usernames: [anotherUser.username!], + }); + } + }); + + after(() => Promise.all([deleteUser(anotherUser), deleteRoom({ type: roomType, roomId: extraRoom._id })])); + + it('should not allow to confirm a file from another user', async function () { + if (roomType === 'd') { + this.skip(); + } + + let fileId: string; + await request + .post(api(`rooms.media/${testRoom._id}`)) + .set(anotherUserCreds) + .attach('file', imgURL) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + fileId = res.body.file._id; + }); + + await request + .post(api(`rooms.mediaConfirm/${testRoom._id}/${fileId!}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should not allow to confirm a file that was not uploaded to the same room', async () => { + let fileId: string; + + await request + .post(api(`rooms.media/${extraRoom._id}`)) + .set(anotherUserCreds) + .attach('file', imgURL) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + fileId = res.body.file._id; + }); + + await request + .post(api(`rooms.mediaConfirm/${testRoom._id}/${fileId!}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400); + }); + }); } diff --git a/apps/meteor/tests/e2e/.eslintrc.json b/apps/meteor/tests/e2e/.eslintrc.json deleted file mode 100644 index aa970258b4c7e..0000000000000 --- a/apps/meteor/tests/e2e/.eslintrc.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "root": true, - "extends": ["@rocket.chat/eslint-config", "@rocket.chat/eslint-config/react", "prettier", "plugin:@typescript-eslint/recommended"], - "parser": "@typescript-eslint/parser", - "plugins": ["prettier", "testing-library", "anti-trojan-source", "no-floating-promise"], - "rules": { - "@typescript-eslint/no-unused-vars": [ - "error", - { - "argsIgnorePattern": "^_", - "ignoreRestSiblings": true - } - ], - "@typescript-eslint/no-floating-promises": "error", - "import/named": "error", - "import/order": [ - "error", - { - "newlines-between": "always", - "groups": ["builtin", "external", "internal", ["parent", "sibling", "index"]], - "alphabetize": { - "order": "asc" - } - } - ] - }, - "settings": { - "import/resolver": { - "node": { - "extensions": [".js", ".ts", ".tsx"] - } - } - }, - "parserOptions": { - "project": ["./tsconfig.json"] - } -} diff --git a/apps/meteor/tests/e2e/channel-management.spec.ts b/apps/meteor/tests/e2e/channel-management.spec.ts index bd76bafb4ec16..9823ee6850498 100644 --- a/apps/meteor/tests/e2e/channel-management.spec.ts +++ b/apps/meteor/tests/e2e/channel-management.spec.ts @@ -155,7 +155,7 @@ test.describe.serial('channel-management', () => { test('should access targetTeam through discussion header', async ({ page }) => { await poHomeChannel.navbar.openChat(targetChannel); - await page.locator('[data-qa-type="message"]', { hasText: discussionName }).locator('button').first().click(); + await page.getByRole('listitem', { name: discussionName }).getByRole('button', { name: 'Reply' }).click(); await page.getByRole('button', { name: `Back to ${targetChannel} channel`, exact: true }).focus(); await page.keyboard.press('Space'); @@ -249,7 +249,7 @@ test.describe.serial('channel-management', () => { await user1Page.close(); }); - test('should ignore user1 messages', async () => { + test('should ignore user1 messages', async ({ page }) => { await poHomeChannel.navbar.openChat(targetChannel); await poHomeChannel.roomToolbar.openMembersTab(); await poHomeChannel.tabs.members.showAllUsers(); @@ -257,13 +257,14 @@ test.describe.serial('channel-management', () => { await poHomeChannel.tabs.members.openMoreActions(); await expect(poHomeChannel.tabs.members.getMenuItemAction('Unignore')).toBeVisible(); + await page.keyboard.press('Escape'); const user1Channel = new HomeChannel(user1Page); await user1Page.goto(`/channel/${targetChannel}`); await user1Channel.content.waitForChannel(); await user1Channel.content.sendMessage('message to check ignore'); - await expect(poHomeChannel.content.lastUserMessageBody).toContainText('This message was ignored'); + await expect(poHomeChannel.content.lastUserMessageBody.getByRole('button', { name: 'This message was ignored' })).toBeVisible(); }); test('should unignore single user1 message', async () => { @@ -281,7 +282,7 @@ test.describe.serial('channel-management', () => { await expect(poHomeChannel.content.lastUserMessageBody).toContainText('only message to be unignored'); }); - test('should unignore user1 messages', async () => { + test('should unignore user1 messages', async ({ page }) => { const user1Channel = new HomeChannel(user1Page); await user1Page.goto(`/channel/${targetChannel}`); await user1Channel.content.waitForChannel(); @@ -296,6 +297,7 @@ test.describe.serial('channel-management', () => { await poHomeChannel.tabs.members.openMoreActions(); await expect(poHomeChannel.tabs.members.getMenuItemAction('Ignore')).toBeVisible(); + await page.keyboard.press('Escape'); await user1Channel.content.sendMessage('message after being unignored'); diff --git a/apps/meteor/tests/e2e/e2e-encryption/e2ee-encrypted-channels.spec.ts b/apps/meteor/tests/e2e/e2e-encryption/e2ee-encrypted-channels.spec.ts index 4e5708d56aa5d..bc5e101dbf3e4 100644 --- a/apps/meteor/tests/e2e/e2e-encryption/e2ee-encrypted-channels.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption/e2ee-encrypted-channels.spec.ts @@ -92,9 +92,7 @@ test.describe('E2EE Encrypted Channels', () => { await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('This is the thread main message.'); await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); - - await page.locator('[data-qa-type="message"]').last().hover(); - await page.locator('role=button[name="Reply in thread"]').click(); + await poHomeChannel.content.openReplyInThread(); await expect(page).toHaveURL(/.*thread/); @@ -106,7 +104,7 @@ test.describe('E2EE Encrypted Channels', () => { await page.keyboard.press('Enter'); await expect(poHomeChannel.content.lastThreadMessageText).toContainText('This is an encrypted thread message also sent in channel'); await expect(poHomeChannel.content.lastThreadMessageText.locator('.rcx-icon--name-key')).toBeVisible(); - await expect(poHomeChannel.content.lastUserMessage).toContainText('This is an encrypted thread message also sent in channel'); + await expect(poHomeChannel.content.lastThreadMessagePreview).toContainText('This is an encrypted thread message also sent in channel'); await expect(poHomeChannel.content.mainThreadMessageText).toContainText('This is the thread main message.'); await expect(poHomeChannel.content.mainThreadMessageText.locator('.rcx-icon--name-key')).toBeVisible(); }); @@ -125,7 +123,7 @@ test.describe('E2EE Encrypted Channels', () => { await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('This is an encrypted message.'); await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); - await page.locator('[data-qa-type="message"]').last().hover(); + await poHomeChannel.content.lastUserMessage.hover(); await expect(page.locator('role=button[name="Forward message not available on encrypted content"]')).toBeDisabled(); await poHomeChannel.content.openLastMessageMenu(); @@ -308,7 +306,10 @@ test.describe('E2EE Encrypted Channels', () => { await expect(page.getByRole('dialog', { name: 'Pinned Messages' })).toBeVisible(); - const lastPinnedMessage = page.getByRole('dialog', { name: 'Pinned Messages' }).locator('[data-qa-type="message"]').last(); + const lastPinnedMessage = page + .getByRole('dialog', { name: 'Pinned Messages' }) + .locator('[role="listitem"][aria-roledescription="message"]') + .last(); await expect(lastPinnedMessage).toContainText('This message should be pinned and stared.'); await lastPinnedMessage.hover(); await lastPinnedMessage.locator('role=button[name="More"]').waitFor(); @@ -320,7 +321,10 @@ test.describe('E2EE Encrypted Channels', () => { await poHomeChannel.tabs.kebab.click(); await poHomeChannel.tabs.btnStarredMessageList.click(); - const lastStarredMessage = page.getByRole('dialog', { name: 'Starred Messages' }).locator('[data-qa-type="message"]').last(); + const lastStarredMessage = page + .getByRole('dialog', { name: 'Starred Messages' }) + .locator('[role="listitem"][aria-roledescription="message"]') + .last(); await expect(page.getByRole('dialog', { name: 'Starred Messages' })).toBeVisible(); await expect(lastStarredMessage).toContainText('This message should be pinned and stared.'); await lastStarredMessage.hover(); diff --git a/apps/meteor/tests/e2e/e2e-encryption/e2ee-encryption-decryption.spec.ts b/apps/meteor/tests/e2e/e2e-encryption/e2ee-encryption-decryption.spec.ts index e60a2777ff596..330b306cb4a86 100644 --- a/apps/meteor/tests/e2e/e2e-encryption/e2ee-encryption-decryption.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption/e2ee-encryption-decryption.spec.ts @@ -102,7 +102,7 @@ test.describe('E2EE Encryption and Decryption - Basic Features', () => { // Check the file upload await expect(encryptedRoomPage.lastMessage.encryptedIcon).toBeVisible(); - await expect(encryptedRoomPage.lastMessage.fileUploadName).toContainText(fileName); + await expect(encryptedRoomPage.lastMessage.getFileUploadByName(fileName)).toBeVisible(); await expect(encryptedRoomPage.lastMessage.body).toHaveText(fileDescription); }); @@ -118,7 +118,7 @@ test.describe('E2EE Encryption and Decryption - Basic Features', () => { await fileUploadModal.send(); await expect(encryptedRoomPage.lastMessage.encryptedIcon).not.toBeVisible(); - await expect(encryptedRoomPage.lastMessage.fileUploadName).toContainText(fileName); + await expect(encryptedRoomPage.lastMessage.getFileUploadByName(fileName)).toBeVisible(); await expect(encryptedRoomPage.lastMessage.body).toHaveText(fileDescription); }); @@ -144,7 +144,7 @@ test.describe('E2EE Encryption and Decryption - Basic Features', () => { await expect(encryptedRoomPage.lastNthMessage(1).encryptedIcon).toBeVisible(); await expect(encryptedRoomPage.lastMessage.encryptedIcon).not.toBeVisible(); - await expect(encryptedRoomPage.lastMessage.fileUploadName).toContainText(fileName); + await expect(encryptedRoomPage.lastMessage.getFileUploadByName(fileName)).toBeVisible(); await expect(encryptedRoomPage.lastMessage.body).toHaveText(fileDescription); }); diff --git a/apps/meteor/tests/e2e/e2e-encryption/e2ee-file-encryption.spec.ts b/apps/meteor/tests/e2e/e2e-encryption/e2ee-file-encryption.spec.ts index 173ae4cf2036f..da41810380978 100644 --- a/apps/meteor/tests/e2e/e2e-encryption/e2ee-file-encryption.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption/e2ee-file-encryption.spec.ts @@ -63,7 +63,7 @@ test.describe('E2EE File Encryption', () => { await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); await expect(poHomeChannel.content.getFileDescription).toHaveText('any_description'); - await expect(poHomeChannel.content.lastMessageFileName).toContainText('any_file1.txt'); + await expect(poHomeChannel.content.getLastMessageByFileName('any_file1.txt')).toBeVisible(); }); await test.step('edit the description', async () => { @@ -97,7 +97,7 @@ test.describe('E2EE File Encryption', () => { await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); await expect(poHomeChannel.content.getFileDescription).toHaveText('message 1'); - await expect(poHomeChannel.content.lastMessageFileName).toContainText('any_file1.txt'); + await expect(poHomeChannel.content.getLastMessageByFileName('any_file1.txt')).toBeVisible(); }); await test.step('set whitelisted media type setting', async () => { @@ -112,7 +112,7 @@ test.describe('E2EE File Encryption', () => { await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); await expect(poHomeChannel.content.getFileDescription).toHaveText('message 2'); - await expect(poHomeChannel.content.lastMessageFileName).toContainText('any_file2.txt'); + await expect(poHomeChannel.content.getLastMessageByFileName('any_file2.txt')).toBeVisible(); }); await test.step('set blacklisted media type setting to not accept application/octet-stream media type', async () => { @@ -127,7 +127,7 @@ test.describe('E2EE File Encryption', () => { await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); await expect(poHomeChannel.content.getFileDescription).toHaveText('message 2'); - await expect(poHomeChannel.content.lastMessageFileName).toContainText('any_file2.txt'); + await expect(poHomeChannel.content.getLastMessageByFileName('any_file2.txt')).toBeVisible(); }); }); @@ -158,7 +158,7 @@ test.describe('E2EE File Encryption', () => { await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).not.toBeVisible(); await expect(poHomeChannel.content.getFileDescription).toHaveText('any_description'); - await expect(poHomeChannel.content.lastMessageFileName).toContainText('any_file1.txt'); + await expect(poHomeChannel.content.getLastMessageByFileName('any_file1.txt')).toBeVisible(); }); }); }); diff --git a/apps/meteor/tests/e2e/federation/page-objects/fragments/home-content.ts b/apps/meteor/tests/e2e/federation/page-objects/fragments/home-content.ts index a60c5c4955b92..4fb82d858afd6 100644 --- a/apps/meteor/tests/e2e/federation/page-objects/fragments/home-content.ts +++ b/apps/meteor/tests/e2e/federation/page-objects/fragments/home-content.ts @@ -15,12 +15,16 @@ export class FederationHomeContent { return this.page.locator('role=menu[name="People"]'); } + get messageListItems(): Locator { + return this.page.locator('[role="listitem"][aria-roledescription="message"]'); + } + get lastUserMessage(): Locator { - return this.page.locator('[data-qa-type="message"]').last(); + return this.messageListItems.last(); } get lastUserMessageBody(): Locator { - return this.lastUserMessage.locator('[data-qa-type="message-body"]'); + return this.lastUserMessage.locator('[role="document"][aria-roledescription="message body"]'); } async sendMessage(text: string): Promise { @@ -96,30 +100,36 @@ export class FederationHomeContent { } get lastMessageFileName(): Locator { - return this.page.locator('[data-qa-type="message"]:last-child'); + return this.page.locator('[role="listitem"][aria-roledescription="message"]:last-child'); } async getLastFileMessageByFileName(filename: string): Promise { - return this.page.locator('[data-qa-type="message"]:last-child .rcx-message-container').last().locator(`div[title="${filename}"]`); + return this.page + .locator('[role="listitem"][aria-roledescription="message"]:last-child .rcx-message-container') + .last() + .locator(`div[title="${filename}"]`); } async getLastFileThreadMessageByFileName(filename: string): Promise { return this.page - .locator('div.thread-list ul.thread [data-qa-type="message"]:last-child .rcx-message-container') + .locator('div.thread-list ul.thread [role="listitem"][aria-roledescription="message"]:last-child .rcx-message-container') .last() .locator(`div[title="${filename}"]`); } get lastFileMessage(): Locator { - return this.page.locator('[data-qa-type="message"]:last-child .rcx-message-container').last(); + return this.page.locator('[role="listitem"][aria-roledescription="message"]:last-child .rcx-message-container').last(); } get waitForLastMessageTextAttachmentEqualsText(): Locator { - return this.page.locator('[data-qa-type="message"]:last-child .rcx-attachment__details .rcx-message-body'); + return this.page.locator('[role="listitem"][aria-roledescription="message"]:last-child .rcx-attachment__details .rcx-message-body'); } get waitForLastThreadMessageTextAttachmentEqualsText(): Locator { - return this.page.locator('div.thread-list ul.thread [data-qa-type="message"]').last().locator('.rcx-attachment__details'); + return this.page + .locator('div.thread-list ul.thread [role="listitem"][aria-roledescription="message"]') + .last() + .locator('.rcx-attachment__details'); } get btnOptionEditMessage(): Locator { @@ -167,7 +177,7 @@ export class FederationHomeContent { } get lastThreadMessageText(): Locator { - return this.page.locator('div.thread-list ul.thread [data-qa-type="message"]').last(); + return this.page.locator('div.thread-list ul.thread [role="listitem"][aria-roledescription="message"]').last(); } async sendFileMessage(fileName: string): Promise { @@ -180,9 +190,9 @@ export class FederationHomeContent { } async openLastMessageMenu(): Promise { - await this.page.locator('[data-qa-type="message"]').last().hover(); - await this.page.locator('[data-qa-type="message"]').last().locator('[data-qa-type="message-action-menu"][data-qa-id="menu"]').waitFor(); - await this.page.locator('[data-qa-type="message"]').last().locator('[data-qa-type="message-action-menu"][data-qa-id="menu"]').click(); + await this.lastUserMessage.hover(); + await this.lastUserMessage.getByRole('button', { name: 'More', exact: true }).waitFor(); + await this.lastUserMessage.getByRole('button', { name: 'More', exact: true }).click(); } threadSendToChannelAlso(): Locator { @@ -197,19 +207,9 @@ export class FederationHomeContent { } async openLastThreadMessageMenu(): Promise { - await this.page.getByRole('dialog').locator('[data-qa-type="message"]').last().hover(); - await this.page - .getByRole('dialog') - .locator('[data-qa-type="message"]') - .last() - .locator('[data-qa-type="message-action-menu"][data-qa-id="menu"]') - .waitFor(); - await this.page - .getByRole('dialog') - .locator('[data-qa-type="message"]') - .last() - .locator('[data-qa-type="message-action-menu"][data-qa-id="menu"]') - .click(); + await this.lastThreadMessageText.hover(); + await this.lastThreadMessageText.getByRole('button', { name: 'More', exact: true }).waitFor(); + await this.lastThreadMessageText.getByRole('button', { name: 'More', exact: true }).click(); } async quoteMessageInsideThread(message: string): Promise { @@ -226,19 +226,19 @@ export class FederationHomeContent { } async unreactLastMessage(): Promise { - await this.page.locator('[data-qa-type="message"]').last().locator('.rcx-message-reactions__reaction').nth(1).waitFor(); - await this.page.locator('[data-qa-type="message"]').last().locator('.rcx-message-reactions__reaction').nth(1).click(); + await this.lastUserMessage.locator('.rcx-message-reactions__reaction').nth(1).waitFor(); + await this.lastUserMessage.locator('.rcx-message-reactions__reaction').nth(1).click(); } async getSystemMessageByText(text: string): Promise { - return this.page.locator('div[data-qa="system-message"] div[data-qa-type="system-message-body"]', { hasText: text }); + return this.page.locator('[role="document"][aria-roledescription="system message body"]', { hasText: text }); } async getLastSystemMessageName(): Promise { - return this.page.locator('div[data-qa="system-message"]:last-child span.rcx-message-system__name'); + return this.page.locator('[role="listitem"][aria-roledescription="system message"]').last().getByRole('button'); } async getAllReactions(): Promise { - return this.page.locator('[data-qa-type="message"]').last().locator('.rcx-message-reactions__reaction'); + return this.lastUserMessage.locator('.rcx-message-reactions__reaction'); } } diff --git a/apps/meteor/tests/e2e/file-upload.spec.ts b/apps/meteor/tests/e2e/file-upload.spec.ts index 7603332fb0d7a..a7a77ec903726 100644 --- a/apps/meteor/tests/e2e/file-upload.spec.ts +++ b/apps/meteor/tests/e2e/file-upload.spec.ts @@ -48,7 +48,7 @@ test.describe.serial('file-upload', () => { await poHomeChannel.content.btnModalConfirm.click(); await expect(poHomeChannel.content.getFileDescription).toHaveText('any_description'); - await expect(poHomeChannel.content.lastMessageFileName).toContainText('any_file1.txt'); + await expect(poHomeChannel.content.getLastMessageByFileName('any_file1.txt')).toBeVisible(); }); test('should send lst file successfully', async () => { @@ -57,7 +57,7 @@ test.describe.serial('file-upload', () => { await poHomeChannel.content.btnModalConfirm.click(); await expect(poHomeChannel.content.getFileDescription).toHaveText('lst_description'); - await expect(poHomeChannel.content.lastMessageFileName).toContainText('lst-test.lst'); + await expect(poHomeChannel.content.getLastMessageByFileName('lst-test.lst')).toBeVisible(); }); test('should send drawio (unknown media type) file successfully', async ({ page }) => { @@ -67,7 +67,7 @@ test.describe.serial('file-upload', () => { await poHomeChannel.content.btnModalConfirm.click(); await expect(poHomeChannel.content.getFileDescription).toHaveText('drawio_description'); - await expect(poHomeChannel.content.lastMessageFileName).toContainText('diagram.drawio'); + await expect(poHomeChannel.content.getLastMessageByFileName('diagram.drawio')).toBeVisible(); }); test('should not to send drawio file (unknown media type) when the default media type is blocked', async ({ api, page }) => { diff --git a/apps/meteor/tests/e2e/files-management.spec.ts b/apps/meteor/tests/e2e/files-management.spec.ts index 5ee95a3a787e0..2f193e614437d 100644 --- a/apps/meteor/tests/e2e/files-management.spec.ts +++ b/apps/meteor/tests/e2e/files-management.spec.ts @@ -29,7 +29,7 @@ test.describe.serial('files-management', () => { test('should send a file and manage it in the list', async () => { await poHomeChannel.content.dragAndDropTxtFile(); await poHomeChannel.content.btnModalConfirm.click(); - await expect(poHomeChannel.content.lastMessageFileName).toHaveText(TEST_FILE_TXT); + await expect(poHomeChannel.content.getLastMessageByFileName(TEST_FILE_TXT)).toBeVisible(); await poHomeChannel.roomToolbar.openMoreOptions(); await poHomeChannel.roomToolbar.menuItemFiles.click(); @@ -49,7 +49,7 @@ test.describe.serial('files-management', () => { await poHomeChannel.tabs.files.deleteFile(TEST_FILE_TXT); await expect(poHomeChannel.tabs.files.getFileByName(TEST_FILE_TXT)).toHaveCount(0); - await expect(poHomeChannel.content.lastUserMessage).not.toBeVisible(); + await expect(poHomeChannel.content.getLastMessageByFileName(TEST_FILE_TXT)).not.toBeVisible(); }); }); }); diff --git a/apps/meteor/tests/e2e/message-actions.spec.ts b/apps/meteor/tests/e2e/message-actions.spec.ts index 5e043d198d37e..787a6fd7df311 100644 --- a/apps/meteor/tests/e2e/message-actions.spec.ts +++ b/apps/meteor/tests/e2e/message-actions.spec.ts @@ -34,47 +34,35 @@ test.describe.serial('message-actions', () => { test('expect reply the message', async ({ page }) => { await poHomeChannel.content.sendMessage('this is a message for reply'); - await page.locator('[data-qa-type="message"]').last().hover(); - await page.locator('role=button[name="Reply in thread"]').click(); + await poHomeChannel.content.openReplyInThread(); await page.locator('.rcx-vertical-bar').locator(`role=textbox[name="Message #${targetChannel}"]`).type('this is a reply message'); await page.keyboard.press('Enter'); - await expect(poHomeChannel.tabs.flexTabViewThreadMessage).toHaveText('this is a reply message'); + await expect(poHomeChannel.content.lastThreadMessageText).toHaveText('this is a reply message'); }); // with thread open we listen to the subscription and update the collection from there test('expect follow/unfollow message with thread open', async ({ page }) => { await test.step('start thread', async () => { await poHomeChannel.content.sendMessage('this is a message for reply'); - await page.locator('[data-qa-type="message"]').last().hover(); - await page.locator('role=button[name="Reply in thread"]').click(); + await poHomeChannel.content.openReplyInThread(); await page.getByRole('dialog').locator(`role=textbox[name="Message #${targetChannel}"]`).fill('this is a reply message'); await page.keyboard.press('Enter'); - await expect(poHomeChannel.tabs.flexTabViewThreadMessage).toHaveText('this is a reply message'); + await expect(poHomeChannel.content.lastThreadMessageText).toHaveText('this is a reply message'); }); await test.step('unfollow thread', async () => { - const unFollowButton = page - .locator('[data-qa-type="message"]', { has: page.getByRole('button', { name: 'Following' }) }) - .last() - .getByRole('button', { name: 'Following' }); + const unFollowButton = poHomeChannel.content.lastUserMessage.getByRole('button', { name: 'Following' }); await expect(unFollowButton).toBeVisible(); + await unFollowButton.click(); }); await test.step('follow thread', async () => { - const followButton = page - .locator('[data-qa-type="message"]', { has: page.getByRole('button', { name: 'Not following' }) }) - .last() - .getByRole('button', { name: 'Not following' }); + const followButton = poHomeChannel.content.lastUserMessage.getByRole('button', { name: 'Not following' }); await expect(followButton).toBeVisible(); await followButton.click(); - await expect( - page - .locator('[data-qa-type="message"]', { has: page.getByRole('button', { name: 'Following' }) }) - .last() - .getByRole('button', { name: 'Following' }), - ).toBeVisible(); + await expect(poHomeChannel.content.lastUserMessage.getByRole('button', { name: 'Following' })).toBeVisible(); }); }); @@ -82,27 +70,26 @@ test.describe.serial('message-actions', () => { test('expect follow/unfollow message with thread closed', async ({ page }) => { await test.step('start thread', async () => { await poHomeChannel.content.sendMessage('this is a message for reply'); - await page.locator('[data-qa-type="message"]').last().hover(); - await page.locator('role=button[name="Reply in thread"]').click(); + await poHomeChannel.content.openReplyInThread(); await page.locator('.rcx-vertical-bar').locator(`role=textbox[name="Message #${targetChannel}"]`).fill('this is a reply message'); await page.keyboard.press('Enter'); - await expect(poHomeChannel.tabs.flexTabViewThreadMessage).toHaveText('this is a reply message'); + await expect(poHomeChannel.content.lastThreadMessageText).toHaveText('this is a reply message'); }); // close thread before testing because the behavior changes await page.getByRole('dialog').getByRole('button', { name: 'Close', exact: true }).click(); await test.step('unfollow thread', async () => { - const unFollowButton = page.locator('[data-qa-type="message"]').last().getByTitle('Following'); + const unFollowButton = poHomeChannel.content.lastUserMessage.getByRole('button', { name: 'Following' }); await expect(unFollowButton).toBeVisible(); await unFollowButton.click(); }); await test.step('follow thread', async () => { - const followButton = page.locator('[data-qa-type="message"]').last().getByTitle('Not following'); + const followButton = poHomeChannel.content.lastUserMessage.getByRole('button', { name: 'Not following' }); await expect(followButton).toBeVisible(); await followButton.click(); - await expect(page.locator('[data-qa-type="message"]').last().getByTitle('Following')).toBeVisible(); + await expect(poHomeChannel.content.lastUserMessage.getByRole('button', { name: 'Following' })).toBeVisible(); }); }); @@ -120,17 +107,15 @@ test.describe.serial('message-actions', () => { await poHomeChannel.content.sendMessage('Message to delete'); await poHomeChannel.content.deleteLastMessage(); - await expect(poHomeChannel.content.lastUserMessage.locator('[data-qa-type="message-body"]:has-text("Message to delete")')).toHaveCount( - 0, - ); + await expect(poHomeChannel.content.lastUserMessageBody).not.toHaveText('Message to delete'); }); test('expect quote the message', async ({ page }) => { const message = `Message for quote - ${Date.now()}`; await poHomeChannel.content.sendMessage(message); - await page.locator('[data-qa-type="message"]').last().hover(); - await page.locator('role=button[name="Quote"]').click(); + await poHomeChannel.content.lastUserMessage.hover(); + await poHomeChannel.content.lastUserMessage.getByRole('button', { name: 'Quote' }).click(); await page.locator('[name="msg"]').fill('this is a quote message'); await page.keyboard.press('Enter'); diff --git a/apps/meteor/tests/e2e/message-composer.spec.ts b/apps/meteor/tests/e2e/message-composer.spec.ts index 14d700e5f21d0..8b203d4d93fcf 100644 --- a/apps/meteor/tests/e2e/message-composer.spec.ts +++ b/apps/meteor/tests/e2e/message-composer.spec.ts @@ -116,6 +116,62 @@ test.describe.serial('message-composer', () => { }); }); + test('should close mention popup when canceling a message edit via "Cancel" button', async ({ page }) => { + await poHomeChannel.navbar.openChat(targetChannel); + await poHomeChannel.content.sendMessage('hello composer'); + + await test.step('expect to edit last message', async () => { + await expect(poHomeChannel.composer.inputMessage).toHaveValue(''); + await poHomeChannel.content.openLastMessageMenu(); + await poHomeChannel.content.btnOptionEditMessage.click(); + await expect(poHomeChannel.composer.inputMessage).toHaveValue('hello composer'); + }); + + await test.step('expect to open popup on mention', async () => { + await page.keyboard.type(' @'); + await expect(poHomeChannel.composer.boxPopup).toBeVisible(); + }); + + await test.step('expect popup to close after the first edit is cancelled', async () => { + await poHomeChannel.composer.btnCancel.click(); + await expect(poHomeChannel.composer.inputMessage).toHaveValue('hello composer'); + await expect(poHomeChannel.composer.boxPopup).not.toBeVisible(); + }); + + await test.step('expect to leave editing mode', async () => { + await poHomeChannel.composer.btnCancel.click(); + await expect(poHomeChannel.composer.inputMessage).toHaveValue(''); + }); + }); + + test('should close mention popup when canceling a message edit via keyboard', async ({ page }) => { + await poHomeChannel.navbar.openChat(targetChannel); + await poHomeChannel.content.sendMessage('hello composer'); + + await test.step('expect to edit last message', async () => { + await expect(poHomeChannel.composer.inputMessage).toHaveValue(''); + await poHomeChannel.content.openLastMessageMenu(); + await poHomeChannel.content.btnOptionEditMessage.click(); + await expect(poHomeChannel.composer.inputMessage).toHaveValue('hello composer'); + }); + + await test.step('expect to open popup on mention', async () => { + await page.keyboard.type(' @'); + await expect(poHomeChannel.composer.boxPopup).toBeVisible(); + }); + + await test.step('expect popup to close after the first edit is cancelled', async () => { + await page.keyboard.press('Escape'); + await expect(poHomeChannel.composer.inputMessage).toHaveValue('hello composer'); + await expect(poHomeChannel.composer.boxPopup).not.toBeVisible(); + }); + + await test.step('expect to leave editing mode', async () => { + await page.keyboard.press('Escape'); + await expect(poHomeChannel.composer.inputMessage).toHaveValue(''); + }); + }); + test.describe('audio recorder', () => { test('should open audio recorder', async () => { await poHomeChannel.navbar.openChat(targetChannel); diff --git a/apps/meteor/tests/e2e/messaging.spec.ts b/apps/meteor/tests/e2e/messaging.spec.ts index c6b781aaa2fca..fafc19da35073 100644 --- a/apps/meteor/tests/e2e/messaging.spec.ts +++ b/apps/meteor/tests/e2e/messaging.spec.ts @@ -36,18 +36,18 @@ test.describe('Messaging', () => { await test.step('move focus to the second message', async () => { await page.keyboard.press('Shift+Tab'); - await expect(page.locator('[data-qa-type="message"]').last()).toBeFocused(); + await expect(poHomeChannel.content.lastUserMessage).toBeFocused(); }); await test.step('move focus to the first system message', async () => { await page.keyboard.press('ArrowUp'); await page.keyboard.press('ArrowUp'); - await expect(page.locator('[data-qa="system-message"]').first()).toBeFocused(); + await expect(poHomeChannel.content.systemMessageListItems.first()).toBeFocused(); }); await test.step('move focus to the first typed message', async () => { await page.keyboard.press('ArrowDown'); - await expect(page.locator('[data-qa-type="message"]:has-text("msg1")')).toBeFocused(); + await expect(poHomeChannel.content.getMessageByText('msg1')).toBeFocused(); }); await test.step('move focus to the room title', async () => { @@ -59,20 +59,21 @@ test.describe('Messaging', () => { await page.keyboard.press('Tab'); await page.keyboard.press('Tab'); await page.keyboard.press('Tab'); - await expect(page.locator('[data-qa-type="message"]:has-text("msg1")')).toBeFocused(); + await expect(poHomeChannel.content.getMessageByText('msg1')).toBeFocused(); }); await test.step('move focus to the message toolbar', async () => { - await page - .locator('[data-qa-type="message"]:has-text("msg1")') + await poHomeChannel.content + .getMessageByText('msg1') .locator('[role=toolbar][aria-label="Message actions"]') .getByRole('button', { name: 'Add reaction' }) .waitFor(); + await page.keyboard.press('Tab'); await page.keyboard.press('Tab'); await expect( - page - .locator('[data-qa-type="message"]:has-text("msg1")') + poHomeChannel.content + .getMessageByText('msg1') .locator('[role=toolbar][aria-label="Message actions"]') .getByRole('button', { name: 'Add reaction' }), ).toBeFocused(); @@ -80,8 +81,8 @@ test.describe('Messaging', () => { await test.step('move focus to the composer', async () => { await page.keyboard.press('Tab'); - await page - .locator('[data-qa-type="message"]:has-text("msg2")') + await poHomeChannel.content + .getMessageByText('msg2') .locator('[role=toolbar][aria-label="Message actions"]') .getByRole('button', { name: 'Add reaction' }) .waitFor(); @@ -123,18 +124,19 @@ test.describe('Messaging', () => { test('should not restore focus on the last focused if it was triggered by click', async ({ page }) => { await poHomeChannel.navbar.openChat(targetChannel); - await page.locator('[data-qa-type="message"]:has-text("msg1")').click(); + await poHomeChannel.content.getMessageByText('msg1').click(); + await poHomeChannel.composer.inputMessage.click(); await page.keyboard.press('Shift+Tab'); - await expect(page.locator('[data-qa-type="message"]:has-text("msg2")')).toBeFocused(); + await expect(poHomeChannel.content.getMessageByText('msg2')).toBeFocused(); }); - test('should not focus on the last message when focusing by click', async ({ page }) => { + test('should not focus on the last message when focusing by click', async () => { await poHomeChannel.navbar.openChat(targetChannel); - await page.locator('[data-qa-type="message"]:has-text("msg1")').click(); + await poHomeChannel.content.getMessageByText('msg1').click(); - await expect(page.locator('[data-qa-type="message"]').last()).not.toBeFocused(); + await expect(poHomeChannel.content.lastUserMessage).not.toBeFocused(); }); test('should focus the latest message when moving the focus on the list and theres no previous focus', async ({ page }) => { @@ -145,7 +147,7 @@ test.describe('Messaging', () => { await page.keyboard.press('Tab'); await page.keyboard.press('Tab'); await page.keyboard.press('Tab'); - await expect(page.locator('[data-qa-type="message"]').last()).toBeFocused(); + await expect(poHomeChannel.content.lastUserMessage).toBeFocused(); }); await test.step('move focus to the list again', async () => { @@ -153,7 +155,7 @@ test.describe('Messaging', () => { await page.keyboard.press('Tab'); await page.keyboard.press('Tab'); await page.keyboard.press('Tab'); - await expect(page.locator('[data-qa-type="message"]').last()).toBeFocused(); + await expect(poHomeChannel.content.lastUserMessage).toBeFocused(); }); }); }); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-canned-responses-usage.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-canned-responses-usage.spec.ts index a6c8be054e005..636ade51487a7 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-canned-responses-usage.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-canned-responses-usage.spec.ts @@ -104,7 +104,7 @@ test.describe('OC - Canned Responses Usage', () => { }); await test.step('expect to use the canned response in the chat', async () => { - await agent.poHomeChannel.content.useCannedResponse(cannedResponseName); + await agent.poHomeChannel.content.selectCannedResponse(cannedResponseName); }); await test.step('expect canned response text to appear in message composer', async () => { @@ -134,7 +134,7 @@ test.describe('OC - Canned Responses Usage', () => { }); await test.step('expect to use the canned response with placeholder', async () => { - await agent.poHomeChannel.content.useCannedResponse(placeholderResponseName); + await agent.poHomeChannel.content.selectCannedResponse(placeholderResponseName); }); await test.step('expect placeholder to be replaced with actual visitor name', async () => { @@ -159,7 +159,7 @@ test.describe('OC - Canned Responses Usage', () => { }); await test.step('expect to use existing canned response and modify it', async () => { - await agent.poHomeChannel.content.useCannedResponse(cannedResponseName); + await agent.poHomeChannel.content.selectCannedResponse(cannedResponseName); }); await test.step('expect to modify the canned response text before sending', async () => { @@ -193,13 +193,13 @@ test.describe('OC - Canned Responses Usage', () => { }); await test.step('expect to use first canned response', async () => { - await agent.poHomeChannel.content.useCannedResponse(cannedResponseName); + await agent.poHomeChannel.content.selectCannedResponse(cannedResponseName); await expect(agent.poHomeChannel.composer.inputMessage).toHaveValue(`${cannedResponseText} `); await agent.page.keyboard.press('Enter'); }); await test.step('expect to use second canned response', async () => { - await agent.poHomeChannel.content.useCannedResponse(secondResponseName); + await agent.poHomeChannel.content.selectCannedResponse(secondResponseName); await expect(agent.poHomeChannel.composer.inputMessage).toHaveValue(`${secondResponseText} `); await agent.page.keyboard.press('Enter'); }); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts new file mode 100644 index 0000000000000..ff9e1ff384e74 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts @@ -0,0 +1,154 @@ +import { faker } from '@faker-js/faker'; + +import { createFakeVisitor } from '../../mocks/data'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { createAgent } from '../utils/omnichannel/agents'; +import { createCustomField, setVisitorCustomFieldValue } from '../utils/omnichannel/custom-field'; +import { createManager } from '../utils/omnichannel/managers'; +import { createConversation } from '../utils/omnichannel/rooms'; +import { test, expect } from '../utils/test'; + +const visitor = createFakeVisitor(); + +test.use({ storageState: Users.user1.state }); + +test.describe.serial('OC - Custom fields usage, scope : room and visitor', () => { + let poHomeChannel: HomeOmnichannel; + + const roomCustomFieldLabel = `room_cf_${faker.string.alpha(8)}`; + const roomCustomFieldName = roomCustomFieldLabel; + const roomCustomFieldValue = faker.lorem.words(3); + + const visitorCustomFieldLabel = `visitor_cf_${faker.string.alpha(8)}`; + const visitorCustomFieldName = visitorCustomFieldLabel; + const visitorCustomFieldValue = faker.lorem.words(3); + const visitorToken = faker.string.uuid(); + + let agent: Awaited>; + let manager: Awaited>; + let conversation: Awaited>; + let roomCustomField: Awaited>; + let visitorCustomField: Awaited>; + + test.beforeAll('Set up agent, manager and custom fields', async ({ api }) => { + [agent, manager] = await Promise.all([createAgent(api, 'user1'), createManager(api, 'user1')]); + + [roomCustomField, visitorCustomField, conversation] = await Promise.all([ + createCustomField(api, { + field: roomCustomFieldLabel, + label: roomCustomFieldName, + scope: 'room', + }), + createCustomField(api, { + field: visitorCustomFieldLabel, + label: visitorCustomFieldName, + scope: 'visitor', + }), + createConversation(api, { + visitorName: visitor.name, + agentId: 'user1', + visitorToken, + }), + ]); + + await setVisitorCustomFieldValue(api, { + token: visitorToken, + customFieldId: visitorCustomField.customField._id, + value: visitorCustomFieldValue, + }); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeOmnichannel(page); + await page.goto('/'); + await poHomeChannel.waitForHome(); + }); + + test.afterAll('Remove agent, manager, custom fields and conversation', async () => { + await Promise.all([agent.delete(), manager.delete(), roomCustomField.delete(), visitorCustomField.delete(), conversation.delete()]); + }); + + test('Should be allowed to set room custom field for a conversation', async () => { + await test.step('Agent opens the conversation', async () => { + await poHomeChannel.sidebar.getSidebarItemByName(visitor.name).click(); + }); + + await test.step('Agent opens edit room', async () => { + await poHomeChannel.roomInfo.waitForDisplay(); + await poHomeChannel.roomInfo.btnEdit.click(); + await poHomeChannel.editRoomInfo.waitForDisplay(); + }); + + await test.step('Agent fills room custom field and saves', async () => { + await poHomeChannel.editRoomInfo.getRoomCustomField(roomCustomFieldLabel).fill(roomCustomFieldValue); + await poHomeChannel.editRoomInfo.btnSave.click(); + }); + + await test.step('Custom field should be updated successfully', async () => { + await poHomeChannel.roomInfo.btnEdit.click(); + await poHomeChannel.editRoomInfo.waitForDisplay(); + await expect(poHomeChannel.editRoomInfo.getRoomCustomField(roomCustomFieldLabel)).toHaveValue(roomCustomFieldValue); + }); + }); + + test('Should be allowed to update existing room custom field', async () => { + const updatedValue = faker.lorem.words(2); + + await test.step('Agent opens the conversation', async () => { + await poHomeChannel.sidebar.getSidebarItemByName(visitor.name).click(); + }); + + await test.step('Agent opens edit room and updates custom field', async () => { + await poHomeChannel.roomInfo.waitForDisplay(); + await poHomeChannel.roomInfo.btnEdit.click(); + await poHomeChannel.editRoomInfo.waitForDisplay(); + await poHomeChannel.editRoomInfo.getRoomCustomField(roomCustomFieldLabel).fill(updatedValue); + await poHomeChannel.editRoomInfo.btnSave.click(); + }); + + await test.step('Room Information displays the updated custom field value', async () => { + await poHomeChannel.roomInfo.btnEdit.click(); + await poHomeChannel.editRoomInfo.waitForDisplay(); + await expect(poHomeChannel.editRoomInfo.getRoomCustomField(roomCustomFieldLabel)).toHaveValue(updatedValue); + }); + }); + + test('Should verify that the visitor custom field is set', async () => { + await test.step('Agent opens the conversation', async () => { + await poHomeChannel.sidebar.getSidebarItemByName(visitor.name).click(); + }); + + await test.step('Agent opens Contact Information', async () => { + await poHomeChannel.roomToolbar.openContactInfo(); + await poHomeChannel.contacts.contactInfo.waitForDisplay(); + }); + + await test.step('Assert custom field is set successfully', async () => { + await expect(poHomeChannel.contacts.contactInfo.getInfoByValue(visitorCustomFieldValue)).toBeVisible(); + }); + }); + + test('Should be allowed to update existing visitor custom field', async () => { + const updatedVisitorCustomFieldValue = faker.lorem.words(2); + + await test.step('Agent opens the conversation', async () => { + await poHomeChannel.sidebar.getSidebarItemByName(visitor.name).click(); + }); + + await test.step('Agent opens Contact Information', async () => { + await poHomeChannel.roomToolbar.openContactInfo(); + await poHomeChannel.contacts.contactInfo.waitForDisplay(); + }); + + await test.step('Agent clicks edit and updates visitor custom field', async () => { + await poHomeChannel.contacts.contactInfo.btnEdit.click(); + await poHomeChannel.contacts.contactInfo.getVisitorCustomField(visitorCustomFieldLabel).fill(updatedVisitorCustomFieldValue); + await poHomeChannel.contacts.contactInfo.btnSave.click(); + }); + + await test.step('Assert custom field is updated successfully', async () => { + await expect(poHomeChannel.contacts.contactInfo.getInfoByValue(updatedVisitorCustomFieldValue)).toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-send-pdf-transcript.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-send-pdf-transcript.spec.ts index 5171e1c4739f6..381cf4c8eca3a 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-send-pdf-transcript.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-send-pdf-transcript.spec.ts @@ -63,7 +63,8 @@ test.describe('omnichannel- export chat transcript as PDF', () => { await test.step('Expect to have exported PDF in rocket.cat', async () => { await page.waitForTimeout(3000); await agent.poHomeChannel.navbar.openChat('rocket.cat'); - await expect(agent.poHomeChannel.transcript.DownloadedPDF).toBeVisible(); + await expect(agent.poHomeChannel.content.lastUserMessage.getByText('PDF Transcript successfully generated')).toBeVisible(); + await expect(agent.poHomeChannel.content.lastUserMessage.getByRole('link', { name: 'Transcript' })).toBeVisible(); }); // PDF can be exported from Omnichannel Contact Center diff --git a/apps/meteor/tests/e2e/page-objects/encrypted-room.ts b/apps/meteor/tests/e2e/page-objects/encrypted-room.ts index 4b8a4e98c1e59..e54f92906043c 100644 --- a/apps/meteor/tests/e2e/page-objects/encrypted-room.ts +++ b/apps/meteor/tests/e2e/page-objects/encrypted-room.ts @@ -12,11 +12,11 @@ export class EncryptedRoomPage extends HomeContent { } get lastMessage() { - return new Message(this.page.locator('[data-qa-type="message"]').last()); + return new Message(this.lastUserMessage); } lastNthMessage(index: number) { - return new Message(this.page.locator(`[data-qa-type="message"]`).nth(-index - 1)); + return new Message(this.nthMessage(-index - 1)); } async enableEncryption() { diff --git a/apps/meteor/tests/e2e/page-objects/fragments/composer.ts b/apps/meteor/tests/e2e/page-objects/fragments/composer.ts index e0dfbc6e54821..e3ecc115e52b1 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/composer.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/composer.ts @@ -33,6 +33,10 @@ export abstract class Composer { return this.root.getByRole('button', { name: 'Send' }); } + get btnCancel(): Locator { + return this.root.getByRole('button', { name: 'Cancel', exact: true }); + } + get btnOptionFileUpload(): Locator { return this.toolbarPrimaryActions.getByRole('button', { name: 'Upload file' }); } diff --git a/apps/meteor/tests/e2e/page-objects/fragments/edit-room-flextab.ts b/apps/meteor/tests/e2e/page-objects/fragments/edit-room-flextab.ts index bfc88661991bb..8fa085c271e89 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/edit-room-flextab.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/edit-room-flextab.ts @@ -84,4 +84,8 @@ export class OmnichannelEditRoomFlexTab extends EditRoomFlexTab { get inputTags(): Locator { return this.root.getByRole('textbox', { name: 'Select an option' }); } + + getRoomCustomField(label: string): Locator { + return this.root.getByRole('group', { name: 'Custom Fields' }).getByLabel(label); + } } diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts index eba1a2f986a26..2577cd557216e 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts @@ -41,28 +41,48 @@ export class HomeContent { return this.page.locator('role=menu[name="People"]'); } + get mainMessageList(): Locator { + return this.page.getByRole('list', { name: 'Message list', exact: true }); + } + + get threadMessageList(): Locator { + return this.page.getByRole('list', { name: 'Thread message list', exact: true }); + } + + get messageListItems(): Locator { + return this.mainMessageList.locator('[role="listitem"][aria-roledescription="message"]'); + } + + get systemMessageListItems(): Locator { + return this.mainMessageList.locator('[role="listitem"][aria-roledescription="system message"]'); + } + + get threadMessageListItems(): Locator { + return this.threadMessageList.locator('[role="listitem"][aria-roledescription="thread message"]'); + } + get lastUserMessage(): Locator { - return this.page.locator('[data-qa-type="message"]').last(); + return this.messageListItems.last(); } - nthMessage(index: number): Locator { - return this.page.locator('[data-qa-type="message"]').nth(index); + get lastThreadMessagePreview(): Locator { + return this.page.getByRole('listitem').locator('[role="link"][aria-roledescription="thread message preview"]').last(); } - get lastUserMessageNotThread(): Locator { - return this.page.locator('div.messages-box [data-qa-type="message"]').last(); + nthMessage(index: number): Locator { + return this.messageListItems.nth(index); } get lastUserMessageBody(): Locator { - return this.lastUserMessage.locator('[data-qa-type="message-body"]'); + return this.lastUserMessage.locator('[role="document"][aria-roledescription="message body"]'); } get lastUserMessageAttachment(): Locator { - return this.page.locator('[data-qa-type="message-attachment"]').last(); + return this.page.locator('[role="document"][aria-roledescription="message attachment"]').last(); } get lastUserMessageNotSequential(): Locator { - return this.page.locator('[data-qa-type="message"][data-sequential="false"]').last(); + return this.mainMessageList.locator('[role="listitem"][aria-roledescription="message"][data-sequential="false"]').last(); } get encryptedRoomHeaderIcon(): Locator { @@ -119,7 +139,7 @@ export class HomeContent { } async forwardMessage(chatName: string) { - await this.page.locator('[data-qa-type="message"]').last().hover(); + await this.messageListItems.last().hover(); await this.page.locator('role=button[name="Forward message"]').click(); await this.page.getByRole('textbox', { name: 'Person or Channel', exact: true }).click(); @@ -173,7 +193,7 @@ export class HomeContent { } get getFileDescription(): Locator { - return this.page.locator('[data-qa-type="message"]:last-child [data-qa-type="message-body"]'); + return this.lastUserMessage.locator('[role="document"][aria-roledescription="message body"]'); } get fileNameInput(): Locator { @@ -182,16 +202,19 @@ export class HomeContent { // ----------------------------------------- - get lastMessageFileName(): Locator { - return this.page.locator('[data-qa-type="message"]:last-child [data-qa-type="attachment-title-link"]'); + getLastMessageByFileName(filename: string): Locator { + return this.messageListItems + .filter({ has: this.page.getByRole('link', { name: filename }) }) + .last() + .getByRole('link', { name: filename }); } get lastMessageTextAttachment(): Locator { - return this.page.locator('[data-qa-type="message"]:last-child [data-qa-type="message-attachment"]'); + return this.messageListItems.last().locator('[role="document"][aria-roledescription="message attachment"]'); } get lastMessageTextAttachmentEqualsText(): Locator { - return this.page.locator('[data-qa-type="message"]:last-child .rcx-attachment__details .rcx-message-body'); + return this.messageListItems.last().locator('.rcx-attachment__details .rcx-message-body'); } get btnQuoteMessage(): Locator { @@ -239,15 +262,15 @@ export class HomeContent { } get lastThreadMessageTextAttachmentEqualsText(): Locator { - return this.page.locator('div.thread-list ul.thread [data-qa-type="message"]').last().locator('.rcx-attachment__details'); + return this.threadMessageListItems.last().locator('.rcx-attachment__details'); } get mainThreadMessageText(): Locator { - return this.page.locator('div.thread-list ul.thread [data-qa-type="message"]').first(); + return this.threadMessageListItems.first(); } get lastThreadMessageText(): Locator { - return this.page.locator('div.thread-list ul.thread [data-qa-type="message"]').last(); + return this.threadMessageListItems.last(); } get lastThreadMessagePreviewText(): Locator { @@ -255,11 +278,11 @@ export class HomeContent { } get lastThreadMessageFileDescription(): Locator { - return this.page.locator('div.thread-list ul.thread [data-qa-type="message"]').last().locator('[data-qa-type="message-body"]'); + return this.threadMessageListItems.last().locator('[role="document"][aria-roledescription="message body"]'); } - get lastThreadMessageFileName(): Locator { - return this.page.locator('div.thread-list ul.thread [data-qa-type="message"]').last().locator('[data-qa-type="attachment-title-link"]'); + getLastThreadMessageByFileName(filename: string): Locator { + return this.threadMessageListItems.last().getByRole('link', { name: filename }); } // TODO: improve locator specificity @@ -268,7 +291,7 @@ export class HomeContent { } get lastThreadMessageTextAttachment(): Locator { - return this.page.locator('div.thread-list ul.thread [data-qa-type="message"]').last().locator('[data-qa-type="message-attachment"]'); + return this.threadMessageListItems.last().locator('[role="document"][aria-roledescription="message attachment"]'); } get btnOptionEditMessage(): Locator { @@ -393,10 +416,6 @@ export class HomeContent { await this.lastUserMessage.getByRole('button', { name: 'More', exact: true }).click(); } - get threadMessageList(): Locator { - return this.page.getByRole('list', { name: 'Thread message list' }); - } - async openLastThreadMessageMenu(): Promise { await this.threadMessageList.last().hover(); await this.threadMessageList.last().getByRole('button', { name: 'More', exact: true }).waitFor(); @@ -411,7 +430,7 @@ export class HomeContent { } get lastSystemMessageBody(): Locator { - return this.page.locator('[data-qa-type="system-message-body"]').last(); + return this.page.locator('[role=document][aria-roledescription="system message body"]').last(); } get resumeOnHoldOmnichannelChatButton(): Locator { @@ -480,7 +499,7 @@ export class HomeContent { // TODO: use getSystemMessageByText instead findSystemMessage(text: string): Locator { - return this.page.locator(`[data-qa-type="system-message-body"] >> text="${text}"`); + return this.page.locator(`[role="document"][aria-roledescription="system message body"]`, { hasText: text }); } getSystemMessageByText(text: string): Locator { @@ -492,7 +511,7 @@ export class HomeContent { } getMessageById(id: string): Locator { - return this.page.locator(`[data-qa-type="message"][id="${id}"]`); + return this.page.locator(`[role="listitem"][aria-roledescription="message"][id="${id}"]`); } async waitForChannel(): Promise { @@ -505,9 +524,9 @@ export class HomeContent { } async openReplyInThread(): Promise { - await this.page.locator('[data-qa-type="message"]').last().hover(); - await this.page.locator('[data-qa-type="message"]').last().locator('role=button[name="Reply in thread"]').waitFor(); - await this.page.locator('[data-qa-type="message"]').last().locator('role=button[name="Reply in thread"]').click(); + await this.lastUserMessage.hover(); + await this.lastUserMessage.getByRole('button', { name: 'Reply in thread' }).waitFor(); + await this.lastUserMessage.getByRole('button', { name: 'Reply in thread' }).click(); } async sendMessageInThread(text: string): Promise { diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-flextab-pruneMessages.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-flextab-pruneMessages.ts index d73896abbf9ba..7d5f933be8203 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-flextab-pruneMessages.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-flextab-pruneMessages.ts @@ -12,11 +12,11 @@ export class HomeFlextabPruneMessages { } get doNotPrunePinned(): Locator { - return this.form.getByRole('checkbox', { name: 'Do not prune pinned messages', exact: true }); + return this.form.locator('label', { hasText: 'Do not prune pinned messages' }); } get filesOnly(): Locator { - return this.form.getByRole('checkbox', { name: 'Only remove the attached files, keep messages', exact: true }); + return this.form.locator('label', { hasText: 'Only remove the attached files, keep messages' }); } async prune(): Promise { diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-flextab.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-flextab.ts index be9185d755cab..af808345e597b 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-flextab.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-flextab.ts @@ -72,10 +72,6 @@ export class HomeFlextab { return this.page.locator('role=menuitem[name="Enable E2E encryption"]'); } - get flexTabViewThreadMessage(): Locator { - return this.page.locator('div.thread-list ul.thread [data-qa-type="message"]').last().locator('[data-qa-type="message-body"]'); - } - get userInfoUsername(): Locator { return this.page.locator('[data-qa="UserInfoUserName"]'); } diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-omnichannel-content.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-omnichannel-content.ts index 71534bdb38498..a933aebf975a2 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-omnichannel-content.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-omnichannel-content.ts @@ -38,10 +38,7 @@ export class HomeOmnichannelContent extends HomeContent { return this.page.locator('.rcx-room-header').getByRole('heading'); } - /** - * FIXME: useX naming convention should be exclusively for react hooks - **/ - async useCannedResponse(cannedResponseName: string): Promise { + async selectCannedResponse(cannedResponseName: string): Promise { await this.composer.inputMessage.pressSequentially('!'); await this.page.locator('[role="menu"][name="ComposerBoxPopup"]').waitFor({ state: 'visible' }); await this.composer.inputMessage.pressSequentially(cannedResponseName); diff --git a/apps/meteor/tests/e2e/page-objects/fragments/message.ts b/apps/meteor/tests/e2e/page-objects/fragments/message.ts index 77dee24c85ca0..cb2bc36db5281 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/message.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/message.ts @@ -4,11 +4,11 @@ export class Message { constructor(public readonly root: Locator) {} get body() { - return this.root.locator('[data-qa-type="message-body"]'); + return this.root.locator('[role="document"][aria-roledescription="message body"]'); } - get fileUploadName() { - return this.root.locator('[data-qa-type="attachment-title-link"]'); + getFileUploadByName(filename: string) { + return this.root.getByRole('link', { name: filename }); } get encryptedIcon() { diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-info.ts b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-info.ts index d911a81305d00..5d6a877e71022 100644 --- a/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-info.ts +++ b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-info.ts @@ -39,6 +39,18 @@ export class OmnichannelContactInfo extends FlexTab { return this.root.getByRole('button', { name: 'See conflicts' }); } + private get customFieldsGroup() { + return this.root.getByRole('group', { name: 'Custom Fields' }); + } + + getInfoByValue(value: string): Locator { + return this.root.getByText(value, { exact: true }); + } + + getVisitorCustomField(label: string): Locator { + return this.customFieldsGroup.getByLabel(label); + } + async solveConflict(field: string, value: string) { await this.btnSeeConflicts.click(); await this.contactReviewModal.solveConfirmation(field, value); diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-transcript.ts b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-transcript.ts index a05a185849168..851c338626d6e 100644 --- a/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-transcript.ts +++ b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-transcript.ts @@ -18,8 +18,4 @@ export class OmnichannelTranscript extends OmnichannelAdmin { get btnOpenChat(): Locator { return this.page.getByRole('dialog').getByRole('button', { name: 'Open chat', exact: true }); } - - get DownloadedPDF(): Locator { - return this.page.locator('[data-qa-type="attachment-title-link"]').last(); - } } diff --git a/apps/meteor/tests/e2e/prune-messages.spec.ts b/apps/meteor/tests/e2e/prune-messages.spec.ts index d56856bfff51c..0845ebcdf2911 100644 --- a/apps/meteor/tests/e2e/prune-messages.spec.ts +++ b/apps/meteor/tests/e2e/prune-messages.spec.ts @@ -44,7 +44,7 @@ test.describe('prune-messages', () => { await content.sendFileMessage('any_file.txt'); await content.descriptionInput.fill('a message with a file'); await content.btnModalConfirm.click(); - await expect(content.lastMessageFileName).toHaveText('any_file.txt'); + await expect(content.getLastMessageByFileName('any_file.txt')).toBeVisible(); await sendTargetChannelMessage(api, targetChannel.fname as string, { msg: 'a message without files', @@ -112,7 +112,7 @@ test.describe('prune-messages', () => { await content.sendFileMessage('any_file.txt'); await content.descriptionInput.fill('a message with a file'); await content.btnModalConfirm.click(); - await expect(content.lastMessageFileName).toHaveText('any_file.txt'); + await expect(content.getLastMessageByFileName('any_file.txt')).toBeVisible(); await test.step('prune files only', async () => { await pruneMessages.filesOnly.check({ force: true }); @@ -124,7 +124,7 @@ test.describe('prune-messages', () => { }); await test.step('check message list for prune message-attachment', async () => { - await expect(content.lastMessageFileName).not.toBeVisible(); + await expect(content.getLastMessageByFileName('any_file.txt')).not.toBeVisible(); await expect(content.lastMessageTextAttachment, 'Prune message attachment replaces file attachment').toHaveText( 'File removed by prune', ); @@ -147,10 +147,9 @@ test.describe('prune-messages', () => { await content.sendFileMessage('any_file.txt'); await content.descriptionInput.fill('a message with a file'); await content.btnModalConfirm.click(); - await expect(content.lastMessageFileName).toHaveText('any_file.txt'); + await expect(content.getLastMessageByFileName('any_file.txt')).toBeVisible(); - await content.lastUserMessage.hover(); - await content.lastUserMessage.getByTitle('Reply in thread').click(); + await content.openReplyInThread(); expect( ( await api.post('/rooms.cleanHistory', { @@ -163,7 +162,7 @@ test.describe('prune-messages', () => { ).toBe(200); await test.step('check main thread message for prune message-attachment', async () => { - await expect(content.lastThreadMessageFileName).not.toBeVisible(); + await expect(content.getLastThreadMessageByFileName('any_file.txt')).not.toBeVisible(); await expect(content.lastThreadMessageTextAttachment, 'Prune message attachment replaces file attachment').toHaveText( 'File removed by prune', ); diff --git a/apps/meteor/tests/e2e/quote-attachment.spec.ts b/apps/meteor/tests/e2e/quote-attachment.spec.ts index ddf505d5600c9..8b20af3ae54c7 100644 --- a/apps/meteor/tests/e2e/quote-attachment.spec.ts +++ b/apps/meteor/tests/e2e/quote-attachment.spec.ts @@ -76,7 +76,7 @@ test.describe.parallel('Quote Attachment', () => { await poHomeChannel.content.btnModalConfirm.click(); await expect(poHomeChannel.content.lastThreadMessageFileDescription).toHaveText(fileDescription); - await expect(poHomeChannel.content.lastThreadMessageFileName).toContainText(textFileName); + await expect(poHomeChannel.content.getLastThreadMessageByFileName(textFileName)).toBeVisible(); }); await test.step('Quote the message with attachment in thread', async () => { diff --git a/apps/meteor/tests/e2e/register.spec.ts b/apps/meteor/tests/e2e/register.spec.ts index 1cd36d94c355b..eb081b62bb708 100644 --- a/apps/meteor/tests/e2e/register.spec.ts +++ b/apps/meteor/tests/e2e/register.spec.ts @@ -162,7 +162,7 @@ test.describe.parallel('register', () => { await poRegistration.inputPasswordConfirm.fill('P@ssw0rd1234.!'); await poRegistration.btnRegister.click(); - await expect(page.getByRole('alert').filter({ hasText: 'Email already exists' })).toBeVisible(); + await expect(page.getByRole('alert').filter({ hasText: 'Email already in use' })).toBeVisible(); }); }); }); diff --git a/apps/meteor/tests/e2e/system-messages.spec.ts b/apps/meteor/tests/e2e/system-messages.spec.ts index c8f5669439288..bc787f6e822bc 100644 --- a/apps/meteor/tests/e2e/system-messages.spec.ts +++ b/apps/meteor/tests/e2e/system-messages.spec.ts @@ -16,7 +16,8 @@ const userData = { password: faker.internet.password(), }; -const findSysMes = (page: Page, id: string): Locator => page.locator(`[data-qa="system-message"][data-system-message-type="${id}"]`); +const findSysMes = (page: Page, id: string): Locator => + page.locator(`[role="listitem"][aria-roledescription="system message"][data-system-message-type="${id}"]`); // There currently are over 33 system messages. Testing only a couple due to test being too slow right now. // Ideally, we should test all. diff --git a/apps/meteor/tests/e2e/team-management.spec.ts b/apps/meteor/tests/e2e/team-management.spec.ts index 81f76ef60e628..a633d7748bfb4 100644 --- a/apps/meteor/tests/e2e/team-management.spec.ts +++ b/apps/meteor/tests/e2e/team-management.spec.ts @@ -146,12 +146,10 @@ test.describe.serial('teams-management', () => { test('should send hello in the targetTeam and reply in a thread', async ({ page }) => { await poHomeTeam.navbar.openChat(targetTeam); await poHomeTeam.content.sendMessage('hello'); - await page.locator('[data-qa-type="message"]').last().hover(); - - await page.locator('role=button[name="Reply in thread"]').click(); + await poHomeTeam.content.openReplyInThread(); await page.locator('.rcx-vertical-bar').locator(`role=textbox[name="Message #${targetTeam}"]`).type('any-reply-message'); await page.keyboard.press('Enter'); - await expect(poHomeTeam.tabs.flexTabViewThreadMessage).toHaveText('any-reply-message'); + await expect(poHomeTeam.content.lastThreadMessageText).toHaveText('any-reply-message'); }); test('should set targetTeam as readonly', async () => { diff --git a/apps/meteor/tests/e2e/threads.spec.ts b/apps/meteor/tests/e2e/threads.spec.ts index f7ddbdd51a906..f52212e92ba37 100644 --- a/apps/meteor/tests/e2e/threads.spec.ts +++ b/apps/meteor/tests/e2e/threads.spec.ts @@ -29,8 +29,7 @@ test.describe.serial('Threads', () => { test('expect thread message preview if alsoSendToChannel checkbox is checked', async ({ page }) => { await poHomeChannel.content.sendMessage('this is a message for reply'); - await page.locator('[data-qa-type="message"]').last().hover(); - await page.locator('role=button[name="Reply in thread"]').click(); + await poHomeChannel.content.openReplyInThread(); await expect(page).toHaveURL(/.*thread/); @@ -38,7 +37,7 @@ test.describe.serial('Threads', () => { await page.getByRole('dialog').locator('[name="msg"]').last().fill('This is a thread message also sent in channel'); await page.keyboard.press('Enter'); await expect(poHomeChannel.content.lastThreadMessageText).toContainText('This is a thread message also sent in channel'); - await expect(poHomeChannel.content.lastUserMessage).toContainText('This is a thread message also sent in channel'); + await expect(poHomeChannel.content.lastThreadMessagePreview).toContainText('This is a thread message also sent in channel'); }); test('expect open threads contextual bar when clicked on thread preview', async ({ page }) => { await poHomeChannel.content.lastThreadMessagePreviewText.click(); @@ -59,7 +58,7 @@ test.describe.serial('Threads', () => { test('expect to close thread contextual bar on clicking outside', async ({ page }) => { await poHomeChannel.content.lastThreadMessagePreviewText.click(); await expect(page).toHaveURL(/.*thread/); - await poHomeChannel.content.lastUserMessageNotThread.click(); + await poHomeChannel.content.lastUserMessage.click(); await expect(page).not.toHaveURL(/.*thread/); }); test('expect open threads contextual bar when clicked on thread preview', async ({ page }) => { @@ -90,7 +89,7 @@ test.describe.serial('Threads', () => { await poHomeChannel.content.btnModalConfirm.click(); await expect(poHomeChannel.content.lastThreadMessageFileDescription).toHaveText('any_description'); - await expect(poHomeChannel.content.lastThreadMessageFileName).toContainText('any_file1.txt'); + await expect(poHomeChannel.content.getLastThreadMessageByFileName('any_file1.txt')).toBeVisible(); }); test.describe('thread message actions', () => { @@ -99,8 +98,7 @@ test.describe.serial('Threads', () => { await page.goto('/home'); await poHomeChannel.navbar.openChat(targetChannel); await poHomeChannel.content.sendMessage('this is a message for reply'); - await page.locator('[data-qa-type="message"]').last().hover(); - await page.locator('role=button[name="Reply in thread"]').click(); + await poHomeChannel.content.openReplyInThread(); }); test('expect delete the thread message and close thread if has only one message', async ({ page }) => { @@ -133,8 +131,8 @@ test.describe.serial('Threads', () => { }); test('expect quote the thread message', async ({ page }) => { - await page.getByRole('dialog').locator('[data-qa-type="message"]').last().hover(); - await page.locator('role=button[name="Quote"]').click(); + await poHomeChannel.content.lastThreadMessageText.hover(); + await poHomeChannel.content.lastThreadMessageText.getByRole('button', { name: 'Quote' }).click(); await page.locator('[name="msg"]').last().fill('this is a quote message'); await page.keyboard.press('Enter'); @@ -170,7 +168,7 @@ test.describe.serial('Threads', () => { test('expect close thread if has only one message and user press escape', async ({ page }) => { await expect(page).toHaveURL(/.*thread/); - await expect(page.getByRole('dialog').locator('[data-qa-type="message"]')).toBeVisible(); + await expect(poHomeChannel.content.lastThreadMessageText).toBeVisible(); await expect(page.locator('[name="msg"]').last()).toBeFocused(); await page.keyboard.press('Escape'); await expect(page).not.toHaveURL(/.*thread/); @@ -178,7 +176,7 @@ test.describe.serial('Threads', () => { test('expect reset the thread composer to original message if user presses escape', async ({ page }) => { await expect(page).toHaveURL(/.*thread/); - await expect(page.getByRole('dialog').locator('[data-qa-type="message"]')).toBeVisible(); + await expect(poHomeChannel.content.lastThreadMessageText).toBeVisible(); await expect(page.locator('[name="msg"]').last()).toBeFocused(); await page.locator('[name="msg"]').last().fill('message to be edited'); @@ -195,7 +193,7 @@ test.describe.serial('Threads', () => { test('expect clean composer and keep the thread open if user is editing message and presses escape', async ({ page }) => { await expect(page).toHaveURL(/.*thread/); - await expect(page.getByRole('dialog').locator('[data-qa-type="message"]')).toBeVisible(); + await expect(poHomeChannel.content.lastThreadMessageText).toBeVisible(); await expect(page.locator('[name="msg"]').last()).toBeFocused(); await page.locator('[name="msg"]').last().fill('message to be edited'); diff --git a/apps/meteor/tests/e2e/utils/omnichannel/custom-field.ts b/apps/meteor/tests/e2e/utils/omnichannel/custom-field.ts index fca092c4133a5..7aa2f914f6ef3 100644 --- a/apps/meteor/tests/e2e/utils/omnichannel/custom-field.ts +++ b/apps/meteor/tests/e2e/utils/omnichannel/custom-field.ts @@ -10,6 +10,23 @@ export const removeCustomField = (api: BaseTest['api'], id: string) => { }); }; +export const setVisitorCustomFieldValue = async ( + api: BaseTest['api'], + params: { token: string; customFieldId: string; value: string; overwrite?: boolean }, +) => { + const response = await api.post('/livechat/custom.field', { + token: params.token, + key: params.customFieldId, + value: params.value, + overwrite: params.overwrite ?? true, + }); + if (!response.ok()) { + throw new Error(`Failed to set visitor custom field [http status: ${response.status()}]`); + } + const { field } = await response.json(); + return { response, field }; +}; + export const createCustomField = async (api: BaseTest['api'], overwrites: Partial) => { const response = await api.post('/livechat/custom-fields.save', { customFieldId: null, diff --git a/apps/meteor/tests/end-to-end/api/channels.ts b/apps/meteor/tests/end-to-end/api/channels.ts index f2d348240f3cf..98224d6babd3e 100644 --- a/apps/meteor/tests/end-to-end/api/channels.ts +++ b/apps/meteor/tests/end-to-end/api/channels.ts @@ -1771,6 +1771,106 @@ describe('[Channels]', () => { }) .end(done); }); + + describe('inclusive parameter', () => { + let testChannel: IRoom; + let oldestMessage: IMessage; + let middleMessage: IMessage; + let latestMessage: IMessage; + + before(async () => { + const channelRes = await request + .post(api('channels.create')) + .set(credentials) + .send({ name: `inclusive-test-channel-${Date.now()}` }); + testChannel = channelRes.body.channel; + + // Send messages with small delays to ensure distinct timestamps + const msg1 = await sendMessage({ message: { rid: testChannel._id, msg: 'oldest message' } }); + oldestMessage = msg1.body.message; + + // Small delay to ensure timestamps are different + await new Promise((resolve) => setTimeout(resolve, 50)); + + const msg2 = await sendMessage({ message: { rid: testChannel._id, msg: 'middle message' } }); + middleMessage = msg2.body.message; + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const msg3 = await sendMessage({ message: { rid: testChannel._id, msg: 'latest message' } }); + latestMessage = msg3.body.message; + }); + + after(async () => { + if (testChannel?._id) { + await deleteRoom({ type: 'c', roomId: testChannel._id }); + } + }); + + it('should include boundary messages when inclusive=true', async () => { + const res = await request + .get(api('channels.history')) + .set(credentials) + .query({ + roomId: testChannel._id, + oldest: oldestMessage.ts, + latest: latestMessage.ts, + inclusive: 'true', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').that.is.an('array'); + + const messageIds = res.body.messages.map((m: IMessage) => m._id); + expect(messageIds).to.include(oldestMessage._id, 'oldest message should be included'); + expect(messageIds).to.include(latestMessage._id, 'latest message should be included'); + }); + + it('should exclude boundary messages when inclusive=false', async () => { + const res = await request + .get(api('channels.history')) + .set(credentials) + .query({ + roomId: testChannel._id, + oldest: oldestMessage.ts, + latest: latestMessage.ts, + inclusive: 'false', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').that.is.an('array'); + + const messageIds = res.body.messages.map((m: IMessage) => m._id); + expect(messageIds).to.not.include(oldestMessage._id, 'oldest message should be excluded'); + expect(messageIds).to.not.include(latestMessage._id, 'latest message should be excluded'); + // Middle message should still be included if it exists in the range + expect(messageIds).to.include(middleMessage._id, 'middle message should be included'); + }); + + it('should exclude boundary messages by default (no inclusive param)', async () => { + const res = await request + .get(api('channels.history')) + .set(credentials) + .query({ + roomId: testChannel._id, + oldest: oldestMessage.ts, + latest: latestMessage.ts, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').that.is.an('array'); + + const messageIds = res.body.messages.map((m: IMessage) => m._id); + expect(messageIds).to.not.include(oldestMessage._id, 'oldest message should be excluded by default'); + expect(messageIds).to.not.include(latestMessage._id, 'latest message should be excluded by default'); + }); + }); }); describe('/channels.members', () => { diff --git a/apps/meteor/tests/end-to-end/api/commands.ts b/apps/meteor/tests/end-to-end/api/commands.ts index 3e47d190e86bf..fbb115e87a6be 100644 --- a/apps/meteor/tests/end-to-end/api/commands.ts +++ b/apps/meteor/tests/end-to-end/api/commands.ts @@ -22,7 +22,7 @@ describe('[Commands]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body.error).to.be.equal('The query param "command" must be provided.'); + expect(res.body.error).to.be.equal(`must have required property 'command'`); }) .end(done); }); diff --git a/apps/meteor/tests/end-to-end/api/custom-sounds.ts b/apps/meteor/tests/end-to-end/api/custom-sounds.ts index d21cabd7f14b6..b035a5d6fec40 100644 --- a/apps/meteor/tests/end-to-end/api/custom-sounds.ts +++ b/apps/meteor/tests/end-to-end/api/custom-sounds.ts @@ -6,6 +6,7 @@ import { expect } from 'chai'; import { before, describe, it, after } from 'mocha'; import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { updateSetting } from '../../data/permissions.helper'; async function insertOrUpdateSound(fileName: string, fileId?: string): Promise { fileId = fileId ?? ''; @@ -44,17 +45,33 @@ async function uploadCustomSound(binary: string, fileName: string, fileId: strin .expect(200); } +async function deleteCustomSound(_id: string) { + await request + .post(api('method.call/deleteCustomSound')) + .set(credentials) + .send({ + message: JSON.stringify({ + msg: 'method', + id: '1', + method: 'deleteCustomSound', + params: [_id], + }), + }) + .expect(200); +} + describe('[CustomSounds]', () => { const fileName = `test-file-${randomUUID()}`; let fileId: string; let fileId2: string; let uploadDate: string | undefined; + let binary: string; before((done) => getCredentials(done)); before(async () => { const data = readFileSync(path.resolve(__dirname, '../../mocks/files/audio_mock.wav')); - const binary = data.toString('binary'); + binary = data.toString('binary'); fileId = await insertOrUpdateSound(fileName); fileId2 = await insertOrUpdateSound(`${fileName}-2`); @@ -184,4 +201,131 @@ describe('[CustomSounds]', () => { .end(done); }); }); + + describe('[/custom-sounds.getOne]', () => { + it('should return unauthorized if not authenticated', async () => { + await request.get(api('custom-sounds.getOne')).query({ _id: fileId }).expect(401); + }); + + it('should return not found if custom sound does not exist', async () => { + await request.get(api('custom-sounds.getOne')).set(credentials).query({ _id: 'invalid-id' }).expect(404); + }); + + it('should return bad request if the _id length is not more than one', async () => { + await request.get(api('custom-sounds.getOne')).set(credentials).query({ _id: '' }).expect(400); + }); + + it('should return the custom sound successfully', async () => { + await request + .get(api('custom-sounds.getOne')) + .set(credentials) + .query({ _id: fileId }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('sound').and.to.be.an('object'); + expect(res.body.sound).to.have.property('_id', fileId); + expect(res.body.sound).to.have.property('name').and.to.be.a('string'); + expect(res.body.sound).to.have.property('extension').and.to.be.a('string'); + }); + }); + + it('should reject regex injection via query object', async () => { + await request + .get(api('custom-sounds.getOne')) + .set(credentials) + .query({ + _id: { $regex: '.*' }, + }) + .expect(400); + }); + + it('should reject regex injection via bracket syntax', async () => { + await request.get(api('custom-sounds.getOne')).set(credentials).query('_id[$regex]=.*').expect(400); + }); + + it('should reject encoded regex injection attempt', async () => { + await request + .get(api('custom-sounds.getOne')) + .set(credentials) + .query({ + _id: '{"$regex":".*"}', + }) + .expect(404); // valid string, but it doesn't exist + }); + }); + + describe('Sounds storage settings reactivity', () => { + let fsFileId: string; + let gridFsFileId: string; + + before(async () => { + await updateSetting('CustomSounds_FileSystemPath', '', false); + await updateSetting('CustomSounds_Storage_Type', 'FileSystem'); + fsFileId = await insertOrUpdateSound(`${fileName}-3`); + await uploadCustomSound(binary, `${fileName}-3`, fsFileId); + + await updateSetting('CustomSounds_Storage_Type', 'GridFS'); + gridFsFileId = await insertOrUpdateSound(`${fileName}-4`); + await uploadCustomSound(binary, `${fileName}-4`, gridFsFileId); + }); + + after(async () => { + await updateSetting('CustomSounds_Storage_Type', 'FileSystem', false); + await updateSetting('CustomSounds_FileSystemPath', ''); + await deleteCustomSound(fsFileId); + await updateSetting('CustomSounds_Storage_Type', 'GridFS'); + await deleteCustomSound(gridFsFileId); + }); + + describe('CustomSounds_Storage_Type', () => { + describe('when storage is GridFS', () => { + before(async () => { + await updateSetting('CustomSounds_Storage_Type', 'GridFS'); + }); + + it('should resolve GridFS files only', async () => { + await request.get(`/custom-sounds/${gridFsFileId}.wav`).set(credentials).expect(200); + await request.get(`/custom-sounds/${fsFileId}.wav`).set(credentials).expect(404); + }); + }); + + describe('when storage is FileSystem', () => { + before(async () => { + await updateSetting('CustomSounds_Storage_Type', 'FileSystem'); + }); + + it('should resolve FileSystem files only', async () => { + await request.get(`/custom-sounds/${gridFsFileId}.wav`).set(credentials).expect(404); + await request.get(`/custom-sounds/${fsFileId}.wav`).set(credentials).expect(200); + }); + }); + }); + + describe('CustomSounds_FileSystemPath', () => { + before(async () => { + await updateSetting('CustomSounds_Storage_Type', 'FileSystem'); + }); + + describe('when file system path is the default one', () => { + it('should resolve files', async () => { + await request.get(`/custom-sounds/${fsFileId}.wav`).set(credentials).expect(200); + }); + }); + + describe('when file system path is NOT the default one', () => { + before(async () => { + await updateSetting('CustomSounds_FileSystemPath', '~/sounds'); + }); + + after(async () => { + await updateSetting('CustomSounds_FileSystemPath', ''); + }); + + it('should NOT resolve files', async () => { + await request.get(`/custom-sounds/${fsFileId}.wav`).set(credentials).expect(404); + }); + }); + }); + }); }); diff --git a/apps/meteor/tests/end-to-end/api/direct-message.ts b/apps/meteor/tests/end-to-end/api/direct-message.ts index c449ee29e8ac9..67ce007aaadab 100644 --- a/apps/meteor/tests/end-to-end/api/direct-message.ts +++ b/apps/meteor/tests/end-to-end/api/direct-message.ts @@ -194,6 +194,108 @@ describe('[Direct Messages]', () => { .end(done); }); + describe('/im.history inclusive parameter', () => { + let testDMRoom: IRoom; + let testUser2: TestUser; + let oldestMessage: IMessage; + let middleMessage: IMessage; + let latestMessage: IMessage; + + before(async () => { + testUser2 = await createUser(); + const dmRes = await request.post(api('im.create')).set(credentials).send({ username: testUser2.username }); + testDMRoom = dmRes.body.room; + + // Send messages with small delays to ensure distinct timestamps + const msg1 = await sendMessage({ message: { rid: testDMRoom._id, msg: 'oldest message' } }); + oldestMessage = msg1.body.message; + + // Small delay to ensure timestamps are different + await new Promise((resolve) => setTimeout(resolve, 50)); + + const msg2 = await sendMessage({ message: { rid: testDMRoom._id, msg: 'middle message' } }); + middleMessage = msg2.body.message; + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const msg3 = await sendMessage({ message: { rid: testDMRoom._id, msg: 'latest message' } }); + latestMessage = msg3.body.message; + }); + + after(async () => { + if (testDMRoom?._id) { + await deleteRoom({ type: 'd', roomId: testDMRoom._id }); + } + if (testUser2) { + await deleteUser(testUser2); + } + }); + + it('should include boundary messages when inclusive=true', async () => { + const res = await request + .get(api('im.history')) + .set(credentials) + .query({ + roomId: testDMRoom._id, + oldest: oldestMessage.ts, + latest: latestMessage.ts, + inclusive: 'true', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').that.is.an('array'); + + const messageIds = res.body.messages.map((m: IMessage) => m._id); + expect(messageIds).to.include(oldestMessage._id, 'oldest message should be included'); + expect(messageIds).to.include(latestMessage._id, 'latest message should be included'); + }); + + it('should exclude boundary messages when inclusive=false', async () => { + const res = await request + .get(api('im.history')) + .set(credentials) + .query({ + roomId: testDMRoom._id, + oldest: oldestMessage.ts, + latest: latestMessage.ts, + inclusive: 'false', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').that.is.an('array'); + + const messageIds = res.body.messages.map((m: IMessage) => m._id); + expect(messageIds).to.not.include(oldestMessage._id, 'oldest message should be excluded'); + expect(messageIds).to.not.include(latestMessage._id, 'latest message should be excluded'); + // Middle message should still be included if it exists in the range + expect(messageIds).to.include(middleMessage._id, 'middle message should be included'); + }); + + it('should exclude boundary messages by default (no inclusive param)', async () => { + const res = await request + .get(api('im.history')) + .set(credentials) + .query({ + roomId: testDMRoom._id, + oldest: oldestMessage.ts, + latest: latestMessage.ts, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').that.is.an('array'); + + const messageIds = res.body.messages.map((m: IMessage) => m._id); + expect(messageIds).to.not.include(oldestMessage._id, 'oldest message should be excluded by default'); + expect(messageIds).to.not.include(latestMessage._id, 'latest message should be excluded by default'); + }); + }); + it('/im.list', (done) => { void request .get(api('im.list')) diff --git a/apps/meteor/tests/end-to-end/api/emoji-custom.ts b/apps/meteor/tests/end-to-end/api/emoji-custom.ts index 744480ea6f716..b256fd4b54551 100644 --- a/apps/meteor/tests/end-to-end/api/emoji-custom.ts +++ b/apps/meteor/tests/end-to-end/api/emoji-custom.ts @@ -4,6 +4,7 @@ import { before, describe, it, after } from 'mocha'; import { getCredentials, api, request, credentials } from '../../data/api-data'; import { imgURL } from '../../data/interactions'; +import { updateSetting } from '../../data/permissions.helper'; describe('[EmojiCustom]', () => { const customEmojiName = `my-custom-emoji-${Date.now()}`; @@ -481,4 +482,131 @@ describe('[EmojiCustom]', () => { .end(done); }); }); + + describe('Emoji storage settings reactivity', () => { + const normalizeSvg = (svg: string) => svg.replace(/\r\n/g, '\n').trim(); + + const now = Date.now(); + const fsEmojiName = `emoji-fs-${now}`; + const gridFsEmojiName = `emoji-gridfs-${now}`; + const svgFallback = normalizeSvg(` + + + ? + +`); + + before(async () => { + await updateSetting('EmojiUpload_FileSystemPath', '', false); + await updateSetting('EmojiUpload_Storage_Type', 'FileSystem'); + await request.post(api('emoji-custom.create')).set(credentials).attach('emoji', imgURL).field({ name: fsEmojiName }).expect(200); + + await updateSetting('EmojiUpload_Storage_Type', 'GridFS'); + await request.post(api('emoji-custom.create')).set(credentials).attach('emoji', imgURL).field({ name: gridFsEmojiName }).expect(200); + }); + + after(async () => { + const list = await request.get(api('emoji-custom.all')).set(credentials); + const fsEmoji = list.body.emojis.find((e: IEmojiCustom) => e.name === fsEmojiName); + const gridEmoji = list.body.emojis.find((e: IEmojiCustom) => e.name === gridFsEmojiName); + + await updateSetting('EmojiUpload_Storage_Type', 'FileSystem', false); + await updateSetting('EmojiUpload_FileSystemPath', ''); + if (fsEmoji) { + await request.post(api('emoji-custom.delete')).set(credentials).send({ emojiId: fsEmoji._id }); + } + + await updateSetting('EmojiUpload_Storage_Type', 'GridFS'); + if (gridEmoji) { + await request.post(api('emoji-custom.delete')).set(credentials).send({ emojiId: gridEmoji._id }); + } + }); + + describe('EmojiUpload_Storage_Type', () => { + describe('when storage is GridFs', () => { + before(async () => { + await updateSetting('EmojiUpload_Storage_Type', 'GridFS'); + }); + + it('should resolve GridFS files only', async () => { + await request + .get(`/emoji-custom/${fsEmojiName}.png`) + .set(credentials) + .expect(200) + .expect((res) => { + const received = normalizeSvg(res.body.toString()); + expect(received).to.equal(svgFallback); + }); + await request + .get(`/emoji-custom/${gridFsEmojiName}.png`) + .set(credentials) + .expect(200) + .expect((res) => expect(res.headers).to.have.property('content-type', 'image/png')); + }); + }); + + describe('when storage is FileSystem', () => { + before(async () => { + await updateSetting('EmojiUpload_Storage_Type', 'FileSystem'); + }); + + it('should resolve FileSystem files only', async () => { + await request + .get(`/emoji-custom/${fsEmojiName}.png`) + .set(credentials) + .expect(200) + .expect((res) => expect(res.headers).to.have.property('content-type', 'image/png')); + await request + .get(`/emoji-custom/${gridFsEmojiName}.png`) + .set(credentials) + .expect(200) + .expect((res) => { + const received = normalizeSvg(res.body.toString()); + expect(received).to.equal(svgFallback); + }); + }); + }); + }); + + describe('EmojiUpload_FileSystemPath', () => { + before(async () => { + await updateSetting('EmojiUpload_Storage_Type', 'FileSystem'); + }); + + describe('when file system path is the default one', () => { + before(async () => { + await updateSetting('EmojiUpload_FileSystemPath', ''); + }); + + it('should resolve files', async () => { + await request + .get(`/emoji-custom/${fsEmojiName}.png`) + .set(credentials) + .expect(200) + .expect((res) => expect(res.headers).to.have.property('content-type', 'image/png')); + }); + }); + + describe('when file system path is NOT the default one', () => { + before(async () => { + await updateSetting('EmojiUpload_FileSystemPath', '~/emoji-test'); + }); + + after(async () => { + await updateSetting('CustomSounds_FileSystemPath', ''); + }); + + it('should NOT resolve files', async () => { + await request + .get(`/emoji-custom/${fsEmojiName}.png`) + .set(credentials) + .expect(200) + .expect((res) => { + const received = normalizeSvg(res.body.toString()); + expect(received).to.equal(svgFallback); + }); + }); + }); + }); + }); }); diff --git a/apps/meteor/tests/end-to-end/api/groups.ts b/apps/meteor/tests/end-to-end/api/groups.ts index b92ac79bd3046..6eecca278af35 100644 --- a/apps/meteor/tests/end-to-end/api/groups.ts +++ b/apps/meteor/tests/end-to-end/api/groups.ts @@ -1200,6 +1200,106 @@ describe('[Groups]', () => { }) .end(done); }); + + describe('inclusive parameter', () => { + let testGroup: IRoom; + let oldestMessage: IMessage; + let middleMessage: IMessage; + let latestMessage: IMessage; + + before(async () => { + const groupRes = await request + .post(api('groups.create')) + .set(credentials) + .send({ name: `inclusive-test-group-${Date.now()}` }); + testGroup = groupRes.body.group; + + // Send messages with small delays to ensure distinct timestamps + const msg1 = await sendMessage({ message: { rid: testGroup._id, msg: 'oldest message' } }); + oldestMessage = msg1.body.message; + + // Small delay to ensure timestamps are different + await new Promise((resolve) => setTimeout(resolve, 50)); + + const msg2 = await sendMessage({ message: { rid: testGroup._id, msg: 'middle message' } }); + middleMessage = msg2.body.message; + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const msg3 = await sendMessage({ message: { rid: testGroup._id, msg: 'latest message' } }); + latestMessage = msg3.body.message; + }); + + after(async () => { + if (testGroup?._id) { + await request.post(api('groups.delete')).set(credentials).send({ roomId: testGroup._id }); + } + }); + + it('should include boundary messages when inclusive=true', async () => { + const res = await request + .get(api('groups.history')) + .set(credentials) + .query({ + roomId: testGroup._id, + oldest: oldestMessage.ts, + latest: latestMessage.ts, + inclusive: 'true', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').that.is.an('array'); + + const messageIds = res.body.messages.map((m: IMessage) => m._id); + expect(messageIds).to.include(oldestMessage._id, 'oldest message should be included'); + expect(messageIds).to.include(latestMessage._id, 'latest message should be included'); + }); + + it('should exclude boundary messages when inclusive=false', async () => { + const res = await request + .get(api('groups.history')) + .set(credentials) + .query({ + roomId: testGroup._id, + oldest: oldestMessage.ts, + latest: latestMessage.ts, + inclusive: 'false', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').that.is.an('array'); + + const messageIds = res.body.messages.map((m: IMessage) => m._id); + expect(messageIds).to.not.include(oldestMessage._id, 'oldest message should be excluded'); + expect(messageIds).to.not.include(latestMessage._id, 'latest message should be excluded'); + // Middle message should still be included if it exists in the range + expect(messageIds).to.include(middleMessage._id, 'middle message should be included'); + }); + + it('should exclude boundary messages by default (no inclusive param)', async () => { + const res = await request + .get(api('groups.history')) + .set(credentials) + .query({ + roomId: testGroup._id, + oldest: oldestMessage.ts, + latest: latestMessage.ts, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').that.is.an('array'); + + const messageIds = res.body.messages.map((m: IMessage) => m._id); + expect(messageIds).to.not.include(oldestMessage._id, 'oldest message should be excluded by default'); + expect(messageIds).to.not.include(latestMessage._id, 'latest message should be excluded by default'); + }); + }); }); describe('/groups.archive', () => { diff --git a/apps/meteor/tests/end-to-end/api/livechat/07-queue.ts b/apps/meteor/tests/end-to-end/api/livechat/07-queue.ts index 08724bab3305a..ffd0acb52e9da 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/07-queue.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/07-queue.ts @@ -20,6 +20,8 @@ import { makeAgentAvailable, updateDepartment, startANewLivechatRoomAndTakeIt, + fetchInquiry, + takeInquiry, } from '../../../data/livechat/rooms'; import { createAnOnlineAgent, updateLivechatSettingsForUser } from '../../../data/livechat/users'; import { sleep } from '../../../data/livechat/utils'; @@ -1069,3 +1071,411 @@ describe('LIVECHAT - Queue', () => { }); }); }); + +(IS_EE ? describe : describe.skip)('Livechat - Chat limits - Manual Selection', () => { + let manualUser: { user: IUser; credentials: Credentials }; + let manualDepartment: ILivechatDepartment; + let manualDepartment2: ILivechatDepartment; + const manualRoomsToClose: IOmnichannelRoom[] = []; + const manualVisitorsToDelete: ILivechatVisitor[] = []; + + before((done) => getCredentials(done)); + + before(async () => { + await Promise.all([ + updateSetting('Livechat_enabled', true), + updateSetting('Livechat_Routing_Method', 'Manual_Selection'), + updateSetting('Omnichannel_enable_department_removal', true), + updateEESetting('Livechat_maximum_chats_per_agent', 0), + updateEESetting('Livechat_waiting_queue', true), + ]); + + await sleep(1000); + }); + + before(async () => { + const user = await createUser(); + await createAgent(user.username); + const creds = await login(user.username, password); + await makeAgentAvailable(creds); + + manualUser = { user, credentials: creds }; + }); + + before(async () => { + manualDepartment = await createDepartment([{ agentId: manualUser.user._id }], `${new Date().toISOString()}-manual-dept`, true, { + maxNumberSimultaneousChat: 2, + }); + manualDepartment2 = await createDepartment([{ agentId: manualUser.user._id }], `${new Date().toISOString()}-manual-dept2`, true, { + maxNumberSimultaneousChat: 2, + }); + await updateLivechatSettingsForUser(manualUser.user._id, { maxNumberSimultaneousChat: 1 }, [ + manualDepartment._id, + manualDepartment2._id, + ]); + }); + + after(async () => { + await Promise.all(manualRoomsToClose.map((room) => closeOmnichannelRoom(room._id))); + await Promise.all(manualVisitorsToDelete.map((visitor) => deleteVisitor(visitor.token))); + await Promise.all([ + deleteUser(manualUser.user), + updateEESetting('Livechat_maximum_chats_per_agent', 0), + updateEESetting('Livechat_waiting_queue', false), + updateSetting('Livechat_Routing_Method', 'Auto_Selection'), + deleteDepartment(manualDepartment._id), + deleteDepartment(manualDepartment2._id), + ]); + await updateSetting('Omnichannel_enable_department_removal', false); + }); + + describe('when agent limit is 1 and has 0 chats', () => { + let room: IOmnichannelRoom; + let visitor: ILivechatVisitor; + + before(async () => { + visitor = await createVisitor(manualDepartment._id); + room = await createLivechatRoom(visitor.token); + manualVisitorsToDelete.push(visitor); + manualRoomsToClose.push(room); + }); + + it('should allow agent to manually take the inquiry', async () => { + const inquiry = await fetchInquiry(room._id); + await takeInquiry(inquiry._id, manualUser.credentials); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(manualUser.user._id); + }); + }); + + describe('when agent limit is 1 and has 1 chat', () => { + let room: IOmnichannelRoom; + let visitor: ILivechatVisitor; + + before(async () => { + visitor = await createVisitor(manualDepartment._id); + room = await createLivechatRoom(visitor.token); + manualVisitorsToDelete.push(visitor); + manualRoomsToClose.push(room); + }); + + it('should not allow agent to manually take the inquiry', async () => { + const inquiry = await fetchInquiry(room._id); + + await request + .post(api('livechat/inquiries.take')) + .set(manualUser.credentials) + .send({ userId: manualUser.user._id, inquiryId: inquiry._id, options: { clientAction: true } }) + .expect(400); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.undefined; + }); + + describe('when agent limit is increased to 2', () => { + before(async () => { + await updateLivechatSettingsForUser(manualUser.user._id, { maxNumberSimultaneousChat: 2 }, [ + manualDepartment._id, + manualDepartment2._id, + ]); + }); + + it('should allow agent to take the pending inquiry', async () => { + const inquiry = await fetchInquiry(room._id); + await takeInquiry(inquiry._id, manualUser.credentials); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(manualUser.user._id); + }); + }); + }); + + describe('when agent limit is 2 and already has 2 chats on department A', () => { + let room: IOmnichannelRoom; + let visitor: ILivechatVisitor; + + before(async () => { + visitor = await createVisitor(manualDepartment2._id); + room = await createLivechatRoom(visitor.token); + manualVisitorsToDelete.push(visitor); + manualRoomsToClose.push(room); + }); + + it('should not allow agent to take inquiry on department B', async () => { + const inquiry = await fetchInquiry(room._id); + + await request + .post(api('livechat/inquiries.take')) + .set(manualUser.credentials) + .send({ userId: manualUser.user._id, inquiryId: inquiry._id, options: { clientAction: true } }) + .expect(400); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.undefined; + }); + + describe('when agent limit is increased to 3', () => { + before(async () => { + await updateLivechatSettingsForUser(manualUser.user._id, { maxNumberSimultaneousChat: 3 }, [ + manualDepartment._id, + manualDepartment2._id, + ]); + }); + + it('should allow agent to take the pending inquiry on department B', async () => { + const inquiry = await fetchInquiry(room._id); + await takeInquiry(inquiry._id, manualUser.credentials); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(manualUser.user._id); + }); + }); + }); + + describe('when agent limit is 0 and department B limit is 2 (agent has 3 chats)', () => { + let room: IOmnichannelRoom; + let visitor: ILivechatVisitor; + + before(async () => { + await updateLivechatSettingsForUser(manualUser.user._id, { maxNumberSimultaneousChat: 0 }, [ + manualDepartment._id, + manualDepartment2._id, + ]); + + visitor = await createVisitor(manualDepartment2._id); + room = await createLivechatRoom(visitor.token); + manualVisitorsToDelete.push(visitor); + manualRoomsToClose.push(room); + }); + + it('should allow agent to take inquiry on department B', async () => { + const inquiry = await fetchInquiry(room._id); + await takeInquiry(inquiry._id, manualUser.credentials); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(manualUser.user._id); + }); + }); + + describe('when agent has 4 chats and 2 on department B (at department limit)', () => { + let room: IOmnichannelRoom; + let visitor: ILivechatVisitor; + + before(async () => { + visitor = await createVisitor(manualDepartment2._id); + room = await createLivechatRoom(visitor.token); + manualVisitorsToDelete.push(visitor); + manualRoomsToClose.push(room); + }); + + it('should not allow agent to take inquiry on department B', async () => { + const inquiry = await fetchInquiry(room._id); + + await request + .post(api('livechat/inquiries.take')) + .set(manualUser.credentials) + .send({ userId: manualUser.user._id, inquiryId: inquiry._id, options: { clientAction: true } }) + .expect(400); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.undefined; + }); + + describe('when global limit is set to 6', () => { + before(async () => { + await updateEESetting('Livechat_maximum_chats_per_agent', 6); + }); + + it('should not allow agent to take inquiry on department B even if global limit allows it', async () => { + const inquiry = await fetchInquiry(room._id); + + await request + .post(api('livechat/inquiries.take')) + .set(manualUser.credentials) + .send({ userId: manualUser.user._id, inquiryId: inquiry._id, options: { clientAction: true } }) + .expect(400); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.undefined; + }); + + describe('when department B limit is removed', () => { + before(async () => { + await updateDepartment({ + departmentId: manualDepartment2._id, + opts: { maxNumberSimultaneousChat: 0 }, + userCredentials: credentials, + }); + }); + + it('should allow agent to take the pending inquiry', async () => { + const inquiry = await fetchInquiry(room._id); + await takeInquiry(inquiry._id, manualUser.credentials); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(manualUser.user._id); + }); + }); + }); + }); + + describe('when agent has 5 chats, global limit is 6, department limit is 0', () => { + let room: IOmnichannelRoom; + let visitor: ILivechatVisitor; + + before(async () => { + visitor = await createVisitor(manualDepartment2._id); + room = await createLivechatRoom(visitor.token); + manualVisitorsToDelete.push(visitor); + manualRoomsToClose.push(room); + }); + + it('should allow agent to take inquiry on department B', async () => { + const inquiry = await fetchInquiry(room._id); + await takeInquiry(inquiry._id, manualUser.credentials); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(manualUser.user._id); + }); + }); + + describe('when agent has 6 chats, global limit is 6, department limit is 0', () => { + let room: IOmnichannelRoom; + let visitor: ILivechatVisitor; + + before(async () => { + visitor = await createVisitor(manualDepartment2._id); + room = await createLivechatRoom(visitor.token); + manualVisitorsToDelete.push(visitor); + manualRoomsToClose.push(room); + }); + + it('should not allow agent to take inquiry on department B', async () => { + const inquiry = await fetchInquiry(room._id); + + await request + .post(api('livechat/inquiries.take')) + .set(manualUser.credentials) + .send({ userId: manualUser.user._id, inquiryId: inquiry._id, options: { clientAction: true } }) + .expect(400); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.undefined; + }); + + describe('when global limit is removed', () => { + before(async () => { + await updateEESetting('Livechat_maximum_chats_per_agent', 0); + }); + + it('should allow agent to take the pending inquiry', async () => { + const inquiry = await fetchInquiry(room._id); + await takeInquiry(inquiry._id, manualUser.credentials); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(manualUser.user._id); + }); + }); + }); + + describe('when department A limit is still 2 (agent has 7 chats)', () => { + let room: IOmnichannelRoom; + let visitor: ILivechatVisitor; + + before(async () => { + visitor = await createVisitor(manualDepartment._id); + room = await createLivechatRoom(visitor.token); + manualVisitorsToDelete.push(visitor); + manualRoomsToClose.push(room); + }); + + it('should not allow agent to take inquiry on department A', async () => { + const inquiry = await fetchInquiry(room._id); + + await request + .post(api('livechat/inquiries.take')) + .set(manualUser.credentials) + .send({ userId: manualUser.user._id, inquiryId: inquiry._id, options: { clientAction: true } }) + .expect(400); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.undefined; + }); + + describe('when department A limit is removed', () => { + before(async () => { + await updateDepartment({ + departmentId: manualDepartment._id, + opts: { maxNumberSimultaneousChat: 0 }, + userCredentials: credentials, + }); + }); + + it('should allow agent to take the pending inquiry on department A', async () => { + const inquiry = await fetchInquiry(room._id); + await takeInquiry(inquiry._id, manualUser.credentials); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(manualUser.user._id); + }); + }); + }); + + describe('when agent limit is set to 4 and global limit is high (agent has 8 chats)', () => { + let room: IOmnichannelRoom; + let visitor: ILivechatVisitor; + + before(async () => { + await updateEESetting('Livechat_maximum_chats_per_agent', 100000); + await updateLivechatSettingsForUser(manualUser.user._id, { maxNumberSimultaneousChat: 4 }, [ + manualDepartment._id, + manualDepartment2._id, + ]); + + visitor = await createVisitor(manualDepartment._id); + room = await createLivechatRoom(visitor.token); + manualVisitorsToDelete.push(visitor); + manualRoomsToClose.push(room); + }); + + it('should honor agent limit over global limit and not allow taking inquiry', async () => { + const inquiry = await fetchInquiry(room._id); + + await request + .post(api('livechat/inquiries.take')) + .set(manualUser.credentials) + .send({ userId: manualUser.user._id, inquiryId: inquiry._id, options: { clientAction: true } }) + .expect(400); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.undefined; + }); + + describe('when agent limit is removed', () => { + before(async () => { + await updateLivechatSettingsForUser(manualUser.user._id, { maxNumberSimultaneousChat: 0 }, [ + manualDepartment._id, + manualDepartment2._id, + ]); + }); + + it('should allow agent to take the pending inquiry', async () => { + const inquiry = await fetchInquiry(room._id); + await takeInquiry(inquiry._id, manualUser.credentials); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(manualUser.user._id); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/methods.ts b/apps/meteor/tests/end-to-end/api/methods.ts index 020840d05f736..d233542082a21 100644 --- a/apps/meteor/tests/end-to-end/api/methods.ts +++ b/apps/meteor/tests/end-to-end/api/methods.ts @@ -8,7 +8,7 @@ import { retry } from './helpers/retry'; import { api, credentials, getCredentials, methodCall, request } from '../../data/api-data'; import { sendSimpleMessage } from '../../data/chat.helper'; import { CI_MAX_ROOMS_PER_GUEST as maxRoomsPerGuest } from '../../data/constants'; -import { closeOmnichannelRoom, createAgent, createLivechatRoom, createVisitor } from '../../data/livechat/rooms'; +import { closeOmnichannelRoom, createAgent, createLivechatRoom, createVisitor, makeAgentAvailable } from '../../data/livechat/rooms'; import { updatePermission, updateSetting } from '../../data/permissions.helper'; import { createRoom, deleteRoom } from '../../data/rooms.helper'; import { password } from '../../data/user'; @@ -3389,6 +3389,10 @@ describe('Meteor.methods', () => { let userCredentials: Credentials; before(async () => { + await updateSetting('Livechat_enabled', true); + await createAgent(); + await makeAgentAvailable(); + const visitor = await createVisitor(); room = await createLivechatRoom(visitor.token); await closeOmnichannelRoom(room._id); @@ -3398,7 +3402,7 @@ describe('Meteor.methods', () => { userCredentials = await login(user.username, password); }); - after(() => Promise.all([deleteUser(user)])); + after(() => Promise.all([deleteUser(user), updateSetting('Livechat_enabled', false)])); it('should not allow an agent to join a closed livechat room', async () => { await request diff --git a/apps/meteor/tests/end-to-end/api/push.ts b/apps/meteor/tests/end-to-end/api/push.ts index 8319862fa5a7f..a5a8183b1a614 100644 --- a/apps/meteor/tests/end-to-end/api/push.ts +++ b/apps/meteor/tests/end-to-end/api/push.ts @@ -8,9 +8,46 @@ describe('[Push]', () => { before((done) => getCredentials(done)); describe('POST [/push.token]', () => { + it('should succeed with a valid gcm token', async () => { + await request + .post(api('push.token')) + .set(credentials) + .send({ + type: 'gcm', + value: 'token', + appName: 'com.example.rocketchat', + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('result').and.to.be.an('object'); + }); + }); + + it('should succeed with a valid apn token', async () => { + await request + .post(api('push.token')) + .set(credentials) + .send({ + type: 'apn', + value: 'token', + appName: 'com.example.rocketchat', + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('result').and.to.be.an('object'); + }); + }); + it('should fail if not logged in', async () => { await request .post(api('push.token')) + .send({ + type: 'gcm', + value: 'token', + appName: 'com.example.rocketchat', + }) .expect(401) .expect((res) => { expect(res.body).to.have.property('status', 'error'); @@ -29,7 +66,8 @@ describe('[Push]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('errorType', 'error-type-param-not-valid'); + expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('error').that.includes(`must have required property 'type'`); }); }); @@ -44,7 +82,8 @@ describe('[Push]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('errorType', 'error-token-param-not-valid'); + expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('error').that.includes(`must have required property 'value'`); }); }); @@ -59,7 +98,8 @@ describe('[Push]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('errorType', 'error-appName-param-not-valid'); + expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('error').that.includes(`must have required property 'appName'`); }); }); @@ -69,11 +109,14 @@ describe('[Push]', () => { .set(credentials) .send({ type: 'unknownPlatform', + value: 'token', + appName: 'com.example.rocketchat', }) .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('errorType', 'error-type-param-not-valid'); + expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('error').that.includes(`must be equal to one of the allowed values`); }); }); @@ -89,23 +132,8 @@ describe('[Push]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('errorType', 'error-token-param-not-valid'); - }); - }); - - it('should add a token if valid', async () => { - await request - .post(api('push.token')) - .set(credentials) - .send({ - type: 'gcm', - value: 'token', - appName: 'com.example.rocketchat', - }) - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - expect(res.body).to.have.property('result').and.to.be.an('object'); + expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('error').that.includes(`must NOT have fewer than 1 characters`); }); }); }); @@ -132,7 +160,8 @@ describe('[Push]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('errorType', 'error-token-param-not-valid'); + expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('error').that.includes(`must have required property 'token'`); }); }); @@ -146,7 +175,8 @@ describe('[Push]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('errorType', 'error-token-param-not-valid'); + expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('error').that.includes(`must NOT have fewer than 1 characters`); }); }); diff --git a/apps/meteor/tests/end-to-end/api/rooms.ts b/apps/meteor/tests/end-to-end/api/rooms.ts index f20b56e563df7..8bf1bb3ab08c7 100644 --- a/apps/meteor/tests/end-to-end/api/rooms.ts +++ b/apps/meteor/tests/end-to-end/api/rooms.ts @@ -2421,10 +2421,12 @@ describe('[Rooms]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('error', "The 'roomId' param is required"); + expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('error').include("must have required property 'roomId'"); }) .end(done); }); + it('should delete a room when the request is correct', (done) => { void request .post(api('rooms.delete')) diff --git a/apps/meteor/tests/end-to-end/api/users.ts b/apps/meteor/tests/end-to-end/api/users.ts index c377f977e8716..049429291babe 100644 --- a/apps/meteor/tests/end-to-end/api/users.ts +++ b/apps/meteor/tests/end-to-end/api/users.ts @@ -154,14 +154,17 @@ const registerUser = async ( name?: string; pass?: string; } = {}, - overrideCredentials = credentials, + overrideCredentials: Credentials | null = credentials, ) => { const username = userData.username || `user.test.${Date.now()}`; const email = userData.email || `${username}@rocket.chat`; - const result = await request - .post(api('users.register')) - .set(overrideCredentials) - .send({ email, name: username, username, pass: password, ...userData }); + + const req = request.post(api('users.register')); + + if (overrideCredentials) { + req.set(overrideCredentials); + } + const result = await req.send({ email, name: username, username, pass: password, ...userData }); return result.body.user; }; @@ -883,6 +886,25 @@ describe('[Users]', () => { }) .end(done); }); + + it('should return an error when logged in user tries to register', (done) => { + void request + .post(api('users.register')) + .set(credentials) + .send({ + email: `newuser${Date.now()}@email.com`, + name: 'New User', + username: `newuser${Date.now()}`, + pass: 'P@ssw0rd1234.!', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error').and.to.be.equal('Logged in users can not register again.'); + }) + .end(done); + }); }); describe('[/users.info]', () => { @@ -3358,12 +3380,15 @@ describe('[Users]', () => { let userCredentials: Credentials; before(async () => { - targetUser = await registerUser({ - email: `${testUsername}.@test.com`, - username: `${testUsername}test`, - name: testUsername, - pass: password, - }); + targetUser = await registerUser( + { + email: `${testUsername}.@test.com`, + username: `${testUsername}test`, + name: testUsername, + pass: password, + }, + null, + ); userCredentials = await login(targetUser.username, password); }); @@ -3388,7 +3413,7 @@ describe('[Users]', () => { let userCredentials: Credentials; before(async () => { - targetUser = await registerUser(); + targetUser = await registerUser(undefined, null); userCredentials = await login(targetUser.username, password); }); @@ -3456,7 +3481,7 @@ describe('[Users]', () => { let userCredentials: Credentials; before(async () => { - targetUser = await registerUser(); + targetUser = await registerUser(undefined, null); userCredentials = await login(targetUser.username, password); }); @@ -3634,7 +3659,7 @@ describe('[Users]', () => { let targetUser: TestUser; let room: IRoom; beforeEach(async () => { - targetUser = await registerUser(); + targetUser = await registerUser(undefined, null); room = ( await createRoom({ type: 'c', diff --git a/apps/meteor/tests/unit/app/lib/server/functions/extractMentionsFromMessageAST.spec.ts b/apps/meteor/tests/unit/app/lib/server/functions/extractMentionsFromMessageAST.spec.ts new file mode 100644 index 0000000000000..5d4177a0f77ad --- /dev/null +++ b/apps/meteor/tests/unit/app/lib/server/functions/extractMentionsFromMessageAST.spec.ts @@ -0,0 +1,396 @@ +import type { Root } from '@rocket.chat/message-parser'; +import { expect } from 'chai'; + +import { extractMentionsFromMessageAST } from '../../../../../../app/lib/server/functions/extractMentionsFromMessageAST'; + +describe('extractMentionsFromMessageAST', () => { + it('should return empty arrays when AST has no mentions', () => { + const ast: Root = [ + { + type: 'PARAGRAPH', + value: [ + { + type: 'PLAIN_TEXT', + value: 'Hello world', + }, + ], + }, + ]; + + const result = extractMentionsFromMessageAST(ast); + + expect(result.mentions).to.be.an('array').that.is.empty; + expect(result.channels).to.be.an('array').that.is.empty; + }); + + it('should extract user mentions from AST', () => { + const ast: Root = [ + { + type: 'PARAGRAPH', + value: [ + { + type: 'PLAIN_TEXT', + value: 'Hello ', + }, + { + type: 'MENTION_USER', + value: { + type: 'PLAIN_TEXT', + value: 'john.doe', + }, + }, + ], + }, + ]; + + const result = extractMentionsFromMessageAST(ast); + + expect(result.mentions).to.deep.equal(['john.doe']); + expect(result.channels).to.be.an('array').that.is.empty; + }); + + it('should extract channel mentions from AST', () => { + const ast: Root = [ + { + type: 'PARAGRAPH', + value: [ + { + type: 'PLAIN_TEXT', + value: 'Check ', + }, + { + type: 'MENTION_CHANNEL', + value: { + type: 'PLAIN_TEXT', + value: 'general', + }, + }, + ], + }, + ]; + + const result = extractMentionsFromMessageAST(ast); + + expect(result.mentions).to.be.an('array').that.is.empty; + expect(result.channels).to.deep.equal(['general']); + }); + + it('should extract both user and channel mentions', () => { + const ast: Root = [ + { + type: 'PARAGRAPH', + value: [ + { + type: 'MENTION_USER', + value: { + type: 'PLAIN_TEXT', + value: 'admin', + }, + }, + { + type: 'PLAIN_TEXT', + value: ' please check ', + }, + { + type: 'MENTION_CHANNEL', + value: { + type: 'PLAIN_TEXT', + value: 'support', + }, + }, + ], + }, + ]; + + const result = extractMentionsFromMessageAST(ast); + + expect(result.mentions).to.deep.equal(['admin']); + expect(result.channels).to.deep.equal(['support']); + }); + + it('should extract multiple user mentions', () => { + const ast: Root = [ + { + type: 'PARAGRAPH', + value: [ + { + type: 'MENTION_USER', + value: { + type: 'PLAIN_TEXT', + value: 'user1', + }, + }, + { + type: 'PLAIN_TEXT', + value: ' and ', + }, + { + type: 'MENTION_USER', + value: { + type: 'PLAIN_TEXT', + value: 'user2', + }, + }, + ], + }, + ]; + + const result = extractMentionsFromMessageAST(ast); + + expect(result.mentions).to.have.members(['user1', 'user2']); + expect(result.mentions).to.have.lengthOf(2); + }); + + it('should deduplicate repeated mentions', () => { + const ast: Root = [ + { + type: 'PARAGRAPH', + value: [ + { + type: 'MENTION_USER', + value: { + type: 'PLAIN_TEXT', + value: 'admin', + }, + }, + { + type: 'PLAIN_TEXT', + value: ' hello ', + }, + { + type: 'MENTION_USER', + value: { + type: 'PLAIN_TEXT', + value: 'admin', + }, + }, + ], + }, + ]; + + const result = extractMentionsFromMessageAST(ast); + + expect(result.mentions).to.deep.equal(['admin']); + }); + + it('should extract mentions from nested structures like blockquotes', () => { + const ast: Root = [ + { + type: 'QUOTE', + value: [ + { + type: 'PARAGRAPH', + value: [ + { + type: 'MENTION_USER', + value: { + type: 'PLAIN_TEXT', + value: 'quoted.user', + }, + }, + ], + }, + ], + }, + ]; + + const result = extractMentionsFromMessageAST(ast); + + expect(result.mentions).to.deep.equal(['quoted.user']); + }); + + it('should extract mentions from bold text', () => { + const ast: Root = [ + { + type: 'PARAGRAPH', + value: [ + { + type: 'BOLD', + value: [ + { + type: 'MENTION_USER', + value: { + type: 'PLAIN_TEXT', + value: 'bold.user', + }, + }, + ], + }, + ], + }, + ]; + + const result = extractMentionsFromMessageAST(ast); + + expect(result.mentions).to.deep.equal(['bold.user']); + }); + + it('should extract mentions from italic text', () => { + const ast: Root = [ + { + type: 'PARAGRAPH', + value: [ + { + type: 'ITALIC', + value: [ + { + type: 'MENTION_CHANNEL', + value: { + type: 'PLAIN_TEXT', + value: 'italic-channel', + }, + }, + ], + }, + ], + }, + ]; + + const result = extractMentionsFromMessageAST(ast); + + expect(result.channels).to.deep.equal(['italic-channel']); + }); + + it('should handle special mentions like all and here', () => { + const ast: Root = [ + { + type: 'PARAGRAPH', + value: [ + { + type: 'MENTION_USER', + value: { + type: 'PLAIN_TEXT', + value: 'all', + }, + }, + { + type: 'PLAIN_TEXT', + value: ' and ', + }, + { + type: 'MENTION_USER', + value: { + type: 'PLAIN_TEXT', + value: 'here', + }, + }, + ], + }, + ]; + + const result = extractMentionsFromMessageAST(ast); + + expect(result.mentions).to.have.members(['all', 'here']); + }); + + it('should handle empty AST', () => { + const ast: Root = []; + + const result = extractMentionsFromMessageAST(ast); + + expect(result.mentions).to.be.an('array').that.is.empty; + expect(result.channels).to.be.an('array').that.is.empty; + }); + + it('should extract mentions from list items', () => { + const ast: Root = [ + { + type: 'UNORDERED_LIST', + value: [ + { + type: 'LIST_ITEM', + value: [ + { + type: 'MENTION_USER', + value: { + type: 'PLAIN_TEXT', + value: 'list.user', + }, + }, + ], + }, + ], + }, + ]; + + const result = extractMentionsFromMessageAST(ast); + + expect(result.mentions).to.deep.equal(['list.user']); + }); + + it('should extract mentions from tasks', () => { + const ast: Root = [ + { + type: 'TASKS', + value: [ + { + type: 'TASK', + status: false, + value: [ + { + type: 'MENTION_USER', + value: { + type: 'PLAIN_TEXT', + value: 'task.assignee', + }, + }, + ], + }, + ], + }, + ]; + + const result = extractMentionsFromMessageAST(ast); + + expect(result.mentions).to.deep.equal(['task.assignee']); + }); + + it('should handle BigEmoji AST structure', () => { + const ast: Root = [ + { + type: 'BIG_EMOJI', + value: [ + { + type: 'EMOJI', + value: { + type: 'PLAIN_TEXT', + value: 'smile', + }, + shortCode: ':smile:', + }, + ], + }, + ]; + + const result = extractMentionsFromMessageAST(ast); + + expect(result.mentions).to.be.an('array').that.is.empty; + expect(result.channels).to.be.an('array').that.is.empty; + }); + + it('should extract mentions from spoiler blocks', () => { + const ast: Root = [ + { + type: 'SPOILER_BLOCK', + value: [ + { + type: 'PARAGRAPH', + value: [ + { + type: 'MENTION_USER', + value: { + type: 'PLAIN_TEXT', + value: 'hidden.user', + }, + }, + ], + }, + ], + }, + ]; + + const result = extractMentionsFromMessageAST(ast); + + expect(result.mentions).to.deep.equal(['hidden.user']); + }); +}); diff --git a/apps/meteor/tests/unit/server/modules/streamer/streamer.module.spec.ts b/apps/meteor/tests/unit/server/modules/streamer/streamer.module.spec.ts new file mode 100644 index 0000000000000..2cf64b7857a97 --- /dev/null +++ b/apps/meteor/tests/unit/server/modules/streamer/streamer.module.spec.ts @@ -0,0 +1,112 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; + +import { Streamer, StreamerCentral } from '../../../../../server/modules/streamer/streamer.module'; + +class TestStreamer extends Streamer { + registerPublication(): void { + // no-op for unit test subclass + } + + registerMethod(): void { + // no-op for unit test subclass + } + + changedPayload(): string { + return 'payload'; + } +} + +type TestSubscription = { + entry: any; + connection: Record; + send: sinon.SinonSpy; +}; + +const makeSubscription = (connectionId: string): TestSubscription => { + const send = sinon.spy(); + const connection = { id: connectionId }; + + return { + connection, + send, + entry: { + eventName: 'event', + subscription: { + connection, + _session: { + socket: { send }, + }, + }, + }, + }; +}; + +describe('Streamer.sendToManySubscriptions', () => { + let streamer: TestStreamer; + let streamerNameSeed = 0; + + beforeEach(() => { + streamer = new TestStreamer(`streamer-test-${streamerNameSeed++}`); + }); + + afterEach(() => { + sinon.restore(); + delete StreamerCentral.instances[streamer.name]; + }); + + it('waits for async permission checks before resolving', async () => { + const sub = makeSubscription('conn-1'); + + const isEmitAllowed = sinon.stub(streamer, 'isEmitAllowed').resolves(true); + + const sendPromise = streamer.sendToManySubscriptions(new Set([sub.entry]), undefined, 'event', [], 'test-msg'); + + expect(sub.send.called).to.equal(false); + + await sendPromise; + + expect(isEmitAllowed.calledOnceWithExactly(sub.entry.subscription, 'event')).to.equal(true); + expect(sub.send.calledOnceWithExactly('test-msg')).to.equal(true); + }); + + it('skips origin subscription and sends only to allowed subscriptions', async () => { + const originSub = makeSubscription('origin'); + const allowedSub = makeSubscription('allowed'); + const deniedSub = makeSubscription('denied'); + + const isEmitAllowed = sinon.stub(streamer, 'isEmitAllowed'); + isEmitAllowed.onFirstCall().resolves(true); + isEmitAllowed.onSecondCall().resolves(false); + + await streamer.sendToManySubscriptions( + new Set([originSub.entry, allowedSub.entry, deniedSub.entry]), + originSub.connection as any, + 'event', + [], + 'test-msg', + ); + + expect(originSub.send.called).to.equal(false); + expect(allowedSub.send.calledOnceWithExactly('test-msg')).to.equal(true); + expect(deniedSub.send.called).to.equal(false); + }); + + it('continues dispatching to other subscribers when a permission check rejects', async () => { + const failingSub = makeSubscription('failing'); + const successSub = makeSubscription('success'); + const error = new Error('boom'); + + const isEmitAllowed = sinon.stub(streamer, 'isEmitAllowed'); + isEmitAllowed.onFirstCall().rejects(error); + isEmitAllowed.onSecondCall().resolves(true); + + await streamer.sendToManySubscriptions(new Set([failingSub.entry, successSub.entry]), undefined, 'event-name', [], 'test-msg'); + + expect(isEmitAllowed.calledTwice).to.equal(true); + expect(isEmitAllowed.firstCall.calledWithExactly(failingSub.entry.subscription, 'event-name')).to.equal(true); + expect(isEmitAllowed.secondCall.calledWithExactly(successSub.entry.subscription, 'event-name')).to.equal(true); + expect(failingSub.send.called).to.equal(false); + expect(successSub.send.calledOnceWithExactly('test-msg')).to.equal(true); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/team/service.tests.ts b/apps/meteor/tests/unit/server/services/team/service.tests.ts new file mode 100644 index 0000000000000..d06c34b8d46d0 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/team/service.tests.ts @@ -0,0 +1,118 @@ +import { expect } from 'chai'; +import { beforeEach, describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const Rooms = { + findDefaultRoomsForTeam: sinon.stub(), +}; + +const Users = { + findActiveByIds: sinon.stub(), +}; + +const addUserToRoom = sinon.stub(); + +const { TeamService } = proxyquire.noCallThru().load('../../../../../server/services/team/service', { + '@rocket.chat/core-services': { + Room: {}, + Authorization: {}, + Message: {}, + ServiceClassInternal: class {}, + api: {}, + }, + '@rocket.chat/models': { + Team: {}, + Rooms, + Subscriptions: {}, + Users, + TeamMember: {}, + }, + '@rocket.chat/string-helpers': { + escapeRegExp: (value: string) => value, + }, + '../../../app/channel-settings/server': { + saveRoomName: sinon.stub(), + }, + '../../../app/channel-settings/server/functions/saveRoomType': { + saveRoomType: sinon.stub(), + }, + '../../../app/lib/server/functions/addUserToRoom': { + addUserToRoom, + }, + '../../../app/lib/server/functions/checkUsernameAvailability': { + checkUsernameAvailability: sinon.stub(), + }, + '../../../app/lib/server/functions/getRoomsWithSingleOwner': { + getSubscribedRoomsForUserWithDetails: sinon.stub(), + }, + '../../../app/lib/server/functions/removeUserFromRoom': { + removeUserFromRoom: sinon.stub(), + }, + '../../../app/lib/server/lib/notifyListener': { + notifyOnSubscriptionChangedByRoomIdAndUserId: sinon.stub(), + notifyOnRoomChangedById: sinon.stub(), + }, + '../../../app/settings/server': { + settings: { get: sinon.stub() }, + }, +}); + +const service = new TeamService(); + +describe('Team service', () => { + beforeEach(() => { + addUserToRoom.reset(); + Rooms.findDefaultRoomsForTeam.reset(); + Users.findActiveByIds.reset(); + }); + + it('should wait for default room membership operations to finish', async function () { + this.timeout(15000); + + addUserToRoom.onFirstCall().resolves(true); + addUserToRoom.onSecondCall().returns( + new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, 20); + }), + ); + + Rooms.findDefaultRoomsForTeam.returns({ + toArray: () => Promise.resolve([{ _id: 'default-room' }]), + }); + Users.findActiveByIds.returns({ + toArray: () => + Promise.resolve([ + { _id: 'user-1', username: 'user-1' }, + { _id: 'user-2', username: 'user-2' }, + ]), + }); + + await service.addMembersToDefaultRooms({ _id: 'inviter', username: 'inviter' }, 'team-id', [ + { userId: 'user-1' }, + { userId: 'user-2' }, + ]); + + expect(addUserToRoom.callCount).to.equal(2); + }); + + it('should propagate errors from default room membership operations', async function () { + this.timeout(15000); + + addUserToRoom.rejects(new Error('room-add-failed')); + Rooms.findDefaultRoomsForTeam.returns({ + toArray: () => Promise.resolve([{ _id: 'default-room' }]), + }); + Users.findActiveByIds.returns({ + toArray: () => Promise.resolve([{ _id: 'user-1', username: 'user-1' }]), + }); + + await expect( + service.addMembersToDefaultRooms({ _id: 'inviter', username: 'inviter' }, 'team-id', [{ userId: 'user-1' }]), + ).to.be.rejectedWith('room-add-failed'); + + expect(addUserToRoom.callCount).to.equal(1); + }); +}); diff --git a/apps/uikit-playground/.eslintrc.json b/apps/uikit-playground/.eslintrc.json deleted file mode 100644 index bf9b095684aee..0000000000000 --- a/apps/uikit-playground/.eslintrc.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "env": { "browser": true, "es2020": true }, - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended", - "plugin:react/recommended", - "plugin:react/jsx-runtime", - "plugin:react-hooks/recommended" - ], - "parser": "@typescript-eslint/parser", - "parserOptions": { "ecmaVersion": "latest", "sourceType": "module" }, - "plugins": ["react-refresh"], - "rules": { - "react-refresh/only-export-components": "warn" - }, - "ignorePatterns": [ - "dist", - "build", - "storybook-static", - "!.jest", - "!.storybook", - ".storybook/jest-results.json", - ".DS_Store", - ".env.local", - ".env.development.local", - ".env.test.local", - ".env.production.local", - "npm-debug.log*", - "yarn-debug.log*", - "yarn-error.log*" - ] -} diff --git a/apps/uikit-playground/.prettierrc b/apps/uikit-playground/.prettierrc deleted file mode 100644 index e97ea1bfabbaf..0000000000000 --- a/apps/uikit-playground/.prettierrc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "tabWidth": 2, - "useTabs": false, - "singleQuote": true -} diff --git a/apps/uikit-playground/CHANGELOG.md b/apps/uikit-playground/CHANGELOG.md index 8319dcdfd99e9..013b41ea66235 100644 --- a/apps/uikit-playground/CHANGELOG.md +++ b/apps/uikit-playground/CHANGELOG.md @@ -1,5 +1,19 @@ # @rocket.chat/uikit-playground +## 0.7.8-rc.0 + +### Patch Changes + +- ([#38989](https://github.com/RocketChat/Rocket.Chat/pull/38989)) chore(eslint): Upgrades ESLint and its configuration + +-
Updated dependencies [539659af22bc19880eda047dfc0b152472ccb65c, 722df6f60bc86c51b204e28a39acb3dc8710bdeb, c117492ad90d291a361eedc929506f557495caf7]: + + - @rocket.chat/fuselage-ui-kit@29.0.0-rc.0 + - @rocket.chat/core-typings@8.3.0-rc.0 + - @rocket.chat/ui-contexts@29.0.0-rc.0 + - @rocket.chat/ui-avatar@25.0.0-rc.0 +
+ ## 0.7.7 ### Patch Changes diff --git a/apps/uikit-playground/index.html b/apps/uikit-playground/index.html index 7b1100bf13878..751be9f3fedab 100644 --- a/apps/uikit-playground/index.html +++ b/apps/uikit-playground/index.html @@ -1,13 +1,13 @@ - + - - - - - UiKit-Playground - - -
- - + + + + + UiKit-Playground + + +
+ + diff --git a/apps/uikit-playground/package.json b/apps/uikit-playground/package.json index 8c18ab23a8261..18c993c455819 100644 --- a/apps/uikit-playground/package.json +++ b/apps/uikit-playground/package.json @@ -1,64 +1,59 @@ { - "name": "@rocket.chat/uikit-playground", - "version": "0.7.7", - "private": true, - "type": "module", - "scripts": { - ".:build-preview-move": "mkdir -p ../../.preview/ && cp -r ./dist ../../.preview/uikit-playground", - "build-preview": "tsc && vite build", - "dev": "vite", - "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", - "preview": "vite preview", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@codemirror/lang-javascript": "^6.2.4", - "@codemirror/lang-json": "^6.0.2", - "@hello-pangea/dnd": "^17.0.0", - "@lezer/highlight": "^1.2.3", - "@rocket.chat/core-typings": "workspace:^", - "@rocket.chat/css-in-js": "~0.31.25", - "@rocket.chat/fuselage": "^0.71.0", - "@rocket.chat/fuselage-hooks": "^0.39.0", - "@rocket.chat/fuselage-toastbar": "^0.35.2", - "@rocket.chat/fuselage-tokens": "~0.33.2", - "@rocket.chat/fuselage-ui-kit": "workspace:~", - "@rocket.chat/icons": "~0.46.0", - "@rocket.chat/logo": "^0.32.4", - "@rocket.chat/styled": "~0.32.0", - "@rocket.chat/ui-avatar": "workspace:^", - "@rocket.chat/ui-contexts": "workspace:~", - "codemirror": "^6.0.2", - "eslint4b-prebuilt": "^6.7.2", - "moment": "^2.30.1", - "prettier": "~3.3.3", - "rc-scrollbars": "^1.1.6", - "react": "~18.3.1", - "react-beautiful-dnd": "^13.1.1", - "react-dom": "~18.3.1", - "react-router-dom": "^6.30.3", - "react-split-pane": "^0.1.92", - "react-virtuoso": "^4.12.0", - "reactflow": "^11.11.4" - }, - "devDependencies": { - "@rocket.chat/emitter": "^0.32.0", - "@rocket.chat/tsconfig": "workspace:*", - "@types/lodash": "~4.17.23", - "@types/react": "~18.3.27", - "@types/react-beautiful-dnd": "^13.1.8", - "@types/react-dom": "~18.3.7", - "@typescript-eslint/eslint-plugin": "~5.60.1", - "@typescript-eslint/parser": "~5.60.1", - "@vitejs/plugin-react": "~4.5.2", - "eslint": "~8.45.0", - "eslint-plugin-react": "~7.37.5", - "eslint-plugin-react-hooks": "^5.0.0", - "eslint-plugin-react-refresh": "^0.4.26", - "typescript": "~5.9.3", - "vite": "^6.2.4" - }, - "volta": { - "extends": "../../package.json" - } + "name": "@rocket.chat/uikit-playground", + "version": "0.7.8-rc.0", + "private": true, + "type": "module", + "scripts": { + ".:build-preview-move": "mkdir -p ../../.preview/ && cp -r ./dist ../../.preview/uikit-playground", + "build-preview": "tsc && vite build", + "dev": "vite", + "lint": "eslint .", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-json": "^6.0.2", + "@hello-pangea/dnd": "^17.0.0", + "@lezer/highlight": "^1.2.3", + "@rocket.chat/core-typings": "workspace:^", + "@rocket.chat/css-in-js": "~0.31.25", + "@rocket.chat/fuselage": "^0.71.0", + "@rocket.chat/fuselage-hooks": "^0.39.0", + "@rocket.chat/fuselage-toastbar": "^0.35.2", + "@rocket.chat/fuselage-tokens": "~0.33.2", + "@rocket.chat/fuselage-ui-kit": "workspace:~", + "@rocket.chat/icons": "~0.46.0", + "@rocket.chat/logo": "^0.32.4", + "@rocket.chat/styled": "~0.32.0", + "@rocket.chat/ui-avatar": "workspace:^", + "@rocket.chat/ui-contexts": "workspace:~", + "codemirror": "^6.0.2", + "eslint4b-prebuilt": "^6.7.2", + "moment": "^2.30.1", + "prettier": "~3.3.3", + "rc-scrollbars": "^1.1.6", + "react": "~18.3.1", + "react-beautiful-dnd": "^13.1.1", + "react-dom": "~18.3.1", + "react-router-dom": "^6.30.3", + "react-split-pane": "^0.1.92", + "react-virtuoso": "^4.12.0", + "reactflow": "^11.11.4" + }, + "devDependencies": { + "@rocket.chat/emitter": "^0.32.0", + "@rocket.chat/tsconfig": "workspace:*", + "@types/lodash": "~4.17.23", + "@types/react": "~18.3.27", + "@types/react-beautiful-dnd": "^13.1.8", + "@types/react-dom": "~18.3.7", + "@vitejs/plugin-react": "~4.5.2", + "eslint": "~9.39.3", + "typescript": "~5.9.3", + "vite": "^6.2.4" + }, + "volta": { + "extends": "../../package.json" + } } diff --git a/apps/uikit-playground/src/App.tsx b/apps/uikit-playground/src/App.tsx index ed32f3c388fae..80655038904da 100644 --- a/apps/uikit-playground/src/App.tsx +++ b/apps/uikit-playground/src/App.tsx @@ -1,66 +1,58 @@ -import { useContext, useEffect } from 'react'; -import './App.css'; -import './_global.css'; -import './cssVariables.css'; +import { Box } from '@rocket.chat/fuselage'; +import { useMediaQueries } from '@rocket.chat/fuselage-hooks'; import { ToastBarProvider } from '@rocket.chat/fuselage-toastbar'; +import { useContext, useEffect } from 'react'; import { BrowserRouter, Route, Routes } from 'react-router-dom'; import { HomeLayout } from './Components/Routes/HomeLayout'; -import Playground from './Pages/Playground'; -import SignInToWorkspace from './Pages/SignInSignUp'; -import routes from './Routes/Routes'; -import Home from './Pages/Home'; +import { ProjectSpecificLayout } from './Components/Routes/ProjectSpecificLayout'; import { context, isMobileAction, isTabletAction } from './Context'; -import { useMediaQueries } from '@rocket.chat/fuselage-hooks'; -import { Box } from '@rocket.chat/fuselage'; import FlowDiagram from './Pages/FlowDiagram'; -import { ProjectSpecificLayout } from './Components/Routes/ProjectSpecificLayout'; +import Home from './Pages/Home'; +import Playground from './Pages/Playground'; import Prototype from './Pages/Prototype'; +import SignInToWorkspace from './Pages/SignInSignUp'; +import routes from './Routes/Routes'; + +import './App.css'; +import './_global.css'; +import './cssVariables.css'; function App() { - const { dispatch } = useContext(context); + const { dispatch } = useContext(context); - const [isMobile, isTablet] = useMediaQueries( - '(max-width: 630px)', - '(max-width: 1050px)' - ); + const [isMobile, isTablet] = useMediaQueries('(max-width: 630px)', '(max-width: 1050px)'); - useEffect(() => { - dispatch(isMobileAction(isMobile)); - }, [isMobile, dispatch]); + useEffect(() => { + dispatch(isMobileAction(isMobile)); + }, [isMobile, dispatch]); - useEffect(() => { - dispatch(isTabletAction(isTablet)); - }, [isTablet, dispatch]); - return ( - - - - - }> - } - /> - } - /> - - {/* }> */} - } /> - }> - } /> - } /> - } /> - - } /> - {/* */} - - - - - ); + useEffect(() => { + dispatch(isTabletAction(isTablet)); + }, [isTablet, dispatch]); + return ( + + + + + }> + } /> + } /> + + {/* }> */} + } /> + }> + } /> + } /> + } /> + + } /> + {/* */} + + + + + ); } export default App; diff --git a/apps/uikit-playground/src/Components/CodeEditor/BlockEditor.tsx b/apps/uikit-playground/src/Components/CodeEditor/BlockEditor.tsx index 15e3f7dcc8566..566fe8fc3bafd 100644 --- a/apps/uikit-playground/src/Components/CodeEditor/BlockEditor.tsx +++ b/apps/uikit-playground/src/Components/CodeEditor/BlockEditor.tsx @@ -5,64 +5,51 @@ import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; import { useEffect, useContext } from 'react'; import { updatePayloadAction, context } from '../../Context'; +import { type IPayload } from '../../Context/initialState'; import useCodeMirror from '../../hooks/useCodeMirror'; -import intendCode from '../../utils/intendCode'; -import { IPayload } from '../../Context/initialState'; import useFormatCodeMirrorValue from '../../hooks/useFormatCodeMirrorValue'; +import intendCode from '../../utils/intendCode'; type CodeMirrorProps = { - extensions?: Extension[]; + extensions?: Extension[]; }; const BlockEditor = ({ extensions }: CodeMirrorProps) => { - const { - state: { screens, activeScreen }, - dispatch, - } = useContext(context); - - const { editor, changes, setValue } = useCodeMirror( - extensions, - intendCode(screens[activeScreen]?.payload) - ); - const debounceValue = useDebouncedValue(changes, 1500); - - useFormatCodeMirrorValue( - ( - parsedCode: IPayload, - prettifiedCode: { formatted: string; cursorOffset: number } - ) => { - dispatch( - updatePayloadAction({ - blocks: parsedCode.blocks, - surface: parsedCode.surface, - }) - ); - setValue(prettifiedCode.formatted, { - cursor: prettifiedCode.cursorOffset, - }); - }, - debounceValue - ); - - useEffect(() => { - if (!screens[activeScreen]?.changedByEditor) { - setValue(intendCode(screens[activeScreen]?.payload), {}); - } - }, [ - screens[activeScreen]?.payload.blocks, - screens[activeScreen]?.payload.surface, - activeScreen, - ]); - - useEffect(() => { - setValue(intendCode(screens[activeScreen]?.payload), {}); - }, [activeScreen]); - - return ( - <> - - - ); + const { + state: { screens, activeScreen }, + dispatch, + } = useContext(context); + + const { editor, changes, setValue } = useCodeMirror(extensions, intendCode(screens[activeScreen]?.payload)); + const debounceValue = useDebouncedValue(changes, 1500); + + useFormatCodeMirrorValue((parsedCode: IPayload, prettifiedCode: { formatted: string; cursorOffset: number }) => { + dispatch( + updatePayloadAction({ + blocks: parsedCode.blocks, + surface: parsedCode.surface, + }), + ); + setValue(prettifiedCode.formatted, { + cursor: prettifiedCode.cursorOffset, + }); + }, debounceValue); + + useEffect(() => { + if (!screens[activeScreen]?.changedByEditor) { + setValue(intendCode(screens[activeScreen]?.payload), {}); + } + }, [screens[activeScreen]?.payload.blocks, screens[activeScreen]?.payload.surface, activeScreen]); + + useEffect(() => { + setValue(intendCode(screens[activeScreen]?.payload), {}); + }, [activeScreen]); + + return ( + <> + + + ); }; export default BlockEditor; diff --git a/apps/uikit-playground/src/Components/CodeEditor/Extensions/Extensions.ts b/apps/uikit-playground/src/Components/CodeEditor/Extensions/Extensions.ts index cb276ceaf2f96..709358dd6daa5 100644 --- a/apps/uikit-playground/src/Components/CodeEditor/Extensions/Extensions.ts +++ b/apps/uikit-playground/src/Components/CodeEditor/Extensions/Extensions.ts @@ -8,18 +8,18 @@ import jsonLinter from './jsonLinter'; import theme from './theme'; export const actionBlockExtensions = [ - highlightStyle, - json(), - jsonLinter, - basicSetup, - // payloadLinter, - ...theme, + highlightStyle, + json(), + jsonLinter, + basicSetup, + // payloadLinter, + ...theme, ]; export const actionPreviewExtensions = [ - EditorView.contentAttributes.of({ contenteditable: 'false' }), - highlightStyle, - json(), - basicSetup, - ...theme, + EditorView.contentAttributes.of({ contenteditable: 'false' }), + highlightStyle, + json(), + basicSetup, + ...theme, ]; diff --git a/apps/uikit-playground/src/Components/CodeEditor/Extensions/HighlightStyle.ts b/apps/uikit-playground/src/Components/CodeEditor/Extensions/HighlightStyle.ts index fc5aa5e9e9584..9ef0dc0d72b7f 100644 --- a/apps/uikit-playground/src/Components/CodeEditor/Extensions/HighlightStyle.ts +++ b/apps/uikit-playground/src/Components/CodeEditor/Extensions/HighlightStyle.ts @@ -2,14 +2,14 @@ import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'; import { tags as t } from '@lezer/highlight'; const highLightStyle = () => { - const style = HighlightStyle.define([ - { tag: t.literal, color: 'var(--RCPG-primary-color)' }, - { tag: t.bool, color: 'var(--RCPG-tertary-color)' }, - { tag: t.number, color: 'var(--RCPG-secondary-color)' }, - { tag: t.null, color: 'var(--RCPG-tertary-color)' }, - ]); + const style = HighlightStyle.define([ + { tag: t.literal, color: 'var(--RCPG-primary-color)' }, + { tag: t.bool, color: 'var(--RCPG-tertary-color)' }, + { tag: t.number, color: 'var(--RCPG-secondary-color)' }, + { tag: t.null, color: 'var(--RCPG-tertary-color)' }, + ]); - return syntaxHighlighting(style); + return syntaxHighlighting(style); }; export default highLightStyle(); diff --git a/apps/uikit-playground/src/Components/CodeEditor/Extensions/basicSetup.ts b/apps/uikit-playground/src/Components/CodeEditor/Extensions/basicSetup.ts index e5973a7922170..b3923dd95c21b 100644 --- a/apps/uikit-playground/src/Components/CodeEditor/Extensions/basicSetup.ts +++ b/apps/uikit-playground/src/Components/CodeEditor/Extensions/basicSetup.ts @@ -1,59 +1,35 @@ -import { - completionKeymap, - closeBrackets, - closeBracketsKeymap, -} from '@codemirror/autocomplete'; -import { - defaultKeymap, - history, - historyKeymap, - indentWithTab, -} from '@codemirror/commands'; -import { - defaultHighlightStyle, - syntaxHighlighting, - indentOnInput, - bracketMatching, - foldGutter, - foldKeymap, -} from '@codemirror/language'; +import { completionKeymap, closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete'; +import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'; +import { defaultHighlightStyle, syntaxHighlighting, indentOnInput, bracketMatching, foldGutter, foldKeymap } from '@codemirror/language'; import { lintKeymap } from '@codemirror/lint'; import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'; import type { Extension } from '@codemirror/state'; -import { - keymap, - drawSelection, - dropCursor, - rectangularSelection, - crosshairCursor, - lineNumbers, - EditorView, -} from '@codemirror/view'; +import { keymap, drawSelection, dropCursor, rectangularSelection, crosshairCursor, lineNumbers, EditorView } from '@codemirror/view'; const basicSetup: Extension = (() => [ - lineNumbers(), - history(), - foldGutter(), - drawSelection(), - dropCursor(), - indentOnInput(), - EditorView.lineWrapping, - syntaxHighlighting(defaultHighlightStyle, { fallback: true }), - bracketMatching(), - closeBrackets(), - rectangularSelection(), - crosshairCursor(), - highlightSelectionMatches(), - keymap.of([ - ...closeBracketsKeymap, - ...defaultKeymap, - ...searchKeymap, - ...historyKeymap, - ...foldKeymap, - ...completionKeymap, - ...lintKeymap, - indentWithTab, - ]), + lineNumbers(), + history(), + foldGutter(), + drawSelection(), + dropCursor(), + indentOnInput(), + EditorView.lineWrapping, + syntaxHighlighting(defaultHighlightStyle, { fallback: true }), + bracketMatching(), + closeBrackets(), + rectangularSelection(), + crosshairCursor(), + highlightSelectionMatches(), + keymap.of([ + ...closeBracketsKeymap, + ...defaultKeymap, + ...searchKeymap, + ...historyKeymap, + ...foldKeymap, + ...completionKeymap, + ...lintKeymap, + indentWithTab, + ]), ])(); export default basicSetup; diff --git a/apps/uikit-playground/src/Components/CodeEditor/Extensions/payloadLinter.ts b/apps/uikit-playground/src/Components/CodeEditor/Extensions/payloadLinter.ts index 5eccf262f23fd..25af57c410eb5 100644 --- a/apps/uikit-playground/src/Components/CodeEditor/Extensions/payloadLinter.ts +++ b/apps/uikit-playground/src/Components/CodeEditor/Extensions/payloadLinter.ts @@ -6,60 +6,55 @@ import type { EditorView } from 'codemirror'; import parsePayload from '../Parser'; const payloadLinter = linter((view: EditorView) => { - const diagnostics: Diagnostic[] = []; - const tree = syntaxTree(view.state); - let head = tree.topNode.firstChild; - if (!head || !head.matchContext(['Script'])) { - diagnostics.push({ - from: 0, - to: 0, - message: 'Expecting a Script', - severity: 'error', - }); - return diagnostics; - } - head = head.firstChild; - if (!head || !head.matchContext(['ExpressionStatement'])) { - diagnostics.push({ - from: 0, - to: 0, - message: 'Expecting an expression statement', - severity: 'error', - }); - return diagnostics; - } - head = head.firstChild; - if (!head || !head.matchContext(['ArrayExpression'])) { - diagnostics.push({ - from: 0, - to: 0, - message: 'Expecting an array expression', - severity: 'error', - }); - return diagnostics; - } - // while (head.nextSibling && head.nextSibling.name !== ']') { - // } - do { - if ( - head.name !== '[' && - head.name !== ',' && - head.name !== ']' && - head.name !== 'ObjectExpression' - ) { - diagnostics.push({ - from: head.from, - to: head.to, - message: 'Expecting an Object expression', - severity: 'error', - }); - return diagnostics; - } - if (head.name === 'ObjectExpression') parsePayload(head, view); - head = head.nextSibling; - } while (head); + const diagnostics: Diagnostic[] = []; + const tree = syntaxTree(view.state); + let head = tree.topNode.firstChild; + if (!head?.matchContext(['Script'])) { + diagnostics.push({ + from: 0, + to: 0, + message: 'Expecting a Script', + severity: 'error', + }); + return diagnostics; + } + head = head.firstChild; + if (!head?.matchContext(['ExpressionStatement'])) { + diagnostics.push({ + from: 0, + to: 0, + message: 'Expecting an expression statement', + severity: 'error', + }); + return diagnostics; + } + head = head.firstChild; + if (!head?.matchContext(['ArrayExpression'])) { + diagnostics.push({ + from: 0, + to: 0, + message: 'Expecting an array expression', + severity: 'error', + }); + return diagnostics; + } + // while (head.nextSibling && head.nextSibling.name !== ']') { + // } + do { + if (head.name !== '[' && head.name !== ',' && head.name !== ']' && head.name !== 'ObjectExpression') { + diagnostics.push({ + from: head.from, + to: head.to, + message: 'Expecting an Object expression', + severity: 'error', + }); + return diagnostics; + } + if (head.name === 'ObjectExpression') parsePayload(head, view); + head = head.nextSibling; + } while (head); - return diagnostics; + return diagnostics; }); export default payloadLinter; diff --git a/apps/uikit-playground/src/Components/CodeEditor/Extensions/theme.ts b/apps/uikit-playground/src/Components/CodeEditor/Extensions/theme.ts index 71938add617fc..11caa7bb159ef 100644 --- a/apps/uikit-playground/src/Components/CodeEditor/Extensions/theme.ts +++ b/apps/uikit-playground/src/Components/CodeEditor/Extensions/theme.ts @@ -2,40 +2,40 @@ import type { Extension } from '@codemirror/state'; import { EditorView } from '@codemirror/view'; const gutters: Extension = EditorView.theme({ - '.cm-gutters': { - backgroundColor: 'transparent', - border: 'none', - userSelect: 'none', - minWidth: '32px', - display: 'flex', - justifyContent: 'flex-end', - }, + '.cm-gutters': { + backgroundColor: 'transparent', + border: 'none', + userSelect: 'none', + minWidth: '32px', + display: 'flex', + justifyContent: 'flex-end', + }, - '.cm-activeLineGutter': { - backgroundColor: 'transparent', - }, + '.cm-activeLineGutter': { + backgroundColor: 'transparent', + }, }); const selection: Extension = EditorView.theme({ - '.cm-selectionBackground': { - backgroundColor: 'var(--RCPG-secondary-color) !important', - opacity: 0.3, - }, + '.cm-selectionBackground': { + backgroundColor: 'var(--RCPG-secondary-color) !important', + opacity: 0.3, + }, - '.cm-selectionMatch': { - backgroundColor: '#74808930 !important', - }, + '.cm-selectionMatch': { + backgroundColor: '#74808930 !important', + }, - '.cm-matchingBracket': { - backgroundColor: 'transparent !important', - border: '1px solid #1d74f580', - }, + '.cm-matchingBracket': { + backgroundColor: 'transparent !important', + border: '1px solid #1d74f580', + }, }); const line: Extension = EditorView.theme({ - '.cm-activeLine': { - backgroundColor: 'transparent !important', - }, + '.cm-activeLine': { + backgroundColor: 'transparent !important', + }, }); export default [gutters, selection, line] as const; diff --git a/apps/uikit-playground/src/Components/CodeEditor/Parser/parsePayload.ts b/apps/uikit-playground/src/Components/CodeEditor/Parser/parsePayload.ts index 8a431bc05e158..4faeaf519ce03 100644 --- a/apps/uikit-playground/src/Components/CodeEditor/Parser/parsePayload.ts +++ b/apps/uikit-playground/src/Components/CodeEditor/Parser/parsePayload.ts @@ -1,15 +1,7 @@ -// import type { Diagnostic } from '@codemirror/lint'; import type { EditorView } from 'codemirror'; -const parsePayload = ( - head: { from: number; to: number }, - // Diagnostic: Diagnostic[], - view: EditorView -) => { - const payload = JSON.parse( - view.state.doc.toString().slice(head.from, head.to) - ); - payload && 1; +const parsePayload = (head: { from: number; to: number }, view: EditorView) => { + JSON.parse(view.state.doc.toString().slice(head.from, head.to)); }; export default parsePayload; diff --git a/apps/uikit-playground/src/Components/CodeEditor/PreviewEditor.tsx b/apps/uikit-playground/src/Components/CodeEditor/PreviewEditor.tsx index 0a0396167e3d7..8edf7b168a1c5 100644 --- a/apps/uikit-playground/src/Components/CodeEditor/PreviewEditor.tsx +++ b/apps/uikit-playground/src/Components/CodeEditor/PreviewEditor.tsx @@ -7,28 +7,25 @@ import useCodeMirror from '../../hooks/useCodeMirror'; import intendCode from '../../utils/intendCode'; type CodeMirrorProps = { - extensions?: Extension[]; + extensions?: Extension[]; }; const PreviewEditor = ({ extensions }: CodeMirrorProps) => { - const { - state: { screens, activeScreen }, - } = useContext(context); - const { editor, setValue } = useCodeMirror( - extensions, - intendCode(screens[activeScreen]?.actionPreview) - ); + const { + state: { screens, activeScreen }, + } = useContext(context); + const { editor, setValue } = useCodeMirror(extensions, intendCode(screens[activeScreen]?.actionPreview)); - useEffect(() => { - setValue(intendCode(screens[activeScreen]?.actionPreview), {}); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [screens[activeScreen]?.actionPreview]); + useEffect(() => { + setValue(intendCode(screens[activeScreen]?.actionPreview), {}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [screens[activeScreen]?.actionPreview]); - return ( - <> - - - ); + return ( + <> + + + ); }; export default PreviewEditor; diff --git a/apps/uikit-playground/src/Components/CodeEditor/index.tsx b/apps/uikit-playground/src/Components/CodeEditor/index.tsx index 8a5b7e40848b8..4510b4aac74ab 100644 --- a/apps/uikit-playground/src/Components/CodeEditor/index.tsx +++ b/apps/uikit-playground/src/Components/CodeEditor/index.tsx @@ -5,67 +5,64 @@ import json5 from 'json5'; import { useEffect, useContext } from 'react'; import { updatePayloadAction, context } from '../../Context'; +import { type ILayoutBlock } from '../../Context/initialState'; import useCodeMirror from '../../hooks/useCodeMirror'; import codePrettier from '../../utils/codePrettier'; -import { ILayoutBlock } from '../../Context/initialState'; type CodeMirrorProps = { - extensions?: Extension[]; + extensions?: Extension[]; }; const CodeEditor = ({ extensions }: CodeMirrorProps) => { - const { - state: { screens, activeScreen }, - dispatch, - } = useContext(context); - const { editor, changes, setValue } = useCodeMirror( - extensions, - json5.stringify(screens[activeScreen].payload, undefined, 4), - ); - const debounceValue = useDebouncedValue(changes?.value, 1500); + const { + state: { screens, activeScreen }, + dispatch, + } = useContext(context); + const { editor, changes, setValue } = useCodeMirror(extensions, json5.stringify(screens[activeScreen].payload, undefined, 4)); + const debounceValue = useDebouncedValue(changes?.value, 1500); - useEffect(() => { - if (!changes?.isDispatch) { - try { - const parsedCode: ILayoutBlock[] = json5.parse(changes.value); - dispatch( - updatePayloadAction({ - blocks: parsedCode, - changedByEditor: false, - }), - ); + useEffect(() => { + if (!changes?.isDispatch) { + try { + const parsedCode: ILayoutBlock[] = json5.parse(changes.value); + dispatch( + updatePayloadAction({ + blocks: parsedCode, + changedByEditor: false, + }), + ); - dispatch(updatePayloadAction({ blocks: parsedCode })); - } catch (e) { - // do nothing - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [changes?.value]); + dispatch(updatePayloadAction({ blocks: parsedCode })); + } catch (e) { + // do nothing + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [changes?.value]); - useEffect(() => { - if (!changes?.isDispatch) { - codePrettier(changes.value, changes.cursor || 0).then((prettierCode) => { - setValue(prettierCode.formatted, { - cursor: prettierCode.cursorOffset, - }); - }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [debounceValue]); + useEffect(() => { + if (!changes?.isDispatch) { + void codePrettier(changes.value, changes.cursor || 0).then((prettierCode) => { + setValue(prettierCode.formatted, { + cursor: prettierCode.cursorOffset, + }); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debounceValue]); - useEffect(() => { - if (!screens[activeScreen].changedByEditor) { - setValue(JSON.stringify(screens[activeScreen].payload, undefined, 4), {}); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [screens[activeScreen].payload]); + useEffect(() => { + if (!screens[activeScreen].changedByEditor) { + setValue(JSON.stringify(screens[activeScreen].payload, undefined, 4), {}); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [screens[activeScreen].payload]); - return ( - <> - - - ); + return ( + <> + + + ); }; export default CodeEditor; diff --git a/apps/uikit-playground/src/Components/ComponentSideBar/ScrollableSideBar.tsx b/apps/uikit-playground/src/Components/ComponentSideBar/ScrollableSideBar.tsx index 1d0a1bf318c0a..9dfb4681f0ec0 100644 --- a/apps/uikit-playground/src/Components/ComponentSideBar/ScrollableSideBar.tsx +++ b/apps/uikit-playground/src/Components/ComponentSideBar/ScrollableSideBar.tsx @@ -1,24 +1,23 @@ import { css } from '@rocket.chat/css-in-js'; import { Scrollable, Box } from '@rocket.chat/fuselage'; -import type { FC } from 'react'; import BlocksTree from '../../Payload/actionBlock/BlocksTree'; import DropDown from '../DropDown'; -const ScrollableSideBar: FC = () => ( - - - - - +const ScrollableSideBar = () => ( + + + + + ); export default ScrollableSideBar; diff --git a/apps/uikit-playground/src/Components/ComponentSideBar/SideBar.tsx b/apps/uikit-playground/src/Components/ComponentSideBar/SideBar.tsx index 8deae40dffa78..e180b5a6ec39c 100644 --- a/apps/uikit-playground/src/Components/ComponentSideBar/SideBar.tsx +++ b/apps/uikit-playground/src/Components/ComponentSideBar/SideBar.tsx @@ -1,45 +1,37 @@ import { css } from '@rocket.chat/css-in-js'; import { Box } from '@rocket.chat/fuselage'; -import type { FC } from 'react'; import { useEffect, useContext } from 'react'; -import { context, sidebarToggleAction } from '../../Context'; import ScrollableSideBar from './ScrollableSideBar'; import SliderBtn from './SliderBtn'; +import { context, sidebarToggleAction } from '../../Context'; -const SideBar: FC = () => { - const { state, dispatch } = useContext(context); +const SideBar = () => { + const { state, dispatch } = useContext(context); - useEffect(() => { - dispatch(sidebarToggleAction(false)); - }, [state?.isMobile, dispatch]); + useEffect(() => { + dispatch(sidebarToggleAction(false)); + }, [state?.isMobile, dispatch]); - const slide = state?.isMobile - ? css` - width: 100%; - user-select: none; - transform: translateX(${state?.sideBarToggle ? '0' : '-100%'}); - transition: var(--animation-default); - ` - : css` - width: var(--sidebar-width); - user-select: none; - transition: var(--animation-default); - `; + const slide = state?.isMobile + ? css` + width: 100%; + user-select: none; + transform: translateX(${state?.sideBarToggle ? '0' : '-100%'}); + transition: var(--animation-default); + ` + : css` + width: var(--sidebar-width); + user-select: none; + transition: var(--animation-default); + `; - return ( - - - - - ); + return ( + + + + + ); }; export default SideBar; diff --git a/apps/uikit-playground/src/Components/ComponentSideBar/SliderBtn.tsx b/apps/uikit-playground/src/Components/ComponentSideBar/SliderBtn.tsx index 6d1e7982bc0cc..f5d96319d69cc 100644 --- a/apps/uikit-playground/src/Components/ComponentSideBar/SliderBtn.tsx +++ b/apps/uikit-playground/src/Components/ComponentSideBar/SliderBtn.tsx @@ -1,115 +1,90 @@ import { css } from '@rocket.chat/css-in-js'; import { Box, Label } from '@rocket.chat/fuselage'; -import type { FC } from 'react'; -import { useContext } from 'react'; +import { useContext, useMemo } from 'react'; import { context, sidebarToggleAction } from '../../Context'; -const SliderBtn: FC = () => { - const { - state: { sideBarToggle, isMobile }, - dispatch, - } = useContext(context); - const slideBtnAnimation = sideBarToggle - ? css` - clip-path: polygon( - 10% 0, - 50% 40%, - 90% 0, - 100% 10%, - 60% 50%, - 100% 90%, - 90% 100%, - 50% 60%, - 10% 100%, - 0 90%, - 40% 50%, - 0 10% - ); - cursor: pointer; - transition: var(--animation-default); - ` - : css` - clip-path: polygon( - 32% 35%, - 32% 35%, - 79% 0, - 87% 10%, - 32% 50%, - 87% 90%, - 79% 100%, - 32% 64%, - 32% 65%, - 13% 50%, - 13% 50%, - 13% 50% - ); - transform: rotate(180deg); - transition: var(--animation-default); - `; +const SliderBtn = () => { + const { + state: { sideBarToggle, isMobile }, + dispatch, + } = useContext(context); + const slideBtnAnimation = sideBarToggle + ? css` + clip-path: polygon(10% 0, 50% 40%, 90% 0, 100% 10%, 60% 50%, 100% 90%, 90% 100%, 50% 60%, 10% 100%, 0 90%, 40% 50%, 0 10%); + cursor: pointer; + transition: var(--animation-default); + ` + : css` + clip-path: polygon(32% 35%, 32% 35%, 79% 0, 87% 10%, 32% 50%, 87% 90%, 79% 100%, 32% 64%, 32% 65%, 13% 50%, 13% 50%, 13% 50%); + transform: rotate(180deg); + transition: var(--animation-default); + `; - const toggleStyle = !isMobile - ? css` - left: 0px; - ` - : sideBarToggle - ? css` - right: 0; - transition: var(--animation-default); - ` - : css` - right: 0; - transform: translateX(100%); - cursor: pointer; - transition: var(--animation-default); - `; + const toggleStyle = useMemo(() => { + if (!isMobile) { + return css` + left: 0px; + `; + } - return ( - - !sideBarToggle && dispatch(sidebarToggleAction(!sideBarToggle)) - } - zIndex={1} - className={toggleStyle} - > - - {isMobile && ( - - sideBarToggle && dispatch(sidebarToggleAction(!sideBarToggle)) - } - className={css` - cursor: pointer; - `} - > - - - )} - - ); + if (sideBarToggle) { + return css` + right: 0; + transition: var(--animation-default); + `; + } + + return css` + right: 0; + transform: translateX(100%); + cursor: pointer; + transition: var(--animation-default); + `; + }, [isMobile, sideBarToggle]); + + return ( + !sideBarToggle && dispatch(sidebarToggleAction(!sideBarToggle))} + zIndex={1} + className={toggleStyle} + > + + {isMobile && ( + sideBarToggle && dispatch(sidebarToggleAction(!sideBarToggle))} + className={css` + cursor: pointer; + `} + > + + + )} + + ); }; export default SliderBtn; diff --git a/apps/uikit-playground/src/Components/CreateNewScreen/CreateNewScreenContainer.tsx b/apps/uikit-playground/src/Components/CreateNewScreen/CreateNewScreenContainer.tsx index e32ed198562ba..0220e1fb13060 100644 --- a/apps/uikit-playground/src/Components/CreateNewScreen/CreateNewScreenContainer.tsx +++ b/apps/uikit-playground/src/Components/CreateNewScreen/CreateNewScreenContainer.tsx @@ -3,107 +3,101 @@ import { Box, Button, Icon, Scrollable } from '@rocket.chat/fuselage'; import { useOutsideClick, useMergedRefs } from '@rocket.chat/fuselage-hooks'; import { useContext, useRef } from 'react'; +import ScreenThumbnail from './ScreenThumbnail'; import { context } from '../../Context'; -import { - openCreateNewScreenAction, - createNewScreenAction, -} from '../../Context/action'; +import { openCreateNewScreenAction, createNewScreenAction } from '../../Context/action'; import { useHorizontalScroll } from '../../hooks/useHorizontalScroll'; -import ScreenThumbnail from './ScreenThumbnail'; const CreateNewScreenContainer = () => { - const { - state: { projects, screens, activeProject, openCreateNewScreen }, - dispatch, - } = useContext(context); - const ref = useRef(null); + const { + state: { projects, screens, activeProject, openCreateNewScreen }, + dispatch, + } = useContext(context); + const ref = useRef(null); - const onClosehandler = () => { - dispatch(openCreateNewScreenAction(false)); - }; - useOutsideClick([ref], onClosehandler); + const onClosehandler = () => { + dispatch(openCreateNewScreenAction(false)); + }; + useOutsideClick([ref], onClosehandler); - const scrollRef = useHorizontalScroll(); + const scrollRef = useHorizontalScroll(); - const mergedRef = useMergedRefs(scrollRef, ref); - const createNewScreenhandler = () => { - dispatch(createNewScreenAction()); - }; + const mergedRef = useMergedRefs(scrollRef, ref); + const createNewScreenhandler = () => { + dispatch(createNewScreenAction()); + }; - return ( - - - - {openCreateNewScreen && ( - - {projects[activeProject]?.screens - .map((id) => screens[id]) - .map((screen, i) => ( - screens[id]) - .length <= 1 - } - /> - ))} - - - )} - - - ); + return ( + + + + {openCreateNewScreen && ( + + {projects[activeProject]?.screens + .map((id) => screens[id]) + .map((screen, i) => ( + screens[id]).length <= 1} + /> + ))} + + + )} + + + ); }; export default CreateNewScreenContainer; diff --git a/apps/uikit-playground/src/Components/CreateNewScreen/ScreenThumbnail.tsx b/apps/uikit-playground/src/Components/CreateNewScreen/ScreenThumbnail.tsx index d3720a07595ce..a8527efc3d8aa 100644 --- a/apps/uikit-playground/src/Components/CreateNewScreen/ScreenThumbnail.tsx +++ b/apps/uikit-playground/src/Components/CreateNewScreen/ScreenThumbnail.tsx @@ -3,75 +3,69 @@ import { useToastBarDispatch } from '@rocket.chat/fuselage-toastbar'; import type { ChangeEvent, MouseEvent } from 'react'; import { useContext, useState } from 'react'; -import ScreenThumbnailWrapper from '../ScreenThumbnail/ScreenThumbnailWrapper'; -import Thumbnail from '../ScreenThumbnail/Thumbnail'; import { context, renameScreenAction } from '../../Context'; import { activeScreenAction } from '../../Context/action/activeScreenAction'; import { deleteScreenAction } from '../../Context/action/deleteScreenAction'; import { duplicateScreenAction } from '../../Context/action/duplicateScreenAction'; +import { type ScreenType } from '../../Context/initialState'; import renderPayload from '../RenderPayload/RenderPayload'; -import { ScreenType } from '../../Context/initialState'; import EditMenu from '../ScreenThumbnail/EditMenu/EditMenu'; +import ScreenThumbnailWrapper from '../ScreenThumbnail/ScreenThumbnailWrapper'; +import Thumbnail from '../ScreenThumbnail/Thumbnail'; -const ScreenThumbnail = ({ - screen, - disableDelete, -}: { - screen: ScreenType; - disableDelete: boolean; -}) => { - const { dispatch } = useContext(context); - const [name, setName] = useState(screen?.name); - const toast = useToastBarDispatch(); +const ScreenThumbnail = ({ screen, disableDelete }: { screen: ScreenType; disableDelete: boolean }) => { + const { dispatch } = useContext(context); + const [name, setName] = useState(screen?.name); + const toast = useToastBarDispatch(); - const activateScreenHandler = (e: MouseEvent) => { - e.stopPropagation(); - dispatch(activeScreenAction(screen?.id)); - }; + const activateScreenHandler = (e: MouseEvent) => { + e.stopPropagation(); + dispatch(activeScreenAction(screen?.id)); + }; - const duplicateScreenHandler = () => { - dispatch(duplicateScreenAction({ id: screen?.id })); - }; + const duplicateScreenHandler = () => { + dispatch(duplicateScreenAction({ id: screen?.id })); + }; - const onChangeNameHandler = (e: ChangeEvent) => { - setName(e.currentTarget.value); - }; + const onChangeNameHandler = (e: ChangeEvent) => { + setName(e.currentTarget.value); + }; - const nameSaveHandler = () => { - if (!name.trim()) { - setName(screen.name); - return toast({ - type: 'error', - message: 'Cannot rename screen to empty name.', - }); - } - dispatch(renameScreenAction({ id: screen.id, name })); - }; + const nameSaveHandler = () => { + if (!name.trim()) { + setName(screen.name); + return toast({ + type: 'error', + message: 'Cannot rename screen to empty name.', + }); + } + dispatch(renameScreenAction({ id: screen.id, name })); + }; - const deleteScreenHandler = () => { - if (disableDelete) - return toast({ - type: 'info', - message: 'Cannot delete last screen.', - }); - dispatch(deleteScreenAction(screen?.id)); - }; - return ( - - - - - - - ); + const deleteScreenHandler = () => { + if (disableDelete) + return toast({ + type: 'info', + message: 'Cannot delete last screen.', + }); + dispatch(deleteScreenAction(screen?.id)); + }; + return ( + + + + + + + ); }; export default ScreenThumbnail; diff --git a/apps/uikit-playground/src/Components/Draggable/DraggableList.tsx b/apps/uikit-playground/src/Components/Draggable/DraggableList.tsx index db7b3968a3d91..7158b02ed6e3c 100644 --- a/apps/uikit-playground/src/Components/Draggable/DraggableList.tsx +++ b/apps/uikit-playground/src/Components/Draggable/DraggableList.tsx @@ -1,44 +1,35 @@ -import { memo } from 'react'; import type { OnDragEndResponder } from '@hello-pangea/dnd'; import { DragDropContext, Droppable } from '@hello-pangea/dnd'; +import { memo } from 'react'; import DraggableListItem from './DraggableListItem'; +import { type ILayoutBlock } from '../../Context/initialState'; import { SurfaceOptions } from '../Preview/Display/Surface/constant'; -import { ILayoutBlock } from '../../Context/initialState'; export type Block = { - id: string; - payload: ILayoutBlock; + id: string; + payload: ILayoutBlock; }; export type DraggableListProps = { - blocks: Block[]; - surface?: SurfaceOptions; - onDragEnd: OnDragEndResponder; + blocks: Block[]; + surface?: SurfaceOptions; + onDragEnd: OnDragEndResponder; }; const DraggableList = ({ blocks, surface, onDragEnd }: DraggableListProps) => ( - - - {(provided) => ( -
- {blocks.map((block, index) => ( - - ))} - {provided.placeholder} -
- )} -
-
+ + + {(provided) => ( +
+ {blocks.map((block, index) => ( + + ))} + {provided.placeholder} +
+ )} +
+
); export default memo(DraggableList); diff --git a/apps/uikit-playground/src/Components/Draggable/DraggableListItem.tsx b/apps/uikit-playground/src/Components/Draggable/DraggableListItem.tsx index 5c22a7f7627ea..b923566b70f64 100644 --- a/apps/uikit-playground/src/Components/Draggable/DraggableListItem.tsx +++ b/apps/uikit-playground/src/Components/Draggable/DraggableListItem.tsx @@ -1,35 +1,27 @@ import { Draggable } from '@hello-pangea/dnd'; +import type { Block } from './DraggableList'; import DeleteElementBtn from '../Preview/Display/UiKitElementWrapper/DeleteElementBtn'; import UiKitElementWrapper from '../Preview/Display/UiKitElementWrapper/UiKitElementWrapper'; import RenderPayload from '../RenderPayload/RenderPayload'; -import type { Block } from './DraggableList'; export type DraggableListItemProps = { - block: Block; - surface: number; - index: number; + block: Block; + surface: number; + index: number; }; -const DraggableListItem = ({ - block, - surface, - index, -}: DraggableListItemProps) => ( - - {(provided) => ( -
- - - - -
- )} -
+const DraggableListItem = ({ block, surface, index }: DraggableListItemProps) => ( + + {(provided) => ( +
+ + + + +
+ )} +
); export default DraggableListItem; diff --git a/apps/uikit-playground/src/Components/DropDown/DropDown.tsx b/apps/uikit-playground/src/Components/DropDown/DropDown.tsx index 8377a96c8a99a..921274c6eaa15 100644 --- a/apps/uikit-playground/src/Components/DropDown/DropDown.tsx +++ b/apps/uikit-playground/src/Components/DropDown/DropDown.tsx @@ -4,31 +4,28 @@ import { Fragment } from 'react'; import Items from './Items'; import type { Item, ItemBranch } from './types'; -interface DropDownProps { - readonly BlocksTree: Item; -} +type DropDownProps = { + readonly blocksTree: Item; +}; -const DropDown = ({ BlocksTree }: DropDownProps) => { - const layer = 1; +const DropDown = ({ blocksTree }: DropDownProps) => { + const layer = 1; - const recursiveComponentTree = (branch: ItemBranch, layer: number) => ( - - {branch.branches && - branch.branches.map((branch: ItemBranch, index: number) => ( - - {recursiveComponentTree(branch, layer + 1)} - - ))} - - ); + const recursiveComponentTree = (branch: ItemBranch, layer: number) => ( + + {branch.branches?.map((branch: ItemBranch, index: number) => ( + {recursiveComponentTree(branch, layer + 1)} + ))} + + ); - return ( - - {BlocksTree.map((branch: ItemBranch, i: number) => ( - {recursiveComponentTree(branch, layer)} - ))} - - ); + return ( + + {blocksTree.map((branch: ItemBranch, i: number) => ( + {recursiveComponentTree(branch, layer)} + ))} + + ); }; export default DropDown; diff --git a/apps/uikit-playground/src/Components/DropDown/Items.tsx b/apps/uikit-playground/src/Components/DropDown/Items.tsx index 18eb6a10dcc50..85bcd833007cd 100644 --- a/apps/uikit-playground/src/Components/DropDown/Items.tsx +++ b/apps/uikit-playground/src/Components/DropDown/Items.tsx @@ -2,68 +2,61 @@ import { css } from '@rocket.chat/css-in-js'; import { Box, Label, Chevron } from '@rocket.chat/fuselage'; import { useState, useContext } from 'react'; -import { context, updatePayloadAction } from '../../Context'; import ItemsIcon from './ItemsIcon'; import { itemStyle, labelStyle } from './itemsStyle'; import type { ItemProps } from './types'; +import { context, updatePayloadAction } from '../../Context'; import getUniqueId from '../../utils/getUniqueId'; const Items = ({ label, children, layer, payload }: ItemProps) => { - const [isOpen, toggleItemOpen] = useState(layer === 1); - const [hover, setHover] = useState(false); - const { state, dispatch } = useContext(context); + const [isOpen, toggleItemOpen] = useState(layer === 1); + const [hover, setHover] = useState(false); + const { state, dispatch } = useContext(context); - const itemClickHandler = () => { - toggleItemOpen(!isOpen); - payload && - dispatch( - updatePayloadAction({ - blocks: [ - ...state.screens[state.activeScreen].payload.blocks, - { actionId: getUniqueId(), ...payload[0] }, - ], - changedByEditor: false, - }) - ); - }; + const itemClickHandler = () => { + toggleItemOpen(!isOpen); + if (!payload) return; + dispatch( + updatePayloadAction({ + blocks: [...state.screens[state.activeScreen].payload.blocks, { actionId: getUniqueId(), ...payload[0] }], + changedByEditor: false, + }), + ); + }; - return ( - - setHover(true)} - onMouseLeave={() => setHover(false)} - onClick={itemClickHandler} - > - - {children && children.length > 0 && ( - - - - )} - - - - - - - {isOpen && children} - - ); + return ( + + setHover(true)} + onMouseLeave={() => setHover(false)} + onClick={itemClickHandler} + > + + {children && children.length > 0 && ( + + + + )} + + + + + + + {isOpen && children} + + ); }; export default Items; diff --git a/apps/uikit-playground/src/Components/DropDown/ItemsIcon.tsx b/apps/uikit-playground/src/Components/DropDown/ItemsIcon.tsx index fb74ae3971135..1e8ab7e34bddd 100644 --- a/apps/uikit-playground/src/Components/DropDown/ItemsIcon.tsx +++ b/apps/uikit-playground/src/Components/DropDown/ItemsIcon.tsx @@ -1,26 +1,16 @@ import { Icon } from '@rocket.chat/fuselage'; -const ItemsIcon = ({ - layer, - lastNode, - hover, -}: { - layer: number; - lastNode: boolean; - hover: boolean; -}) => { - const selectIcon = (layer: number, hover: boolean) => { - if (layer === 1) { - return ( - - ); - } - if (lastNode) { - return ; - } - return ; - }; - return <>{selectIcon(layer, hover)}; +const ItemsIcon = ({ layer, lastNode, hover }: { layer: number; lastNode: boolean; hover: boolean }) => { + const selectIcon = (layer: number, hover: boolean) => { + if (layer === 1) { + return ; + } + if (lastNode) { + return ; + } + return ; + }; + return <>{selectIcon(layer, hover)}; }; export default ItemsIcon; diff --git a/apps/uikit-playground/src/Components/DropDown/itemsStyle.ts b/apps/uikit-playground/src/Components/DropDown/itemsStyle.ts index d91dc5b80f9dd..4a50380f67b6c 100644 --- a/apps/uikit-playground/src/Components/DropDown/itemsStyle.ts +++ b/apps/uikit-playground/src/Components/DropDown/itemsStyle.ts @@ -1,47 +1,45 @@ import { css } from '@rocket.chat/css-in-js'; export const itemStyle = (layer: number, hover: boolean) => { - const style = css` - cursor: pointer !important; - padding-left: ${10 + (layer - 1) * 16}px !important; - background-color: ${hover - ? 'var(--RCPG-primary-color) !important' - : 'transparent !important'}; - `; - return style; + const style = css` + cursor: pointer !important; + padding-left: ${10 + (layer - 1) * 16}px !important; + background-color: ${hover ? 'var(--RCPG-primary-color) !important' : 'transparent !important'}; + `; + return style; }; export const labelStyle = (layer: number, hover: boolean) => { - let customStyle; - const basicStyle = css` - cursor: pointer !important; - padding-left: 4px !important; - `; - switch (layer) { - case 1: - customStyle = css` - font-weight: 700 !important; - font-size: 14px !important; - letter-spacing: 0.3px !important; - color: ${hover ? '#fff !important' : '#999 !important'}; - text-transform: uppercase !important; - `; - break; - case 2: - customStyle = css` - letter-spacing: 0.1px !important; - font-size: 12px !important; - color: ${hover ? '#fff !important' : '#555 !important'}; - text-transform: capitalize !important; - `; - break; - default: - customStyle = css` - font-size: 12px !important; - color: ${hover ? '#fff !important' : '#555 !important'}; - text-transform: capitalize !important; - `; - break; - } - return [customStyle, basicStyle]; + let customStyle; + const basicStyle = css` + cursor: pointer !important; + padding-left: 4px !important; + `; + switch (layer) { + case 1: + customStyle = css` + font-weight: 700 !important; + font-size: 14px !important; + letter-spacing: 0.3px !important; + color: ${hover ? '#fff !important' : '#999 !important'}; + text-transform: uppercase !important; + `; + break; + case 2: + customStyle = css` + letter-spacing: 0.1px !important; + font-size: 12px !important; + color: ${hover ? '#fff !important' : '#555 !important'}; + text-transform: capitalize !important; + `; + break; + default: + customStyle = css` + font-size: 12px !important; + color: ${hover ? '#fff !important' : '#555 !important'}; + text-transform: capitalize !important; + `; + break; + } + return [customStyle, basicStyle]; }; diff --git a/apps/uikit-playground/src/Components/DropDown/types.ts b/apps/uikit-playground/src/Components/DropDown/types.ts index fb50ee7f69598..47100c0e7ffc0 100644 --- a/apps/uikit-playground/src/Components/DropDown/types.ts +++ b/apps/uikit-playground/src/Components/DropDown/types.ts @@ -2,16 +2,16 @@ import type { LayoutBlock } from '@rocket.chat/ui-kit'; import type { JSX } from 'react'; export type ItemProps = { - label: string; - layer: number; - payload?: readonly LayoutBlock[]; - children?: ReadonlyArray; + label: string; + layer: number; + payload?: readonly LayoutBlock[]; + children?: ReadonlyArray; }; export type ItemBranch = { - label: string; - branches?: Item; - payload?: readonly LayoutBlock[]; + label: string; + branches?: Item; + payload?: readonly LayoutBlock[]; }; export type Item = ItemBranch[]; diff --git a/apps/uikit-playground/src/Components/FlowContainer/ConnectionLine.tsx b/apps/uikit-playground/src/Components/FlowContainer/ConnectionLine.tsx index 82fb979ff075b..d9e098e7add69 100644 --- a/apps/uikit-playground/src/Components/FlowContainer/ConnectionLine.tsx +++ b/apps/uikit-playground/src/Components/FlowContainer/ConnectionLine.tsx @@ -1,34 +1,27 @@ const ConnectionLine = ({ - fromX, - fromY, - toX, - toY, + fromX, + fromY, + toX, + toY, }: { - fromX: number; - fromY: number; - fromPosition: string; - toX: number; - toY: number; - toPosition: string; - connectionLineType: string; + fromX: number; + fromY: number; + fromPosition: string; + toX: number; + toY: number; + toPosition: string; + connectionLineType: string; }) => ( - - - - + + + + ); export default ConnectionLine; diff --git a/apps/uikit-playground/src/Components/FlowContainer/ControlButtons/ControlButtons.tsx b/apps/uikit-playground/src/Components/FlowContainer/ControlButtons/ControlButtons.tsx index ecea95fdd0678..d06bccff5c3f9 100644 --- a/apps/uikit-playground/src/Components/FlowContainer/ControlButtons/ControlButtons.tsx +++ b/apps/uikit-playground/src/Components/FlowContainer/ControlButtons/ControlButtons.tsx @@ -1,7 +1,7 @@ import { Controls } from 'reactflow'; const ControlButtons = () => { - return ; + return ; }; export default ControlButtons; diff --git a/apps/uikit-playground/src/Components/FlowContainer/ControlButtons/index.ts b/apps/uikit-playground/src/Components/FlowContainer/ControlButtons/index.ts index 63963693d46d8..9d122e2d002b2 100644 --- a/apps/uikit-playground/src/Components/FlowContainer/ControlButtons/index.ts +++ b/apps/uikit-playground/src/Components/FlowContainer/ControlButtons/index.ts @@ -1 +1 @@ -export {default} from './ControlButtons'; \ No newline at end of file +export { default } from './ControlButtons'; diff --git a/apps/uikit-playground/src/Components/FlowContainer/FlowContainer.tsx b/apps/uikit-playground/src/Components/FlowContainer/FlowContainer.tsx index 311ddaae795c7..52b3428450c28 100644 --- a/apps/uikit-playground/src/Components/FlowContainer/FlowContainer.tsx +++ b/apps/uikit-playground/src/Components/FlowContainer/FlowContainer.tsx @@ -1,117 +1,115 @@ import { useCallback, useContext, useMemo, useRef, useState } from 'react'; import ReactFlow, { - MiniMap, - Background, - addEdge, - updateEdge, - Node, - Viewport, - ReactFlowInstance, - useReactFlow, - Connection, - Edge, + MiniMap, + Background, + addEdge, + updateEdge, + type Node, + type Viewport, + type ReactFlowInstance, + useReactFlow, + type Connection, + type Edge, } from 'reactflow'; import 'reactflow/dist/style.css'; -import { context } from '../../Context'; import ConnectionLine from './ConnectionLine'; +import ControlButton from './ControlButtons'; import UIKitWrapper from './UIKitWrapper/UIKitWrapper'; import { FlowParams } from './utils'; -import ControlButton from './ControlButtons'; -import { useNodesAndEdges } from '../../hooks/useNodesAndEdges'; +import { context } from '../../Context'; import { updateNodesAndViewPortAction } from '../../Context/action/updateNodesAndViewPortAction'; +import { useNodesAndEdges } from '../../hooks/useNodesAndEdges'; const FlowContainer = () => { - const { dispatch } = useContext(context); + const { dispatch } = useContext(context); - const { nodes, edges, Viewport, onNodesChange, onEdgesChange, setEdges } = - useNodesAndEdges(); - const { setViewport } = useReactFlow(); + const { nodes, edges, Viewport, onNodesChange, onEdgesChange, setEdges } = useNodesAndEdges(); + const { setViewport } = useReactFlow(); - const nodeTypes = useMemo( - () => ({ - custom: UIKitWrapper, - }), - // used to rerender edge lines on reorder payload - // eslint-disable-next-line react-hooks/exhaustive-deps - [edges] - ); + const nodeTypes = useMemo( + () => ({ + custom: UIKitWrapper, + }), + // used to rerender edge lines on reorder payload + // eslint-disable-next-line react-hooks/exhaustive-deps + [edges], + ); - const [rfInstance, setRfInstance] = useState(); - const edgeUpdateSuccessful = useRef(true); + const [rfInstance, setRfInstance] = useState(); + const edgeUpdateSuccessful = useRef(true); - const onConnect = useCallback( - (connection: Connection) => { - if (connection.source === connection.target) return; - const newEdge = { - ...connection, - type: FlowParams.edgeType, - markerEnd: FlowParams.markerEnd, - style: FlowParams.style, - }; - setEdges((eds) => addEdge(newEdge, eds)); - }, - [setEdges] - ); + const onConnect = useCallback( + (connection: Connection) => { + if (connection.source === connection.target) return; + const newEdge = { + ...connection, + type: FlowParams.edgeType, + markerEnd: FlowParams.markerEnd, + style: FlowParams.style, + }; + setEdges((eds) => addEdge(newEdge, eds)); + }, + [setEdges], + ); - const onEdgeUpdateStart = useCallback(() => { - edgeUpdateSuccessful.current = false; - }, []); + const onEdgeUpdateStart = useCallback(() => { + edgeUpdateSuccessful.current = false; + }, []); - const onEdgeUpdate = useCallback( - (oldEdge: Edge, newConnection: Connection) => { - edgeUpdateSuccessful.current = true; - setEdges((els) => updateEdge(oldEdge, newConnection, els)); - }, - [setEdges] - ); + const onEdgeUpdate = useCallback( + (oldEdge: Edge, newConnection: Connection) => { + edgeUpdateSuccessful.current = true; + setEdges((els) => updateEdge(oldEdge, newConnection, els)); + }, + [setEdges], + ); - const onEdgeUpdateEnd = useCallback( - (_: MouseEvent | TouchEvent, edge: Edge) => { - if (!edgeUpdateSuccessful.current) { - setEdges((eds) => { - return eds.filter((e) => e.id !== edge.id); - }); - } - edgeUpdateSuccessful.current = true; - }, - [setEdges] - ); + const onEdgeUpdateEnd = useCallback( + (_: MouseEvent | TouchEvent, edge: Edge) => { + if (!edgeUpdateSuccessful.current) { + setEdges((eds) => { + return eds.filter((e) => e.id !== edge.id); + }); + } + edgeUpdateSuccessful.current = true; + }, + [setEdges], + ); - const onNodeDragStop = () => { - if (!rfInstance?.toObject()) return; - const { nodes, viewport }: { nodes: Node[]; viewport: Viewport } = - rfInstance.toObject(); - dispatch(updateNodesAndViewPortAction({ nodes, viewport })); - }; + const onNodeDragStop = () => { + if (!rfInstance?.toObject()) return; + const { nodes, viewport }: { nodes: Node[]; viewport: Viewport } = rfInstance.toObject(); + dispatch(updateNodesAndViewPortAction({ nodes, viewport })); + }; - const onInit = (instance: ReactFlowInstance) => { - setRfInstance(instance); - Viewport && setViewport(Viewport); - }; + const onInit = (instance: ReactFlowInstance) => { + setRfInstance(instance); + if (Viewport) setViewport(Viewport); + }; - return ( - - - - - - ); + return ( + + + + + + ); }; export default FlowContainer; diff --git a/apps/uikit-playground/src/Components/FlowContainer/UIKitWrapper/UIKitWrapper.tsx b/apps/uikit-playground/src/Components/FlowContainer/UIKitWrapper/UIKitWrapper.tsx index 79010f9442cc9..8c48b01528c50 100644 --- a/apps/uikit-playground/src/Components/FlowContainer/UIKitWrapper/UIKitWrapper.tsx +++ b/apps/uikit-playground/src/Components/FlowContainer/UIKitWrapper/UIKitWrapper.tsx @@ -3,51 +3,32 @@ import { useContext } from 'react'; import { Handle, Position } from 'reactflow'; import './UIKitWrapper.scss'; -import RenderPayload from '../../RenderPayload/RenderPayload'; -import SurfaceRender from '../../Preview/Display/Surface/SurfaceRender'; -import { idType } from '../../../Context/initialState'; import { context } from '../../../Context'; +import { type idType } from '../../../Context/initialState'; +import SurfaceRender from '../../Preview/Display/Surface/SurfaceRender'; +import RenderPayload from '../../RenderPayload/RenderPayload'; const UIKitWrapper = ({ id, data }: { id: string; data: idType }) => { - const { - state: { screens }, - } = useContext(context); - if (!screens[data]) return null; - const { blocks, surface } = screens[data].payload; - return ( - - - - {blocks.map((block, index) => ( - - - - - - - ))} - - - ); + const { + state: { screens }, + } = useContext(context); + if (!screens[data]) return null; + const { blocks, surface } = screens[data].payload; + return ( + + + + {blocks.map((block, index) => ( + + + + + + + ))} + + + ); }; export default UIKitWrapper; diff --git a/apps/uikit-playground/src/Components/FlowContainer/UIKitWrapper/index.ts b/apps/uikit-playground/src/Components/FlowContainer/UIKitWrapper/index.ts index 82f4f03d69d30..d6fc539add635 100644 --- a/apps/uikit-playground/src/Components/FlowContainer/UIKitWrapper/index.ts +++ b/apps/uikit-playground/src/Components/FlowContainer/UIKitWrapper/index.ts @@ -1 +1 @@ -export {default} from './UIKitWrapper'; \ No newline at end of file +export { default } from './UIKitWrapper'; diff --git a/apps/uikit-playground/src/Components/FlowContainer/utils.ts b/apps/uikit-playground/src/Components/FlowContainer/utils.ts index 2318fe1f1efcf..39200a5b3950d 100644 --- a/apps/uikit-playground/src/Components/FlowContainer/utils.ts +++ b/apps/uikit-playground/src/Components/FlowContainer/utils.ts @@ -3,32 +3,32 @@ import { MarkerType } from 'reactflow'; import type { ScreenType } from '../../Context/initialState'; export function createNodesAndEdges(screens: ScreenType[]) { - const center = { x: window.innerWidth / 2, y: window.innerHeight / 2 }; + const center = { x: window.innerWidth / 2, y: window.innerHeight / 2 }; - const nodes = screens.map((screen, i) => { - const degrees = i * (360 / 8); - const radians = degrees * (Math.PI / 180); - const x = 250 * Math.cos(radians) + center.x; - const y = 250 * Math.sin(radians) + center.y; + const nodes = screens.map((screen, i) => { + const degrees = i * (360 / 8); + const radians = degrees * (Math.PI / 180); + const x = 250 * Math.cos(radians) + center.x; + const y = 250 * Math.sin(radians) + center.y; - return { - id: screen.id, - type: 'custom', - position: { x, y }, - data: screen, - }; - }); + return { + id: screen.id, + type: 'custom', + position: { x, y }, + data: screen, + }; + }); - return { nodes }; + return { nodes }; } export const FlowParams = { - edgeType: 'smoothstep', - markerEnd: { - type: MarkerType.Arrow, - }, - style: { - strokeWidth: 2, - stroke: 'var(--RCPG-primary-color)', - }, + edgeType: 'smoothstep', + markerEnd: { + type: MarkerType.Arrow, + }, + style: { + strokeWidth: 2, + stroke: 'var(--RCPG-primary-color)', + }, }; diff --git a/apps/uikit-playground/src/Components/HomeContainer/HomeContainer.tsx b/apps/uikit-playground/src/Components/HomeContainer/HomeContainer.tsx index e6bf0e9274a76..1f05d9ba5148f 100644 --- a/apps/uikit-playground/src/Components/HomeContainer/HomeContainer.tsx +++ b/apps/uikit-playground/src/Components/HomeContainer/HomeContainer.tsx @@ -1,40 +1,32 @@ +import { css } from '@rocket.chat/css-in-js'; import { Box, Label } from '@rocket.chat/fuselage'; -import ProjectsList from './ProjectsList/ProjectsList'; import { useContext } from 'react'; + +import ProjectsList from './ProjectsList/ProjectsList'; import { context, createNewProjectAction } from '../../Context'; import CreateNewScreenButton from '../ScreenThumbnail/CreateNewScreenButton'; -import { css } from '@rocket.chat/css-in-js'; const HomeContainer = () => { - const { dispatch } = useContext(context); - return ( - - - - - dispatch(createNewProjectAction())} - /> - - - - - ); + const { dispatch } = useContext(context); + return ( + + + + + dispatch(createNewProjectAction())} /> + + + + + ); }; export default HomeContainer; diff --git a/apps/uikit-playground/src/Components/HomeContainer/ProjectsList/ProjectsList.tsx b/apps/uikit-playground/src/Components/HomeContainer/ProjectsList/ProjectsList.tsx index c767f406ad4eb..d53a080a17318 100644 --- a/apps/uikit-playground/src/Components/HomeContainer/ProjectsList/ProjectsList.tsx +++ b/apps/uikit-playground/src/Components/HomeContainer/ProjectsList/ProjectsList.tsx @@ -1,34 +1,35 @@ +import { css } from '@rocket.chat/css-in-js'; import { Box } from '@rocket.chat/fuselage'; -import { context } from '../../../Context'; import { useContext } from 'react'; + import ProjectsThumbnail from './ProjectsThumbnail'; -import { css } from '@rocket.chat/css-in-js'; +import { context } from '../../../Context'; const ProjectsList = () => { - const { - state: { screens, projects }, - } = useContext(context); + const { + state: { screens, projects }, + } = useContext(context); - return ( - - {Object.values(projects).map((project) => ( - - ))} - - ); + return ( + + {Object.values(projects).map((project) => ( + + ))} + + ); }; export default ProjectsList; diff --git a/apps/uikit-playground/src/Components/HomeContainer/ProjectsList/ProjectsThumbnail.tsx b/apps/uikit-playground/src/Components/HomeContainer/ProjectsList/ProjectsThumbnail.tsx index bcba0fca63065..f96bf299c55c8 100644 --- a/apps/uikit-playground/src/Components/HomeContainer/ProjectsList/ProjectsThumbnail.tsx +++ b/apps/uikit-playground/src/Components/HomeContainer/ProjectsList/ProjectsThumbnail.tsx @@ -1,99 +1,76 @@ +import { css } from '@rocket.chat/css-in-js'; import { Box } from '@rocket.chat/fuselage'; -import ScreenThumbnailWrapper from '../../ScreenThumbnail/ScreenThumbnailWrapper'; -import Thumbnail from '../../ScreenThumbnail/Thumbnail'; -import RenderPayload from '../../RenderPayload/RenderPayload'; -import { - activeProjectAction, - context, - renameProjectAction, -} from '../../../Context'; -import { ChangeEvent, useContext, useState } from 'react'; +import { useToastBarDispatch } from '@rocket.chat/fuselage-toastbar'; +import { type ChangeEvent, useContext, useState } from 'react'; import { useNavigate } from 'react-router-dom'; + +import { activeProjectAction, context, renameProjectAction } from '../../../Context'; +import { deleteProjectAction } from '../../../Context/action/deleteProjectAction'; +import { type ILayoutBlock } from '../../../Context/initialState'; +import routes from '../../../Routes/Routes'; import { formatDate } from '../../../utils/formatDate'; +import RenderPayload from '../../RenderPayload/RenderPayload'; import EditMenu from '../../ScreenThumbnail/EditMenu'; import EditableLabel from '../../ScreenThumbnail/EditableLabel/EditableLabel'; -import { css } from '@rocket.chat/css-in-js'; -import { deleteProjectAction } from '../../../Context/action/deleteProjectAction'; -import { useToastBarDispatch } from '@rocket.chat/fuselage-toastbar'; -import routes from '../../../Routes/Routes'; -import { ILayoutBlock } from '../../../Context/initialState'; +import ScreenThumbnailWrapper from '../../ScreenThumbnail/ScreenThumbnailWrapper'; +import Thumbnail from '../../ScreenThumbnail/Thumbnail'; -const ProjectsThumbnail = ({ - id, - name: _name, - date, - blocks, -}: { - id: string; - name: string; - date: string; - blocks: ILayoutBlock[]; -}) => { - const [name, setName] = useState(_name); - const navigate = useNavigate(); - const { dispatch } = useContext(context); - const toast = useToastBarDispatch(); - const activeProjectHandler = () => { - dispatch(activeProjectAction(id)); - navigate(`/${id}/${routes.project}`); - }; +const ProjectsThumbnail = ({ id, name: _name, date, blocks }: { id: string; name: string; date: string; blocks: ILayoutBlock[] }) => { + const [name, setName] = useState(_name); + const navigate = useNavigate(); + const { dispatch } = useContext(context); + const toast = useToastBarDispatch(); + const activeProjectHandler = () => { + dispatch(activeProjectAction(id)); + navigate(`/${id}/${routes.project}`); + }; - const deleteScreenHandler = () => { - dispatch(deleteProjectAction(id)); - }; + const deleteScreenHandler = () => { + dispatch(deleteProjectAction(id)); + }; - const onChangeNameHandler = (e: ChangeEvent) => { - setName(e.currentTarget.value); - }; + const onChangeNameHandler = (e: ChangeEvent) => { + setName(e.currentTarget.value); + }; - const nameSaveHandler = () => { - if (!name.trim()) { - setName(_name); - return toast({ - type: 'error', - message: 'Cannot rename project to empty name.', - }); - } - dispatch(renameProjectAction({ id, name })); - }; + const nameSaveHandler = () => { + if (!name.trim()) { + setName(_name); + return toast({ + type: 'error', + message: 'Cannot rename project to empty name.', + }); + } + dispatch(renameProjectAction({ id, name })); + }; - return ( - - - - - e.stopPropagation()}> - - - - {formatDate(date)} - - - ); + return ( + + + + } /> + e.stopPropagation()}> + + + + {formatDate(date)} + + + ); }; export default ProjectsThumbnail; diff --git a/apps/uikit-playground/src/Components/NavBar/BurgerIcon/BurgerIcon.tsx b/apps/uikit-playground/src/Components/NavBar/BurgerIcon/BurgerIcon.tsx index af02130ca4e00..03d895f216d71 100644 --- a/apps/uikit-playground/src/Components/NavBar/BurgerIcon/BurgerIcon.tsx +++ b/apps/uikit-playground/src/Components/NavBar/BurgerIcon/BurgerIcon.tsx @@ -2,24 +2,24 @@ import { usePrefersReducedMotion } from '@rocket.chat/fuselage-hooks'; import type { ReactElement, ReactNode } from 'react'; import { useContext } from 'react'; -import { context } from '../../../Context'; import Line from './Line'; import Wrapper from './Wrapper'; +import { context } from '../../../Context'; const BurgerIcon = ({ children }: { children?: ReactNode }): ReactElement => { - const isReducedMotionPreferred = usePrefersReducedMotion(); - const { - state: { navMenuToggle }, - } = useContext(context); + const isReducedMotionPreferred = usePrefersReducedMotion(); + const { + state: { navMenuToggle }, + } = useContext(context); - return ( - - - - - {children} - - ); + return ( + + + + + {children} + + ); }; export default BurgerIcon; diff --git a/apps/uikit-playground/src/Components/NavBar/BurgerIcon/Line.tsx b/apps/uikit-playground/src/Components/NavBar/BurgerIcon/Line.tsx index 64eb886b2967b..5e188bba2cbc4 100644 --- a/apps/uikit-playground/src/Components/NavBar/BurgerIcon/Line.tsx +++ b/apps/uikit-playground/src/Components/NavBar/BurgerIcon/Line.tsx @@ -2,51 +2,38 @@ import { css } from '@rocket.chat/css-in-js'; import { Box } from '@rocket.chat/fuselage'; import type { ReactElement } from 'react'; -const Line = ({ - animated, - moved, -}: { - animated: boolean; - moved?: boolean; -}): ReactElement => { - const animatedStyle = animated - ? css` - will-change: transform; - transition: transform 0.1s ease-out; - ` - : ''; +const Line = ({ animated, moved }: { animated: boolean; moved?: boolean }): ReactElement => { + const animatedStyle = animated + ? css` + will-change: transform; + transition: transform 0.1s ease-out; + ` + : ''; - const movedStyle = moved - ? css` - &:nth-child(1), - &:nth-child(3) { - transform-origin: 50%, 50%, 0; - } - &:nth-child(1) { - transform: translate(-25%, 3px) rotate(-45deg) scale(0.5, 1); - } - [dir='rtl'] &:nth-child(1) { - transform: translate(25%, 3px) rotate(45deg) scale(0.5, 1); - } - &:nth-child(3) { - transform: translate(-25%, -3px) rotate(45deg) scale(0.5, 1); - } - [dir='rtl'] &:nth-child(3) { - transform: translate(25%, -3px) rotate(-45deg) scale(0.5, 1); - } - ` - : ''; + const movedStyle = moved + ? css` + &:nth-child(1), + &:nth-child(3) { + transform-origin: 50%, 50%, 0; + } + &:nth-child(1) { + transform: translate(-25%, 3px) rotate(-45deg) scale(0.5, 1); + } + [dir='rtl'] &:nth-child(1) { + transform: translate(25%, 3px) rotate(45deg) scale(0.5, 1); + } + &:nth-child(3) { + transform: translate(-25%, -3px) rotate(45deg) scale(0.5, 1); + } + [dir='rtl'] &:nth-child(3) { + transform: translate(25%, -3px) rotate(-45deg) scale(0.5, 1); + } + ` + : ''; - return ( -