From 33aea75a82aef935c3402b94d82f6d073b78ea07 Mon Sep 17 00:00:00 2001 From: albuquerquefabio <albuquerquefabio@icloud.com> Date: Tue, 21 Jun 2022 15:45:38 -0300 Subject: [PATCH] Squashed commit of the following: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commit 7857ff483ad38d66083c9f5d9ff93875a2f6aa32 Author: Guilherme Gazzo <guilhermegazzo@gmail.com> Date: Tue Jun 21 13:34:13 2022 -0300 Chore: Improve CI cache (#25907) Co-authored-by: Diego Sampaio <chinello@gmail.com> commit d1c59c36586e45f81def07555c1b53d61e8cc52e Author: Henrique Guimarães Ribeiro <henrique.jobs1@gmail.com> Date: Tue Jun 21 12:46:46 2022 -0300 Regression: Re-add view logs button (#25876) commit 8e7135be0fcf98c303217d9ff43cff9167d91024 Author: Tasso Evangelista <tasso.evangelista@rocket.chat> Date: Tue Jun 21 12:41:09 2022 -0300 Chore: `@rocket.chat/favicon` (#25920) commit dfd1d77674a4a40acea397e50f4fa5355aeaebe2 Author: Hugo Costa <hugocarreiracosta@gmail.com> Date: Tue Jun 21 12:26:00 2022 -0300 [FIX] VOIP CallContext snapshot infinite loop (#25947) <!-- This is a pull request template, you do not need to uncomment or remove the comments, they won't show up in the PR text. --> <!-- Your Pull Request name should start with one of the following tags [NEW] For new features [IMPROVE] For an improvement (performance or little improvements) in existing features [FIX] For bug fixes that affect the end-user [BREAK] For pull requests including breaking changes Chore: For small tasks Doc: For documentation --> <!-- Checklist!!! If you're unsure about any of them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before merging your code. - I have read the Contributing Guide - https://github.com/RocketChat/Rocket.Chat/blob/develop/.github/CONTRIBUTING.md#contributing-to-rocketchat doc - I have signed the CLA - https://cla-assistant.io/RocketChat/Rocket.Chat - Lint and unit tests pass locally with my changes - I have added tests that prove my fix is effective or that my feature works (if applicable) - I have added necessary documentation (if applicable) - Any dependent changes have been merged and published in downstream modules --> <!-- CHANGELOG --> The application was crashing due to an error on the `useCallerInfo()` hook. The error was:   To prevent this issue to happen it was added a cached and out-of-scope snapshot variable to the hook using `useSyncExternalStore` <!-- Describe the big picture of your changes here to communicate to the maintainers why we should accept this pull request. If it fixes a bug or resolves a feature request, be sure to link to that issue below. This description will appear in the release notes if we accept the contribution. --> <!-- END CHANGELOG --> <!-- Link the issues being closed by or related to this PR. For example, you can use #594 if this PR closes issue number 594 --> 1. Open rocket.chat server 2. Enable Omnichannel 3. Enable Voip 4. Refresh de page <!-- If this is a relatively large or complex change, kick off the discussion by explaining why you chose the solution you did and what alternatives you considered, etc... --> commit 63d4e30f0e55226a85782cf866bf58822bf4e3f9 Author: Diego Sampaio <chinello@gmail.com> Date: Mon Jun 20 19:08:04 2022 -0300 Use correct Docker image name on publish commit 9d319efd7df0b715a7761a41ee7911c069c2eb35 Author: Diego Sampaio <chinello@gmail.com> Date: Mon Jun 20 17:56:02 2022 -0300 Regression: Docker image publish (#25931) commit 668e6d4ec8045f172ee57a0996565160a7a01ffc Author: souzaramon <souzaramon.dev@gmail.com> Date: Mon Jun 20 16:54:41 2022 -0300 Revert "Chore: Collect e2e coverage (#25743)" (#25936) This reverts commit e43d0d2b795b6c1f9b96f92285e9c1d87f3be3df. commit e43d0d2b795b6c1f9b96f92285e9c1d87f3be3df Author: souzaramon <ramon.souza@objective.com.br> Date: Mon Jun 20 14:23:48 2022 -0300 Chore: Collect e2e coverage (#25743) - collect e2e coverage - publish to artifacts - publish badges to gh-pages commit 48e960cc1c6cf296c8502e9d6db5ded95720c76d Author: Murtaza Patrawala <34130764+murtaza98@users.noreply.github.com> Date: Mon Jun 20 22:37:26 2022 +0530 Regression: Unable to edit user details via admin panel (#25923) commit 9a41c4fa343ed6ab7e2468d2a9ddea23e38d0323 Author: Douglas Fabris <devfabris@gmail.com> Date: Mon Jun 20 10:38:15 2022 -0300 [FIX] Members selection field on creating team modal (#25871) <!-- This is a pull request template, you do not need to uncomment or remove the comments, they won't show up in the PR text. --> <!-- Your Pull Request name should start with one of the following tags [NEW] For new features [IMPROVE] For an improvement (performance or little improvements) in existing features [FIX] For bug fixes that affect the end-user [BREAK] For pull requests including breaking changes Chore: For small tasks Doc: For documentation --> <!-- Checklist!!! If you're unsure about any of them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before merging your code. - I have read the Contributing Guide - https://github.com/RocketChat/Rocket.Chat/blob/develop/.github/CONTRIBUTING.md#contributing-to-rocketchat doc - I have signed the CLA - https://cla-assistant.io/RocketChat/Rocket.Chat - Lint and unit tests pass locally with my changes - I have added tests that prove my fix is effective or that my feature works (if applicable) - I have added necessary documentation (if applicable) - Any dependent changes have been merged and published in downstream modules --> <!-- CHANGELOG --> <!-- Describe the big picture of your changes here to communicate to the maintainers why we should accept this pull request. If it fixes a bug or resolves a feature request, be sure to link to that issue below. This description will appear in the release notes if we accept the contribution. --> - Fix: add members breaking when searching users  <!-- END CHANGELOG --> <!-- Link the issues being closed by or related to this PR. For example, you can use #594 if this PR closes issue number 594 --> <!-- Mention how you would reproduce the bug if not mentioned on the issue page already. Also mention which screens are going to have the changes if applicable --> <!-- If this is a relatively large or complex change, kick off the discussion by explaining why you chose the solution you did and what alternatives you considered, etc... --> Co-authored-by: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com> commit acb74f1c5c59a2490d1b190f65afe7624e884c42 Author: Martin Schoeler <martin.schoeler@rocket.chat> Date: Mon Jun 20 10:17:49 2022 -0300 Chore: Remove Imperative Modal from context (#25911) <!-- This is a pull request template, you do not need to uncomment or remove the comments, they won't show up in the PR text. --> <!-- Your Pull Request name should start with one of the following tags [NEW] For new features [IMPROVE] For an improvement (performance or little improvements) in existing features [FIX] For bug fixes that affect the end-user [BREAK] For pull requests including breaking changes Chore: For small tasks Doc: For documentation --> <!-- Checklist!!! If you're unsure about any of them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before merging your code. - I have read the Contributing Guide - https://github.com/RocketChat/Rocket.Chat/blob/develop/.github/CONTRIBUTING.md#contributing-to-rocketchat doc - I have signed the CLA - https://cla-assistant.io/RocketChat/Rocket.Chat - Lint and unit tests pass locally with my changes - I have added tests that prove my fix is effective or that my feature works (if applicable) - I have added necessary documentation (if applicable) - Any dependent changes have been merged and published in downstream modules --> This PR revises the usage of the modal inside the call provider, by moving the modal provider a little bit up the three. commit d0078fefabc3622e99df97693de907b9aaf9cbba Author: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com> Date: Mon Jun 20 09:56:56 2022 -0300 Chore: Keep the option to run only the meteor app (#25915) <!-- This is a pull request template, you do not need to uncomment or remove the comments, they won't show up in the PR text. --> <!-- Your Pull Request name should start with one of the following tags [NEW] For new features [IMPROVE] For an improvement (performance or little improvements) in existing features [FIX] For bug fixes that affect the end-user [BREAK] For pull requests including breaking changes Chore: For small tasks Doc: For documentation --> <!-- Checklist!!! If you're unsure about any of them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before merging your code. - I have read the Contributing Guide - https://github.com/RocketChat/Rocket.Chat/blob/develop/.github/CONTRIBUTING.md#contributing-to-rocketchat doc - I have signed the CLA - https://cla-assistant.io/RocketChat/Rocket.Chat - Lint and unit tests pass locally with my changes - I have added tests that prove my fix is effective or that my feature works (if applicable) - I have added necessary documentation (if applicable) - Any dependent changes have been merged and published in downstream modules --> <!-- CHANGELOG --> <!-- Describe the big picture of your changes here to communicate to the maintainers why we should accept this pull request. If it fixes a bug or resolves a feature request, be sure to link to that issue below. This description will appear in the release notes if we accept the contribution. --> <!-- END CHANGELOG --> <!-- Link the issues being closed by or related to this PR. For example, you can use #594 if this PR closes issue number 594 --> <!-- Mention how you would reproduce the bug if not mentioned on the issue page already. Also mention which screens are going to have the changes if applicable --> <!-- If this is a relatively large or complex change, kick off the discussion by explaining why you chose the solution you did and what alternatives you considered, etc... --> commit d6f4d1b23847e6375264d4ac3a7364bb6946ec7d Author: Kevin Aleman <kaleman960@gmail.com> Date: Mon Jun 20 04:57:06 2022 -0600 [FIX] Update chartjs usage to v3 (#25873) <!-- This is a pull request template, you do not need to uncomment or remove the comments, they won't show up in the PR text. --> <!-- Your Pull Request name should start with one of the following tags [NEW] For new features [IMPROVE] For an improvement (performance or little improvements) in existing features [FIX] For bug fixes that affect the end-user [BREAK] For pull requests including breaking changes Chore: For small tasks Doc: For documentation --> <!-- Checklist!!! If you're unsure about any of them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before merging your code. - I have read the Contributing Guide - https://github.com/RocketChat/Rocket.Chat/blob/develop/.github/CONTRIBUTING.md#contributing-to-rocketchat doc - I have signed the CLA - https://cla-assistant.io/RocketChat/Rocket.Chat - Lint and unit tests pass locally with my changes - I have added tests that prove my fix is effective or that my feature works (if applicable) - I have added necessary documentation (if applicable) - Any dependent changes have been merged and published in downstream modules --> <!-- CHANGELOG --> <!-- Describe the big picture of your changes here to communicate to the maintainers why we should accept this pull request. If it fixes a bug or resolves a feature request, be sure to link to that issue below. This description will appear in the release notes if we accept the contribution. --> <!-- END CHANGELOG --> <!-- Link the issues being closed by or related to this PR. For example, you can use #594 if this PR closes issue number 594 --> <!-- Mention how you would reproduce the bug if not mentioned on the issue page already. Also mention which screens are going to have the changes if applicable --> <!-- If this is a relatively large or complex change, kick off the discussion by explaining why you chose the solution you did and what alternatives you considered, etc... --> commit 8637ba263099579fe2b470d84558ee78b059fdff Author: csuadev <72958726+csuadev@users.noreply.github.com> Date: Mon Jun 20 02:33:55 2022 -0500 Chore: Rewrite AddUsers to TS (#25830) <!-- This is a pull request template, you do not need to uncomment or remove the comments, they won't show up in the PR text. --> <!-- Your Pull Request name should start with one of the following tags [NEW] For new features [IMPROVE] For an improvement (performance or little improvements) in existing features [FIX] For bug fixes that affect the end-user [BREAK] For pull requests including breaking changes Chore: For small tasks Doc: For documentation --> <!-- Checklist!!! If you're unsure about any of them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before merging your code. - I have read the Contributing Guide - https://github.com/RocketChat/Rocket.Chat/blob/develop/.github/CONTRIBUTING.md#contributing-to-rocketchat doc - I have signed the CLA - https://cla-assistant.io/RocketChat/Rocket.Chat - Lint and unit tests pass locally with my changes - I have added tests that prove my fix is effective or that my feature works (if applicable) - I have added necessary documentation (if applicable) - Any dependent changes have been merged and published in downstream modules --> <!-- CHANGELOG --> <!-- Describe the big picture of your changes here to communicate to the maintainers why we should accept this pull request. If it fixes a bug or resolves a feature request, be sure to link to that issue below. This description will appear in the release notes if we accept the contribution. --> <!-- END CHANGELOG --> <!-- Link the issues being closed by or related to this PR. For example, you can use #594 if this PR closes issue number 594 --> <!-- Mention how you would reproduce the bug if not mentioned on the issue page already. Also mention which screens are going to have the changes if applicable --> <!-- If this is a relatively large or complex change, kick off the discussion by explaining why you chose the solution you did and what alternatives you considered, etc... --> Co-authored-by: Douglas Fabris <27704687+dougfabris@users.noreply.github.com> Co-authored-by: Yash Rajpal <58601732+yash-rajpal@users.noreply.github.com> commit 086275180170d3f8318e2c5f31a7854ae1b79658 Author: Tasso Evangelista <tasso.evangelista@rocket.chat> Date: Fri Jun 17 22:09:23 2022 -0300 Chore: Replace `useSubscription` with `useSyncExternalStore` (#25909) <!-- This is a pull request template, you do not need to uncomment or remove the comments, they won't show up in the PR text. --> <!-- Your Pull Request name should start with one of the following tags [NEW] For new features [IMPROVE] For an improvement (performance or little improvements) in existing features [FIX] For bug fixes that affect the end-user [BREAK] For pull requests including breaking changes Chore: For small tasks Doc: For documentation --> <!-- Checklist!!! If you're unsure about any of them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before merging your code. - I have read the Contributing Guide - https://github.com/RocketChat/Rocket.Chat/blob/develop/.github/CONTRIBUTING.md#contributing-to-rocketchat doc - I have signed the CLA - https://cla-assistant.io/RocketChat/Rocket.Chat - Lint and unit tests pass locally with my changes - I have added tests that prove my fix is effective or that my feature works (if applicable) - I have added necessary documentation (if applicable) - Any dependent changes have been merged and published in downstream modules --> <!-- CHANGELOG --> <!-- Describe the big picture of your changes here to communicate to the maintainers why we should accept this pull request. If it fixes a bug or resolves a feature request, be sure to link to that issue below. This description will appear in the release notes if we accept the contribution. --> <!-- END CHANGELOG --> <!-- Link the issues being closed by or related to this PR. For example, you can use #594 if this PR closes issue number 594 --> <!-- Mention how you would reproduce the bug if not mentioned on the issue page already. Also mention which screens are going to have the changes if applicable --> <!-- If this is a relatively large or complex change, kick off the discussion by explaining why you chose the solution you did and what alternatives you considered, etc... --> commit eedc18b3d97d086324c0248963d8a770720d7c6c Author: Diego Sampaio <chinello@gmail.com> Date: Fri Jun 17 18:00:04 2022 -0300 Chore: Run tests on docker (#25556) Co-authored-by: Guilherme Gazzo <guilhermegazzo@gmail.com> commit 391bb8cc2092961621dccc370915508c1286a951 Author: Júlia Jaeger Foresti <60678893+juliajforesti@users.noreply.github.com> Date: Fri Jun 17 17:32:56 2022 -0300 Chore: Convert RoomMenu (#25914) <!-- This is a pull request template, you do not need to uncomment or remove the comments, they won't show up in the PR text. --> <!-- Your Pull Request name should start with one of the following tags [NEW] For new features [IMPROVE] For an improvement (performance or little improvements) in existing features [FIX] For bug fixes that affect the end-user [BREAK] For pull requests including breaking changes Chore: For small tasks Doc: For documentation --> <!-- Checklist!!! If you're unsure about any of them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before merging your code. - I have read the Contributing Guide - https://github.com/RocketChat/Rocket.Chat/blob/develop/.github/CONTRIBUTING.md#contributing-to-rocketchat doc - I have signed the CLA - https://cla-assistant.io/RocketChat/Rocket.Chat - Lint and unit tests pass locally with my changes - I have added tests that prove my fix is effective or that my feature works (if applicable) - I have added necessary documentation (if applicable) - Any dependent changes have been merged and published in downstream modules --> <!-- CHANGELOG --> <!-- Describe the big picture of your changes here to communicate to the maintainers why we should accept this pull request. If it fixes a bug or resolves a feature request, be sure to link to that issue below. This description will appear in the release notes if we accept the contribution. --> <!-- END CHANGELOG --> <!-- Link the issues being closed by or related to this PR. For example, you can use #594 if this PR closes issue number 594 --> <!-- Mention how you would reproduce the bug if not mentioned on the issue page already. Also mention which screens are going to have the changes if applicable --> <!-- If this is a relatively large or complex change, kick off the discussion by explaining why you chose the solution you did and what alternatives you considered, etc... --> commit 610670cadfb921b6454503c413ffffbfb9d1c850 Author: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com> Date: Fri Jun 17 16:17:18 2022 -0300 [NEW] Create Team with a member list of usernames (#25868) <!-- This is a pull request template, you do not need to uncomment or remove the comments, they won't show up in the PR text. --> <!-- Your Pull Request name should start with one of the following tags [NEW] For new features [IMPROVE] For an improvement (performance or little improvements) in existing features [FIX] For bug fixes that affect the end-user [BREAK] For pull requests including breaking changes Chore: For small tasks Doc: For documentation --> <!-- Checklist!!! If you're unsure about any of them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before merging your code. - I have read the Contributing Guide - https://github.com/RocketChat/Rocket.Chat/blob/develop/.github/CONTRIBUTING.md#contributing-to-rocketchat doc - I have signed the CLA - https://cla-assistant.io/RocketChat/Rocket.Chat - Lint and unit tests pass locally with my changes - I have added tests that prove my fix is effective or that my feature works (if applicable) - I have added necessary documentation (if applicable) - Any dependent changes have been merged and published in downstream modules --> <!-- CHANGELOG --> <!-- Describe the big picture of your changes here to communicate to the maintainers why we should accept this pull request. If it fixes a bug or resolves a feature request, be sure to link to that issue below. This description will appear in the release notes if we accept the contribution. --> <!-- END CHANGELOG --> <!-- Link the issues being closed by or related to this PR. For example, you can use #594 if this PR closes issue number 594 --> <!-- Mention how you would reproduce the bug if not mentioned on the issue page already. Also mention which screens are going to have the changes if applicable --> <!-- If this is a relatively large or complex change, kick off the discussion by explaining why you chose the solution you did and what alternatives you considered, etc... --> Co-authored-by: Douglas Fabris <27704687+dougfabris@users.noreply.github.com> commit 92e3230788b7517dc6bcebc681b65f16d83a508f Author: Júlia Jaeger Foresti <60678893+juliajforesti@users.noreply.github.com> Date: Fri Jun 17 15:38:21 2022 -0300 Chore: Convert apps/meteor/client/sidebar/search (#25754) <!-- This is a pull request template, you do not need to uncomment or remove the comments, they won't show up in the PR text. --> <!-- Your Pull Request name should start with one of the following tags [NEW] For new features [IMPROVE] For an improvement (performance or little improvements) in existing features [FIX] For bug fixes that affect the end-user [BREAK] For pull requests including breaking changes Chore: For small tasks Doc: For documentation --> <!-- Checklist!!! If you're unsure about any of them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before merging your code. - I have read the Contributing Guide - https://github.com/RocketChat/Rocket.Chat/blob/develop/.github/CONTRIBUTING.md#contributing-to-rocketchat doc - I have signed the CLA - https://cla-assistant.io/RocketChat/Rocket.Chat - Lint and unit tests pass locally with my changes - I have added tests that prove my fix is effective or that my feature works (if applicable) - I have added necessary documentation (if applicable) - Any dependent changes have been merged and published in downstream modules --> <!-- CHANGELOG --> <!-- Describe the big picture of your changes here to communicate to the maintainers why we should accept this pull request. If it fixes a bug or resolves a feature request, be sure to link to that issue below. This description will appear in the release notes if we accept the contribution. --> <!-- END CHANGELOG --> <!-- Link the issues being closed by or related to this PR. For example, you can use #594 if this PR closes issue number 594 --> <!-- Mention how you would reproduce the bug if not mentioned on the issue page already. Also mention which screens are going to have the changes if applicable --> <!-- If this is a relatively large or complex change, kick off the discussion by explaining why you chose the solution you did and what alternatives you considered, etc... --> commit b46cb69e2696bd0908eb7f9e37dcec88eac208f6 Author: Douglas Fabris <devfabris@gmail.com> Date: Fri Jun 17 15:38:09 2022 -0300 Chore: Split useUserInfoActions into small hooks (#25747) <!-- This is a pull request template, you do not need to uncomment or remove the comments, they won't show up in the PR text. --> <!-- Your Pull Request name should start with one of the following tags [NEW] For new features [IMPROVE] For an improvement (performance or little improvements) in existing features [FIX] For bug fixes that affect the end-user [BREAK] For pull requests including breaking changes Chore: For small tasks Doc: For documentation --> <!-- Checklist!!! If you're unsure about any of them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before merging your code. - I have read the Contributing Guide - https://github.com/RocketChat/Rocket.Chat/blob/develop/.github/CONTRIBUTING.md#contributing-to-rocketchat doc - I have signed the CLA - https://cla-assistant.io/RocketChat/Rocket.Chat - Lint and unit tests pass locally with my changes - I have added tests that prove my fix is effective or that my feature works (if applicable) - I have added necessary documentation (if applicable) - Any dependent changes have been merged and published in downstream modules --> <!-- CHANGELOG --> <!-- Describe the big picture of your changes here to communicate to the maintainers why we should accept this pull request. If it fixes a bug or resolves a feature request, be sure to link to that issue below. This description will appear in the release notes if we accept the contribution. --> <!-- END CHANGELOG --> <!-- Link the issues being closed by or related to this PR. For example, you can use #594 if this PR closes issue number 594 --> <!-- Mention how you would reproduce the bug if not mentioned on the issue page already. Also mention which screens are going to have the changes if applicable --> <!-- If this is a relatively large or complex change, kick off the discussion by explaining why you chose the solution you did and what alternatives you considered, etc... --> commit 06043f52cc947cdbb94e46f38786683e5b4f6ef6 Author: gabriellsh <40830821+gabriellsh@users.noreply.github.com> Date: Fri Jun 17 13:31:14 2022 -0300 Chore: Watch for package changes (#25910) <!-- This is a pull request template, you do not need to uncomment or remove the comments, they won't show up in the PR text. --> <!-- Your Pull Request name should start with one of the following tags [NEW] For new features [IMPROVE] For an improvement (performance or little improvements) in existing features [FIX] For bug fixes that affect the end-user [BREAK] For pull requests including breaking changes Chore: For small tasks Doc: For documentation --> <!-- Checklist!!! If you're unsure about any of them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before merging your code. - I have read the Contributing Guide - https://github.com/RocketChat/Rocket.Chat/blob/develop/.github/CONTRIBUTING.md#contributing-to-rocketchat doc - I have signed the CLA - https://cla-assistant.io/RocketChat/Rocket.Chat - Lint and unit tests pass locally with my changes - I have added tests that prove my fix is effective or that my feature works (if applicable) - I have added necessary documentation (if applicable) - Any dependent changes have been merged and published in downstream modules --> <!-- CHANGELOG --> <!-- Describe the big picture of your changes here to communicate to the maintainers why we should accept this pull request. If it fixes a bug or resolves a feature request, be sure to link to that issue below. This description will appear in the release notes if we accept the contribution. --> With the current `dev` pipeline, whenever we modify a package (e.g. `api-client`), we have to kill the meteor proccess and run `yarn dev` again in order for the changes to be compiled and the new output to be used by meteor. This has the drawback of taking a little longer to run the dev environment, since we can't cache a watched buid. In the other hand, it reduces the friction of modifying internal packages since we don't need to rebuild the project for changes to take effect. This will enable us to move more things to separate packages without affecting the dev experience too much. <!-- END CHANGELOG --> <!-- Link the issues being closed by or related to this PR. For example, you can use #594 if this PR closes issue number 594 --> <!-- Mention how you would reproduce the bug if not mentioned on the issue page already. Also mention which screens are going to have the changes if applicable --> <!-- If this is a relatively large or complex change, kick off the discussion by explaining why you chose the solution you did and what alternatives you considered, etc... --> commit 42ae7aa8292fcd70a8c880b0b54d77d5fbfa88e4 Author: Marcos Spessatto Defendi <marcos.defendi@rocket.chat> Date: Fri Jun 17 13:22:44 2022 -0300 Chore: Convert assets endpoint to Typescript (#25358) Co-authored-by: Guilherme Gazzo <guilhermegazzo@gmail.com> Co-authored-by: Guilherme Gazzo <guilherme@gazzo.xyz> commit 6b6f9dcb74dcfea8e9355d8ecab925c0801c4a26 Author: Guilherme Gazzo <guilhermegazzo@gmail.com> Date: Fri Jun 17 10:37:17 2022 -0300 Chore: Convert users endpoints (#25635) Co-authored-by: Guilherme Gazzo <guilherme@gazzo.xyz> Co-authored-by: Tasso Evangelista <tasso.evangelista@rocket.chat> commit 2c05ffd12647f2685efeccf142477c158e7af844 Author: Diego Sampaio <chinello@gmail.com> Date: Fri Jun 17 09:59:27 2022 -0300 [FIX] Settings not being overwritten to their default values (#25891) <!-- This is a pull request template, you do not need to uncomment or remove the comments, they won't show up in the PR text. --> <!-- Your Pull Request name should start with one of the following tags [NEW] For new features [IMPROVE] For an improvement (performance or little improvements) in existing features [FIX] For bug fixes that affect the end-user [BREAK] For pull requests including breaking changes Chore: For small tasks Doc: For documentation --> <!-- Checklist!!! If you're unsure about any of them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before merging your code. - I have read the Contributing Guide - https://github.com/RocketChat/Rocket.Chat/blob/develop/.github/CONTRIBUTING.md#contributing-to-rocketchat doc - I have signed the CLA - https://cla-assistant.io/RocketChat/Rocket.Chat - Lint and unit tests pass locally with my changes - I have added tests that prove my fix is effective or that my feature works (if applicable) - I have added necessary documentation (if applicable) - Any dependent changes have been merged and published in downstream modules --> <!-- CHANGELOG --> <!-- Describe the big picture of your changes here to communicate to the maintainers why we should accept this pull request. If it fixes a bug or resolves a feature request, be sure to link to that issue below. This description will appear in the release notes if we accept the contribution. --> <!-- END CHANGELOG --> <!-- Link the issues being closed by or related to this PR. For example, you can use #594 if this PR closes issue number 594 --> <!-- Mention how you would reproduce the bug if not mentioned on the issue page already. Also mention which screens are going to have the changes if applicable --> <!-- If this is a relatively large or complex change, kick off the discussion by explaining why you chose the solution you did and what alternatives you considered, etc... --> --- .../build-docker-image-service/action.yml | 69 ++ .github/actions/build-docker-image/action.yml | 88 ++ .github/workflows/build_and_test.yml | 958 ++++++++------- .yarnrc.yml | 3 +- _templates/package/new/package.json.ejs.t | 3 +- apps/meteor/.docker/Dockerfile.alpine | 2 +- apps/meteor/.eslintignore | 1 - apps/meteor/.gitignore | 1 + apps/meteor/.meteorignore | 1 + apps/meteor/.scripts/start.js | 196 ---- apps/meteor/app/api/server/api.d.ts | 11 +- apps/meteor/app/api/server/lib/users.ts | 15 +- .../api/server/v1/{assets.js => assets.ts} | 26 +- apps/meteor/app/api/server/v1/teams.ts | 8 +- .../app/api/server/v1/{users.js => users.ts} | 1041 ++++++++--------- .../assets/server/{assets.js => assets.ts} | 205 ++-- .../app/assets/server/{index.js => index.ts} | 0 apps/meteor/app/favico/client/favico.js | 844 ------------- apps/meteor/app/favico/client/index.js | 3 - apps/meteor/app/favico/index.js | 1 - ...{getFullUserData.js => getFullUserData.ts} | 40 +- .../app/lib/server/functions/setUserAvatar.ts | 22 +- .../app/livechat/client/lib/chartHandler.js | 221 ---- .../app/livechat/client/lib/chartHandler.ts | 232 ++++ apps/meteor/app/models/server/raw/Users.js | 9 + .../app/settings/server/SettingsRegistry.ts | 18 +- apps/meteor/app/utils/lib/mimeTypes.js | 8 - apps/meteor/app/utils/lib/mimeTypes.ts | 14 + .../components/Header/Header.stories.tsx | 13 +- .../client/components/Omnichannel/Tags.tsx | 5 +- .../context/OmnichannelRoomIconContext.tsx | 21 +- .../provider/OmnichannelRoomIconProvider.tsx | 32 +- .../VerticalBar/VerticalBarActionBack.tsx | 9 - .../VerticalBar/VerticalBarBack.tsx | 13 + .../client/components/VerticalBar/index.ts | 4 +- .../components/voip/modal/WrapUpCallModal.tsx | 8 +- apps/meteor/client/contexts/CallContext.ts | 38 +- apps/meteor/client/hooks/usePresence.ts | 25 +- apps/meteor/client/hooks/useReactiveValue.ts | 16 +- apps/meteor/client/hooks/useUserData.ts | 25 +- apps/meteor/client/importPackages.ts | 1 - apps/meteor/client/lib/RoomManager.ts | 44 +- apps/meteor/client/lib/appLayout.ts | 7 +- apps/meteor/client/lib/banners.ts | 9 +- apps/meteor/client/lib/createSidebarItems.ts | 22 +- .../client/lib/createValueSubscription.ts | 22 - .../meteor/client/lib/portals/blazePortals.ts | 12 +- .../client/lib/portals/portalsSubscription.ts | 12 +- apps/meteor/client/lib/presence.ts | 14 - .../providers/CallProvider/CallProvider.tsx | 10 +- .../client/providers/MeteorProvider.tsx | 12 +- .../client/providers/RouterProvider.tsx | 11 +- .../createReactiveSubscriptionFactory.ts | 15 +- .../RoomList/SideBarItemTemplateWithData.tsx | 4 +- .../sidebar/{RoomMenu.js => RoomMenu.tsx} | 66 +- .../meteor/client/sidebar/Sidebar.stories.tsx | 33 +- .../client/sidebar/search/{Row.js => Row.tsx} | 11 +- .../sidebar/search/ScrollerWithCustomProps.js | 16 - .../search/ScrollerWithCustomProps.tsx | 16 + .../search/{SearchList.js => SearchList.tsx} | 137 ++- .../search/{UserItem.js => UserItem.tsx} | 24 +- apps/meteor/client/startup/unread.ts | 15 +- .../client/views/account/AccountSidebar.tsx | 6 +- apps/meteor/client/views/account/index.ts | 2 +- .../client/views/account/sidebarItems.ts | 3 +- .../views/admin/EditableSettingsContext.ts | 53 +- .../meteor/client/views/admin/apps/AppMenu.js | 32 +- .../views/admin/sidebar/AdminSidebarPages.tsx | 7 +- .../meteor/client/views/admin/sidebarItems.ts | 3 +- .../client/views/admin/users/EditUser.js | 1 - .../client/views/banners/BannerRegion.tsx | 4 +- .../client/views/hooks/useActionSpread.ts | 14 +- .../views/omnichannel/additionalForms.tsx | 23 +- .../views/omnichannel/agents/AgentEdit.tsx | 5 +- .../analytics/InterchangeableChart.js | 12 +- .../BusinessHoursFormContainer.js | 5 +- .../omnichannel/currentChats/FilterByText.tsx | 5 +- .../customFields/EditCustomFieldsPage.js | 5 +- .../customFields/NewCustomFieldsPage.js | 5 +- .../omnichannel/departments/EditDepartment.js | 5 +- .../directory/chats/contextualBar/RoomEdit.js | 5 +- .../contacts/contextualBar/ContactNewEdit.js | 5 +- .../charts/AgentStatusChart.js | 2 +- .../charts/ChatDurationChart.js | 12 +- .../charts/ChatsPerAgentChart.js | 1 + .../charts/ResponseTimesChart.js | 12 +- .../sidebar/OmnichannelSidebar.tsx | 6 +- .../client/views/omnichannel/sidebarItems.ts | 3 +- .../contexts/SelectedMessagesContext.tsx | 50 +- .../providers/MessageHighlightProvider.tsx | 6 +- .../providers/messageHighlightSubscription.ts | 27 +- .../RoomMembers/AddUsers/AddUsers.stories.tsx | 2 +- .../AddUsers/{AddUsers.js => AddUsers.tsx} | 17 +- ...dUsersWithData.js => AddUsersWithData.tsx} | 21 +- .../views/room/hooks/useUserHasRoomRole.ts | 8 + .../views/room/hooks/useUserInfoActions.js | 415 ------- .../actions/useAudioCallAction.ts | 33 + .../actions/useBlockUserAction.ts | 51 + .../actions/useChangeLeaderAction.ts | 53 + .../actions/useChangeModeratorAction.ts | 54 + .../actions/useChangeOwnerAction.tsx | 53 + .../actions/useDirectMessageAction.ts | 54 + .../actions/useIgnoreUserAction.ts | 52 + .../actions/useMuteUserAction.tsx | 119 ++ .../actions/useRemoveUserAction.tsx | 92 ++ .../actions/useVideoCallAction.tsx | 33 + .../room/hooks/useUserInfoActions/index.ts | 1 + .../useUserInfoActions/useUserInfoActions.ts | 60 + .../views/room/lib/getRoomDirectives.ts | 33 + apps/meteor/client/views/root/AppLayout.tsx | 6 +- .../client/views/root/PortalsWrapper.tsx | 4 +- .../teams/CreateTeamModal/CreateTeamModal.tsx | 211 +--- .../teams/CreateTeamModal/UsersInput.tsx | 90 -- .../useCreateTeamModalState.ts | 193 +++ .../client/views/teams/{index.js => index.ts} | 0 .../definition/externals/meteor/meteor.d.ts | 4 + .../externals/meteor/webapp-hashing.d.ts | 5 + apps/meteor/package.json | 10 +- .../rocketchat-i18n/i18n/en.i18n.json | 2 + apps/meteor/server/sdk/types/ITeamService.ts | 1 + apps/meteor/server/services/team/service.ts | 16 +- .../e2e/utils/configs/verifyTestBaseUrl.ts | 3 - apps/meteor/tests/end-to-end/api/01-users.js | 2 +- apps/meteor/tests/end-to-end/api/25-teams.js | 41 +- .../tests/mocks/client/RouterContextMock.tsx | 41 +- apps/meteor/tests/unit/lib/mimeTypes.tests.ts | 15 + package.json | 4 +- packages/api-client/package.json | 3 +- packages/core-typings/package.json | 1 + .../core-typings/src/IRocketChatAssets.ts | 54 + packages/core-typings/src/ISetting.ts | 6 +- packages/core-typings/src/ISubscription.ts | 2 +- packages/core-typings/src/IUser.ts | 1 + packages/core-typings/src/index.ts | 1 + packages/favicon/.eslintrc | 8 + packages/favicon/package.json | 20 + packages/favicon/src/badge.ts | 87 ++ packages/favicon/src/index.ts | 111 ++ packages/favicon/tsconfig.json | 9 + packages/livechat/package.json | 1 + packages/rest-typings/package.json | 1 + packages/rest-typings/src/index.ts | 9 + packages/rest-typings/src/v1/assets.ts | 28 + packages/rest-typings/src/v1/groups.ts | 18 + packages/rest-typings/src/v1/users.ts | 233 +++- .../src/v1/users/UserCreateParamsPOST.ts | 51 + .../v1/users/UserDeactivateIdleParamsPOST.ts | 26 + .../src/v1/users/UserLogoutParamsPOST.ts | 22 + .../src/v1/users/UserRegisterParamsPOST.ts | 47 + .../v1/users/UserSetActiveStatusParamsPOST.ts | 24 + .../v1/users/UsersAutocompleteParamsGET.ts | 20 + .../src/v1/users/UsersInfoParamsGet.ts | 44 + .../src/v1/users/UsersListTeamsParamsGET.ts | 21 + .../src/v1/users/UsersSetAvatarParamsPOST.ts | 33 + .../v1/users/UsersSetPreferenceParamsPOST.ts | 190 +++ .../UsersUpdateOwnBasicInfoParamsPOST.ts | 67 ++ .../src/v1/users/UsersUpdateParamsPOST.ts | 118 ++ packages/ui-contexts/package.json | 9 +- .../ui-contexts/src/AuthorizationContext.ts | 38 +- packages/ui-contexts/src/RouterContext.ts | 41 +- .../ui-contexts/src/ServerContext/methods.ts | 20 + packages/ui-contexts/src/SessionContext.ts | 8 +- packages/ui-contexts/src/SettingsContext.ts | 19 +- packages/ui-contexts/src/UserContext.ts | 43 +- .../src/hooks/useAllPermissions.ts | 6 +- .../src/hooks/useAtLeastOnePermission.ts | 9 +- .../ui-contexts/src/hooks/useCurrentRoute.ts | 5 +- .../ui-contexts/src/hooks/usePermission.ts | 6 +- .../src/hooks/useQueryStringParameter.ts | 6 +- packages/ui-contexts/src/hooks/useRole.ts | 6 +- .../src/hooks/useRolesDescription.ts | 14 +- packages/ui-contexts/src/hooks/useRoute.ts | 5 +- .../src/hooks/useRouteParameter.ts | 6 +- .../ui-contexts/src/hooks/useRoutePath.ts | 9 +- packages/ui-contexts/src/hooks/useRouteUrl.ts | 9 +- packages/ui-contexts/src/hooks/useSession.ts | 6 +- .../src/hooks/useSettingStructure.ts | 6 +- packages/ui-contexts/src/hooks/useSettings.ts | 6 +- .../src/hooks/useUserPreference.ts | 6 +- packages/ui-contexts/src/hooks/useUserRoom.ts | 6 +- .../src/hooks/useUserSubscription.ts | 6 +- .../src/hooks/useUserSubscriptionByName.ts | 8 +- .../src/hooks/useUserSubscriptions.ts | 6 +- turbo.json | 4 + yarn.lock | 81 +- 185 files changed, 4599 insertions(+), 3931 deletions(-) create mode 100644 .github/actions/build-docker-image-service/action.yml create mode 100644 .github/actions/build-docker-image/action.yml delete mode 100644 apps/meteor/.scripts/start.js rename apps/meteor/app/api/server/v1/{assets.js => assets.ts} (73%) rename apps/meteor/app/api/server/v1/{users.js => users.ts} (66%) rename apps/meteor/app/assets/server/{assets.js => assets.ts} (63%) rename apps/meteor/app/assets/server/{index.js => index.ts} (100%) delete mode 100644 apps/meteor/app/favico/client/favico.js delete mode 100644 apps/meteor/app/favico/client/index.js delete mode 100644 apps/meteor/app/favico/index.js rename apps/meteor/app/lib/server/functions/{getFullUserData.js => getFullUserData.ts} (65%) delete mode 100644 apps/meteor/app/livechat/client/lib/chartHandler.js create mode 100644 apps/meteor/app/livechat/client/lib/chartHandler.ts delete mode 100644 apps/meteor/app/utils/lib/mimeTypes.js create mode 100644 apps/meteor/app/utils/lib/mimeTypes.ts delete mode 100644 apps/meteor/client/components/VerticalBar/VerticalBarActionBack.tsx create mode 100644 apps/meteor/client/components/VerticalBar/VerticalBarBack.tsx delete mode 100644 apps/meteor/client/lib/createValueSubscription.ts rename apps/meteor/client/sidebar/{RoomMenu.js => RoomMenu.tsx} (76%) rename apps/meteor/client/sidebar/search/{Row.js => Row.tsx} (73%) delete mode 100644 apps/meteor/client/sidebar/search/ScrollerWithCustomProps.js create mode 100644 apps/meteor/client/sidebar/search/ScrollerWithCustomProps.tsx rename apps/meteor/client/sidebar/search/{SearchList.js => SearchList.tsx} (60%) rename apps/meteor/client/sidebar/search/{UserItem.js => UserItem.tsx} (59%) rename apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/{AddUsers.js => AddUsers.tsx} (61%) rename apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/{AddUsersWithData.js => AddUsersWithData.tsx} (67%) create mode 100644 apps/meteor/client/views/room/hooks/useUserHasRoomRole.ts delete mode 100644 apps/meteor/client/views/room/hooks/useUserInfoActions.js create mode 100644 apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useAudioCallAction.ts create mode 100644 apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useBlockUserAction.ts create mode 100644 apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeLeaderAction.ts create mode 100644 apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeModeratorAction.ts create mode 100644 apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeOwnerAction.tsx create mode 100644 apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useDirectMessageAction.ts create mode 100644 apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useIgnoreUserAction.ts create mode 100644 apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useMuteUserAction.tsx create mode 100644 apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useRemoveUserAction.tsx create mode 100644 apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useVideoCallAction.tsx create mode 100644 apps/meteor/client/views/room/hooks/useUserInfoActions/index.ts create mode 100644 apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts create mode 100644 apps/meteor/client/views/room/lib/getRoomDirectives.ts delete mode 100644 apps/meteor/client/views/teams/CreateTeamModal/UsersInput.tsx create mode 100644 apps/meteor/client/views/teams/CreateTeamModal/useCreateTeamModalState.ts rename apps/meteor/client/views/teams/{index.js => index.ts} (100%) create mode 100644 apps/meteor/definition/externals/meteor/webapp-hashing.d.ts create mode 100644 apps/meteor/tests/unit/lib/mimeTypes.tests.ts create mode 100644 packages/core-typings/src/IRocketChatAssets.ts create mode 100644 packages/favicon/.eslintrc create mode 100644 packages/favicon/package.json create mode 100644 packages/favicon/src/badge.ts create mode 100644 packages/favicon/src/index.ts create mode 100644 packages/favicon/tsconfig.json create mode 100644 packages/rest-typings/src/v1/assets.ts create mode 100644 packages/rest-typings/src/v1/users/UserCreateParamsPOST.ts create mode 100644 packages/rest-typings/src/v1/users/UserDeactivateIdleParamsPOST.ts create mode 100644 packages/rest-typings/src/v1/users/UserLogoutParamsPOST.ts create mode 100644 packages/rest-typings/src/v1/users/UserRegisterParamsPOST.ts create mode 100644 packages/rest-typings/src/v1/users/UserSetActiveStatusParamsPOST.ts create mode 100644 packages/rest-typings/src/v1/users/UsersAutocompleteParamsGET.ts create mode 100644 packages/rest-typings/src/v1/users/UsersInfoParamsGet.ts create mode 100644 packages/rest-typings/src/v1/users/UsersListTeamsParamsGET.ts create mode 100644 packages/rest-typings/src/v1/users/UsersSetAvatarParamsPOST.ts create mode 100644 packages/rest-typings/src/v1/users/UsersSetPreferenceParamsPOST.ts create mode 100644 packages/rest-typings/src/v1/users/UsersUpdateOwnBasicInfoParamsPOST.ts create mode 100644 packages/rest-typings/src/v1/users/UsersUpdateParamsPOST.ts diff --git a/.github/actions/build-docker-image-service/action.yml b/.github/actions/build-docker-image-service/action.yml new file mode 100644 index 00000000000..aba20175b94 --- /dev/null +++ b/.github/actions/build-docker-image-service/action.yml @@ -0,0 +1,69 @@ +name: 'Build Micro Services Docker image' +description: 'Build Rocket.Chat Micro Services Docker images' + +inputs: + docker-tag: + required: true + service: + required: true + username: + required: false + password: + required: false + +outputs: + image-name: + value: ${{ steps.build-image.outputs.image-name }} + +runs: + using: "composite" + steps: + # - shell: bash + # name: Free disk space + # run: | + # sudo swapoff -a + # sudo rm -f /swapfile + # sudo apt clean + # docker rmi $(docker image ls -aq) + # df -h + + - shell: bash + id: build-image + run: | + LOWERCASE_REPOSITORY=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") + + IMAGE_TAG="${{ inputs.docker-tag }}" + + IMAGE_NAME="ghcr.io/${LOWERCASE_REPOSITORY}/${{ inputs.service }}-service:${IMAGE_TAG}" + + echo "Building Docker image for service: ${{ inputs.service }}:${IMAGE_TAG}" + + if [[ "${{ inputs.service }}" == "ddp-streamer" ]]; then + DOCKERFILE_PATH="./ee/apps/ddp-streamer/Dockerfile" + else + DOCKERFILE_PATH="./apps/meteor/ee/server/services/Dockerfile" + fi + + docker build \ + --build-arg SERVICE=${{ inputs.service }} \ + -t ${IMAGE_NAME} \ + -f ${DOCKERFILE_PATH} \ + . + + echo "::set-output name=image-name::${IMAGE_NAME}" + + - name: Login to GitHub Container Registry + if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop' + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ inputs.username }} + password: ${{ inputs.password }} + + - name: Publish image + shell: bash + if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop' + run: | + echo "Push Docker image: ${{ steps.build-image.outputs.image-name }}" + + docker push ${{ steps.build-image.outputs.image-name }} diff --git a/.github/actions/build-docker-image/action.yml b/.github/actions/build-docker-image/action.yml new file mode 100644 index 00000000000..240c502efd1 --- /dev/null +++ b/.github/actions/build-docker-image/action.yml @@ -0,0 +1,88 @@ +name: 'Build Docker image' +description: 'Build Rocket.Chat Docker image' + +inputs: + root-dir: + required: true + docker-tag: + required: true + release: + required: true + username: + required: false + password: + required: false + +outputs: + image-name: + value: ${{ steps.build-image.outputs.image-name }} + +runs: + using: composite + steps: + # - shell: bash + # name: Free disk space + # run: | + # sudo swapoff -a + # sudo rm -f /swapfile + # sudo apt clean + # docker rmi $(docker image ls -aq) + # df -h + + - shell: bash + id: build-image + run: | + cd ${{ inputs.root-dir }} + + LOWERCASE_REPOSITORY=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") + + IMAGE_NAME_BASE="ghcr.io/${LOWERCASE_REPOSITORY}/rocket.chat:${{ inputs.docker-tag }}" + + IMAGE_NAME="${IMAGE_NAME_BASE}.${{ inputs.release }}" + + echo "Build Docker image ${IMAGE_NAME}" + + DOCKER_PATH="${GITHUB_WORKSPACE}/apps/meteor/.docker" + if [[ '${{ inputs.release }}' = 'preview' ]]; then + DOCKER_PATH="${DOCKER_PATH}-mongo" + fi; + + DOCKERFILE_PATH="${DOCKER_PATH}/Dockerfile" + if [[ '${{ inputs.release }}' = 'alpine' ]]; then + DOCKERFILE_PATH="${DOCKERFILE_PATH}.${{ inputs.release }}" + fi; + + echo "Copy Dockerfile for release: ${{ inputs.release }}" + cp $DOCKERFILE_PATH ./Dockerfile + if [ -e ${DOCKER_PATH}/entrypoint.sh ]; then + cp ${DOCKER_PATH}/entrypoint.sh . + fi; + + echo "Build ${{ inputs.release }} Docker image" + docker build -t $IMAGE_NAME . + + echo "::set-output name=image-name-base::${IMAGE_NAME_BASE}" + echo "::set-output name=image-name::${IMAGE_NAME}" + + - name: Login to GitHub Container Registry + if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop' + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ inputs.username }} + password: ${{ inputs.password }} + + - name: Publish image + shell: bash + if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop' + run: | + echo "Push Docker image: ${{ steps.build-image.outputs.image-name }}" + + docker push ${{ steps.build-image.outputs.image-name }} + + if [[ '${{ inputs.release }}' = 'official' ]]; then + echo "Push release official without variant" + + docker tag ${{ steps.build-image.outputs.image-name }} ${{ steps.build-image.outputs.image-name-base }} + docker push ${{ steps.build-image.outputs.image-name-base }} + fi; diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index ecfb34c5b1e..e62ee821748 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -15,10 +15,50 @@ concurrency: env: CI: true - MONGO_URL: mongodb://localhost:27017 + MONGO_URL: mongodb://localhost:27017/rocketchat + MONGO_OPLOG_URL: mongodb://mongo:27017/local TOOL_NODE_FLAGS: --max_old_space_size=4096 + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ secrets.TURBO_TEAM }} jobs: + release-versions: + runs-on: ubuntu-latest + outputs: + release: ${{ steps.by-tag.outputs.release }} + latest-release: ${{ steps.latest.outputs.latest-release }} + docker-tag: ${{ steps.docker.outputs.docker-tag }} + gh-docker-tag: ${{ steps.docker.outputs.gh-docker-tag }} + steps: + - id: by-tag + run: | + if echo "$GITHUB_REF_NAME" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' ; then + RELEASE="latest" + elif echo "$GITHUB_REF_NAME" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$' ; then + RELEASE="release-candidate" + fi + echo "RELEASE: ${RELEASE}" + echo "::set-output name=release::${RELEASE}" + + - id: latest + run: | + LATEST_RELEASE="$( + git -c 'versionsort.suffix=-' ls-remote -t --exit-code --refs --sort=-v:refname "https://github.com/$GITHUB_REPOSITORY" '*' | + sed -En '1!q;s/^[[:xdigit:]]+[[:space:]]+refs\/tags\/(.+)/\1/gp' + )" + echo "LATEST_RELEASE: ${LATEST_RELEASE}" + echo "::set-output name=latest-release::${LATEST_RELEASE}" + + - id: docker + run: | + if [[ '${{ github.event_name }}' == 'pull_request' ]]; then + DOCKER_TAG="pr-${{ github.event.number }}" + else + DOCKER_TAG="gh-${{ github.run_id }}" + fi + echo "DOCKER_TAG: ${DOCKER_TAG}" + echo "::set-output name=gh-docker-tag::${DOCKER_TAG}" + build: runs-on: ubuntu-20.04 @@ -33,12 +73,13 @@ jobs: echo "github.event_name: ${{ github.event_name }}" cat $GITHUB_EVENT_PATH + - uses: actions/checkout@v3 + - name: Use Node.js 14.18.3 uses: actions/setup-node@v3 with: node-version: '14.18.3' - - - uses: actions/checkout@v3 + cache: 'yarn' - name: Free disk space run: | @@ -48,40 +89,22 @@ jobs: docker rmi $(docker image ls -aq) df -h - # TODO is this required? - # - name: check package-lock - # run: | - # npx package-lock-check - - - uses: c-hive/gha-yarn-cache@v2 - - name: Cache turbo - id: cache-turbo - uses: actions/cache@v2 - with: - path: | - ./node_modules/.turbo - key: ${{ runner.OS }}-turbo-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-turbo- - ${{ runner.os }}- - - # TODO change to use turbo cache - name: Cache meteor local uses: actions/cache@v2 with: path: ./apps/meteor/.meteor/local - key: ${{ runner.OS }}-meteor_cache-${{ hashFiles('apps/meteor/.meteor/versions') }} + key: meteor-local-cache-${{ runner.OS }}-${{ hashFiles('apps/meteor/.meteor/versions') }} restore-keys: | - ${{ runner.os }}-meteor_cache- - ${{ runner.os }}- + meteor-local-cache-${{ runner.os }}- + - name: Cache meteor uses: actions/cache@v2 with: path: ~/.meteor - key: ${{ runner.OS }}-meteor-${{ hashFiles('apps/meteor/.meteor/release') }} + key: meteor-cache-${{ runner.OS }}-${{ hashFiles('apps/meteor/.meteor/release') }} restore-keys: | - ${{ runner.os }}-meteor- - ${{ runner.os }}- + meteor-cache-${{ runner.os }}- + - name: Install Meteor run: | # Restore bin from cache @@ -114,13 +137,20 @@ jobs: - name: yarn install run: yarn - - run: yarn lint + - name: TurboRepo local server + uses: felixmosh/turborepo-gh-artifacts@v1 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + server-token: ${{ secrets.TURBO_SERVER_TOKEN }} + + - name: Lint + run: yarn lint --api="http://127.0.0.1:9080" - - run: yarn turbo run translation-check + - name: Translation check + run: yarn turbo run translation-check --api="http://127.0.0.1:9080" - name: TS typecheck - run: | - yarn turbo run typecheck + run: yarn turbo run typecheck --api="http://127.0.0.1:9080" - name: Reset Meteor if: startsWith(github.ref, 'refs/tags/') == 'true' || github.ref == 'refs/heads/develop' @@ -132,39 +162,61 @@ jobs: if: startsWith(github.ref, 'refs/pull/') == true env: METEOR_PROFILE: 1000 - run: | - yarn build:ci -- --debug --directory /tmp/build-test + run: yarn build:ci --api="http://127.0.0.1:9080" -- --debug --directory dist - name: Build Rocket.Chat if: startsWith(github.ref, 'refs/pull/') != true - run: | - yarn build:ci -- --directory /tmp/build-test + run: yarn build:ci --api="http://127.0.0.1:9080" -- --directory dist - name: Prepare build run: | - mkdir /tmp/build/ - cd /tmp/build-test - tar czf /tmp/build/Rocket.Chat.tar.gz bundle - cd /tmp/build-test/bundle/programs/server - npm install --production - cd /tmp - tar czf Rocket.Chat.test.tar.gz ./build-test - - - name: Store build for tests - uses: actions/upload-artifact@v2 - with: - name: build-test - path: /tmp/Rocket.Chat.test.tar.gz + cd apps/meteor/dist + tar czf /tmp/Rocket.Chat.tar.gz bundle - name: Store build uses: actions/upload-artifact@v2 + with: + name: build + path: /tmp/Rocket.Chat.tar.gz + + build-docker-preview: + runs-on: ubuntu-20.04 + needs: [build, release-versions] + if: github.event_name == 'release' || github.ref == 'refs/heads/develop' + steps: + - uses: actions/checkout@v3 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + cache: 'yarn' + + - name: Restore build + uses: actions/download-artifact@v2 with: name: build path: /tmp/build + - name: Unpack build + run: | + cd /tmp/build + tar xzf Rocket.Chat.tar.gz + rm Rocket.Chat.tar.gz + + - name: Build Docker image + id: build-docker-image-preview + uses: ./.github/actions/build-docker-image + with: + root-dir: /tmp/build + docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + release: preview + username: ${{ secrets.CR_USER }} + password: ${{ secrets.CR_PAT }} + test: runs-on: ubuntu-20.04 - needs: build + needs: [build, release-versions] strategy: matrix: @@ -177,79 +229,142 @@ jobs: with: mongoDBVersion: ${{ matrix.mongodb-version }} --replSet=rs0 - - name: Restore build for tests - uses: actions/download-artifact@v2 - with: - name: build-test - path: /tmp - - - name: Decompress build - run: | - cd /tmp - tar xzf Rocket.Chat.test.tar.gz - cd - + - uses: actions/checkout@v3 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} + cache: 'yarn' - name: Setup Chrome - run: | - npm i chromedriver + run: npm i chromedriver - name: Configure Replica Set run: | docker exec mongo mongo --eval 'rs.initiate({_id:"rs0", members: [{"_id":1, "host":"localhost:27017"}]})' docker exec mongo mongo --eval 'rs.status()' - - uses: actions/checkout@v3 + - name: yarn install + run: yarn - - uses: c-hive/gha-yarn-cache@v2 - - name: Cache turbo - id: cache-turbo - uses: actions/cache@v2 + - name: TurboRepo local server + uses: felixmosh/turborepo-gh-artifacts@v1 with: - path: | - ./node_modules/.turbo - key: ${{ runner.OS }}-turbo-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-turbo- - ${{ runner.os }}- - - - name: Yarn install - # if: steps.cache-nodemodules.outputs.cache-hit != 'true' || steps.cache-cypress.outputs.cache-hit != 'true' - run: yarn + repo-token: ${{ secrets.GITHUB_TOKEN }} + server-token: ${{ secrets.TURBO_SERVER_TOKEN }} - name: Unit Test - run: yarn testunit + run: yarn testunit --api="http://127.0.0.1:9080" - - name: Install Playwright + - name: Restore build + uses: actions/download-artifact@v2 + with: + name: build + path: /tmp/build + + - name: Unpack build run: | - cd ./apps/meteor - npx playwright install --with-deps + cd /tmp/build + tar xzf Rocket.Chat.tar.gz + rm Rocket.Chat.tar.gz + + - name: Build Docker image + id: build-docker-image + if: matrix.mongodb-version != '5.0' + uses: ./.github/actions/build-docker-image + with: + root-dir: /tmp/build + docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + release: official + username: ${{ secrets.CR_USER }} + password: ${{ secrets.CR_PAT }} + + - name: Build Alpine Docker image + id: build-docker-image-alpine + if: matrix.mongodb-version == '5.0' + uses: ./.github/actions/build-docker-image + with: + root-dir: /tmp/build + docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + release: alpine + username: ${{ secrets.CR_USER }} + password: ${{ secrets.CR_PAT }} + + # TODO move startup/restart to its own github action + - name: Start up Rocket.Chat + run: | + LOWERCASE_REPOSITORY=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") + + # test alpine image on mongo 5.0 (no special reason to be mongo 5.0 but we need to test alpine at least once) + if [[ '${{ matrix.mongodb-version }}' = '5.0' ]]; then + IMAGE_TAG="${{ needs.release-versions.outputs.gh-docker-tag }}.alpine" + else + IMAGE_TAG="${{ needs.release-versions.outputs.gh-docker-tag }}.official" + fi; + + IMAGE_NAME="ghcr.io/${LOWERCASE_REPOSITORY}/rocket.chat:${IMAGE_TAG}" + + docker run --name rocketchat -d \ + --link mongo \ + -p 3000:3000 \ + -e TEST_MODE=true \ + -e MONGO_URL=mongodb://mongo:27017/rocketchat \ + -e MONGO_OPLOG_URL=mongodb://mongo:27017/local \ + ${IMAGE_NAME} - name: E2E Test API - env: - TEST_MODE: 'true' - MONGO_URL: mongodb://localhost:27017/rocketchat - MONGO_OPLOG_URL: mongodb://localhost:27017/local run: | + docker logs rocketchat --tail=50 + cd ./apps/meteor - echo -e 'pcm.!default {\n type hw\n card 0\n}\n\nctl.!default {\n type hw\n card 0\n}' > ~/.asoundrc - Xvfb -screen 0 1024x768x24 :99 & - for i in $(seq 1 5); do (docker exec mongo mongo rocketchat --eval 'db.dropDatabase()') && npm run testci -- --test=testapi && s=0 && break || s=$? && sleep 1; done; (exit $s) + for i in $(seq 1 5); do + docker stop rocketchat + docker exec mongo mongo rocketchat --eval 'db.dropDatabase()' - - name: E2E Test UI - env: - TEST_MODE: 'true' - MONGO_URL: mongodb://localhost:27017/rocketchat - MONGO_OPLOG_URL: mongodb://localhost:27017/local + NOW=$(date "+%Y-%m-%dT%H:%M:%SZ") + echo $NOW + + docker start rocketchat + + until echo "$(docker logs rocketchat --since $NOW)" | grep -q "SERVER RUNNING"; do + echo "Waiting Rocket.Chat to start up" + ((c++)) && ((c==10)) && exit 1 + sleep 10 + done + + npm run testapi && s=0 && break || s=$? && docker logs rocketchat --tail=100; + done; + exit $s + + - name: Install Playwright run: | cd ./apps/meteor + npx playwright install --with-deps + + - name: E2E Test UI + run: | echo -e 'pcm.!default {\n type hw\n card 0\n}\n\nctl.!default {\n type hw\n card 0\n}' > ~/.asoundrc Xvfb -screen 0 1024x768x24 :99 & - docker exec mongo mongo rocketchat --eval 'db.dropDatabase()' && npm run testci -- --test=test:playwright + + docker logs rocketchat --tail=50 + + docker stop rocketchat + docker exec mongo mongo rocketchat --eval 'db.dropDatabase()' + + NOW=$(date "+%Y-%m-%dT%H:%M:%SZ") + echo $NOW + + docker start rocketchat + + until echo "$(docker logs rocketchat --since $NOW)" | grep -q "SERVER RUNNING"; do + echo "Waiting Rocket.Chat to start up" + ((c++)) && ((c==10)) && exit 1 + sleep 10 + done + + cd ./apps/meteor + npm run test:playwright - name: Store playwright test trace uses: actions/upload-artifact@v2 @@ -260,7 +375,7 @@ jobs: test-ee: runs-on: ubuntu-20.04 - needs: build + needs: [build, release-versions] strategy: matrix: @@ -276,311 +391,304 @@ jobs: - name: Launch NATS run: sudo docker run --name nats -d -p 4222:4222 nats:2.4 - - name: Restore build for tests - uses: actions/download-artifact@v2 - with: - name: build-test - path: /tmp - - - name: Decompress build - run: | - cd /tmp - tar xzf Rocket.Chat.test.tar.gz - cd - + - uses: actions/checkout@v3 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} + cache: 'yarn' - name: Setup Chrome - run: | - npm i chromedriver + run: npm i chromedriver - name: Configure Replica Set run: | docker exec mongo mongo --eval 'rs.initiate({_id:"rs0", members: [{"_id":1, "host":"localhost:27017"}]})' docker exec mongo mongo --eval 'rs.status()' - - uses: actions/checkout@v3 - - - uses: c-hive/gha-yarn-cache@v2 - - name: Cache turbo - id: cache-turbo - uses: actions/cache@v2 + - name: TurboRepo local server + uses: felixmosh/turborepo-gh-artifacts@v1 with: - path: | - ./node_modules/.turbo - key: ${{ runner.OS }}-turbo-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-turbo- - ${{ runner.os }}- + repo-token: ${{ secrets.GITHUB_TOKEN }} + server-token: ${{ secrets.TURBO_SERVER_TOKEN }} - - name: Yarn install + - name: yarn install run: yarn - - name: Build micro services - run: yarn build - - - name: E2E Test API - env: - TEST_MODE: 'true' - MONGO_URL: mongodb://localhost:27017/rocketchat - MONGO_OPLOG_URL: mongodb://localhost:27017/local - ENTERPRISE_LICENSE: ${{ secrets.ENTERPRISE_LICENSE }} - TRANSPORTER: nats://localhost:4222 - SKIP_PROCESS_EVENT_REGISTRATION: 'true' - run: | - echo -e 'pcm.!default {\n type hw\n card 0\n}\n\nctl.!default {\n type hw\n card 0\n}' > ~/.asoundrc - Xvfb -screen 0 1024x768x24 :99 & - - cd ./apps/meteor/ + - name: Unit Test + run: yarn testunit --api="http://127.0.0.1:9080" - for i in $(seq 1 5); do (docker exec mongo mongo rocketchat --eval 'db.dropDatabase()') && npm run testci -- --enterprise --test=testapi && s=0 && break || s=$? && sleep 1; done; (exit $s) + - name: Restore build + uses: actions/download-artifact@v2 + with: + name: build + path: /tmp/build - - name: Install Playwright + - name: Unpack build run: | - cd ./apps/meteor/ - npx playwright install --with-deps - - - name: E2E Test UI - env: - TEST_MODE: 'true' - MONGO_URL: mongodb://localhost:27017/rocketchat - MONGO_OPLOG_URL: mongodb://localhost:27017/local - ENTERPRISE_LICENSE: ${{ secrets.ENTERPRISE_LICENSE }} - TRANSPORTER: nats://localhost:4222 - TEST_API_URL: http://localhost:4000 - OVERWRITE_SETTING_Site_Url: http://localhost:4000 - SKIP_PROCESS_EVENT_REGISTRATION: 'true' - run: | - echo -e 'pcm.!default {\n type hw\n card 0\n}\n\nctl.!default {\n type hw\n card 0\n}' > ~/.asoundrc - Xvfb -screen 0 1024x768x24 :99 & - - cd ./apps/meteor - - docker exec mongo mongo rocketchat --eval 'db.dropDatabase()' && npm run testci -- --enterprise --test=test:playwright:ee + cd /tmp/build + tar xzf Rocket.Chat.tar.gz + rm Rocket.Chat.tar.gz - - name: Store playwright test trace - uses: actions/upload-artifact@v2 - if: failure() + - name: Build Docker image + id: build-docker-image + uses: ./.github/actions/build-docker-image with: - name: ee-playwright-test-trace - path: ./apps/meteor/tests/e2e/test-failures* - - # notification: - # runs-on: ubuntu-20.04 - # needs: test - - # steps: - # - name: Rocket.Chat Notification - # uses: RocketChat/Rocket.Chat.GitHub.Action.Notification@1.1.1 - # with: - # type: ${{ job.status }} - # job_name: '**Build and Test**' - # url: ${{ secrets.ROCKETCHAT_WEBHOOK }} - # commit: true - # token: ${{ secrets.GITHUB_TOKEN }} - - build-image-pr: - runs-on: ubuntu-20.04 - if: github.event.pull_request.head.repo.full_name == github.repository - - strategy: - matrix: - release: ['official', 'preview'] - - steps: - - uses: actions/checkout@v3 + root-dir: /tmp/build + docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + release: official + username: ${{ secrets.CR_USER }} + password: ${{ secrets.CR_PAT }} - - name: Login to GitHub Container Registry - uses: docker/login-action@v1 + - name: 'Build Docker image: account' + uses: ./.github/actions/build-docker-image-service with: - registry: ghcr.io + docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + service: account username: ${{ secrets.CR_USER }} password: ${{ secrets.CR_PAT }} - - name: Free disk space - run: | - sudo swapoff -a - sudo rm -f /swapfile - sudo apt clean - docker rmi $(docker image ls -aq) - df -h - - uses: c-hive/gha-yarn-cache@v2 - - name: Cache turbo - id: cache-turbo - uses: actions/cache@v2 + - name: 'Build Docker image: authorization' + uses: ./.github/actions/build-docker-image-service with: - path: | - ./node_modules/.turbo - key: ${{ runner.OS }}-turbo-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-turbo- - ${{ runner.os }}- + docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + service: authorization + username: ${{ secrets.CR_USER }} + password: ${{ secrets.CR_PAT }} - - name: Cache meteor local - uses: actions/cache@v2 + - name: 'Build Docker image: ddp-streamer' + uses: ./.github/actions/build-docker-image-service with: - path: ./apps/meteor/.meteor/local - key: ${{ runner.OS }}-meteor_cache-${{ hashFiles('.meteor/versions') }} - restore-keys: | - ${{ runner.os }}-meteor_cache- - ${{ runner.os }}- - - name: Cache meteor - uses: actions/cache@v2 + docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + service: ddp-streamer + username: ${{ secrets.CR_USER }} + password: ${{ secrets.CR_PAT }} + + - name: 'Build Docker image: presence' + uses: ./.github/actions/build-docker-image-service with: - path: ~/.meteor - key: ${{ runner.OS }}-meteor-${{ hashFiles('.meteor/release') }} - restore-keys: | - ${{ runner.os }}-meteor- - ${{ runner.os }}- - - name: Use Node.js 14.18.3 - uses: actions/setup-node@v3 + docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + service: presence + username: ${{ secrets.CR_USER }} + password: ${{ secrets.CR_PAT }} + + - name: 'Build Docker image: stream-hub' + uses: ./.github/actions/build-docker-image-service with: - node-version: '14.18.3' + docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + service: stream-hub + username: ${{ secrets.CR_USER }} + password: ${{ secrets.CR_PAT }} - - name: Install Meteor + - name: Launch Traefik run: | - # Restore bin from cache - set +e - METEOR_SYMLINK_TARGET=$(readlink ~/.meteor/meteor) - METEOR_TOOL_DIRECTORY=$(dirname "$METEOR_SYMLINK_TARGET") - set -e - LAUNCHER=$HOME/.meteor/$METEOR_TOOL_DIRECTORY/scripts/admin/launch-meteor - if [ -e $LAUNCHER ] - then - echo "Cached Meteor bin found, restoring it" - sudo cp "$LAUNCHER" "/usr/local/bin/meteor" - else - echo "No cached Meteor bin found." - fi - - # only install meteor if bin isn't found - command -v meteor >/dev/null 2>&1 || curl https://install.meteor.com | sed s/--progress-bar/-sL/g | /bin/sh + docker run --name traefik -d \ + -p 3000:80 \ + -v /var/run/docker.sock:/var/run/docker.sock \ + traefik:2.7 \ + --providers.docker=true - - name: Versions + # TODO move startup/restart to its own github action + - name: Start up Rocket.Chat run: | - npm --versions - yarn -v - node -v - meteor --version - meteor npm --versions - meteor node -v - git version + LOWERCASE_REPOSITORY=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") - - name: Yarn install - # if: steps.cache-nodemodules.outputs.cache-hit != 'true' - run: yarn + docker run --name rocketchat -d \ + --link mongo \ + --link nats \ + -e TEST_MODE=true \ + -e MONGO_URL=mongodb://mongo:27017/rocketchat \ + -e MONGO_OPLOG_URL=mongodb://mongo:27017/local \ + -e TRANSPORTER=nats://nats:4222 \ + -e MOLECULER_LOG_LEVEL=info \ + -e ENTERPRISE_LICENSE="${{ secrets.ENTERPRISE_LICENSE }}" \ + -e SKIP_PROCESS_EVENT_REGISTRATION=true \ + --label 'traefik.http.routers.rocketchat.rule=PathPrefix(`/`)' \ + ghcr.io/${LOWERCASE_REPOSITORY}/rocket.chat:${{ needs.release-versions.outputs.gh-docker-tag }}.official + + # spin up all micro services + docker run --name ddp-streamer -d \ + --link mongo \ + --link nats \ + -e PORT=4000 \ + -e MONGO_URL=mongodb://mongo:27017/rocketchat \ + -e MONGO_OPLOG_URL=mongodb://mongo:27017/local \ + -e TRANSPORTER=nats://nats:4222 \ + -e MOLECULER_LOG_LEVEL=info \ + --label 'traefik.http.services.ddp-streamer.loadbalancer.server.port=4000' \ + --label 'traefik.http.routers.ddp-streamer.rule=PathPrefix(`/websocket`) || PathPrefix(`/sockjs`)' \ + ghcr.io/${LOWERCASE_REPOSITORY}/ddp-streamer-service:${{ needs.release-versions.outputs.gh-docker-tag }} + + - name: 'Start service: stream-hub' + run: | + LOWERCASE_REPOSITORY=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") - # To reduce memory need during actual build, build the packages solely first - # - name: Build a Meteor cache - # run: | - # # to do this we can clear the main files and it build the rest - # echo "" > server/main.ts - # echo "" > client/main.ts - # sed -i.backup 's/rocketchat:livechat/#rocketchat:livechat/' .meteor/packages - # meteor build --server-only --debug --directory /tmp/build-temp - # git checkout -- server/main.ts client/main.ts .meteor/packages + docker run --name stream-hub -d \ + --link mongo \ + --link nats \ + -e MONGO_URL=mongodb://mongo:27017/rocketchat \ + -e MONGO_OPLOG_URL=mongodb://mongo:27017/local \ + -e TRANSPORTER=nats://nats:4222 \ + -e MOLECULER_LOG_LEVEL=info \ + ghcr.io/${LOWERCASE_REPOSITORY}/stream-hub-service:${{ needs.release-versions.outputs.gh-docker-tag }} - - name: Build Rocket.Chat - run: yarn build:ci -- --directory /tmp/build-pr + until echo "$(docker logs stream-hub)" | grep -q "NetworkBroker started successfully"; do + echo "Waiting 'stream-hub' to start up" + ((c++)) && ((c==10)) && exit 1 + sleep 10 + done - - name: Build Docker image for PRs + - name: 'Start service: account' run: | - cd /tmp/build-pr + LOWERCASE_REPOSITORY=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") + + docker run --name account -d \ + --link mongo \ + --link nats \ + -e MONGO_URL=mongodb://mongo:27017/rocketchat \ + -e MONGO_OPLOG_URL=mongodb://mongo:27017/local \ + -e TRANSPORTER=nats://nats:4222 \ + -e MOLECULER_LOG_LEVEL=info \ + ghcr.io/${LOWERCASE_REPOSITORY}/account-service:${{ needs.release-versions.outputs.gh-docker-tag }} + until echo "$(docker logs account)" | grep -q "NetworkBroker started successfully"; do + echo "Waiting 'account' to start up" + ((c++)) && ((c==10)) && exit 1 + sleep 10 + done + + - name: 'Start service: authorization' + run: | LOWERCASE_REPOSITORY=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") - IMAGE_NAME="rocket.chat" - if [[ '${{ matrix.release }}' = 'preview' ]]; then - IMAGE_NAME="${IMAGE_NAME}.preview" - fi; - IMAGE_NAME="ghcr.io/${LOWERCASE_REPOSITORY}/${IMAGE_NAME}:pr-${{ github.event.number }}" + docker run --name authorization -d \ + --link mongo \ + --link nats \ + -e MONGO_URL=mongodb://mongo:27017/rocketchat \ + -e MONGO_OPLOG_URL=mongodb://mongo:27017/local \ + -e TRANSPORTER=nats://nats:4222 \ + -e MOLECULER_LOG_LEVEL=info \ + ghcr.io/${LOWERCASE_REPOSITORY}/authorization-service:${{ needs.release-versions.outputs.gh-docker-tag }} - echo "Build official Docker image ${IMAGE_NAME}" + until echo "$(docker logs authorization)" | grep -q "NetworkBroker started successfully"; do + echo "Waiting 'authorization' to start up" + ((c++)) && ((c==10)) && exit 1 + sleep 10 + done - DOCKER_PATH="${GITHUB_WORKSPACE}/apps/meteor/.docker" - if [[ '${{ matrix.release }}' = 'preview' ]]; then - DOCKER_PATH="${DOCKER_PATH}-mongo" - fi; + - name: 'Start service: presence' + run: | + LOWERCASE_REPOSITORY=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") - echo "Build ${{ matrix.release }} Docker image" - cp ${DOCKER_PATH}/Dockerfile . - if [ -e ${DOCKER_PATH}/entrypoint.sh ]; then - cp ${DOCKER_PATH}/entrypoint.sh . - fi; + docker run --name presence -d \ + --link mongo \ + --link nats \ + -e MONGO_URL=mongodb://mongo:27017/rocketchat \ + -e MONGO_OPLOG_URL=mongodb://mongo:27017/local \ + -e TRANSPORTER=nats://nats:4222 \ + -e MOLECULER_LOG_LEVEL=info \ + ghcr.io/${LOWERCASE_REPOSITORY}/presence-service:${{ needs.release-versions.outputs.gh-docker-tag }} + + until echo "$(docker logs presence)" | grep -q "NetworkBroker started successfully"; do + echo "Waiting 'presence' to start up" + ((c++)) && ((c==10)) && exit 1 + sleep 10 + done - docker build -t $IMAGE_NAME . - docker push $IMAGE_NAME + - name: E2E Test API + run: | + cd ./apps/meteor + for i in $(seq 1 5); do + docker stop rocketchat + docker stop stream-hub + docker stop account + docker stop authorization + docker stop ddp-streamer + docker stop presence + + docker exec mongo mongo rocketchat --eval 'db.dropDatabase()' + + NOW=$(date "+%Y-%m-%dT%H:%M:%SZ") + echo $NOW + + docker start rocketchat + docker start stream-hub + docker start account + docker start authorization + docker start ddp-streamer + docker start presence + + until echo "$(docker logs rocketchat --since $NOW)" | grep -q "SERVER RUNNING"; do + echo "Waiting Rocket.Chat to start up" + ((c++)) && ((c==10)) && exit 1 + sleep 10 + done + + docker logs rocketchat --tail=50 + docker logs stream-hub --tail=50 + docker logs account --tail=50 + docker logs authorization --tail=50 + docker logs ddp-streamer --tail=50 + docker logs presence --tail=50 + + npm run testapi && s=0 && break || s=$? && docker logs rocketchat --tail=100 && docker logs authorization --tail=50; + done; + exit $s - services-image-build-check: - runs-on: ubuntu-20.04 - if: github.event.pull_request.head.repo.full_name == github.repository + - name: Install Playwright + run: | + cd ./apps/meteor + npx playwright install --with-deps - strategy: - matrix: - service: ['ddp-streamer'] + - name: E2E Test UI + run: | + echo -e 'pcm.!default {\n type hw\n card 0\n}\n\nctl.!default {\n type hw\n card 0\n}' > ~/.asoundrc + Xvfb -screen 0 1024x768x24 :99 & - steps: - - uses: actions/checkout@v3 + docker logs rocketchat --tail=50 - - name: Use Node.js 14.18.3 - uses: actions/setup-node@v3 - with: - node-version: '14.18.3' + docker stop rocketchat + docker stop stream-hub + docker stop account + docker stop authorization + docker stop ddp-streamer + docker stop presence - - uses: c-hive/gha-yarn-cache@v2 - - name: Cache turbo - id: cache-turbo - uses: actions/cache@v2 - with: - path: | - ./node_modules/.turbo - key: ${{ runner.OS }}-turbo-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-turbo- - ${{ runner.os }}- + docker exec mongo mongo rocketchat --eval 'db.dropDatabase()' - - name: Build Docker images - env: - IMAGE_TAG: check - run: | - yarn - yarn build + NOW=$(date "+%Y-%m-%dT%H:%M:%SZ") + echo $NOW - echo "Building Docker image for service: ${{ matrix.service }}:${IMAGE_TAG}" + docker start rocketchat + docker start stream-hub + docker start account + docker start authorization + docker start ddp-streamer + docker start presence - docker build \ - --build-arg SERVICE=${{ matrix.service }} \ - -t rocketchat/${{ matrix.service }}-service:${IMAGE_TAG} \ - -f ./ee/apps/ddp-streamer/Dockerfile \ - . + until echo "$(docker logs rocketchat --since $NOW)" | grep -q "SERVER RUNNING"; do + echo "Waiting Rocket.Chat to start up" + ((c++)) && ((c==10)) && exit 1 + sleep 10 + done - release-versions: - runs-on: ubuntu-latest - outputs: - release: ${{ steps.by-tag.outputs.release }} - latest-release: ${{ steps.latest.outputs.latest-release }} - steps: - - id: by-tag - run: | - if echo "$GITHUB_REF_NAME" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' ; then - RELEASE="latest" - elif echo "$GITHUB_REF_NAME" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$' ; then - RELEASE="release-candidate" - fi - echo "RELEASE: ${RELEASE}" - echo "::set-output name=release::${RELEASE}" + docker logs rocketchat --tail=50 + docker logs stream-hub --tail=50 + docker logs account --tail=50 + docker logs authorization --tail=50 + docker logs ddp-streamer --tail=50 + docker logs presence --tail=50 - - id: latest - run: | - LATEST_RELEASE="$( - git -c 'versionsort.suffix=-' ls-remote -t --exit-code --refs --sort=-v:refname "https://github.com/$GITHUB_REPOSITORY" '*' | - sed -En '1!q;s/^[[:xdigit:]]+[[:space:]]+refs\/tags\/(.+)/\1/gp' - )" - echo "LATEST_RELEASE: ${LATEST_RELEASE}" - echo "::set-output name=latest-release::${LATEST_RELEASE}" + cd ./apps/meteor + npm run test:playwright + + - name: Store playwright test trace + uses: actions/upload-artifact@v2 + if: failure() + with: + name: playwright-test-trace + path: ./apps/meteor/tests/e2e/test-failures* deploy: runs-on: ubuntu-20.04 @@ -661,114 +769,88 @@ jobs: -d '{"tag":"'$GIT_TAG'"}' fi - image-build: + docker-image-publish: runs-on: ubuntu-20.04 - needs: [deploy, release-versions] + needs: [deploy, build-docker-preview, release-versions] strategy: matrix: - # this is current a mix of variants and different images + # this is currently a mix of variants and different images release: ['official', 'preview', 'alpine'] env: IMAGE_NAME: 'rocketchat/rocket.chat' steps: - - uses: actions/checkout@v3 - - name: Login to DockerHub - uses: docker/login-action@v1 + uses: docker/login-action@v2 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASS }} - - name: Restore build - uses: actions/download-artifact@v2 + - name: Login to GitHub Container Registry + uses: docker/login-action@v2 with: - name: build - path: /tmp/build + registry: ghcr.io + username: ${{ secrets.CR_USER }} + password: ${{ secrets.CR_PAT }} - - name: Unpack build and prepare Docker files + - name: Get Docker image name + id: gh-docker run: | - cd /tmp/build - tar xzf Rocket.Chat.tar.gz - rm Rocket.Chat.tar.gz - - DOCKER_PATH="${GITHUB_WORKSPACE}/apps/meteor/.docker" - if [[ '${{ matrix.release }}' = 'preview' ]]; then - DOCKER_PATH="${DOCKER_PATH}-mongo" - fi; + LOWERCASE_REPOSITORY=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") - DOCKERFILE_PATH="${DOCKER_PATH}/Dockerfile" - if [[ '${{ matrix.release }}' = 'alpine' ]]; then - DOCKERFILE_PATH="${DOCKERFILE_PATH}.${{ matrix.release }}" - fi; + GH_IMAGE_NAME="ghcr.io/${LOWERCASE_REPOSITORY}/rocket.chat:${{ needs.release-versions.outputs.gh-docker-tag }}.${{ matrix.release }}" - echo "Copy Dockerfile for release: ${{ matrix.release }}" - cp $DOCKERFILE_PATH ./Dockerfile - if [ -e ${DOCKER_PATH}/entrypoint.sh ]; then - cp ${DOCKER_PATH}/entrypoint.sh . - fi; + echo "GH_IMAGE_NAME: $GH_IMAGE_NAME" - - name: Build Docker image for tag - if: github.event_name == 'release' - run: | - cd /tmp/build + echo "::set-output name=gh-image-name::${GH_IMAGE_NAME}" - DOCKER_TAG=$GITHUB_REF_NAME + - name: Pull Docker image + run: docker pull ${{ steps.gh-docker.outputs.gh-image-name }} + - name: Publish Docker image + run: | if [[ '${{ matrix.release }}' = 'preview' ]]; then IMAGE_NAME="${IMAGE_NAME}.preview" fi; + # 'develop' or 'tag' + DOCKER_TAG=$GITHUB_REF_NAME + # append the variant name to docker tag if [[ '${{ matrix.release }}' = 'alpine' ]]; then DOCKER_TAG="${DOCKER_TAG}-${{ matrix.release }}" fi; - RELEASE="${{ needs.release-versions.outputs.release }}" - - if [[ '${{ matrix.release }}' = 'alpine' ]]; then - RELEASE="${RELEASE}-${{ matrix.release }}" - fi; - echo "IMAGE_NAME: $IMAGE_NAME" echo "DOCKER_TAG: $DOCKER_TAG" - echo "RELEASE: $RELEASE" - # build and push the specific tag version - docker build -t $IMAGE_NAME:$DOCKER_TAG . + # tag and push the specific tag version + docker tag ${{ steps.gh-docker.outputs.gh-image-name }} $IMAGE_NAME:$DOCKER_TAG docker push $IMAGE_NAME:$DOCKER_TAG - if [[ $RELEASE == 'latest' ]]; then - if [[ '${{ needs.release-versions.outputs.latest-release }}' == $GITHUB_REF_NAME ]]; then - docker tag $IMAGE_NAME:$DOCKER_TAG $IMAGE_NAME:$RELEASE - docker push $IMAGE_NAME:$RELEASE - fi - else - docker tag $IMAGE_NAME:$DOCKER_TAG $IMAGE_NAME:$RELEASE - docker push $IMAGE_NAME:$RELEASE - fi - - - name: Build Docker image for develop - if: github.ref == 'refs/heads/develop' - run: | - cd /tmp/build + if [[ $GITHUB_REF == refs/tags/* ]]; then + RELEASE="${{ needs.release-versions.outputs.release }}" - DOCKER_TAG=develop + if [[ '${{ matrix.release }}' = 'alpine' ]]; then + RELEASE="${RELEASE}-${{ matrix.release }}" + fi; - if [[ '${{ matrix.release }}' = 'preview' ]]; then - IMAGE_NAME="${IMAGE_NAME}.preview" - fi; + echo "RELEASE: $RELEASE" - if [[ '${{ matrix.release }}' = 'alpine' ]]; then - DOCKER_TAG="${DOCKER_TAG}-${{ matrix.release }}" - fi; - - docker build -t $IMAGE_NAME:$DOCKER_TAG . - docker push $IMAGE_NAME:$DOCKER_TAG + if [[ $RELEASE == 'latest' ]]; then + if [[ '${{ needs.release-versions.outputs.latest-release }}' == $GITHUB_REF_NAME ]]; then + docker tag ${{ steps.gh-docker.outputs.gh-image-name }} $IMAGE_NAME:$RELEASE + docker push $IMAGE_NAME:$RELEASE + fi + else + docker tag ${{ steps.gh-docker.outputs.gh-image-name }} $IMAGE_NAME:$RELEASE + docker push $IMAGE_NAME:$RELEASE + fi + fi - services-image-build: + services-docker-image-publish: runs-on: ubuntu-20.04 needs: [deploy, release-versions] @@ -777,57 +859,35 @@ jobs: service: ['account', 'authorization', 'ddp-streamer', 'presence', 'stream-hub'] steps: - - uses: actions/checkout@v3 - - - name: Use Node.js 14.18.3 - uses: actions/setup-node@v3 - with: - node-version: '14.18.3' - - uses: c-hive/gha-yarn-cache@v2 - - name: Cache turbo - id: cache-turbo - uses: actions/cache@v2 - with: - path: | - ./node_modules/.turbo - key: ${{ runner.OS }}-turbo-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-turbo- - ${{ runner.os }}- - - name: Login to DockerHub - uses: docker/login-action@v1 + uses: docker/login-action@v2 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASS }} - - name: Build Docker images + - name: Login to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ secrets.CR_USER }} + password: ${{ secrets.CR_PAT }} + + - name: Publish Docker images run: | - # defines image tag - if [[ $GITHUB_REF == refs/tags/* ]]; then - IMAGE_TAG="${GITHUB_REF#refs/tags/}" - else - IMAGE_TAG="${GITHUB_REF#refs/heads/}" - fi + LOWERCASE_REPOSITORY=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") - # first install repo dependencies - yarn - yarn build + IMAGE_TAG="${{ needs.release-versions.outputs.gh-docker-tag }}" - echo "Building Docker image for service: ${{ matrix.service }}:${IMAGE_TAG}" + GH_IMAGE_NAME="ghcr.io/${LOWERCASE_REPOSITORY}/${{ matrix.service }}-service:${IMAGE_TAG}" - if [[ "${{ matrix.service }}" == "ddp-streamer" ]]; then - DOCKERFILE_PATH="./ee/apps/ddp-streamer/Dockerfile" - else - DOCKERFILE_PATH="./apps/meteor/ee/server/services/Dockerfile" - fi + echo "GH_IMAGE_NAME: $GH_IMAGE_NAME" + + docker pull $GH_IMAGE_NAME - docker build \ - --build-arg SERVICE=${{ matrix.service }} \ - -t rocketchat/${{ matrix.service }}-service:${IMAGE_TAG} \ - -f ${DOCKERFILE_PATH} \ - . + # 'develop' or 'tag' + DOCKER_TAG=$GITHUB_REF_NAME + docker tag $GH_IMAGE_NAME rocketchat/${{ matrix.service }}-service:${IMAGE_TAG} docker push rocketchat/${{ matrix.service }}-service:${IMAGE_TAG} if [[ $GITHUB_REF == refs/tags/* ]]; then diff --git a/.yarnrc.yml b/.yarnrc.yml index 1d00b70c6e5..18948c0be5b 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -11,4 +11,5 @@ plugins: spec: '@yarnpkg/plugin-typescript' yarnPath: .yarn/releases/yarn-3.2.0.cjs -checksumBehavior: 'ignore' +checksumBehavior: 'update' +enableImmutableInstalls: false diff --git a/_templates/package/new/package.json.ejs.t b/_templates/package/new/package.json.ejs.t index 827f44a01ad..948e2ded4be 100644 --- a/_templates/package/new/package.json.ejs.t +++ b/_templates/package/new/package.json.ejs.t @@ -17,7 +17,8 @@ to: packages/<%= name %>/package.json "lint": "eslint --ext .js,.jsx,.ts,.tsx .", "lint:fix": "eslint --ext .js,.jsx,.ts,.tsx . --fix", "jest": "jest", - "build": "rm -rf dist && tsc -p tsconfig.json" + "build": "rm -rf dist && tsc -p tsconfig.json", + "dev": "tsc -p --watch --preserveWatchOutput tsconfig.json" }, "main": "./dist/index.js", "typings": "./dist/index.d.ts", diff --git a/apps/meteor/.docker/Dockerfile.alpine b/apps/meteor/.docker/Dockerfile.alpine index 0c7bd994e8f..47d5dacd696 100644 --- a/apps/meteor/.docker/Dockerfile.alpine +++ b/apps/meteor/.docker/Dockerfile.alpine @@ -12,7 +12,7 @@ RUN set -x \ && npm install --production \ # Start hack for sharp... && rm -rf npm/node_modules/sharp \ - && npm install sharp@0.29.3 \ + && npm install sharp@0.30.4 \ && mv node_modules/sharp npm/node_modules/sharp \ # End hack for sharp && cd npm \ diff --git a/apps/meteor/.eslintignore b/apps/meteor/.eslintignore index cb0aed399c3..9b8e1805314 100644 --- a/apps/meteor/.eslintignore +++ b/apps/meteor/.eslintignore @@ -5,7 +5,6 @@ packages/autoupdate/ packages/meteor-streams/ packages/meteor-timesync/ app/emoji-emojione/generateEmojiIndex.js -app/favico/favico.js packages/rocketchat-livechat/assets/rocketchat-livechat.min.js packages/rocketchat-livechat/assets/rocket-livechat.js app/theme/client/vendor/ diff --git a/apps/meteor/.gitignore b/apps/meteor/.gitignore index 3fc755f6bb5..e8a861e5832 100644 --- a/apps/meteor/.gitignore +++ b/apps/meteor/.gitignore @@ -84,3 +84,4 @@ coverage /data tests/e2e/test-failures/ out.txt +dist diff --git a/apps/meteor/.meteorignore b/apps/meteor/.meteorignore index 9b77b349e3f..7b2dc6e71c3 100644 --- a/apps/meteor/.meteorignore +++ b/apps/meteor/.meteorignore @@ -1,3 +1,4 @@ ee/server/services coverage data +dist diff --git a/apps/meteor/.scripts/start.js b/apps/meteor/.scripts/start.js deleted file mode 100644 index a29e7bb1dbf..00000000000 --- a/apps/meteor/.scripts/start.js +++ /dev/null @@ -1,196 +0,0 @@ -#!/usr/bin/env node - -const path = require('path'); -const fs = require('fs'); -const { spawn } = require('child_process'); -const net = require('net'); - -const processes = []; - -const baseDir = path.resolve(__dirname, '..'); -const srcDir = path.resolve(baseDir); - -const isPortTaken = (port) => - new Promise((resolve, reject) => { - const tester = net - .createServer() - .once('error', (err) => (err.code === 'EADDRINUSE' ? resolve(true) : reject(err))) - .once('listening', () => tester.once('close', () => resolve(false)).close()) - .listen(port); - }); - -const waitPortRelease = (port, count = 0) => - new Promise((resolve, reject) => { - isPortTaken(port).then((taken) => { - if (!taken) { - return resolve(); - } - if (count > 60) { - return reject(); - } - console.log('Port', port, 'not released, waiting 1s...'); - setTimeout(() => { - waitPortRelease(port, ++count) - .then(resolve) - .catch(reject); - }, 1000); - }); - }); - -const appOptions = { - env: { - PORT: 3000, - ROOT_URL: 'http://localhost:3000', - }, -}; - -let killingAllProcess = false; -function killAllProcesses(mainExitCode) { - if (killingAllProcess) { - return; - } - killingAllProcess = true; - - processes.forEach((p) => { - console.log('Killing process', p.pid); - p.kill(); - }); - - waitPortRelease(appOptions.env.PORT) - .then(() => { - console.log(`Port ${appOptions.env.PORT} was released, exiting with code ${mainExitCode}`); - process.exit(mainExitCode); - }) - .catch((error) => { - console.error(`Error waiting port ${appOptions.env.PORT} to be released, exiting with code ${mainExitCode}`); - console.error(error); - process.exit(mainExitCode); - }); -} - -function startProcess(opts) { - console.log('Starting process', opts.name, opts.command, opts.params, opts.options.cwd); - const proc = spawn(opts.command, opts.params, opts.options); - processes.push(proc); - - if (opts.onData) { - proc.stdout.on('data', opts.onData); - } - - if (!opts.silent) { - proc.stdout.pipe(process.stdout); - proc.stderr.pipe(process.stderr); - } - - if (opts.logFile) { - const logStream = fs.createWriteStream(opts.logFile, { flags: 'a' }); - proc.stdout.pipe(logStream); - proc.stderr.pipe(logStream); - } - - proc.on('exit', function (code, signal) { - processes.splice(processes.indexOf(proc), 1); - - if (code != null) { - console.log(opts.name, `exited with code ${code}`); - } else { - console.log(opts.name, `exited with signal ${signal}`); - } - - killAllProcesses(code); - }); -} - -function startRocketChat() { - return new Promise((resolve) => { - const waitServerRunning = (message) => { - if (message.toString().match('SERVER RUNNING')) { - return resolve(); - } - }; - - startProcess({ - name: 'Meteor App', - command: 'node', - params: ['/tmp/build-test/bundle/main.js'], - onData: waitServerRunning, - options: { - cwd: srcDir, - env: { - ...appOptions.env, - ...process.env, - }, - }, - }); - }); -} - -async function startMicroservices() { - const waitStart = (resolve) => (message) => { - if (message.toString().match('NetworkBroker started successfully')) { - return resolve(); - } - }; - const startService = (name) => { - return new Promise((resolve) => { - const cwd = - name === 'ddp-streamer' - ? path.resolve(srcDir, '..', '..', 'ee', 'apps', name, 'dist', 'ee', 'apps', name) - : path.resolve(srcDir, 'ee', 'server', 'services', 'dist', 'ee', 'server', 'services', name); - - startProcess({ - name: `${name} service`, - command: 'node', - params: [name === 'ddp-streamer' ? 'src/service.js' : 'service.js'], - onData: waitStart(resolve), - options: { - cwd, - env: { - ...appOptions.env, - ...process.env, - PORT: 4000, - }, - }, - }); - }); - }; - - await Promise.all([ - startService('account'), - startService('authorization'), - startService('ddp-streamer'), - startService('presence'), - startService('stream-hub'), - ]); -} - -function startTests(options = []) { - const testOption = options.find((i) => i.startsWith('--test=')); - const testParam = testOption ? testOption.replace('--test=', '') : 'test'; - - console.log(`Running test "npm run ${testParam}"`); - - startProcess({ - name: 'Tests', - command: 'npm', - params: ['run', testParam], - options: { - env: { - ...process.env, - NODE_PATH: `${process.env.NODE_PATH + path.delimiter + srcDir + path.delimiter + srcDir}/node_modules`, - }, - }, - }); -} - -(async () => { - const [, , ...options] = process.argv; - - await startRocketChat(); - - if (options.includes('--enterprise')) { - await startMicroservices(); - } - - startTests(options); -})(); diff --git a/apps/meteor/app/api/server/api.d.ts b/apps/meteor/app/api/server/api.d.ts index a1dd6371374..ebc4871610e 100644 --- a/apps/meteor/app/api/server/api.d.ts +++ b/apps/meteor/app/api/server/api.d.ts @@ -9,7 +9,7 @@ import type { } from '@rocket.chat/rest-typings'; import type { IUser, IMethodConnection, IRoom } from '@rocket.chat/core-typings'; import type { ValidateFunction } from 'ajv'; -import type { Request } from 'express'; +import type { Request, Response } from 'express'; import { ITwoFactorOptions } from '../../2fa/server/code'; @@ -73,11 +73,13 @@ type Options = ( type PartialThis = { readonly request: Request & { query: Record<string, string> }; + readonly response: Response; }; type ActionThis<TMethod extends Method, TPathPattern extends PathPattern, TOptions> = { readonly requestIp: string; urlParams: UrlParams<TPathPattern>; + readonly response: Response; // TODO make it unsafe readonly queryParams: TMethod extends 'GET' ? TOptions extends { validateParams: ValidateFunction<infer T> } @@ -91,6 +93,9 @@ type ActionThis<TMethod extends Method, TPathPattern extends PathPattern, TOptio ? T : Partial<OperationParams<TMethod, TPathPattern>>; readonly request: Request; + + readonly queryOperations: TOptions extends { queryOperations: infer T } ? T : never; + /* @deprecated */ requestParams(): OperationParams<TMethod, TPathPattern>; getLoggedInUser(): TOptions extends { authRequired: true } ? IUser : IUser | undefined; @@ -106,6 +111,8 @@ type ActionThis<TMethod extends Method, TPathPattern extends PathPattern, TOptio /* @deprecated */ getUserFromParams(): IUser; /* @deprecated */ + isUserFromParams(): boolean; + /* @deprecated */ getUserInfo(me: IUser): TOptions extends { authRequired: true } ? IUser & { email?: string; @@ -153,6 +160,8 @@ type Operations<TPathPattern extends PathPattern, TOptions extends Options = {}> declare class APIClass<TBasePath extends string = '/'> { fieldSeparator: string; + updateRateLimiterDictionaryForRoute(route: string, rateLimiterDictionary: number): void; + limitedUserFieldsToExclude(fields: { [x: string]: unknown }, limitedUserFieldsToExclude: unknown): { [x: string]: unknown }; limitedUserFieldsToExcludeIfIsPrivilegedUser( diff --git a/apps/meteor/app/api/server/lib/users.ts b/apps/meteor/app/api/server/lib/users.ts index 8ff1737cc69..7762b9b20a1 100644 --- a/apps/meteor/app/api/server/lib/users.ts +++ b/apps/meteor/app/api/server/lib/users.ts @@ -16,7 +16,7 @@ export async function findUsersToAutocomplete({ term: string; }; }): Promise<{ - items: IUser[]; + items: Required<Pick<IUser, '_id' | 'name' | 'username' | 'nickname' | 'status' | 'avatarETag'>>[]; }> { if (!(await hasPermissionAsync(uid, 'view-outside-room'))) { return { items: [] }; @@ -69,16 +69,7 @@ export function getInclusiveFields(query: { [k: string]: 1 }): {} { * get the default fields if **fields** are empty (`{}`) or `undefined`/`null` * @param {Object|null|undefined} fields the fields from parsed jsonQuery */ -export function getNonEmptyFields(fields: {}): { - name: number; - username: number; - emails: number; - roles: number; - status: number; - active: number; - avatarETag: number; - lastLogin: number; -} { +export function getNonEmptyFields(fields: { [k: string]: 1 | 0 }): { [k: string]: 1 } { const defaultFields = { name: 1, username: 1, @@ -88,7 +79,7 @@ export function getNonEmptyFields(fields: {}): { active: 1, avatarETag: 1, lastLogin: 1, - }; + } as const; if (!fields || Object.keys(fields).length === 0) { return defaultFields; diff --git a/apps/meteor/app/api/server/v1/assets.js b/apps/meteor/app/api/server/v1/assets.ts similarity index 73% rename from apps/meteor/app/api/server/v1/assets.js rename to apps/meteor/app/api/server/v1/assets.ts index 7138d565034..655d4fa49e2 100644 --- a/apps/meteor/app/api/server/v1/assets.js +++ b/apps/meteor/app/api/server/v1/assets.ts @@ -1,4 +1,5 @@ import { Meteor } from 'meteor/meteor'; +import { isAssetsUnsetAssetProps } from '@rocket.chat/rest-typings'; import { RocketChatAssets } from '../../../assets/server'; import { API } from '../api'; @@ -8,12 +9,10 @@ API.v1.addRoute( 'assets.setAsset', { authRequired: true }, { - post() { - const [asset, { refreshAllClients }, assetName] = Promise.await( - getUploadFormData({ - request: this.request, - }), - ); + async post() { + const [asset, { refreshAllClients }, assetName] = await getUploadFormData({ + request: this.request, + }); const assetsKeys = Object.keys(RocketChatAssets.assets); @@ -36,7 +35,10 @@ API.v1.addRoute( API.v1.addRoute( 'assets.unsetAsset', - { authRequired: true }, + { + authRequired: true, + validateParams: isAssetsUnsetAssetProps, + }, { post() { const { assetName, refreshAllClients } = this.bodyParams; @@ -44,12 +46,10 @@ API.v1.addRoute( if (!isValidAsset) { throw new Meteor.Error('error-invalid-asset', 'Invalid asset'); } - Meteor.runAsUser(this.userId, () => { - Meteor.call('unsetAsset', assetName); - if (refreshAllClients) { - Meteor.call('refreshClients'); - } - }); + Meteor.call('unsetAsset', assetName); + if (refreshAllClients) { + Meteor.call('refreshClients'); + } return API.v1.success(); }, }, diff --git a/apps/meteor/app/api/server/v1/teams.ts b/apps/meteor/app/api/server/v1/teams.ts index 157f1ac6d89..5e82084e609 100644 --- a/apps/meteor/app/api/server/v1/teams.ts +++ b/apps/meteor/app/api/server/v1/teams.ts @@ -141,11 +141,9 @@ API.v1.addRoute( }); } - await Promise.all([ - Team.unsetTeamIdOfRooms(this.userId, team._id), - Team.removeAllMembersFromTeam(team._id), - Team.deleteById(team._id), - ]); + await Promise.all([Team.unsetTeamIdOfRooms(this.userId, team._id), Team.removeAllMembersFromTeam(team._id)]); + + await Team.deleteById(team._id); return API.v1.success(); }, diff --git a/apps/meteor/app/api/server/v1/users.js b/apps/meteor/app/api/server/v1/users.ts similarity index 66% rename from apps/meteor/app/api/server/v1/users.js rename to apps/meteor/app/api/server/v1/users.ts index 094570518e2..8e6808fd34b 100644 --- a/apps/meteor/app/api/server/v1/users.js +++ b/apps/meteor/app/api/server/v1/users.ts @@ -1,57 +1,236 @@ +import { + isUserCreateParamsPOST, + isUserSetActiveStatusParamsPOST, + isUserDeactivateIdleParamsPOST, + isUsersInfoParamsGetProps, + isUserRegisterParamsPOST, + isUserLogoutParamsPOST, + isUsersListTeamsProps, + isUsersAutocompleteProps, + isUsersSetAvatarProps, + isUsersUpdateParamsPOST, + isUsersUpdateOwnBasicInfoParamsPOST, + isUsersSetPreferencesParamsPOST, +} from '@rocket.chat/rest-typings'; import { Meteor } from 'meteor/meteor'; import { Accounts } from 'meteor/accounts-base'; import { Match, check } from 'meteor/check'; import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; -import _ from 'underscore'; +import { IExportOperation, IPersonalAccessToken, IUser } from '@rocket.chat/core-typings'; import { Users, Subscriptions } from '../../../models/server'; import { Users as UsersRaw } from '../../../models/server/raw'; -import { hasPermission } from '../../../authorization'; +import { hasPermission } from '../../../authorization/server'; import { settings } from '../../../settings/server'; -import { getURL } from '../../../utils'; import { validateCustomFields, saveUser, saveCustomFieldsWithoutValidation, checkUsernameAvailability, + setStatusText, setUserAvatar, saveCustomFields, - setStatusText, } from '../../../lib/server'; import { getFullUserDataByIdOrUsername } from '../../../lib/server/functions/getFullUserData'; import { API } from '../api'; -import { getUploadFormData } from '../lib/getUploadFormData'; import { findUsersToAutocomplete, getInclusiveFields, getNonEmptyFields, getNonEmptyQuery } from '../lib/users'; import { getUserForCheck, emailCheck } from '../../../2fa/server/code'; import { resetUserE2EEncriptionKey } from '../../../../server/lib/resetUserE2EKey'; -import { setUserStatus } from '../../../../imports/users-presence/server/activeUsers'; import { resetTOTP } from '../../../2fa/server/functions/resetTOTP'; import { Team } from '../../../../server/sdk'; import { isValidQuery } from '../lib/isValidQuery'; +import { setUserStatus } from '../../../../imports/users-presence/server/activeUsers'; +import { getURL } from '../../../utils/server'; +import { getUploadFormData } from '../lib/getUploadFormData'; API.v1.addRoute( - 'users.create', - { authRequired: true }, + 'users.getAvatar', + { authRequired: false }, + { + get() { + const user = this.getUserFromParams(); + + const url = getURL(`/avatar/${user.username}`, { cdn: false, full: true }); + this.response.setHeader('Location', url); + + return { + statusCode: 307, + body: url, + }; + }, + }, +); + +API.v1.addRoute( + 'users.update', + { authRequired: true, twoFactorRequired: true, validateParams: isUsersUpdateParamsPOST }, + { + post() { + const userData = { _id: this.bodyParams.userId, ...this.bodyParams.data }; + + Meteor.runAsUser(this.userId, () => saveUser(this.userId, userData)); + + if (this.bodyParams.data.customFields) { + saveCustomFields(this.bodyParams.userId, this.bodyParams.data.customFields); + } + + if (typeof this.bodyParams.data.active !== 'undefined') { + const { + userId, + data: { active }, + confirmRelinquish, + } = this.bodyParams; + + Meteor.call('setUserActiveStatus', userId, active, Boolean(confirmRelinquish)); + } + const { fields } = this.parseJsonQuery(); + + return API.v1.success({ user: Users.findOneById(this.bodyParams.userId, { fields }) }); + }, + }, +); + +API.v1.addRoute( + 'users.updateOwnBasicInfo', + { authRequired: true, validateParams: isUsersUpdateOwnBasicInfoParamsPOST }, + { + post() { + const userData = { + email: this.bodyParams.data.email, + realname: this.bodyParams.data.name, + username: this.bodyParams.data.username, + nickname: this.bodyParams.data.nickname, + statusText: this.bodyParams.data.statusText, + newPassword: this.bodyParams.data.newPassword, + typedPassword: this.bodyParams.data.currentPassword, + }; + + // saveUserProfile now uses the default two factor authentication procedures, so we need to provide that + const twoFactorOptions = !userData.typedPassword + ? null + : { + twoFactorCode: userData.typedPassword, + twoFactorMethod: 'password', + }; + + Meteor.call('saveUserProfile', userData, this.bodyParams.customFields, twoFactorOptions); + + return API.v1.success({ + user: Users.findOneById(this.userId, { fields: API.v1.defaultFieldsToExclude }), + }); + }, + }, +); + +API.v1.addRoute( + 'users.setPreferences', + { authRequired: true, validateParams: isUsersSetPreferencesParamsPOST }, { post() { - check(this.bodyParams, { - email: String, - name: String, - password: String, - username: String, - active: Match.Maybe(Boolean), - bio: Match.Maybe(String), - nickname: Match.Maybe(String), - statusText: Match.Maybe(String), - roles: Match.Maybe(Array), - joinDefaultChannels: Match.Maybe(Boolean), - requirePasswordChange: Match.Maybe(Boolean), - setRandomPassword: Match.Maybe(Boolean), - sendWelcomeEmail: Match.Maybe(Boolean), - verified: Match.Maybe(Boolean), - customFields: Match.Maybe(Object), + if (this.bodyParams.userId && this.bodyParams.userId !== this.userId && !hasPermission(this.userId, 'edit-other-user-info')) { + throw new Meteor.Error('error-action-not-allowed', 'Editing user is not allowed'); + } + const userId = this.bodyParams.userId ? this.bodyParams.userId : this.userId; + if (!Users.findOneById(userId)) { + throw new Meteor.Error('error-invalid-user', 'The optional "userId" param provided does not match any users'); + } + + Meteor.runAsUser(userId, () => Meteor.call('saveUserPreferences', this.bodyParams.data)); + const user = Users.findOneById(userId, { + fields: { + 'settings.preferences': 1, + 'language': 1, + }, }); + return API.v1.success({ + user: { + _id: user._id, + settings: { + preferences: { + ...user.settings.preferences, + language: user.language, + }, + }, + }, + }); + }, + }, +); + +API.v1.addRoute( + 'users.setAvatar', + { authRequired: true, validateParams: isUsersSetAvatarProps }, + { + async post() { + const canEditOtherUserAvatar = hasPermission(this.userId, 'edit-other-user-avatar'); + + if (!settings.get('Accounts_AllowUserAvatarChange') && !canEditOtherUserAvatar) { + throw new Meteor.Error('error-not-allowed', 'Change avatar is not allowed', { + method: 'users.setAvatar', + }); + } + + let user = ((): IUser | undefined => { + if (this.isUserFromParams()) { + return Meteor.users.findOne(this.userId) as IUser | undefined; + } + if (canEditOtherUserAvatar) { + return this.getUserFromParams(); + } + })(); + + if (!user) { + return API.v1.unauthorized(); + } + + if (this.bodyParams.avatarUrl) { + setUserAvatar(user, this.bodyParams.avatarUrl, '', 'url'); + return API.v1.success(); + } + + const [image, fields] = await getUploadFormData( + { + request: this.request, + }, + { + field: 'image', + }, + ); + + if (!image) { + return API.v1.failure("The 'image' param is required"); + } + + const sentTheUserByFormData = fields.userId || fields.username; + if (sentTheUserByFormData) { + if (fields.userId) { + user = Users.findOneById(fields.userId, { fields: { username: 1 } }); + } else if (fields.username) { + user = Users.findOneByUsernameIgnoringCase(fields.username, { fields: { username: 1 } }); + } + + if (!user) { + throw new Meteor.Error('error-invalid-user', 'The optional "userId" or "username" param provided does not match any users'); + } + + const isAnotherUser = this.userId !== user._id; + if (isAnotherUser && !hasPermission(this.userId, 'edit-other-user-avatar')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } + } + setUserAvatar(user, image.fileBuffer, image.mimetype, 'rest'); + + return API.v1.success(); + }, + }, +); + +API.v1.addRoute( + 'users.create', + { authRequired: true, validateParams: isUserCreateParamsPOST }, + { + post() { // New change made by pull request #5152 if (typeof this.bodyParams.joinDefaultChannels === 'undefined') { this.bodyParams.joinDefaultChannels = true; @@ -68,9 +247,7 @@ API.v1.addRoute( } if (typeof this.bodyParams.active !== 'undefined') { - Meteor.runAsUser(this.userId, () => { - Meteor.call('setUserActiveStatus', newUserId, this.bodyParams.active); - }); + Meteor.call('setUserActiveStatus', newUserId, this.bodyParams.active); } const { fields } = this.parseJsonQuery(); @@ -92,9 +269,7 @@ API.v1.addRoute( const user = this.getUserFromParams(); const { confirmRelinquish = false } = this.requestParams(); - Meteor.runAsUser(this.userId, () => { - Meteor.call('deleteUser', user._id, confirmRelinquish); - }); + Meteor.call('deleteUser', user._id, confirmRelinquish); return API.v1.success(); }, @@ -116,52 +291,24 @@ API.v1.addRoute( const { confirmRelinquish = false } = this.requestParams(); - Meteor.runAsUser(this.userId, () => { - Meteor.call('deleteUserOwnAccount', password, confirmRelinquish); - }); + Meteor.call('deleteUserOwnAccount', password, confirmRelinquish); return API.v1.success(); }, }, ); -API.v1.addRoute( - 'users.getAvatar', - { authRequired: false }, - { - get() { - const user = this.getUserFromParams(); - - const url = getURL(`/avatar/${user.username}`, { cdn: false, full: true }); - this.response.setHeader('Location', url); - - return { - statusCode: 307, - body: url, - }; - }, - }, -); - API.v1.addRoute( 'users.setActiveStatus', - { authRequired: true }, + { authRequired: true, validateParams: isUserSetActiveStatusParamsPOST }, { post() { - check(this.bodyParams, { - userId: String, - activeStatus: Boolean, - confirmRelinquish: Match.Maybe(Boolean), - }); - if (!hasPermission(this.userId, 'edit-other-user-active-status')) { return API.v1.unauthorized(); } - Meteor.runAsUser(this.userId, () => { - const { userId, activeStatus, confirmRelinquish = false } = this.bodyParams; - Meteor.call('setUserActiveStatus', userId, activeStatus, confirmRelinquish); - }); + const { userId, activeStatus, confirmRelinquish = false } = this.bodyParams; + Meteor.call('setUserActiveStatus', userId, activeStatus, confirmRelinquish); return API.v1.success({ user: Users.findOneById(this.bodyParams.userId, { fields: { active: 1 } }), }); @@ -171,14 +318,9 @@ API.v1.addRoute( API.v1.addRoute( 'users.deactivateIdle', - { authRequired: true }, + { authRequired: true, validateParams: isUserDeactivateIdleParamsPOST }, { post() { - check(this.bodyParams, { - daysIdle: Match.Integer, - role: Match.Optional(String), - }); - if (!hasPermission(this.userId, 'edit-other-user-active-status')) { return API.v1.unauthorized(); } @@ -197,68 +339,41 @@ API.v1.addRoute( }, ); -API.v1.addRoute( - 'users.getPresence', - { authRequired: true }, - { - get() { - if (this.isUserFromParams()) { - const user = Users.findOneById(this.userId); - return API.v1.success({ - presence: user.status, - connectionStatus: user.statusConnection, - lastLogin: user.lastLogin, - }); - } - - const user = this.getUserFromParams(); - - return API.v1.success({ - presence: user.status, - }); - }, - }, -); - API.v1.addRoute( 'users.info', - { authRequired: true }, + { authRequired: true, validateParams: isUsersInfoParamsGetProps }, { - get() { - const { username, userId } = this.requestParams(); + async get() { const { fields } = this.parseJsonQuery(); - check(userId, Match.Maybe(String)); - check(username, Match.Maybe(String)); - - if (userId !== undefined && username !== undefined) { - throw new Meteor.Error('invalid-filter', 'Cannot filter by id and username at once'); - } - - if (!userId && !username) { - throw new Meteor.Error('invalid-filter', 'Must filter by id or username'); - } - - const user = getFullUserDataByIdOrUsername({ userId: this.userId, filterId: userId, filterUsername: username }); + const user = await getFullUserDataByIdOrUsername(this.userId, { + filterId: (this.queryParams as any).userId, + filterUsername: (this.queryParams as any).username, + }); if (!user) { return API.v1.failure('User not found.'); } const myself = user._id === this.userId; if (fields.userRooms === 1 && (myself || hasPermission(this.userId, 'view-other-user-channels'))) { - user.rooms = Subscriptions.findByUserId(user._id, { - fields: { - rid: 1, - name: 1, - t: 1, - roles: 1, - unread: 1, - }, - sort: { - t: 1, - name: 1, + return API.v1.success({ + user: { + ...user, + rooms: Subscriptions.findByUserId(user._id, { + projection: { + rid: 1, + name: 1, + t: 1, + roles: 1, + unread: 1, + }, + sort: { + t: 1, + name: 1, + }, + }).fetch(), }, - }).fetch(); + }); } return API.v1.success({ @@ -298,14 +413,14 @@ API.v1.addRoute( inclusiveFieldsKeys.includes('emails') && 'emails.address.*', inclusiveFieldsKeys.includes('username') && 'username.*', inclusiveFieldsKeys.includes('name') && 'name.*', - ].filter(Boolean), + ].filter(Boolean) as string[], this.queryOperations, ) ) { throw new Meteor.Error('error-invalid-query', isValidQuery.errors.join('\n')); } - const actualSort = sort && sort.name ? { nameInsensitive: sort.name, ...sort } : sort || { username: 1 }; + const actualSort = sort?.name ? { nameInsensitive: sort.name, ...sort } : sort || { username: 1 }; const limit = count !== 0 @@ -373,6 +488,7 @@ API.v1.addRoute( numRequestsAllowed: settings.get('Rate_Limiter_Limit_RegisterUser'), intervalTimeInMS: settings.get('API_Enable_Rate_Limiter_Limit_Time_Default'), }, + validateParams: isUserRegisterParamsPOST, }, { post() { @@ -380,306 +496,40 @@ API.v1.addRoute( return API.v1.failure('Logged in users can not register again.'); } - // We set their username here, so require it - // The `registerUser` checks for the other requirements - check( - this.bodyParams, - Match.ObjectIncluding({ - username: String, - }), - ); - if (!checkUsernameAvailability(this.bodyParams.username)) { return API.v1.failure('Username is already in use'); } // Register the user - const userId = Meteor.call('registerUser', this.bodyParams); - - // Now set their username - Meteor.runAsUser(userId, () => Meteor.call('setUsername', this.bodyParams.username)); - const { fields } = this.parseJsonQuery(); - - return API.v1.success({ user: Users.findOneById(userId, { fields }) }); - }, - }, -); - -API.v1.addRoute( - 'users.resetAvatar', - { authRequired: true }, - { - post() { - const user = this.getUserFromParams(); - - if (settings.get('Accounts_AllowUserAvatarChange') && user._id === this.userId) { - Meteor.runAsUser(this.userId, () => Meteor.call('resetAvatar')); - } else if (hasPermission(this.userId, 'edit-other-user-avatar')) { - Meteor.runAsUser(this.userId, () => Meteor.call('resetAvatar', user._id)); - } else { - throw new Meteor.Error('error-not-allowed', 'Reset avatar is not allowed', { - method: 'users.resetAvatar', - }); - } - - return API.v1.success(); - }, - }, -); - -API.v1.addRoute( - 'users.setAvatar', - { authRequired: true }, - { - async post() { - check( - this.bodyParams, - Match.ObjectIncluding({ - avatarUrl: Match.Maybe(String), - userId: Match.Maybe(String), - username: Match.Maybe(String), - }), - ); - const canEditOtherUserAvatar = hasPermission(this.userId, 'edit-other-user-avatar'); - - if (!settings.get('Accounts_AllowUserAvatarChange') && !canEditOtherUserAvatar) { - throw new Meteor.Error('error-not-allowed', 'Change avatar is not allowed', { - method: 'users.setAvatar', - }); - } - - let user; - if (this.isUserFromParams()) { - user = Meteor.users.findOne(this.userId); - } else if (canEditOtherUserAvatar) { - user = this.getUserFromParams(); - } else { - return API.v1.unauthorized(); - } - - if (this.bodyParams.avatarUrl) { - setUserAvatar(user, this.bodyParams.avatarUrl, '', 'url'); - return API.v1.success(); - } - - const [image, fields] = await getUploadFormData( - { - request: this.request, - }, - { field: 'image' }, - ); - - if (!image) { - return API.v1.failure("The 'image' param is required"); - } - - const sentTheUserByFormData = fields.userId || fields.username; - if (sentTheUserByFormData) { - if (fields.userId) { - user = Users.findOneById(fields.userId, { fields: { username: 1 } }); - } else if (fields.username) { - user = Users.findOneByUsernameIgnoringCase(fields.username, { fields: { username: 1 } }); - } - - if (!user) { - throw new Meteor.Error('error-invalid-user', 'The optional "userId" or "username" param provided does not match any users'); - } - - const isAnotherUser = this.userId !== user._id; - if (isAnotherUser && !hasPermission(this.userId, 'edit-other-user-avatar')) { - throw new Meteor.Error('error-not-allowed', 'Not allowed'); - } - } - - setUserAvatar(user, image.fileBuffer, image.mimetype, 'rest'); - - return API.v1.success(); - }, - }, -); - -API.v1.addRoute( - 'users.getStatus', - { authRequired: true }, - { - get() { - if (this.isUserFromParams()) { - const user = Users.findOneById(this.userId); - return API.v1.success({ - _id: user._id, - message: user.statusText, - connectionStatus: user.statusConnection, - status: user.status, - }); - } - - const user = this.getUserFromParams(); - - return API.v1.success({ - _id: user._id, - message: user.statusText, - status: user.status, - }); - }, - }, -); - -API.v1.addRoute( - 'users.setStatus', - { authRequired: true }, - { - post() { - check( - this.bodyParams, - Match.ObjectIncluding({ - status: Match.Maybe(String), - message: Match.Maybe(String), - }), - ); - - if (!settings.get('Accounts_AllowUserStatusMessageChange')) { - throw new Meteor.Error('error-not-allowed', 'Change status is not allowed', { - method: 'users.setStatus', - }); - } - - let user; - if (this.isUserFromParams()) { - user = Meteor.users.findOne(this.userId); - } else if (hasPermission(this.userId, 'edit-other-user-info')) { - user = this.getUserFromParams(); - } else { - return API.v1.unauthorized(); - } - - Meteor.runAsUser(user._id, () => { - if (this.bodyParams.message || this.bodyParams.message === '') { - setStatusText(user._id, this.bodyParams.message); - } - if (this.bodyParams.status) { - const validStatus = ['online', 'away', 'offline', 'busy']; - if (validStatus.includes(this.bodyParams.status)) { - const { status } = this.bodyParams; - - if (status === 'offline' && !settings.get('Accounts_AllowInvisibleStatusOption')) { - throw new Meteor.Error('error-status-not-allowed', 'Invisible status is disabled', { - method: 'users.setStatus', - }); - } - - Meteor.users.update(user._id, { - $set: { - status, - statusDefault: status, - }, - }); - - setUserStatus(user, status); - } else { - throw new Meteor.Error('error-invalid-status', 'Valid status types include online, away, offline, and busy.', { - method: 'users.setStatus', - }); - } - } - }); - - return API.v1.success(); - }, - }, -); - -API.v1.addRoute( - 'users.update', - { authRequired: true, twoFactorRequired: true }, - { - post() { - check(this.bodyParams, { - userId: String, - data: Match.ObjectIncluding({ - email: Match.Maybe(String), - name: Match.Maybe(String), - password: Match.Maybe(String), - username: Match.Maybe(String), - bio: Match.Maybe(String), - nickname: Match.Maybe(String), - statusText: Match.Maybe(String), - active: Match.Maybe(Boolean), - roles: Match.Maybe(Array), - joinDefaultChannels: Match.Maybe(Boolean), - requirePasswordChange: Match.Maybe(Boolean), - sendWelcomeEmail: Match.Maybe(Boolean), - verified: Match.Maybe(Boolean), - customFields: Match.Maybe(Object), - }), - }); - - const userData = _.extend({ _id: this.bodyParams.userId }, this.bodyParams.data); - - Meteor.runAsUser(this.userId, () => saveUser(this.userId, userData)); - - if (this.bodyParams.data.customFields) { - saveCustomFields(this.bodyParams.userId, this.bodyParams.data.customFields); - } - - if (typeof this.bodyParams.data.active !== 'undefined') { - const { - userId, - data: { active }, - confirmRelinquish = false, - } = this.bodyParams; + const userId = Meteor.call('registerUser', this.bodyParams); - Meteor.runAsUser(this.userId, () => { - Meteor.call('setUserActiveStatus', userId, active, confirmRelinquish); - }); - } + // Now set their username + Meteor.runAsUser(userId, () => Meteor.call('setUsername', this.bodyParams.username)); const { fields } = this.parseJsonQuery(); - return API.v1.success({ user: Users.findOneById(this.bodyParams.userId, { fields }) }); + return API.v1.success({ user: Users.findOneById(userId, { fields }) }); }, }, ); API.v1.addRoute( - 'users.updateOwnBasicInfo', + 'users.resetAvatar', { authRequired: true }, { post() { - check(this.bodyParams, { - data: Match.ObjectIncluding({ - email: Match.Maybe(String), - name: Match.Maybe(String), - username: Match.Maybe(String), - nickname: Match.Maybe(String), - statusText: Match.Maybe(String), - currentPassword: Match.Maybe(String), - newPassword: Match.Maybe(String), - }), - customFields: Match.Maybe(Object), - }); - - const userData = { - email: this.bodyParams.data.email, - realname: this.bodyParams.data.name, - username: this.bodyParams.data.username, - nickname: this.bodyParams.data.nickname, - statusText: this.bodyParams.data.statusText, - newPassword: this.bodyParams.data.newPassword, - typedPassword: this.bodyParams.data.currentPassword, - }; - - // saveUserProfile now uses the default two factor authentication procedures, so we need to provide that - const twoFactorOptions = !userData.typedPassword - ? null - : { - twoFactorCode: userData.typedPassword, - twoFactorMethod: 'password', - }; + const user = this.getUserFromParams(); - Meteor.runAsUser(this.userId, () => Meteor.call('saveUserProfile', userData, this.bodyParams.customFields, twoFactorOptions)); + if (settings.get('Accounts_AllowUserAvatarChange') && user._id === this.userId) { + Meteor.runAsUser(this.userId, () => Meteor.call('resetAvatar')); + } else if (hasPermission(this.userId, 'edit-other-user-avatar')) { + Meteor.runAsUser(this.userId, () => Meteor.call('resetAvatar', user._id)); + } else { + throw new Meteor.Error('error-not-allowed', 'Reset avatar is not allowed', { + method: 'users.resetAvatar', + }); + } - return API.v1.success({ - user: Users.findOneById(this.userId, { fields: API.v1.defaultFieldsToExclude }), - }); + return API.v1.success(); }, }, ); @@ -690,10 +540,7 @@ API.v1.addRoute( { post() { const user = this.getUserFromParams(); - let data; - Meteor.runAsUser(this.userId, () => { - data = Meteor.call('createToken', user._id); - }); + const data = Meteor.call('createToken', user._id); return data ? API.v1.success({ data }) : API.v1.unauthorized(); }, }, @@ -718,77 +565,6 @@ API.v1.addRoute( }, ); -API.v1.addRoute( - 'users.setPreferences', - { authRequired: true }, - { - post() { - check(this.bodyParams, { - userId: Match.Maybe(String), - data: Match.ObjectIncluding({ - newRoomNotification: Match.Maybe(String), - newMessageNotification: Match.Maybe(String), - clockMode: Match.Maybe(Number), - useEmojis: Match.Maybe(Boolean), - convertAsciiEmoji: Match.Maybe(Boolean), - saveMobileBandwidth: Match.Maybe(Boolean), - collapseMediaByDefault: Match.Maybe(Boolean), - autoImageLoad: Match.Maybe(Boolean), - emailNotificationMode: Match.Maybe(String), - unreadAlert: Match.Maybe(Boolean), - notificationsSoundVolume: Match.Maybe(Number), - desktopNotifications: Match.Maybe(String), - pushNotifications: Match.Maybe(String), - enableAutoAway: Match.Maybe(Boolean), - highlights: Match.Maybe(Array), - desktopNotificationRequireInteraction: Match.Maybe(Boolean), - messageViewMode: Match.Maybe(Number), - showMessageInMainThread: Match.Maybe(Boolean), - hideUsernames: Match.Maybe(Boolean), - hideRoles: Match.Maybe(Boolean), - displayAvatars: Match.Maybe(Boolean), - hideFlexTab: Match.Maybe(Boolean), - sendOnEnter: Match.Maybe(String), - language: Match.Maybe(String), - sidebarShowFavorites: Match.Optional(Boolean), - sidebarShowUnread: Match.Optional(Boolean), - sidebarSortby: Match.Optional(String), - sidebarViewMode: Match.Optional(String), - sidebarDisplayAvatar: Match.Optional(Boolean), - sidebarGroupByType: Match.Optional(Boolean), - muteFocusedConversations: Match.Optional(Boolean), - }), - }); - if (this.bodyParams.userId && this.bodyParams.userId !== this.userId && !hasPermission(this.userId, 'edit-other-user-info')) { - throw new Meteor.Error('error-action-not-allowed', 'Editing user is not allowed'); - } - const userId = this.bodyParams.userId ? this.bodyParams.userId : this.userId; - if (!Users.findOneById(userId)) { - throw new Meteor.Error('error-invalid-user', 'The optional "userId" param provided does not match any users'); - } - - Meteor.runAsUser(userId, () => Meteor.call('saveUserPreferences', this.bodyParams.data)); - const user = Users.findOneById(userId, { - fields: { - 'settings.preferences': 1, - 'language': 1, - }, - }); - return API.v1.success({ - user: { - _id: user._id, - settings: { - preferences: { - ...user.settings.preferences, - language: user.language, - }, - }, - }, - }); - }, - }, -); - API.v1.addRoute( 'users.forgotPassword', { authRequired: false }, @@ -810,7 +586,7 @@ API.v1.addRoute( { authRequired: true }, { get() { - const result = Meteor.runAsUser(this.userId, () => Meteor.call('getUsernameSuggestion')); + const result = Meteor.call('getUsernameSuggestion'); return API.v1.success({ result }); }, @@ -826,7 +602,7 @@ API.v1.addRoute( if (!tokenName) { return API.v1.failure("The 'tokenName' param is required"); } - const token = Meteor.runAsUser(this.userId, () => Meteor.call('personalAccessTokens:generateToken', { tokenName, bypassTwoFactor })); + const token = Meteor.call('personalAccessTokens:generateToken', { tokenName, bypassTwoFactor }); return API.v1.success({ token }); }, @@ -842,7 +618,7 @@ API.v1.addRoute( if (!tokenName) { return API.v1.failure("The 'tokenName' param is required"); } - const token = Meteor.runAsUser(this.userId, () => Meteor.call('personalAccessTokens:regenerateToken', { tokenName })); + const token = Meteor.call('personalAccessTokens:regenerateToken', { tokenName }); return API.v1.success({ token }); }, @@ -857,19 +633,19 @@ API.v1.addRoute( if (!hasPermission(this.userId, 'create-personal-access-tokens')) { throw new Meteor.Error('not-authorized', 'Not Authorized'); } - const loginTokens = Users.getLoginTokensByUserId(this.userId).fetch()[0]; - const getPersonalAccessTokens = () => - loginTokens.services.resume.loginTokens - .filter((loginToken) => loginToken.type && loginToken.type === 'personalAccessToken') - .map((loginToken) => ({ - name: loginToken.name, - createdAt: loginToken.createdAt, - lastTokenPart: loginToken.lastTokenPart, - bypassTwoFactor: loginToken.bypassTwoFactor, - })); + + const user = Users.getLoginTokensByUserId(this.userId).fetch()[0] as IUser | undefined; return API.v1.success({ - tokens: loginTokens ? getPersonalAccessTokens() : [], + tokens: + user?.services?.resume?.loginTokens + ?.filter((loginToken: any) => loginToken.type === 'personalAccessToken') + .map((loginToken: IPersonalAccessToken) => ({ + name: loginToken.name, + createdAt: loginToken.createdAt.toISOString(), + lastTokenPart: loginToken.lastTokenPart, + bypassTwoFactor: Boolean(loginToken.bypassTwoFactor), + })) || [], }); }, }, @@ -884,11 +660,9 @@ API.v1.addRoute( if (!tokenName) { return API.v1.failure("The 'tokenName' param is required"); } - Meteor.runAsUser(this.userId, () => - Meteor.call('personalAccessTokens:removeToken', { - tokenName, - }), - ); + Meteor.call('personalAccessTokens:removeToken', { + tokenName, + }); return API.v1.success(); }, @@ -931,7 +705,7 @@ API.v1.addRoute('users.2fa.sendEmailCode', { const userId = this.userId || Users[method](emailOrUsername, { fields: { _id: 1 } })?._id; if (!userId) { - this.logger.error('[2fa] User was not found when requesting 2fa email code'); + // this.logger.error('[2fa] User was not found when requesting 2fa email code'); return API.v1.success(); } @@ -968,7 +742,7 @@ API.v1.addRoute( if (from) { const ts = new Date(from); - const diff = (Date.now() - ts) / 1000 / 60; + const diff = (Date.now() - Number(ts)) / 1000 / 60; if (diff < 10) { return API.v1.success({ @@ -992,10 +766,13 @@ API.v1.addRoute( { get() { const { fullExport = false } = this.queryParams; - const result = Meteor.runAsUser(this.userId, () => Meteor.call('requestDataDownload', { fullExport: fullExport === 'true' })); + const result = Meteor.call('requestDataDownload', { fullExport: fullExport === 'true' }) as { + requested: boolean; + exportOperation: IExportOperation; + }; return API.v1.success({ - requested: result.requested, + requested: Boolean(result.requested), exportOperation: result.exportOperation, }); }, @@ -1007,48 +784,43 @@ API.v1.addRoute( { authRequired: true }, { async post() { - try { - const hashedToken = Accounts._hashLoginToken(this.request.headers['x-auth-token']); + const xAuthToken = this.request.headers['x-auth-token'] as string; - if (!(await UsersRaw.removeNonPATLoginTokensExcept(this.userId, hashedToken))) { - throw new Meteor.Error('error-invalid-user-id', 'Invalid user id'); - } + if (!xAuthToken) { + throw new Meteor.Error('error-parameter-required', 'x-auth-token is required'); + } + const hashedToken = Accounts._hashLoginToken(xAuthToken); - const me = await UsersRaw.findOneById(this.userId, { projection: { 'services.resume.loginTokens': 1 } }); + if (!(await UsersRaw.removeNonPATLoginTokensExcept(this.userId, hashedToken))) { + throw new Meteor.Error('error-invalid-user-id', 'Invalid user id'); + } - const token = me.services.resume.loginTokens.find((token) => token.hashedToken === hashedToken); + const me = (await UsersRaw.findOneById(this.userId, { projection: { 'services.resume.loginTokens': 1 } })) as Pick<IUser, 'services'>; - const tokenExpires = new Date(token.when.getTime() + settings.get('Accounts_LoginExpiration') * 1000); + const token = me.services?.resume?.loginTokens?.find((token) => token.hashedToken === hashedToken); - return API.v1.success({ - token: this.request.headers['x-auth-token'], - tokenExpires, - }); - } catch (error) { - return API.v1.failure(error); - } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const tokenExpires = new Date(token!.when.getTime() + settings.get<number>('Accounts_LoginExpiration') * 1000); + + return API.v1.success({ + token: xAuthToken, + tokenExpires: tokenExpires.toISOString() || '', + }); }, }, ); API.v1.addRoute( 'users.autocomplete', - { authRequired: true }, + { authRequired: true, validateParams: isUsersAutocompleteProps }, { - get() { + async get() { const { selector } = this.queryParams; - - if (!selector) { - return API.v1.failure("The 'selector' param is required"); - } - return API.v1.success( - Promise.await( - findUsersToAutocomplete({ - uid: this.userId, - selector: JSON.parse(selector), - }), - ), + await findUsersToAutocomplete({ + uid: this.userId, + selector: JSON.parse(selector), + }), ); }, }, @@ -1059,7 +831,7 @@ API.v1.addRoute( { authRequired: true }, { post() { - API.v1.success(Meteor.call('removeOtherTokens')); + return API.v1.success(Meteor.call('removeOtherTokens')); }, }, ); @@ -1069,30 +841,28 @@ API.v1.addRoute( { authRequired: true, twoFactorRequired: true, twoFactorOptions: { disableRememberMe: true } }, { post() { - // reset own keys - if (this.isUserFromParams()) { - resetUserE2EEncriptionKey(this.userId, false); - return API.v1.success(); - } + if ('userId' in this.bodyParams || 'username' in this.bodyParams || 'user' in this.bodyParams) { + // reset other user keys + const user = this.getUserFromParams(); + if (!user) { + throw new Meteor.Error('error-invalid-user-id', 'Invalid user id'); + } - // reset other user keys - const user = this.getUserFromParams(); - if (!user) { - throw new Meteor.Error('error-invalid-user-id', 'Invalid user id'); - } + if (!settings.get('Accounts_TwoFactorAuthentication_Enforce_Password_Fallback')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } - if (!settings.get('Accounts_TwoFactorAuthentication_Enforce_Password_Fallback')) { - throw new Meteor.Error('error-not-allowed', 'Not allowed'); - } + if (!hasPermission(this.userId, 'edit-other-user-e2ee')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } - if (!hasPermission(Meteor.userId(), 'edit-other-user-e2ee')) { - throw new Meteor.Error('error-not-allowed', 'Not allowed'); - } + if (!resetUserE2EEncriptionKey(user._id, true)) { + return API.v1.failure(); + } - if (!resetUserE2EEncriptionKey(user._id, true)) { - return API.v1.failure(); + return API.v1.success(); } - + resetUserE2EEncriptionKey(this.userId, false); return API.v1.success(); }, }, @@ -1102,29 +872,28 @@ API.v1.addRoute( 'users.resetTOTP', { authRequired: true, twoFactorRequired: true, twoFactorOptions: { disableRememberMe: true } }, { - post() { - // reset own keys - if (this.isUserFromParams()) { - Promise.await(resetTOTP(this.userId, false)); - return API.v1.success(); - } - - // reset other user keys - const user = this.getUserFromParams(); - if (!user) { - throw new Meteor.Error('error-invalid-user-id', 'Invalid user id'); - } + async post() { + // // reset own keys + if ('userId' in this.bodyParams || 'username' in this.bodyParams || 'user' in this.bodyParams) { + // reset other user keys + if (!hasPermission(this.userId, 'edit-other-user-totp')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } - if (!settings.get('Accounts_TwoFactorAuthentication_Enforce_Password_Fallback')) { - throw new Meteor.Error('error-not-allowed', 'Not allowed'); - } + if (!settings.get('Accounts_TwoFactorAuthentication_Enforce_Password_Fallback')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } - if (!hasPermission(Meteor.userId(), 'edit-other-user-totp')) { - throw new Meteor.Error('error-not-allowed', 'Not allowed'); - } + const user = this.getUserFromParams(); + if (!user) { + throw new Meteor.Error('error-invalid-user-id', 'Invalid user id'); + } - Promise.await(resetTOTP(user._id, true)); + await resetTOTP(user._id, true); + return API.v1.success(); + } + await resetTOTP(this.userId, false); return API.v1.success(); }, }, @@ -1132,25 +901,22 @@ API.v1.addRoute( API.v1.addRoute( 'users.listTeams', - { authRequired: true }, + { authRequired: true, validateParams: isUsersListTeamsProps }, { - get() { + async get() { check( this.queryParams, Match.ObjectIncluding({ userId: Match.Maybe(String), }), ); - const { userId } = this.queryParams; - if (!userId) { - throw new Meteor.Error('error-invalid-user-id', 'Invalid user id'); - } + const { userId } = this.queryParams; // If the caller has permission to view all teams, there's no need to filter the teams const adminId = hasPermission(this.userId, 'view-all-teams') ? undefined : this.userId; - const teams = Promise.await(Team.findBySubscribedUserIds(userId, adminId)); + const teams = await Team.findBySubscribedUserIds(userId, adminId); return API.v1.success({ teams, @@ -1161,7 +927,7 @@ API.v1.addRoute( API.v1.addRoute( 'users.logout', - { authRequired: true }, + { authRequired: true, validateParams: isUserLogoutParamsPOST }, { post() { const userId = this.bodyParams.userId || this.userId; @@ -1182,7 +948,130 @@ API.v1.addRoute( }, ); -settings.watch('Rate_Limiter_Limit_RegisterUser', (value) => { +API.v1.addRoute( + 'users.getPresence', + { authRequired: true }, + { + get() { + if (this.isUserFromParams()) { + const user = Users.findOneById(this.userId); + return API.v1.success({ + presence: user.status || 'offline', + connectionStatus: user.statusConnection || 'offline', + ...(user.lastLogin && { lastLogin: user.lastLogin }), + }); + } + + const user = this.getUserFromParams(); + + return API.v1.success({ + presence: user.status || 'offline', + }); + }, + }, +); + +API.v1.addRoute( + 'users.setStatus', + { authRequired: true }, + { + post() { + check( + this.bodyParams, + Match.ObjectIncluding({ + status: Match.Maybe(String), + message: Match.Maybe(String), + }), + ); + + if (!settings.get('Accounts_AllowUserStatusMessageChange')) { + throw new Meteor.Error('error-not-allowed', 'Change status is not allowed', { + method: 'users.setStatus', + }); + } + + const user = ((): IUser | undefined => { + if (this.isUserFromParams()) { + return Meteor.users.findOne(this.userId) as IUser; + } + if (hasPermission(this.userId, 'edit-other-user-info')) { + return this.getUserFromParams(); + } + })(); + + if (user === undefined) { + return API.v1.unauthorized(); + } + + Meteor.runAsUser(user._id, () => { + if (this.bodyParams.message || this.bodyParams.message === '') { + setStatusText(user._id, this.bodyParams.message); + } + if (this.bodyParams.status) { + const validStatus = ['online', 'away', 'offline', 'busy']; + if (validStatus.includes(this.bodyParams.status)) { + const { status } = this.bodyParams; + + if (status === 'offline' && !settings.get('Accounts_AllowInvisibleStatusOption')) { + throw new Meteor.Error('error-status-not-allowed', 'Invisible status is disabled', { + method: 'users.setStatus', + }); + } + + Meteor.users.update(user._id, { + $set: { + status, + statusDefault: status, + }, + }); + + setUserStatus(user, status); + } else { + throw new Meteor.Error('error-invalid-status', 'Valid status types include online, away, offline, and busy.', { + method: 'users.setStatus', + }); + } + } + }); + + return API.v1.success(); + }, + }, +); + +// status: 'online' | 'offline' | 'away' | 'busy'; +// message?: string; +// _id: string; +// connectionStatus?: 'online' | 'offline' | 'away' | 'busy'; +// }; + +API.v1.addRoute( + 'users.getStatus', + { authRequired: true }, + { + get() { + if (this.isUserFromParams()) { + const user = Users.findOneById(this.userId); + return API.v1.success({ + _id: user._id, + // message: user.statusText, + connectionStatus: (user.statusConnection || 'offline') as 'online' | 'offline' | 'away' | 'busy', + status: (user.status || 'offline') as 'online' | 'offline' | 'away' | 'busy', + }); + } + + const user = this.getUserFromParams(); + + return API.v1.success({ + _id: user._id, + // message: user.statusText, + status: (user.status || 'offline') as 'online' | 'offline' | 'away' | 'busy', + }); + }, + }, +); + +settings.watch<number>('Rate_Limiter_Limit_RegisterUser', (value) => { const userRegisterRoute = '/api/v1/users.registerpost'; API.v1.updateRateLimiterDictionaryForRoute(userRegisterRoute, value); diff --git a/apps/meteor/app/assets/server/assets.js b/apps/meteor/app/assets/server/assets.ts similarity index 63% rename from apps/meteor/app/assets/server/assets.js rename to apps/meteor/app/assets/server/assets.ts index 79ced079ea6..84ed75f8480 100644 --- a/apps/meteor/app/assets/server/assets.js +++ b/apps/meteor/app/assets/server/assets.ts @@ -1,4 +1,5 @@ import crypto from 'crypto'; +import { ServerResponse, IncomingMessage } from 'http'; import { Meteor } from 'meteor/meteor'; import { WebApp, WebAppInternals } from 'meteor/webapp'; @@ -6,19 +7,20 @@ import { WebAppHashing } from 'meteor/webapp-hashing'; import _ from 'underscore'; import sizeOf from 'image-size'; import sharp from 'sharp'; +import { NextHandleFunction } from 'connect'; +import { IRocketChatAssets, IRocketChatAsset } from '@rocket.chat/core-typings'; import { settings, settingsRegistry } from '../../settings/server'; import { getURL } from '../../utils/lib/getURL'; -import { mime } from '../../utils/lib/mimeTypes'; -import { hasPermission } from '../../authorization'; +import { getExtension } from '../../utils/lib/mimeTypes'; +import { hasPermission } from '../../authorization/server'; import { RocketChatFile } from '../../file'; import { Settings } from '../../models/server'; const RocketChatAssetsInstance = new RocketChatFile.GridFS({ name: 'assets', }); - -const assets = { +const assets: IRocketChatAssets = { logo: { label: 'logo (svg, png, jpg)', defaultUrl: 'images/logo/logo.svg', @@ -38,6 +40,7 @@ const assets = { extensions: ['svg', 'png', 'jpg', 'jpeg'], }, }, + // eslint-disable-next-line @typescript-eslint/camelcase favicon_ico: { label: 'favicon (ico)', defaultUrl: 'favicon.ico', @@ -54,6 +57,7 @@ const assets = { extensions: ['svg'], }, }, + // eslint-disable-next-line @typescript-eslint/camelcase favicon_16: { label: 'favicon 16x16 (png)', defaultUrl: 'images/logo/favicon-16x16.png', @@ -64,6 +68,7 @@ const assets = { height: 16, }, }, + // eslint-disable-next-line @typescript-eslint/camelcase favicon_32: { label: 'favicon 32x32 (png)', defaultUrl: 'images/logo/favicon-32x32.png', @@ -74,6 +79,7 @@ const assets = { height: 32, }, }, + // eslint-disable-next-line @typescript-eslint/camelcase favicon_192: { label: 'android-chrome 192x192 (png)', defaultUrl: 'images/logo/android-chrome-192x192.png', @@ -84,6 +90,7 @@ const assets = { height: 192, }, }, + // eslint-disable-next-line @typescript-eslint/camelcase favicon_512: { label: 'android-chrome 512x512 (png)', defaultUrl: 'images/logo/android-chrome-512x512.png', @@ -94,6 +101,7 @@ const assets = { height: 512, }, }, + // eslint-disable-next-line @typescript-eslint/camelcase touchicon_180: { label: 'apple-touch-icon 180x180 (png)', defaultUrl: 'images/logo/apple-touch-icon.png', @@ -104,6 +112,7 @@ const assets = { height: 180, }, }, + // eslint-disable-next-line @typescript-eslint/camelcase touchicon_180_pre: { label: 'apple-touch-icon-precomposed 180x180 (png)', defaultUrl: 'images/logo/apple-touch-icon-precomposed.png', @@ -114,6 +123,7 @@ const assets = { height: 180, }, }, + // eslint-disable-next-line @typescript-eslint/camelcase tile_70: { label: 'mstile 70x70 (png)', defaultUrl: 'images/logo/mstile-70x70.png', @@ -124,6 +134,7 @@ const assets = { height: 70, }, }, + // eslint-disable-next-line @typescript-eslint/camelcase tile_144: { label: 'mstile 144x144 (png)', defaultUrl: 'images/logo/mstile-144x144.png', @@ -134,6 +145,7 @@ const assets = { height: 144, }, }, + // eslint-disable-next-line @typescript-eslint/camelcase tile_150: { label: 'mstile 150x150 (png)', defaultUrl: 'images/logo/mstile-150x150.png', @@ -144,6 +156,7 @@ const assets = { height: 150, }, }, + // eslint-disable-next-line @typescript-eslint/camelcase tile_310_square: { label: 'mstile 310x310 (png)', defaultUrl: 'images/logo/mstile-310x310.png', @@ -154,6 +167,7 @@ const assets = { height: 310, }, }, + // eslint-disable-next-line @typescript-eslint/camelcase tile_310_wide: { label: 'mstile 310x150 (png)', defaultUrl: 'images/logo/mstile-310x150.png', @@ -164,6 +178,7 @@ const assets = { height: 150, }, }, + // eslint-disable-next-line @typescript-eslint/camelcase safari_pinned: { label: 'safari pinned tab (svg)', defaultUrl: 'images/logo/safari-pinned-tab.svg', @@ -174,24 +189,25 @@ const assets = { }, }; -export const RocketChatAssets = new (class { - get mime() { - return mime; - } +function getAssetByKey(key: string): IRocketChatAsset { + return assets[key as keyof IRocketChatAssets]; +} - get assets() { +class RocketChatAssetsClass { + get assets(): IRocketChatAssets { return assets; } - setAsset(binaryContent, contentType, asset) { - if (!assets[asset]) { + public setAsset(binaryContent: BufferEncoding, contentType: string, asset: string): void { + const assetInstance = getAssetByKey(asset); + if (!assetInstance) { throw new Meteor.Error('error-invalid-asset', 'Invalid asset', { function: 'RocketChat.Assets.setAsset', }); } - const extension = mime.extension(contentType); - if (assets[asset].constraints.extensions.includes(extension) === false) { + const extension = getExtension(contentType); + if (assetInstance.constraints.extensions.includes(extension) === false) { throw new Meteor.Error(contentType, `Invalid file type: ${contentType}`, { function: 'RocketChat.Assets.setAsset', errorTitle: 'error-invalid-file-type', @@ -199,14 +215,14 @@ export const RocketChatAssets = new (class { } const file = Buffer.from(binaryContent, 'binary'); - if (assets[asset].constraints.width || assets[asset].constraints.height) { + if (assetInstance.constraints.width || assetInstance.constraints.height) { const dimensions = sizeOf(file); - if (assets[asset].constraints.width && assets[asset].constraints.width !== dimensions.width) { + if (assetInstance.constraints.width && assetInstance.constraints.width !== dimensions.width) { throw new Meteor.Error('error-invalid-file-width', 'Invalid file width', { function: 'Invalid file width', }); } - if (assets[asset].constraints.height && assets[asset].constraints.height !== dimensions.height) { + if (assetInstance.constraints.height && assetInstance.constraints.height !== dimensions.height) { throw new Meteor.Error('error-invalid-file-height'); } } @@ -222,10 +238,11 @@ export const RocketChatAssets = new (class { const key = `Assets_${asset}`; const value = { url: `assets/${asset}.${extension}`, - defaultUrl: assets[asset].defaultUrl, + defaultUrl: assetInstance.defaultUrl, }; Settings.updateValueById(key, value); + // eslint-disable-next-line @typescript-eslint/no-use-before-define return RocketChatAssets.processAsset(key, value); }, 200); }), @@ -234,8 +251,8 @@ export const RocketChatAssets = new (class { rs.pipe(ws); } - unsetAsset(asset) { - if (!assets[asset]) { + public unsetAsset(asset: string): void { + if (!getAssetByKey(asset)) { throw new Meteor.Error('error-invalid-asset', 'Invalid asset', { function: 'RocketChat.Assets.unsetAsset', }); @@ -244,26 +261,27 @@ export const RocketChatAssets = new (class { RocketChatAssetsInstance.deleteFile(asset); const key = `Assets_${asset}`; const value = { - defaultUrl: assets[asset].defaultUrl, + defaultUrl: getAssetByKey(asset).defaultUrl, }; Settings.updateValueById(key, value); + // eslint-disable-next-line @typescript-eslint/no-use-before-define RocketChatAssets.processAsset(key, value); } - refreshClients() { - return process.emit('message', { + public refreshClients(): boolean { + return (process.emit as Function)('message', { refresh: 'client', }); } - processAsset(settingKey, settingValue) { + public processAsset(settingKey: string, settingValue: any): Record<string, any> | undefined { if (settingKey.indexOf('Assets_') !== 0) { return; } const assetKey = settingKey.replace(/^Assets_/, ''); - const assetValue = assets[assetKey]; + const assetValue = getAssetByKey(assetKey); if (!assetValue) { return; @@ -301,23 +319,25 @@ export const RocketChatAssets = new (class { return assetValue.cache; } - getURL(assetName, options = { cdn: false, full: true }) { - const asset = settings.get(assetName); + public getURL(assetName: string, options = { cdn: false, full: true }): string { + const asset = settings.get<IRocketChatAsset>(assetName); const url = asset.url || asset.defaultUrl; return getURL(url, options); } -})(); +} -settingsRegistry.addGroup('Assets'); +export const RocketChatAssets = new RocketChatAssetsClass(); -settingsRegistry.add('Assets_SvgFavicon_Enable', true, { - type: 'boolean', - group: 'Assets', - i18nLabel: 'Enable_Svg_Favicon', +settingsRegistry.addGroup('Assets', function () { + this.add('Assets_SvgFavicon_Enable', true, { + type: 'boolean', + group: 'Assets', + i18nLabel: 'Enable_Svg_Favicon', + }); }); -function addAssetToSetting(asset, value) { +function addAssetToSetting(asset: string, value: IRocketChatAsset): void { const key = `Assets_${asset}`; settingsRegistry.add( @@ -336,16 +356,16 @@ function addAssetToSetting(asset, value) { }, ); - const currentValue = settings.get(key); + const currentValue = settings.get<IRocketChatAsset>(key); - if (typeof currentValue === 'object' && currentValue.defaultUrl !== assets[asset].defaultUrl) { - currentValue.defaultUrl = assets[asset].defaultUrl; + if (typeof currentValue === 'object' && currentValue.defaultUrl !== getAssetByKey(asset).defaultUrl) { + currentValue.defaultUrl = getAssetByKey(asset).defaultUrl; Settings.updateValueById(key, currentValue); } } for (const key of Object.keys(assets)) { - const value = assets[key]; + const value = getAssetByKey(key); addAssetToSetting(key, value); } @@ -353,7 +373,7 @@ settings.watchByRegex(/^Assets_/, (key, value) => RocketChatAssets.processAsset( Meteor.startup(function () { return Meteor.setTimeout(function () { - return process.emit('message', { + return (process.emit as Function)('message', { refresh: 'client', }); }, 200); @@ -361,9 +381,9 @@ Meteor.startup(function () { const { calculateClientHash } = WebAppHashing; -WebAppHashing.calculateClientHash = function (manifest, includeFilter, runtimeConfigOverride) { +WebAppHashing.calculateClientHash = function (manifest: Record<string, any>, includeFilter: Function, runtimeConfigOverride: any): string { for (const key of Object.keys(assets)) { - const value = assets[key]; + const value = getAssetByKey(key); if (!value.cache && !value.defaultUrl) { continue; } @@ -381,7 +401,7 @@ WebAppHashing.calculateClientHash = function (manifest, includeFilter, runtimeCo hash: value.cache.hash, }; } else { - const extension = value.defaultUrl.split('.').pop(); + const extension = value.defaultUrl?.split('.').pop(); cache = { path: `assets/${key}.${extension}`, cacheable: false, @@ -416,7 +436,7 @@ Meteor.methods({ }); } - const _hasPermission = hasPermission(Meteor.userId(), 'manage-assets'); + const _hasPermission = hasPermission(Meteor.userId() as string, 'manage-assets'); if (!_hasPermission) { throw new Meteor.Error('error-action-not-allowed', 'Managing assets not allowed', { method: 'refreshClients', @@ -434,7 +454,7 @@ Meteor.methods({ }); } - const _hasPermission = hasPermission(Meteor.userId(), 'manage-assets'); + const _hasPermission = hasPermission(Meteor.userId() as string, 'manage-assets'); if (!_hasPermission) { throw new Meteor.Error('error-action-not-allowed', 'Managing assets not allowed', { method: 'unsetAsset', @@ -452,7 +472,7 @@ Meteor.methods({ }); } - const _hasPermission = hasPermission(Meteor.userId(), 'manage-assets'); + const _hasPermission = hasPermission(Meteor.userId() as string, 'manage-assets'); if (!_hasPermission) { throw new Meteor.Error('error-action-not-allowed', 'Managing assets not allowed', { method: 'setAsset', @@ -464,62 +484,63 @@ Meteor.methods({ }, }); -WebApp.connectHandlers.use( - '/assets/', - Meteor.bindEnvironment(function (req, res, next) { - const params = { - asset: decodeURIComponent(req.url.replace(/^\//, '').replace(/\?.*$/, '')).replace(/\.[^.]*$/, ''), - }; +const listener = Meteor.bindEnvironment((req: IncomingMessage, res: ServerResponse, next: NextHandleFunction) => { + if (!req.url) { + return; + } + const params = { + asset: decodeURIComponent(req.url.replace(/^\//, '').replace(/\?.*$/, '')).replace(/\.[^.]*$/, ''), + }; - const file = assets[params.asset] && assets[params.asset].cache; + const asset = getAssetByKey(params.asset); + const file = asset?.cache; - const format = req.url.replace(/.*\.([a-z]+)(?:$|\?.*)/i, '$1'); + const format = req.url.split('.').pop() || ''; - if ( - assets[params.asset] && - Array.isArray(assets[params.asset].constraints.extensions) && - !assets[params.asset].constraints.extensions.includes(format) - ) { - res.writeHead(403); - return res.end(); + if (asset && Array.isArray(asset.constraints.extensions) && !asset.constraints.extensions.includes(format)) { + res.writeHead(403); + return res.end(); + } + if (!file) { + const defaultUrl = asset?.defaultUrl; + if (defaultUrl) { + const assetUrl = format && ['png', 'svg'].includes(format) ? defaultUrl.replace(/(svg|png)$/, format) : defaultUrl; + req.url = `/${assetUrl}`; + WebAppInternals.staticFilesMiddleware((WebAppInternals as Record<string, any>).staticFilesByArch, req, res, next); + } else { + res.writeHead(404); + res.end(); } - if (!file) { - const defaultUrl = assets[params.asset] && assets[params.asset].defaultUrl; - if (defaultUrl) { - const assetUrl = format && ['png', 'svg'].includes(format) ? defaultUrl.replace(/(svg|png)$/, format) : defaultUrl; - req.url = `/${assetUrl}`; - WebAppInternals.staticFilesMiddleware(WebAppInternals.staticFilesByArch, req, res, next); - } else { - res.writeHead(404); - res.end(); - } + return; + } + + const reqModifiedHeader = req.headers['if-modified-since']; + if (reqModifiedHeader) { + if (reqModifiedHeader === (file.uploadDate && file.uploadDate.toUTCString())) { + res.setHeader('Last-Modified', reqModifiedHeader); + res.writeHead(304); + res.end(); return; } + } - const reqModifiedHeader = req.headers['if-modified-since']; - if (reqModifiedHeader) { - if (reqModifiedHeader === (file.uploadDate && file.uploadDate.toUTCString())) { - res.setHeader('Last-Modified', reqModifiedHeader); - res.writeHead(304); - res.end(); - return; - } - } + res.setHeader('Cache-Control', 'public, max-age=0'); + res.setHeader('Expires', '-1'); - res.setHeader('Cache-Control', 'public, max-age=0'); - res.setHeader('Expires', '-1'); + if (format && format !== file.extension && ['png', 'jpg', 'jpeg'].includes(format)) { + res.setHeader('Content-Type', `image/${format}`); + sharp(file.content) + .toFormat(format as any) + .pipe(res); + return; + } - if (format && format !== file.extension && ['png', 'jpg', 'jpeg'].includes(format)) { - res.setHeader('Content-Type', `image/${format}`); - sharp(file.content).toFormat(format).pipe(res); - return; - } + res.setHeader('Last-Modified', (file.uploadDate && file.uploadDate.toUTCString()) || new Date().toUTCString()); + res.setHeader('Content-Type', file.contentType); + res.setHeader('Content-Length', file.size); + res.writeHead(200); + res.end(file.content); +}); - res.setHeader('Last-Modified', (file.uploadDate && file.uploadDate.toUTCString()) || new Date().toUTCString()); - res.setHeader('Content-Type', file.contentType); - res.setHeader('Content-Length', file.size); - res.writeHead(200); - res.end(file.content); - }), -); +WebApp.connectHandlers.use('/assets/', listener); diff --git a/apps/meteor/app/assets/server/index.js b/apps/meteor/app/assets/server/index.ts similarity index 100% rename from apps/meteor/app/assets/server/index.js rename to apps/meteor/app/assets/server/index.ts diff --git a/apps/meteor/app/favico/client/favico.js b/apps/meteor/app/favico/client/favico.js deleted file mode 100644 index b85f9b4a4ee..00000000000 --- a/apps/meteor/app/favico/client/favico.js +++ /dev/null @@ -1,844 +0,0 @@ -/** - * @license MIT - * @fileOverview Favico animations - * @author Miroslav Magda, http://blog.ejci.net - * @version 0.3.10 - */ - -/** - * Create new favico instance - * @param {Object} Options - * @return {Object} Favico object - * @example - * var favico = new Favico({ - * bgColor : '#d00', - * textColor : '#fff', - * fontFamily : 'sans-serif', - * fontStyle : 'bold', - * position : 'down', - * type : 'circle', - * animation : 'slide', - * dataUrl: function(url){}, - * win: top - * }); - */ -/* eslint-disable */ - - export const Favico = (function(opt) { - 'use strict'; - opt = (opt) ? opt : {}; - var _def = { - bgColor: '#d00', - textColor: '#fff', - fontFamily: 'sans-serif', //Arial,Verdana,Times New Roman,serif,sans-serif,... - fontStyle: 'bold', //normal,italic,oblique,bold,bolder,lighter,100,200,300,400,500,600,700,800,900 - type: 'circle', - position: 'down', // down, up, left, leftup (upleft) - animation: 'slide', - elementId: false, - dataUrl: false, - win: window - }; - var _opt, _orig, _h, _w, _canvas, _context, _img, _ready, _lastBadge, _running, _readyCb, _stop, _browser, _animTimeout, _drawTimeout, _doc; - - _browser = {}; - _browser.ff = typeof InstallTrigger !== 'undefined'; - _browser.chrome = !!window.chrome; - _browser.opera = !!window.opera || navigator.userAgent.indexOf('Opera') >= 0; - _browser.ie = /*@cc_on!@*/ false; - _browser.safari = Object.prototype.toString.call(window.HTMLElement).indexOf('Constructor') > 0; - _browser.supported = (_browser.chrome || _browser.ff || _browser.opera); - - var _queue = []; - _readyCb = function() {}; - _ready = _stop = false; - /** - * Initialize favico - */ - var init = function() { - //merge initial options - _opt = merge(_def, opt); - _opt.bgColor = hexToRgb(_opt.bgColor); - _opt.textColor = hexToRgb(_opt.textColor); - _opt.position = _opt.position.toLowerCase(); - _opt.animation = (animation.types['' + _opt.animation]) ? _opt.animation : _def.animation; - - _doc = _opt.win.document; - - var isUp = _opt.position.indexOf('up') > -1; - var isLeft = _opt.position.indexOf('left') > -1; - - //transform the animations - if (isUp || isLeft) { - for (var a in animation.types) { - for (var i = 0; i < animation.types[a].length; i++) { - var step = animation.types[a][i]; - - if (isUp) { - if (step.y < 0.6) { - step.y = step.y - 0.4; - } else { - step.y = step.y - 2 * step.y + (1 - step.w); - } - } - - if (isLeft) { - if (step.x < 0.6) { - step.x = step.x - 0.4; - } else { - step.x = step.x - 2 * step.x + (1 - step.h); - } - } - - animation.types[a][i] = step; - } - } - } - _opt.type = (type['' + _opt.type]) ? _opt.type : _def.type; - - _orig = link.getIcons(); - //create temp canvas - _canvas = document.createElement('canvas'); - //create temp image - _img = document.createElement('img'); - var lastIcon = _orig[_orig.length - 1]; - if (lastIcon.hasAttribute('href')) { - _img.setAttribute('crossOrigin', 'anonymous'); - //get width/height - _img.onload = function() { - _h = (_img.height > 0) ? _img.height : 32; - _w = (_img.width > 0) ? _img.width : 32; - _canvas.height = _h; - _canvas.width = _w; - _context = _canvas.getContext('2d'); - icon.ready(); - }; - _img.setAttribute('src', lastIcon.getAttribute('href')); - } else { - _img.onload = function() { - _h = 32; - _w = 32; - _img.height = _h; - _img.width = _w; - _canvas.height = _h; - _canvas.width = _w; - _context = _canvas.getContext('2d'); - icon.ready(); - }; - _img.setAttribute('src', ''); - } - - }; - /** - * Icon namespace - */ - var icon = {}; - /** - * Icon is ready (reset icon) and start animation (if ther is any) - */ - icon.ready = function() { - _ready = true; - icon.reset(); - _readyCb(); - }; - /** - * Reset icon to default state - */ - icon.reset = function() { - //reset - if (!_ready) { - return; - } - _queue = []; - _lastBadge = false; - _running = false; - _context.clearRect(0, 0, _w, _h); - _context.drawImage(_img, 0, 0, _w, _h); - //_stop=true; - link.setIcon(_canvas); - //webcam('stop'); - //video('stop'); - window.clearTimeout(_animTimeout); - window.clearTimeout(_drawTimeout); - }; - /** - * Start animation - */ - icon.start = function() { - if (!_ready || _running) { - return; - } - var finished = function() { - _lastBadge = _queue[0]; - _running = false; - if (_queue.length > 0) { - _queue.shift(); - icon.start(); - } - }; - if (_queue.length > 0) { - _running = true; - var run = function() { - // apply options for this animation - ['type', 'animation', 'bgColor', 'textColor', 'fontFamily', 'fontStyle'].forEach(function(a) { - if (a in _queue[0].options) { - _opt[a] = _queue[0].options[a]; - } - }); - animation.run(_queue[0].options, function() { - finished(); - }, false); - }; - if (_lastBadge) { - animation.run(_lastBadge.options, function() { - run(); - }, true); - } else { - run(); - } - } - }; - - /** - * Badge types - */ - var type = {}; - var options = function(opt) { - opt.n = ((typeof opt.n) === 'number') ? Math.abs(opt.n | 0) : opt.n; - opt.x = _w * opt.x; - opt.y = _h * opt.y; - opt.w = _w * opt.w; - opt.h = _h * opt.h; - opt.len = ('' + opt.n).length; - return opt; - }; - /** - * Generate circle - * @param {Object} opt Badge options - */ - type.circle = function(opt) { - opt = options(opt); - var more = false; - if (opt.len === 2) { - opt.x = opt.x - opt.w * 0.4; - opt.w = opt.w * 1.4; - more = true; - } else if (opt.len >= 3) { - opt.x = opt.x - opt.w * 0.65; - opt.w = opt.w * 1.65; - more = true; - } - _context.clearRect(0, 0, _w, _h); - _context.drawImage(_img, 0, 0, _w, _h); - _context.beginPath(); - _context.font = _opt.fontStyle + ' ' + Math.floor(opt.h * (opt.n > 99 ? 0.85 : 1)) + 'px ' + _opt.fontFamily; - _context.textAlign = 'center'; - if (more) { - _context.moveTo(opt.x + opt.w / 2, opt.y); - _context.lineTo(opt.x + opt.w - opt.h / 2, opt.y); - _context.quadraticCurveTo(opt.x + opt.w, opt.y, opt.x + opt.w, opt.y + opt.h / 2); - _context.lineTo(opt.x + opt.w, opt.y + opt.h - opt.h / 2); - _context.quadraticCurveTo(opt.x + opt.w, opt.y + opt.h, opt.x + opt.w - opt.h / 2, opt.y + opt.h); - _context.lineTo(opt.x + opt.h / 2, opt.y + opt.h); - _context.quadraticCurveTo(opt.x, opt.y + opt.h, opt.x, opt.y + opt.h - opt.h / 2); - _context.lineTo(opt.x, opt.y + opt.h / 2); - _context.quadraticCurveTo(opt.x, opt.y, opt.x + opt.h / 2, opt.y); - } else { - _context.arc(opt.x + opt.w / 2, opt.y + opt.h / 2, opt.h / 2, 0, 2 * Math.PI); - } - _context.fillStyle = 'rgba(' + _opt.bgColor.r + ',' + _opt.bgColor.g + ',' + _opt.bgColor.b + ',' + opt.o + ')'; - _context.fill(); - _context.closePath(); - _context.beginPath(); - _context.stroke(); - _context.fillStyle = 'rgba(' + _opt.textColor.r + ',' + _opt.textColor.g + ',' + _opt.textColor.b + ',' + opt.o + ')'; - //_context.fillText((more) ? '9+' : opt.n, Math.floor(opt.x + opt.w / 2), Math.floor(opt.y + opt.h - opt.h * 0.15)); - if ((typeof opt.n) === 'number' && opt.n > 999) { - _context.fillText(((opt.n > 9999) ? 9 : Math.floor(opt.n / 1000)) + 'k+', Math.floor(opt.x + opt.w / 2), Math.floor(opt.y + opt.h - opt.h * 0.2)); - } else { - _context.fillText(opt.n, Math.floor(opt.x + opt.w / 2), Math.floor(opt.y + opt.h - opt.h * 0.15)); - } - _context.closePath(); - }; - /** - * Generate rectangle - * @param {Object} opt Badge options - */ - type.rectangle = function(opt) { - opt = options(opt); - var more = false; - if (opt.len === 2) { - opt.x = opt.x - opt.w * 0.4; - opt.w = opt.w * 1.4; - more = true; - } else if (opt.len >= 3) { - opt.x = opt.x - opt.w * 0.65; - opt.w = opt.w * 1.65; - more = true; - } - _context.clearRect(0, 0, _w, _h); - _context.drawImage(_img, 0, 0, _w, _h); - _context.beginPath(); - _context.font = _opt.fontStyle + ' ' + Math.floor(opt.h * (opt.n > 99 ? 0.9 : 1)) + 'px ' + _opt.fontFamily; - _context.textAlign = 'center'; - _context.fillStyle = 'rgba(' + _opt.bgColor.r + ',' + _opt.bgColor.g + ',' + _opt.bgColor.b + ',' + opt.o + ')'; - _context.fillRect(opt.x, opt.y, opt.w, opt.h); - _context.fillStyle = 'rgba(' + _opt.textColor.r + ',' + _opt.textColor.g + ',' + _opt.textColor.b + ',' + opt.o + ')'; - //_context.fillText((more) ? '9+' : opt.n, Math.floor(opt.x + opt.w / 2), Math.floor(opt.y + opt.h - opt.h * 0.15)); - if ((typeof opt.n) === 'number' && opt.n > 999) { - _context.fillText(((opt.n > 9999) ? 9 : Math.floor(opt.n / 1000)) + 'k+', Math.floor(opt.x + opt.w / 2), Math.floor(opt.y + opt.h - opt.h * 0.2)); - } else { - _context.fillText(opt.n, Math.floor(opt.x + opt.w / 2), Math.floor(opt.y + opt.h - opt.h * 0.15)); - } - _context.closePath(); - }; - - /** - * Set badge - */ - var badge = function(number, opts) { - opts = ((typeof opts) === 'string' ? { - animation: opts - } : opts) || {}; - _readyCb = function() { - try { - if (typeof(number) === 'number' ? (number > 0) : (number !== '')) { - var q = { - type: 'badge', - options: { - n: number - } - }; - if ('animation' in opts && animation.types['' + opts.animation]) { - q.options.animation = '' + opts.animation; - } - if ('type' in opts && type['' + opts.type]) { - q.options.type = '' + opts.type; - } - ['bgColor', 'textColor'].forEach(function(o) { - if (o in opts) { - q.options[o] = hexToRgb(opts[o]); - } - }); - ['fontStyle', 'fontFamily'].forEach(function(o) { - if (o in opts) { - q.options[o] = opts[o]; - } - }); - _queue.push(q); - if (_queue.length > 100) { - throw new Error('Too many badges requests in queue.'); - } - icon.start(); - } else { - icon.reset(); - } - } catch (e) { - throw new Error('Error setting badge. Message: ' + e.message); - } - }; - if (_ready) { - _readyCb(); - } - }; - - /** - * Set image as icon - */ - var image = function(imageElement) { - _readyCb = function() { - try { - var w = imageElement.width; - var h = imageElement.height; - var newImg = document.createElement('img'); - var ratio = (w / _w < h / _h) ? (w / _w) : (h / _h); - newImg.setAttribute('crossOrigin', 'anonymous'); - newImg.onload = function() { - _context.clearRect(0, 0, _w, _h); - _context.drawImage(newImg, 0, 0, _w, _h); - link.setIcon(_canvas); - }; - newImg.setAttribute('src', imageElement.getAttribute('src')); - newImg.height = (h / ratio); - newImg.width = (w / ratio); - } catch (e) { - throw new Error('Error setting image. Message: ' + e.message); - } - }; - if (_ready) { - _readyCb(); - } - }; - /** - * Set video as icon - */ - var video = function(videoElement) { - _readyCb = function() { - try { - if (videoElement === 'stop') { - _stop = true; - icon.reset(); - _stop = false; - return; - } - //var w = videoElement.width; - //var h = videoElement.height; - //var ratio = (w / _w < h / _h) ? (w / _w) : (h / _h); - videoElement.addEventListener('play', function() { - drawVideo(this); - }, false); - - } catch (e) { - throw new Error('Error setting video. Message: ' + e.message); - } - }; - if (_ready) { - _readyCb(); - } - }; - /** - * Set video as icon - */ - var webcam = function(action) { - //UR - if (!window.URL || !window.URL.createObjectURL) { - window.URL = window.URL || {}; - window.URL.createObjectURL = function(obj) { - return obj; - }; - } - if (_browser.supported) { - var newVideo = false; - navigator.getUserMedia = navigator.getUserMedia || navigator.oGetUserMedia || navigator.msGetUserMedia || navigator.mozGetUserMedia || navigator.webkitGetUserMedia; - _readyCb = function() { - try { - if (action === 'stop') { - _stop = true; - icon.reset(); - _stop = false; - return; - } - newVideo = document.createElement('video'); - newVideo.width = _w; - newVideo.height = _h; - navigator.getUserMedia({ - video: true, - audio: false - }, function(stream) { - newVideo.src = URL.createObjectURL(stream); - newVideo.play(); - drawVideo(newVideo); - }, function() {}); - } catch (e) { - throw new Error('Error setting webcam. Message: ' + e.message); - } - }; - if (_ready) { - _readyCb(); - } - } - - }; - - /** - * Draw video to context and repeat :) - */ - function drawVideo(video) { - if (video.paused || video.ended || _stop) { - return false; - } - //nasty hack for FF webcam (Thanks to Julian Ćwirko, kontakt@redsunmedia.pl) - try { - _context.clearRect(0, 0, _w, _h); - _context.drawImage(video, 0, 0, _w, _h); - } catch (e) { - - } - _drawTimeout = setTimeout(function() { - drawVideo(video); - }, animation.duration); - link.setIcon(_canvas); - } - - var link = {}; - /** - * Get icons from HEAD tag or create a new <link> element - */ - link.getIcons = function() { - var elms = []; - //get link element - var getLinks = function() { - var icons = []; - var links = _doc.getElementsByTagName('head')[0].getElementsByTagName('link'); - for (var i = 0; i < links.length; i++) { - if ((/(^|\s)icon(\s|$)/i).test(links[i].getAttribute('rel'))) { - icons.push(links[i]); - } - } - return icons; - }; - if (_opt.element) { - elms = [_opt.element]; - } else if (_opt.elementId) { - //if img element identified by elementId - elms = [_doc.getElementById(_opt.elementId)]; - elms[0].setAttribute('href', elms[0].getAttribute('src')); - } else { - //if link element - elms = getLinks(); - if (elms.length === 0) { - elms = [_doc.createElement('link')]; - elms[0].setAttribute('rel', 'icon'); - _doc.getElementsByTagName('head')[0].appendChild(elms[0]); - } - } - elms.forEach(function(item) { - item.setAttribute('type', 'image/png'); - }); - return elms; - }; - link.setIcon = function(canvas) { - var url = canvas.toDataURL('image/png'); - if (_opt.dataUrl) { - //if using custom exporter - _opt.dataUrl(url); - } - if (_opt.element) { - _opt.element.setAttribute('href', url); - _opt.element.setAttribute('src', url); - } else if (_opt.elementId) { - //if is attached to element (image) - var elm = _doc.getElementById(_opt.elementId); - elm.setAttribute('href', url); - elm.setAttribute('src', url); - } else { - //if is attached to fav icon - if (_browser.ff || _browser.opera) { - //for FF we need to "recreate" element, atach to dom and remove old <link> - //var originalType = _orig.getAttribute('rel'); - var old = _orig[_orig.length - 1]; - var newIcon = _doc.createElement('link'); - _orig = [newIcon]; - //_orig.setAttribute('rel', originalType); - if (_browser.opera) { - newIcon.setAttribute('rel', 'icon'); - } - newIcon.setAttribute('rel', 'icon'); - newIcon.setAttribute('type', 'image/png'); - _doc.getElementsByTagName('head')[0].appendChild(newIcon); - newIcon.setAttribute('href', url); - if (old.parentNode) { - old.parentNode.removeChild(old); - } - } else { - _orig.forEach(function(icon) { - icon.setAttribute('href', url); - }); - } - } - }; - - //http://stackoverflow.com/questions/5623838/rgb-to-hex-and-hex-to-rgb#answer-5624139 - //HEX to RGB convertor - function hexToRgb(hex) { - var shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; - hex = hex.replace(shorthandRegex, function(m, r, g, b) { - return r + r + g + g + b + b; - }); - var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); - return result ? { - r: parseInt(result[1], 16), - g: parseInt(result[2], 16), - b: parseInt(result[3], 16) - } : false; - } - - /** - * Merge options - */ - function merge(def, opt) { - var mergedOpt = {}; - var attrname; - for (attrname in def) { - mergedOpt[attrname] = def[attrname]; - } - for (attrname in opt) { - mergedOpt[attrname] = opt[attrname]; - } - return mergedOpt; - } - - /** - * Cross-browser page visibility shim - * http://stackoverflow.com/questions/12536562/detect-whether-a-window-is-visible - */ - function isPageHidden() { - return _doc.hidden || _doc.msHidden || _doc.webkitHidden || _doc.mozHidden; - } - - /** - * @namespace animation - */ - var animation = {}; - /** - * Animation "frame" duration - */ - animation.duration = 40; - /** - * Animation types (none,fade,pop,slide) - */ - animation.types = {}; - animation.types.fade = [{ - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 0.0 - }, { - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 0.1 - }, { - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 0.2 - }, { - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 0.3 - }, { - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 0.4 - }, { - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 0.5 - }, { - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 0.6 - }, { - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 0.7 - }, { - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 0.8 - }, { - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 0.9 - }, { - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 1.0 - }]; - animation.types.none = [{ - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 1 - }]; - animation.types.pop = [{ - x: 1, - y: 1, - w: 0, - h: 0, - o: 1 - }, { - x: 0.9, - y: 0.9, - w: 0.1, - h: 0.1, - o: 1 - }, { - x: 0.8, - y: 0.8, - w: 0.2, - h: 0.2, - o: 1 - }, { - x: 0.7, - y: 0.7, - w: 0.3, - h: 0.3, - o: 1 - }, { - x: 0.6, - y: 0.6, - w: 0.4, - h: 0.4, - o: 1 - }, { - x: 0.5, - y: 0.5, - w: 0.5, - h: 0.5, - o: 1 - }, { - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 1 - }]; - animation.types.popFade = [{ - x: 0.75, - y: 0.75, - w: 0, - h: 0, - o: 0 - }, { - x: 0.65, - y: 0.65, - w: 0.1, - h: 0.1, - o: 0.2 - }, { - x: 0.6, - y: 0.6, - w: 0.2, - h: 0.2, - o: 0.4 - }, { - x: 0.55, - y: 0.55, - w: 0.3, - h: 0.3, - o: 0.6 - }, { - x: 0.50, - y: 0.50, - w: 0.4, - h: 0.4, - o: 0.8 - }, { - x: 0.45, - y: 0.45, - w: 0.5, - h: 0.5, - o: 0.9 - }, { - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 1 - }]; - animation.types.slide = [{ - x: 0.4, - y: 1, - w: 0.6, - h: 0.6, - o: 1 - }, { - x: 0.4, - y: 0.9, - w: 0.6, - h: 0.6, - o: 1 - }, { - x: 0.4, - y: 0.9, - w: 0.6, - h: 0.6, - o: 1 - }, { - x: 0.4, - y: 0.8, - w: 0.6, - h: 0.6, - o: 1 - }, { - x: 0.4, - y: 0.7, - w: 0.6, - h: 0.6, - o: 1 - }, { - x: 0.4, - y: 0.6, - w: 0.6, - h: 0.6, - o: 1 - }, { - x: 0.4, - y: 0.5, - w: 0.6, - h: 0.6, - o: 1 - }, { - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 1 - }]; - /** - * Run animation - * @param {Object} opt Animation options - * @param {Object} cb Callabak after all steps are done - * @param {Object} revert Reverse order? true|false - * @param {Object} step Optional step number (frame bumber) - */ - animation.run = function(opt, cb, revert, step) { - var animationType = animation.types[isPageHidden() ? 'none' : _opt.animation]; - if (revert === true) { - step = (typeof step !== 'undefined') ? step : animationType.length - 1; - } else { - step = (typeof step !== 'undefined') ? step : 0; - } - cb = (cb) ? cb : function() {}; - if ((step < animationType.length) && (step >= 0)) { - type[_opt.type](merge(opt, animationType[step])); - _animTimeout = setTimeout(function() { - if (revert) { - step = step - 1; - } else { - step = step + 1; - } - animation.run(opt, cb, revert, step); - }, animation.duration); - - link.setIcon(_canvas); - } else { - cb(); - return; - } - }; - //auto init - init(); - return { - badge: badge, - video: video, - image: image, - webcam: webcam, - reset: icon.reset, - browser: { - supported: _browser.supported - } - }; - }); diff --git a/apps/meteor/app/favico/client/index.js b/apps/meteor/app/favico/client/index.js deleted file mode 100644 index 239a252e455..00000000000 --- a/apps/meteor/app/favico/client/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import { Favico } from './favico'; - -export { Favico }; diff --git a/apps/meteor/app/favico/index.js b/apps/meteor/app/favico/index.js deleted file mode 100644 index 40a7340d388..00000000000 --- a/apps/meteor/app/favico/index.js +++ /dev/null @@ -1 +0,0 @@ -export * from './client/index'; diff --git a/apps/meteor/app/lib/server/functions/getFullUserData.js b/apps/meteor/app/lib/server/functions/getFullUserData.ts similarity index 65% rename from apps/meteor/app/lib/server/functions/getFullUserData.js rename to apps/meteor/app/lib/server/functions/getFullUserData.ts index d328f3b0583..74bf410aee9 100644 --- a/apps/meteor/app/lib/server/functions/getFullUserData.js +++ b/apps/meteor/app/lib/server/functions/getFullUserData.ts @@ -1,7 +1,9 @@ -import { Logger } from '../../../logger'; +import { IUser } from '@rocket.chat/core-typings'; + +import { Logger } from '../../../logger/server'; import { settings } from '../../../settings/server'; import { Users } from '../../../models/server'; -import { hasPermission } from '../../../authorization'; +import { hasPermission } from '../../../authorization/server'; const logger = new Logger('getFullUserData'); @@ -18,7 +20,7 @@ const defaultFields = { statusText: 1, avatarETag: 1, extension: 1, -}; +} as const; const fullFields = { emails: 1, @@ -31,12 +33,12 @@ const fullFields = { requirePasswordChange: 1, requirePasswordChangeReason: 1, roles: 1, -}; +} as const; -let publicCustomFields = {}; -let customFields = {}; +let publicCustomFields: Record<string, 0 | 1> = {}; +let customFields: Record<string, 0 | 1> = {}; -settings.watch('Accounts_CustomFields', (value) => { +settings.watch<string>('Accounts_CustomFields', (value) => { publicCustomFields = {}; customFields = {}; @@ -58,29 +60,23 @@ settings.watch('Accounts_CustomFields', (value) => { } }); -const getCustomFields = (canViewAllInfo) => (canViewAllInfo ? customFields : publicCustomFields); +const getCustomFields = (canViewAllInfo: boolean): Record<string, 0 | 1> => (canViewAllInfo ? customFields : publicCustomFields); -const getFields = (canViewAllInfo) => ({ +const getFields = (canViewAllInfo: boolean): Record<string, 0 | 1> => ({ ...defaultFields, ...(canViewAllInfo && fullFields), ...getCustomFields(canViewAllInfo), }); -const removePasswordInfo = (user) => { - if (user && user.services) { - delete user.services.password; - delete user.services.email; - delete user.services.resume; - delete user.services.emailCode; - delete user.services.cloud; - delete user.services.email2fa; - delete user.services.totp; - } - - return user; +const removePasswordInfo = (user: IUser): Omit<IUser, 'services'> => { + const { services, ...result } = user; + return result; }; -export function getFullUserDataByIdOrUsername({ userId, filterId, filterUsername }) { +export async function getFullUserDataByIdOrUsername( + userId: string, + { filterId, filterUsername }: { filterId: string; filterUsername?: undefined } | { filterId?: undefined; filterUsername: string }, +): Promise<IUser | null> { const caller = Users.findOneById(userId, { fields: { username: 1 } }); const targetUser = filterId || filterUsername; const myself = (filterId && targetUser === userId) || (filterUsername && targetUser === caller.username); diff --git a/apps/meteor/app/lib/server/functions/setUserAvatar.ts b/apps/meteor/app/lib/server/functions/setUserAvatar.ts index e72b847c5ce..4718303b21c 100644 --- a/apps/meteor/app/lib/server/functions/setUserAvatar.ts +++ b/apps/meteor/app/lib/server/functions/setUserAvatar.ts @@ -8,12 +8,26 @@ import { SystemLogger } from '../../../../server/lib/logger/system'; import { api } from '../../../../server/sdk/api'; import { fetch } from '../../../../server/lib/http/fetch'; -export const setUserAvatar = function ( +export function setUserAvatar( + user: Pick<IUser, '_id' | 'username'>, + dataURI: Buffer, + contentType: string, + service: 'rest', + etag?: string, +): void; +export function setUserAvatar( user: Pick<IUser, '_id' | 'username'>, dataURI: string, contentType: string, service: 'initials' | 'url' | 'rest' | string, etag?: string, +): void; +export function setUserAvatar( + user: Pick<IUser, '_id' | 'username'>, + dataURI: string | Buffer, + contentType: string, + service: 'initials' | 'url' | 'rest' | string, + etag?: string, ): void { if (service === 'initials') { Users.setAvatarData(user._id, service, null); @@ -22,7 +36,7 @@ export const setUserAvatar = function ( const { buffer, type } = Promise.await( (async (): Promise<{ buffer: Buffer; type: string }> => { - if (service === 'url') { + if (service === 'url' && typeof dataURI === 'string') { let response: Response; try { response = await fetch(dataURI); @@ -69,7 +83,7 @@ export const setUserAvatar = function ( if (service === 'rest') { return { - buffer: Buffer.from(dataURI, 'binary'), + buffer: dataURI instanceof Buffer ? dataURI : Buffer.from(dataURI, 'binary'), type: contentType, }; } @@ -103,4 +117,4 @@ export const setUserAvatar = function ( avatarETag, }); }, 500); -}; +} diff --git a/apps/meteor/app/livechat/client/lib/chartHandler.js b/apps/meteor/app/livechat/client/lib/chartHandler.js deleted file mode 100644 index b49d09002fa..00000000000 --- a/apps/meteor/app/livechat/client/lib/chartHandler.js +++ /dev/null @@ -1,221 +0,0 @@ -import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; - -const lineChartConfiguration = ({ legends = false, anim = false, smallTicks = false, displayColors = false, tooltipCallbacks = {} }) => { - const config = { - layout: { - padding: { - top: 10, - bottom: 0, - }, - }, - legend: { - display: false, - }, - title: { - display: false, - }, - tooltips: { - enabled: true, - mode: 'point', - displayColors, - ...tooltipCallbacks, - }, - scales: { - xAxes: [ - { - scaleLabel: { - display: false, - }, - gridLines: { - display: true, - color: 'rgba(0, 0, 0, 0.03)', - }, - }, - ], - yAxes: [ - { - scaleLabel: { - display: false, - }, - gridLines: { - display: true, - color: 'rgba(0, 0, 0, 0.03)', - }, - ticks: { - beginAtZero: true, - }, - }, - ], - }, - hover: { - animationDuration: 0, // duration of animations when hovering an item - }, - responsive: true, - maintainAspectRatio: false, - responsiveAnimationDuration: 0, // animation duration after a resize - }; - - if (!anim) { - config.animation = { - duration: 0, // general animation time - }; - } - - if (legends) { - config.legend = { - display: true, - labels: { - boxWidth: 20, - fontSize: 8, - }, - }; - } - - if (smallTicks) { - config.scales.xAxes[0].ticks = { - fontSize: 8, - }; - - config.scales.yAxes[0].ticks = { - beginAtZero: true, - fontSize: 8, - }; - } - - return config; -}; - -const doughnutChartConfiguration = (title, tooltipCallbacks = {}) => ({ - layout: { - padding: { - top: 0, - bottom: 0, - }, - }, - legend: { - display: true, - position: 'right', - labels: { - boxWidth: 20, - fontSize: 8, - }, - }, - title: { - display: 'true', - text: title, - }, - tooltips: { - enabled: true, - mode: 'point', - displayColors: false, // hide color box - ...tooltipCallbacks, - }, - // animation: { - // duration: 0 // general animation time - // }, - hover: { - animationDuration: 0, // duration of animations when hovering an item - }, - responsive: true, - maintainAspectRatio: false, - responsiveAnimationDuration: 0, // animation duration after a resize -}); - -/** - * - * @param {Object} chart - chart element - * @param {Object} chartContext - Context of chart - * @param {Array(String)} chartLabel - * @param {Array(String)} dataLabels - * @param {Array(Array(Double))} dataPoints - */ -export const drawLineChart = async (chart, chartContext, chartLabels, dataLabels, dataSets, options = {}) => { - if (!chart) { - console.log('No chart element'); - return; - } - if (chartContext) { - chartContext.destroy(); - } - const colors = ['#2de0a5', '#ffd21f', '#f5455c', '#cbced1']; - - const datasets = []; - - chartLabels.forEach(function (chartLabel, index) { - datasets.push({ - label: TAPi18n.__(chartLabel), // chart label - data: dataSets[index], // data points corresponding to data labels, x-axis points - backgroundColor: [colors[index]], - borderColor: [colors[index]], - borderWidth: 3, - fill: false, - }); - }); - const chartImport = await import('chart.js'); - const Chart = chartImport.default; - return new Chart(chart, { - type: 'line', - data: { - labels: dataLabels, // data labels, y-axis points - datasets, - }, - options: lineChartConfiguration(options), - }); -}; - -/** - * - * @param {Object} chart - chart element - * @param {Object} chartContext - Context of chart - * @param {Array(String)} dataLabels - * @param {Array(Double)} dataPoints - */ -export const drawDoughnutChart = async (chart, title, chartContext, dataLabels, dataPoints) => { - if (!chart) { - return; - } - if (chartContext) { - chartContext.destroy(); - } - const chartImport = await import('chart.js'); - const Chart = chartImport.default; - return new Chart(chart, { - type: 'doughnut', - data: { - labels: dataLabels, // data labels, y-axis points - datasets: [ - { - data: dataPoints, // data points corresponding to data labels, x-axis points - backgroundColor: ['#2de0a5', '#cbced1', '#f5455c', '#ffd21f'], - borderWidth: 0, - }, - ], - }, - options: doughnutChartConfiguration(title), - }); -}; - -/** - * Update chart - * @param {Object} chart [Chart context] - * @param {String} label [chart label] - * @param {Array(Double)} data [updated data] - */ -export const updateChart = async (c, label, data) => { - const chart = await c; - if (chart.data.labels.indexOf(label) === -1) { - // insert data - chart.data.labels.push(label); - chart.data.datasets.forEach((dataset, idx) => { - dataset.data.push(data[idx]); - }); - } else { - // update data - const index = chart.data.labels.indexOf(label); - chart.data.datasets.forEach((dataset, idx) => { - dataset.data[index] = data[idx]; - }); - } - - chart.update(); -}; diff --git a/apps/meteor/app/livechat/client/lib/chartHandler.ts b/apps/meteor/app/livechat/client/lib/chartHandler.ts new file mode 100644 index 00000000000..7f9b6f1a6f4 --- /dev/null +++ b/apps/meteor/app/livechat/client/lib/chartHandler.ts @@ -0,0 +1,232 @@ +import type { ChartItem, Chart as ChartType, ChartConfiguration } from 'chart.js'; +import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; + +type LineChartConfigOptions = Partial<{ + legends: boolean; + anim: boolean; + displayColors: boolean; + tooltipCallbacks: any; +}>; + +const lineChartConfiguration = ({ + legends = false, + anim = false, + tooltipCallbacks = {}, +}: LineChartConfigOptions): Partial<ChartConfiguration<'line', number, string>['options']> => { + const config: ChartConfiguration<'line', number, string>['options'] = { + layout: { + padding: { + top: 10, + bottom: 0, + }, + }, + legend: { + display: false, + }, + plugins: { + tooltip: { + usePointStyle: true, + enabled: true, + mode: 'point', + yAlign: 'bottom', + displayColors: true, + ...tooltipCallbacks, + }, + }, + scales: { + xAxis: { + title: { + display: false, + }, + grid: { + display: true, + color: 'rgba(0, 0, 0, 0.03)', + }, + }, + yAxis: { + title: { + display: false, + }, + grid: { + display: true, + color: 'rgba(0, 0, 0, 0.03)', + }, + }, + }, + hover: { + intersect: false, // duration of animations when hovering an item + mode: 'index', + }, + responsive: true, + maintainAspectRatio: false, + ...(!anim ? { animation: { duration: 0 } } : {}), + ...(legends ? { legend: { display: true, labels: { boxWidth: 20, fontSize: 8 } } } : {}), + }; + + return config; +}; + +const doughnutChartConfiguration = ( + title: string, + tooltipCallbacks = {}, +): Partial<ChartConfiguration<'doughnut', number, string>['options']> => ({ + layout: { + padding: { + top: 0, + bottom: 0, + }, + }, + plugins: { + legend: { + display: true, + position: 'right', + labels: { + boxWidth: 20, + }, + }, + title: { + display: true, + text: title, + }, + tooltip: { + enabled: true, + mode: 'point', + displayColors: true, // hide color box + ...tooltipCallbacks, + }, + }, + // animation: { + // duration: 0 // general animation time + // }, + hover: { + intersect: true, // duration of animations when hovering an item + }, + responsive: true, + maintainAspectRatio: false, +}); + +type ChartDataSet = { + label: string; + data: number; + backgroundColor: string; + borderColor: string; + borderWidth: number; + fill: boolean; +}; + +/** + * + * @param {Object} chart - chart element + * @param {Object} chartContext - Context of chart + * @param {Array(String)} chartLabel + * @param {Array(String)} dataLabels + * @param {Array(Array(Double))} dataPoints + */ +export const drawLineChart = async ( + chart: HTMLCanvasElement, + chartContext: { destroy: () => void } | undefined, + chartLabels: string[], + dataLabels: string[], + dataSets: number[], + options: LineChartConfigOptions = {}, +): Promise<ChartType<'line', number, string> | void> => { + if (!chart) { + console.error('No chart element'); + return; + } + if (chartContext) { + chartContext.destroy(); + } + const colors = ['#2de0a5', '#ffd21f', '#f5455c', '#cbced1']; + + const datasets: ChartDataSet[] = []; + + chartLabels.forEach(function (chartLabel: string, index: number) { + datasets.push({ + label: TAPi18n.__(chartLabel), // chart label + data: dataSets[index], // data points corresponding to data labels, x-axis points + backgroundColor: colors[index], + borderColor: colors[index], + borderWidth: 3, + fill: false, + }); + }); + const chartjs = await import('chart.js/auto'); + const Chart = chartjs.default; + return new Chart(chart, { + type: 'line', + data: { + labels: dataLabels, // data labels, y-axis points + datasets, + }, + options: lineChartConfiguration(options), + }); +}; + +/** + * + * @param {Object} chart - chart element + * @param {Object} chartContext - Context of chart + * @param {Array(String)} dataLabels + * @param {Array(Double)} dataPoints + */ +export const drawDoughnutChart = async ( + chart: ChartItem, + title: string, + chartContext: { destroy: () => void } | undefined, + dataLabels: string[], + dataPoints: number[], +): Promise<ChartType<'doughnut', number[], string> | void> => { + if (!chart) { + console.error('No chart element'); + return; + } + if (chartContext) { + chartContext.destroy(); + } + const chartjs = await import('chart.js/auto'); + const Chart = chartjs.default; + return new Chart(chart, { + type: 'doughnut', + data: { + labels: dataLabels, // data labels, y-axis points + datasets: [ + { + data: dataPoints, // data points corresponding to data labels, x-axis points + backgroundColor: ['#2de0a5', '#cbced1', '#f5455c', '#ffd21f'], + borderWidth: 0, + }, + ], + }, + options: doughnutChartConfiguration(title), + }); +}; + +/** + * Update chart + * @param {Object} chart [Chart context] + * @param {String} label [chart label] + * @param {Array(Double)} data [updated data] + */ +export const updateChart = async (c: ChartType, label: string, data: { [x: string]: number }): Promise<void> => { + const chart = await c; + if (chart.data?.labels?.indexOf(label) === -1) { + // insert data + chart.data.labels.push(label); + chart.data.datasets.forEach((dataset: { data: any[] }, idx: string | number) => { + dataset.data.push(data[idx]); + }); + } else { + // update data + const index = chart.data?.labels?.indexOf(label); + if (typeof index === 'undefined') { + return; + } + + chart.data.datasets.forEach((dataset: { data: { [x: string]: any } }, idx: string | number) => { + dataset.data[index] = data[idx]; + }); + } + + chart.update(); +}; diff --git a/apps/meteor/app/models/server/raw/Users.js b/apps/meteor/app/models/server/raw/Users.js index 97b6da84167..b0ea06bf102 100644 --- a/apps/meteor/app/models/server/raw/Users.js +++ b/apps/meteor/app/models/server/raw/Users.js @@ -162,6 +162,15 @@ export class UsersRaw extends BaseRaw { return this.find(query, options); } + findActiveByIdsOrUsernames(userIds, options = {}) { + const query = { + $or: [{ _id: { $in: userIds } }, { username: { $in: userIds } }], + active: true, + }; + + return this.find(query, options); + } + findByIds(userIds, options = {}) { const query = { _id: { $in: userIds }, diff --git a/apps/meteor/app/settings/server/SettingsRegistry.ts b/apps/meteor/app/settings/server/SettingsRegistry.ts index d6799596ed9..0c5c00ff2c1 100644 --- a/apps/meteor/app/settings/server/SettingsRegistry.ts +++ b/apps/meteor/app/settings/server/SettingsRegistry.ts @@ -135,8 +135,10 @@ export class SettingsRegistry { throw new Error(`Enterprise setting ${_id} is missing the invalidValue option`); } + const settingFromCodeOverwritten = overwriteSetting(settingFromCode); + const settingStored = this.store.getSetting(_id); - const settingOverwritten = overwriteSetting(settingFromCode); + const settingStoredOverwritten = settingStored && overwriteSetting(settingStored); try { validateSetting(settingFromCode._id, settingFromCode.type, settingFromCode.value); @@ -144,14 +146,14 @@ export class SettingsRegistry { IS_DEVELOPMENT && SystemLogger.error(`Invalid setting code ${_id}: ${(e as Error).message}`); } - const isOverwritten = settingFromCode !== settingOverwritten; + const isOverwritten = settingFromCode !== settingFromCodeOverwritten || (settingStored && settingStored !== settingStoredOverwritten); - const { _id: _, ...settingProps } = settingOverwritten; + const { _id: _, ...settingProps } = settingFromCodeOverwritten; - if (settingStored && !compareSettings(settingStored, settingOverwritten)) { - const { value: _value, ...settingOverwrittenProps } = settingOverwritten; + if (settingStored && !compareSettings(settingStored, settingFromCodeOverwritten)) { + const { value: _value, ...settingOverwrittenProps } = settingFromCodeOverwritten; - const overwrittenKeys = Object.keys(settingOverwritten); + const overwrittenKeys = Object.keys(settingFromCodeOverwritten); const removedKeys = Object.keys(settingStored).filter((key) => !['_updatedAt'].includes(key) && !overwrittenKeys.includes(key)); this.model.upsert( @@ -168,7 +170,7 @@ export class SettingsRegistry { } if (settingStored && isOverwritten) { - if (settingStored.value !== settingOverwritten.value) { + if (settingStored.value !== settingFromCodeOverwritten.value) { this.model.upsert({ _id }, settingProps); } return; @@ -185,7 +187,7 @@ export class SettingsRegistry { const settingOverwrittenDefault = overrideSetting(settingFromCode); - const setting = isOverwritten ? settingOverwritten : settingOverwrittenDefault; + const setting = isOverwritten ? settingFromCodeOverwritten : settingOverwrittenDefault; this.model.insert(setting); // no need to emit unless we remove the oplog diff --git a/apps/meteor/app/utils/lib/mimeTypes.js b/apps/meteor/app/utils/lib/mimeTypes.js deleted file mode 100644 index 70cd99776e4..00000000000 --- a/apps/meteor/app/utils/lib/mimeTypes.js +++ /dev/null @@ -1,8 +0,0 @@ -import mime from 'mime-type/with-db'; - -mime.types.wav = 'audio/wav'; -mime.define('image/vnd.microsoft.icon', { extensions: ['ico'] }, mime.dupAppend); -mime.define('image/x-icon', { extensions: ['ico'] }, mime.dupAppend); -mime.types.ico = 'image/x-icon'; - -export { mime }; diff --git a/apps/meteor/app/utils/lib/mimeTypes.ts b/apps/meteor/app/utils/lib/mimeTypes.ts new file mode 100644 index 00000000000..dd166f17bed --- /dev/null +++ b/apps/meteor/app/utils/lib/mimeTypes.ts @@ -0,0 +1,14 @@ +import mime from 'mime-type/with-db'; + +mime.types.wav = 'audio/wav'; +mime.define('image/vnd.microsoft.icon', { source: '', extensions: ['ico'] }, mime.dupAppend); +mime.define('image/x-icon', { source: '', extensions: ['ico'] }, mime.dupAppend); +mime.types.ico = 'image/x-icon'; + +const getExtension = (param: string): string => { + const extension = mime.extension(param); + + return !extension || typeof extension === 'boolean' ? '' : extension; +}; + +export { mime, getExtension }; diff --git a/apps/meteor/client/components/Header/Header.stories.tsx b/apps/meteor/client/components/Header/Header.stories.tsx index 0f7905c3d0a..a8742be2b80 100644 --- a/apps/meteor/client/components/Header/Header.stories.tsx +++ b/apps/meteor/client/components/Header/Header.stories.tsx @@ -30,8 +30,9 @@ export default { value={{ hasPrivateAccess: true, isLoading: false, - querySetting: (_id) => ({ - getCurrentValue: () => ({ + querySetting: (_id) => [ + () => () => undefined, + () => ({ _id, type: 'action', value: '', @@ -44,12 +45,8 @@ export default { sorter: 1, ts: new Date(), }), - subscribe: () => () => undefined, - }), - querySettings: () => ({ - getCurrentValue: () => [], - subscribe: () => () => undefined, - }), + ], + querySettings: () => [() => () => undefined, () => []], dispatch: async () => undefined, }} > diff --git a/apps/meteor/client/components/Omnichannel/Tags.tsx b/apps/meteor/client/components/Omnichannel/Tags.tsx index ad2d515f437..6887bfe07e4 100644 --- a/apps/meteor/client/components/Omnichannel/Tags.tsx +++ b/apps/meteor/client/components/Omnichannel/Tags.tsx @@ -2,11 +2,10 @@ import { Field, TextInput, Chip, Button } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; import React, { ChangeEvent, ReactElement, useState } from 'react'; -import { useSubscription } from 'use-subscription'; import { AsyncStatePhase } from '../../hooks/useAsyncState'; import { useEndpointData } from '../../hooks/useEndpointData'; -import { formsSubscription } from '../../views/omnichannel/additionalForms'; +import { useFormsSubscription } from '../../views/omnichannel/additionalForms'; import { FormSkeleton } from './Skeleton'; const Tags = ({ @@ -21,7 +20,7 @@ const Tags = ({ tagRequired?: boolean; }): ReactElement => { const t = useTranslation(); - const forms = useSubscription<any>(formsSubscription); + const forms = useFormsSubscription() as any; const { value: tagsResult, phase: stateTags } = useEndpointData('/v1/livechat/tags.list'); diff --git a/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/context/OmnichannelRoomIconContext.tsx b/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/context/OmnichannelRoomIconContext.tsx index 7c825e1d54f..b51a962d8e9 100644 --- a/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/context/OmnichannelRoomIconContext.tsx +++ b/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/context/OmnichannelRoomIconContext.tsx @@ -1,31 +1,26 @@ import { createContext, useMemo, useContext } from 'react'; -import { useSubscription, Unsubscribe } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { AsyncState } from '../../../../lib/asyncState/AsyncState'; import { AsyncStatePhase } from '../../../../lib/asyncState/AsyncStatePhase'; type IOmnichannelRoomIconContext = { - queryIcon( - app: string, - icon: string, - ): { - getCurrentValue: () => AsyncState<string>; - subscribe: (callback: () => void) => Unsubscribe; - }; + queryIcon(app: string, icon: string): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => AsyncState<string>]; }; export const OmnichannelRoomIconContext = createContext<IOmnichannelRoomIconContext>({ - queryIcon: () => ({ - getCurrentValue: (): AsyncState<string> => ({ + queryIcon: () => [ + (): (() => void) => (): void => undefined, + (): AsyncState<string> => ({ phase: AsyncStatePhase.LOADING, value: undefined, error: undefined, }), - subscribe: (): Unsubscribe => (): void => undefined, - }), + ], }); export const useOmnichannelRoomIcon = (app: string, icon: string): AsyncState<string> => { const { queryIcon } = useContext(OmnichannelRoomIconContext); - return useSubscription(useMemo(() => queryIcon(app, icon), [app, queryIcon, icon])); + const [subscribe, getSnapshot] = useMemo(() => queryIcon(app, icon), [app, queryIcon, icon]); + return useSyncExternalStore(subscribe, getSnapshot); }; diff --git a/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/provider/OmnichannelRoomIconProvider.tsx b/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/provider/OmnichannelRoomIconProvider.tsx index 0e3ca29581d..3171ebf3475 100644 --- a/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/provider/OmnichannelRoomIconProvider.tsx +++ b/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/provider/OmnichannelRoomIconProvider.tsx @@ -1,28 +1,37 @@ -import React, { FC, useMemo } from 'react'; +import React, { FC, useCallback, useMemo } from 'react'; import { createPortal } from 'react-dom'; -import { useSubscription, Subscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { AsyncState } from '../../../../lib/asyncState/AsyncState'; import { AsyncStatePhase } from '../../../../lib/asyncState/AsyncStatePhase'; import { OmnichannelRoomIconContext } from '../context/OmnichannelRoomIconContext'; import OmnichannelRoomIcon from '../lib/OmnichannelRoomIcon'; +let icons = Array.from(OmnichannelRoomIcon.icons.values()); + export const OmnichannelRoomIconProvider: FC = ({ children }) => { - const svgIcons = useSubscription( - useMemo( - () => ({ - getCurrentValue: (): string[] => Array.from(OmnichannelRoomIcon.icons.values()), - subscribe: (callback): (() => void) => OmnichannelRoomIcon.on('change', callback), - }), + const svgIcons = useSyncExternalStore( + useCallback( + (callback): (() => void) => + OmnichannelRoomIcon.on('change', () => { + icons = Array.from(OmnichannelRoomIcon.icons.values()); + callback(); + }), [], ), + (): string[] => icons, ); + return ( <OmnichannelRoomIconContext.Provider value={useMemo( () => ({ - queryIcon: (app: string, iconName: string): Subscription<AsyncState<string>> => ({ - getCurrentValue: (): AsyncState<string> => { + queryIcon: ( + app: string, + iconName: string, + ): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => AsyncState<string>] => [ + (callback): (() => void) => OmnichannelRoomIcon.on(`${app}-${iconName}`, callback), + (): AsyncState<string> => { const icon = OmnichannelRoomIcon.get(app, iconName); if (!icon) { @@ -39,8 +48,7 @@ export const OmnichannelRoomIconProvider: FC = ({ children }) => { error: undefined, }; }, - subscribe: (callback): (() => void) => OmnichannelRoomIcon.on(`${app}-${iconName}`, callback), - }), + ], }), [], )} diff --git a/apps/meteor/client/components/VerticalBar/VerticalBarActionBack.tsx b/apps/meteor/client/components/VerticalBar/VerticalBarActionBack.tsx deleted file mode 100644 index 427dbd1d7fc..00000000000 --- a/apps/meteor/client/components/VerticalBar/VerticalBarActionBack.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React, { ReactElement, memo, ComponentProps } from 'react'; - -import VerticalBarAction from './VerticalBarAction'; - -const VerticalBarActionBack = (props: ComponentProps<typeof VerticalBarAction>): ReactElement => ( - <VerticalBarAction {...props} name='arrow-back' /> -); - -export default memo(VerticalBarActionBack); diff --git a/apps/meteor/client/components/VerticalBar/VerticalBarBack.tsx b/apps/meteor/client/components/VerticalBar/VerticalBarBack.tsx new file mode 100644 index 00000000000..ecfcc9716be --- /dev/null +++ b/apps/meteor/client/components/VerticalBar/VerticalBarBack.tsx @@ -0,0 +1,13 @@ +import { useTranslation } from '@rocket.chat/ui-contexts'; +import React, { ReactElement, memo, ComponentProps } from 'react'; + +import VerticalBarAction from './VerticalBarAction'; + +type VerticalBarBackProps = Partial<ComponentProps<typeof VerticalBarAction>>; + +const VerticalBarBack = (props: VerticalBarBackProps): ReactElement => { + const t = useTranslation(); + return <VerticalBarAction {...props} title={t('Back')} name='arrow-back' />; +}; + +export default memo(VerticalBarBack); diff --git a/apps/meteor/client/components/VerticalBar/index.ts b/apps/meteor/client/components/VerticalBar/index.ts index 9d5d37e9246..e970ab61915 100644 --- a/apps/meteor/client/components/VerticalBar/index.ts +++ b/apps/meteor/client/components/VerticalBar/index.ts @@ -1,7 +1,7 @@ import VerticalBar from './VerticalBar'; import VerticalBarAction from './VerticalBarAction'; -import VerticalBarActionBack from './VerticalBarActionBack'; import VerticalBarActions from './VerticalBarActions'; +import VerticalBarBack from './VerticalBarBack'; import VerticalBarButton from './VerticalBarButton'; import VerticalBarClose from './VerticalBarClose'; import VerticalBarContent from './VerticalBarContent'; @@ -26,5 +26,5 @@ export default Object.assign(VerticalBar, { ScrollableContent: VerticalBarScrollableContent, Skeleton: VerticalBarSkeleton, Button: VerticalBarButton, - Back: VerticalBarActionBack, + Back: VerticalBarBack, }); diff --git a/apps/meteor/client/components/voip/modal/WrapUpCallModal.tsx b/apps/meteor/client/components/voip/modal/WrapUpCallModal.tsx index 104fd542707..31a4d07a294 100644 --- a/apps/meteor/client/components/voip/modal/WrapUpCallModal.tsx +++ b/apps/meteor/client/components/voip/modal/WrapUpCallModal.tsx @@ -3,7 +3,6 @@ import { useSetModal, useTranslation } from '@rocket.chat/ui-contexts'; import React, { ReactElement, useEffect } from 'react'; import { useForm, SubmitHandler } from 'react-hook-form'; -import { useCallCloseRoom } from '../../../contexts/CallContext'; import Tags from '../../Omnichannel/Tags'; type WrapUpCallPayload = { @@ -11,9 +10,12 @@ type WrapUpCallPayload = { tags?: string[]; }; -export const WrapUpCallModal = (): ReactElement => { +type WrapUpCallModalProps = { + closeRoom: (data?: { comment?: string; tags?: string[] }) => void; +}; + +export const WrapUpCallModal = ({ closeRoom }: WrapUpCallModalProps): ReactElement => { const setModal = useSetModal(); - const closeRoom = useCallCloseRoom(); const closeModal = (): void => setModal(null); const t = useTranslation(); diff --git a/apps/meteor/client/contexts/CallContext.ts b/apps/meteor/client/contexts/CallContext.ts index f5c02d0292a..b28a325e97c 100644 --- a/apps/meteor/client/contexts/CallContext.ts +++ b/apps/meteor/client/contexts/CallContext.ts @@ -1,7 +1,7 @@ import type { IVoipRoom } from '@rocket.chat/core-typings'; import { ICallerInfo, VoIpCallerInfo } from '@rocket.chat/core-typings'; -import { createContext, useContext, useMemo } from 'react'; -import { useSubscription } from 'use-subscription'; +import { createContext, useCallback, useContext } from 'react'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { VoIPUser } from '../lib/voip/VoIPUser'; @@ -62,10 +62,16 @@ export const useIsCallEnabled = (): boolean => { return enabled; }; +let callerInfo: VoIpCallerInfo; + export const useIsCallReady = (): boolean => { - const { ready } = useContext(CallContext); + const context = useContext(CallContext); + + if (isCallContextReady(context)) { + callerInfo = context.voipClient.callerInfo; + } - return Boolean(ready); + return !!context.ready; }; export const useIsCallError = (): boolean => { @@ -89,20 +95,20 @@ export const useCallerInfo = (): VoIpCallerInfo => { throw new Error('useCallerInfo only if Calls are enabled and ready'); } const { voipClient } = context; - const subscription = useMemo( - () => ({ - getCurrentValue: (): VoIpCallerInfo => voipClient.callerInfo, - subscribe: (callback: () => void): (() => void) => { - voipClient.on('stateChanged', callback); - - return (): void => { - voipClient.off('stateChanged', callback); - }; - }, - }), + const subscribe = useCallback( + (callback: () => void): (() => void) => { + voipClient.on('stateChanged', callback); + + return (): void => { + voipClient.off('stateChanged', callback); + }; + }, [voipClient], ); - return useSubscription(subscription); + + const getSnapshot = (): VoIpCallerInfo => callerInfo; + + return useSyncExternalStore(subscribe, getSnapshot); }; export const useCallCreateRoom = (): CallContextReady['createRoom'] => { diff --git a/apps/meteor/client/hooks/usePresence.ts b/apps/meteor/client/hooks/usePresence.ts index e02fd9c2cc3..488ae58cab8 100644 --- a/apps/meteor/client/hooks/usePresence.ts +++ b/apps/meteor/client/hooks/usePresence.ts @@ -1,5 +1,5 @@ -import { useMemo } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useCallback } from 'react'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { Presence, UserPresence } from '../lib/presence'; @@ -13,18 +13,17 @@ type Presence = 'online' | 'offline' | 'busy' | 'away' | 'loading'; * @public */ export const usePresence = (uid: string | undefined): UserPresence | undefined => { - const subscription = useMemo( - () => ({ - getCurrentValue: (): UserPresence | undefined => (uid ? Presence.store.get(uid) : undefined), - subscribe: (callback: any): any => { - uid && Presence.listen(uid, callback); - return (): void => { - uid && Presence.stop(uid, callback); - }; - }, - }), + const subscribe = useCallback( + (callback: any): any => { + uid && Presence.listen(uid, callback); + return (): void => { + uid && Presence.stop(uid, callback); + }; + }, [uid], ); - return useSubscription(subscription); + const getSnapshot = (): UserPresence | undefined => (uid ? Presence.store.get(uid) : undefined); + + return useSyncExternalStore(subscribe, getSnapshot); }; diff --git a/apps/meteor/client/hooks/useReactiveValue.ts b/apps/meteor/client/hooks/useReactiveValue.ts index 1ffb609429d..8c50037121c 100644 --- a/apps/meteor/client/hooks/useReactiveValue.ts +++ b/apps/meteor/client/hooks/useReactiveValue.ts @@ -1,10 +1,10 @@ import { Tracker } from 'meteor/tracker'; import { useMemo } from 'react'; -import { Subscription, Unsubscribe, useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; export const useReactiveValue = <T>(computeCurrentValue: () => T): T => { - const subscription: Subscription<T> = useMemo(() => { - const callbacks = new Set<Unsubscribe>(); + const [subscribe, getSnapshot] = useMemo(() => { + const callbacks = new Set<() => void>(); let currentValue: T; @@ -15,9 +15,8 @@ export const useReactiveValue = <T>(computeCurrentValue: () => T): T => { }); }); - return { - getCurrentValue: (): T => currentValue, - subscribe: (callback): Unsubscribe => { + return [ + (callback: () => void): (() => void) => { callbacks.add(callback); return (): void => { @@ -28,8 +27,9 @@ export const useReactiveValue = <T>(computeCurrentValue: () => T): T => { } }; }, - }; + (): T => currentValue, + ]; }, [computeCurrentValue]); - return useSubscription(subscription); + return useSyncExternalStore(subscribe, getSnapshot); }; diff --git a/apps/meteor/client/hooks/useUserData.ts b/apps/meteor/client/hooks/useUserData.ts index 15e2e040741..4621cf89512 100644 --- a/apps/meteor/client/hooks/useUserData.ts +++ b/apps/meteor/client/hooks/useUserData.ts @@ -1,5 +1,5 @@ -import { useMemo } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useCallback } from 'react'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { UserPresence, Presence } from '../lib/presence'; @@ -11,18 +11,17 @@ import { UserPresence, Presence } from '../lib/presence'; * @public */ export const useUserData = (uid: string): UserPresence | undefined => { - const subscription = useMemo( - () => ({ - getCurrentValue: (): UserPresence | undefined => Presence.store.get(uid), - subscribe: (callback: any): any => { - Presence.listen(uid, callback); - return (): void => { - Presence.stop(uid, callback); - }; - }, - }), + const subscription = useCallback( + (callback: () => void): (() => void) => { + Presence.listen(uid, callback); + return (): void => { + Presence.stop(uid, callback); + }; + }, [uid], ); - return useSubscription(subscription); + const getSnapshot = (): UserPresence | undefined => Presence.store.get(uid); + + return useSyncExternalStore(subscription, getSnapshot); }; diff --git a/apps/meteor/client/importPackages.ts b/apps/meteor/client/importPackages.ts index 554c3e28039..08c95cf98ea 100644 --- a/apps/meteor/client/importPackages.ts +++ b/apps/meteor/client/importPackages.ts @@ -14,7 +14,6 @@ import '../app/drupal/client'; import '../app/emoji/client'; import '../app/emoji-emojione/client'; import '../app/emoji-custom/client'; -import '../app/favico'; import '../app/file-upload'; import '../app/github-enterprise/client'; import '../app/gitlab/client'; diff --git a/apps/meteor/client/lib/RoomManager.ts b/apps/meteor/client/lib/RoomManager.ts index 31a0d5f0b78..f05bde279d4 100644 --- a/apps/meteor/client/lib/RoomManager.ts +++ b/apps/meteor/client/lib/RoomManager.ts @@ -1,8 +1,8 @@ import type { IRoom } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import { useUserId, useUserRoom, useUserSubscription } from '@rocket.chat/ui-contexts'; -import { useEffect, useMemo } from 'react'; -import { useSubscription, Subscription, Unsubscribe } from 'use-subscription'; +import { useCallback, useEffect } from 'react'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { RoomHistoryManager } from '../../app/ui-utils/client/lib/RoomHistoryManager'; import { useAsyncState } from '../hooks/useAsyncState'; @@ -129,19 +129,15 @@ export const RoomManager = new (class RoomManager extends Emitter<{ } })(); -const subscribeVisitedRooms: Subscription<IRoom['_id'][]> = { - getCurrentValue: () => RoomManager.visitedRooms(), - subscribe(callback) { - return RoomManager.on('changed', callback); - }, -}; +const subscribeVisitedRooms = [ + (callback: () => void): (() => void) => RoomManager.on('changed', callback), + (): IRoom['_id'][] => RoomManager.visitedRooms(), +] as const; -const subscribeOpenedRoom: Subscription<IRoom['_id'] | undefined> = { - getCurrentValue: () => RoomManager.opened, - subscribe(callback) { - return RoomManager.on('opened', callback); - }, -}; +const subscribeOpenedRoom = [ + (callback: () => void): (() => void) => RoomManager.on('opened', callback), + (): IRoom['_id'] | undefined => RoomManager.opened, +] as const; const fields = {}; @@ -165,25 +161,19 @@ export const useHandleRoom = <T extends IRoom>(rid: IRoom['_id']): AsyncState<T> return state; }; -export const useVisitedRooms = (): IRoom['_id'][] => useSubscription(subscribeVisitedRooms); +export const useVisitedRooms = (): IRoom['_id'][] => useSyncExternalStore(...subscribeVisitedRooms); -export const useOpenedRoom = (): IRoom['_id'] | undefined => useSubscription(subscribeOpenedRoom); +export const useOpenedRoom = (): IRoom['_id'] | undefined => useSyncExternalStore(...subscribeOpenedRoom); export const useRoomStore = (rid: IRoom['_id']): RoomStore => { - const subscribeStore: Subscription<RoomStore | undefined> = useMemo( - () => ({ - getCurrentValue: (): RoomStore | undefined => RoomManager.getStore(rid), - subscribe(callback): Unsubscribe { - return RoomManager.on('changed', callback); - }, - }), - [rid], - ); - - const store = useSubscription(subscribeStore); + const subscribe = useCallback((callback: () => void): (() => void) => RoomManager.on('changed', callback), []); + const getSnapshot = (): RoomStore | undefined => RoomManager.getStore(rid); + + const store = useSyncExternalStore(subscribe, getSnapshot); if (!store) { throw new Error('Something wrong'); } + return store; }; diff --git a/apps/meteor/client/lib/appLayout.ts b/apps/meteor/client/lib/appLayout.ts index 4f2f3a519b7..a8a8be72540 100644 --- a/apps/meteor/client/lib/appLayout.ts +++ b/apps/meteor/client/lib/appLayout.ts @@ -1,15 +1,14 @@ import { Emitter } from '@rocket.chat/emitter'; import { ReactElement } from 'react'; -import { Subscription, Unsubscribe } from 'use-subscription'; type AppLayoutDescriptor = ReactElement | null; -class AppLayoutSubscription extends Emitter<{ update: void }> implements Subscription<AppLayoutDescriptor> { +class AppLayoutSubscription extends Emitter<{ update: void }> { private descriptor: AppLayoutDescriptor = null; - getCurrentValue = (): AppLayoutDescriptor => this.descriptor; + getSnapshot = (): AppLayoutDescriptor => this.descriptor; - subscribe = (callback: () => void): Unsubscribe => this.on('update', callback); + subscribe = (onStoreChange: () => void): (() => void) => this.on('update', onStoreChange); setCurrentValue(descriptor: AppLayoutDescriptor): void { this.descriptor = descriptor; diff --git a/apps/meteor/client/lib/banners.ts b/apps/meteor/client/lib/banners.ts index eb8a06850fa..02df87e95ec 100644 --- a/apps/meteor/client/lib/banners.ts +++ b/apps/meteor/client/lib/banners.ts @@ -2,7 +2,6 @@ import { UiKitBannerPayload } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import { Icon } from '@rocket.chat/fuselage'; import { ComponentProps } from 'react'; -import { Subscription } from 'use-subscription'; export type LegacyBannerPayload = { id: string; @@ -27,10 +26,10 @@ const emitter = new Emitter<{ 'update-first': undefined; }>(); -export const firstSubscription: Subscription<BannerPayload | null> = { - getCurrentValue: () => queue[0] ?? null, - subscribe: (callback) => emitter.on('update-first', callback), -}; +export const firstSubscription = [ + (callback: () => void): (() => void) => emitter.on('update-first', callback), + (): BannerPayload | null => queue[0] ?? null, +] as const; export const open = (payload: BannerPayload): void => { let index = queue.findIndex((_payload) => { diff --git a/apps/meteor/client/lib/createSidebarItems.ts b/apps/meteor/client/lib/createSidebarItems.ts index 8b1c7db9e92..b773e733a58 100644 --- a/apps/meteor/client/lib/createSidebarItems.ts +++ b/apps/meteor/client/lib/createSidebarItems.ts @@ -1,5 +1,4 @@ import { IconProps } from '@rocket.chat/fuselage'; -import type { Subscription } from 'use-subscription'; export type SidebarItem = { i18nLabel: string; @@ -17,19 +16,19 @@ export const createSidebarItems = ( ): { registerSidebarItem: (item: SidebarItem) => void; unregisterSidebarItem: (i18nLabel: SidebarItem['i18nLabel']) => void; - itemsSubscription: Subscription<SidebarItem[]>; + getSidebarItems: () => SidebarItem[]; + subscribeToSidebarItems: (callback: () => void) => () => void; } => { const items = initialItems; let updateCb: () => void = () => undefined; - const itemsSubscription: Subscription<SidebarItem[]> = { - subscribe: (cb) => { - updateCb = cb; - return (): void => { - updateCb = (): void => undefined; - }; - }, - getCurrentValue: () => items, + const getSidebarItems = (): SidebarItem[] => items; + + const subscribeToSidebarItems = (cb: () => void): (() => void) => { + updateCb = cb; + return (): void => { + updateCb = (): void => undefined; + }; }; const registerSidebarItem = (item: SidebarItem): void => { @@ -46,6 +45,7 @@ export const createSidebarItems = ( return { registerSidebarItem, unregisterSidebarItem, - itemsSubscription, + getSidebarItems, + subscribeToSidebarItems, }; }; diff --git a/apps/meteor/client/lib/createValueSubscription.ts b/apps/meteor/client/lib/createValueSubscription.ts deleted file mode 100644 index 9702f748e93..00000000000 --- a/apps/meteor/client/lib/createValueSubscription.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Emitter } from '@rocket.chat/emitter'; -import { Subscription, Unsubscribe } from 'use-subscription'; - -type ValueSubscription<T> = Subscription<T> & { - setCurrentValue: (value: T) => void; -}; - -export const createValueSubscription = <T>(initialValue: T): ValueSubscription<T> => { - let value: T = initialValue; - const emitter = new Emitter<{ - update: undefined; - }>(); - - return { - getCurrentValue: (): T => value, - setCurrentValue: (_value: T): void => { - value = _value; - emitter.emit('update'); - }, - subscribe: (callback): Unsubscribe => emitter.on('update', callback), - }; -}; diff --git a/apps/meteor/client/lib/portals/blazePortals.ts b/apps/meteor/client/lib/portals/blazePortals.ts index 0ddf7302674..ab30c96b634 100644 --- a/apps/meteor/client/lib/portals/blazePortals.ts +++ b/apps/meteor/client/lib/portals/blazePortals.ts @@ -1,25 +1,27 @@ import { Emitter } from '@rocket.chat/emitter'; import { Random } from 'meteor/random'; import type { ReactNode } from 'react'; -import type { Subscription, Unsubscribe } from 'use-subscription'; type BlazePortalEntry = { key: string; node: ReactNode; }; -class BlazePortalsSubscriptions extends Emitter<{ update: void }> implements Subscription<BlazePortalEntry[]> { +class BlazePortalsSubscriptions extends Emitter<{ update: void }> { private map = new Map<Blaze.TemplateInstance, BlazePortalEntry>(); - getCurrentValue = (): BlazePortalEntry[] => Array.from(this.map.values()); + private cache = Array.from(this.map.values()); - subscribe = (callback: () => void): Unsubscribe => this.on('update', callback); + getSnapshot = (): BlazePortalEntry[] => this.cache; + + subscribe = (onStoreChange: () => void): (() => void) => this.on('update', onStoreChange); register = (template: Blaze.TemplateInstance, node: ReactNode): void => { const entry = this.map.get(template); if (!entry) { this.map.set(template, { key: Random.id(), node }); + this.cache = Array.from(this.map.values()); this.emit('update'); return; } @@ -29,11 +31,13 @@ class BlazePortalsSubscriptions extends Emitter<{ update: void }> implements Sub } this.map.set(template, { ...entry, node }); + this.cache = Array.from(this.map.values()); this.emit('update'); }; unregister = (template: Blaze.TemplateInstance): void => { if (this.map.delete(template)) { + this.cache = Array.from(this.map.values()); this.emit('update'); } }; diff --git a/apps/meteor/client/lib/portals/portalsSubscription.ts b/apps/meteor/client/lib/portals/portalsSubscription.ts index c17af505c4f..b72aa8f3ded 100644 --- a/apps/meteor/client/lib/portals/portalsSubscription.ts +++ b/apps/meteor/client/lib/portals/portalsSubscription.ts @@ -1,14 +1,15 @@ import { Emitter } from '@rocket.chat/emitter'; import { Random } from 'meteor/random'; import type { ReactElement } from 'react'; -import type { Subscription, Unsubscribe } from 'use-subscription'; type SubscribedPortal = { portal: ReactElement; key: string; }; -type PortalsSubscription = Subscription<SubscribedPortal[]> & { +type PortalsSubscription = { + subscribe: (callback: () => void) => () => void; + getSnapshot: () => SubscribedPortal[]; has: (key: unknown) => boolean; set: (key: unknown, portal: ReactElement) => void; delete: (key: unknown) => void; @@ -16,17 +17,20 @@ type PortalsSubscription = Subscription<SubscribedPortal[]> & { const createPortalsSubscription = (): PortalsSubscription => { const portalsMap = new Map<unknown, SubscribedPortal>(); + let portals = Array.from(portalsMap.values()); const emitter = new Emitter<{ update: void }>(); return { - getCurrentValue: (): SubscribedPortal[] => Array.from(portalsMap.values()), - subscribe: (callback): Unsubscribe => emitter.on('update', callback), + getSnapshot: (): SubscribedPortal[] => portals, + subscribe: (callback): (() => void) => emitter.on('update', callback), delete: (key): void => { portalsMap.delete(key); + portals = Array.from(portalsMap.values()); emitter.emit('update'); }, set: (key, portal): void => { portalsMap.set(key, { portal, key: Random.id() }); + portals = Array.from(portalsMap.values()); emitter.emit('update'); }, has: (key): boolean => portalsMap.has(key), diff --git a/apps/meteor/client/lib/presence.ts b/apps/meteor/client/lib/presence.ts index 5c81820e7e4..4a14c1478b2 100644 --- a/apps/meteor/client/lib/presence.ts +++ b/apps/meteor/client/lib/presence.ts @@ -27,11 +27,6 @@ export type UserPresence = Readonly< Partial<Pick<IUser, 'name' | 'status' | 'utcOffset' | 'statusText' | 'avatarETag' | 'roles' | 'username'>> & Required<Pick<IUser, '_id'>> >; -type UsersPresencePayload = { - users: UserPresence[]; - full: boolean; -}; - const isUid = (eventType: keyof Events): eventType is UserPresence['_id'] => Boolean(eventType) && typeof eventType === 'string' && !['reset', 'restart', 'remove'].includes(eventType); @@ -51,15 +46,6 @@ const notify = (presence: UserPresence): void => { } }; -declare module '@rocket.chat/rest-typings' { - // eslint-disable-next-line @typescript-eslint/interface-name-prefix - export interface Endpoints { - '/v1/users.presence': { - GET: (params: { ids: string[] }) => UsersPresencePayload; - }; - } -} - const getPresence = ((): ((uid: UserPresence['_id']) => void) => { let timer: ReturnType<typeof setTimeout>; diff --git a/apps/meteor/client/providers/CallProvider/CallProvider.tsx b/apps/meteor/client/providers/CallProvider/CallProvider.tsx index fc6c9120b80..ba1e359e4ac 100644 --- a/apps/meteor/client/providers/CallProvider/CallProvider.tsx +++ b/apps/meteor/client/providers/CallProvider/CallProvider.tsx @@ -12,7 +12,7 @@ import { isVoipEventCallAbandoned, } from '@rocket.chat/core-typings'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { useRoute, useUser, useSetting, useEndpoint, useStream } from '@rocket.chat/ui-contexts'; +import { useRoute, useUser, useSetting, useEndpoint, useStream, useSetModal } from '@rocket.chat/ui-contexts'; import { Random } from 'meteor/random'; import React, { useMemo, FC, useRef, useCallback, useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; @@ -21,8 +21,7 @@ import { OutgoingByeRequest } from 'sip.js/lib/core'; import { CustomSounds } from '../../../app/custom-sounds/client'; import { getUserPreference } from '../../../app/utils/client'; import { WrapUpCallModal } from '../../components/voip/modal/WrapUpCallModal'; -import { CallContext, CallContextValue } from '../../contexts/CallContext'; -import { imperativeModal } from '../../lib/imperativeModal'; +import { CallContext, CallContextValue, useCallCloseRoom } from '../../contexts/CallContext'; import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; import { QueueAggregator } from '../../lib/voip/QueueAggregator'; import { useVoipClient } from './hooks/useVoipClient'; @@ -45,6 +44,7 @@ export const CallProvider: FC = ({ children }) => { const voipEnabled = useSetting('VoIP_Enabled'); const subscribeToNotifyUser = useStream('notify-user'); const dispatchEvent = useEndpoint('POST', '/v1/voip/events'); + const setModal = useSetModal(); const result = useVoipClient(); const user = useUser(); @@ -56,8 +56,8 @@ export const CallProvider: FC = ({ children }) => { const [queueName, setQueueName] = useState(''); const openWrapUpModal = useCallback((): void => { - imperativeModal.open({ component: WrapUpCallModal }); - }, []); + setModal(() => <WrapUpCallModal closeRoom={useCallCloseRoom} />); + }, [setModal]); const [queueAggregator, setQueueAggregator] = useState<QueueAggregator>(); diff --git a/apps/meteor/client/providers/MeteorProvider.tsx b/apps/meteor/client/providers/MeteorProvider.tsx index 8504a59b8b1..ef0e5885cf7 100644 --- a/apps/meteor/client/providers/MeteorProvider.tsx +++ b/apps/meteor/client/providers/MeteorProvider.tsx @@ -32,13 +32,13 @@ const MeteorProvider: FC = ({ children }) => ( <CustomSoundProvider> <UserProvider> <AuthorizationProvider> - <CallProvider> - <OmnichannelProvider> - <ModalProvider> + <ModalProvider> + <CallProvider> + <OmnichannelProvider> <AttachmentProvider>{children}</AttachmentProvider> - </ModalProvider> - </OmnichannelProvider> - </CallProvider> + </OmnichannelProvider> + </CallProvider> + </ModalProvider> </AuthorizationProvider> </UserProvider> </CustomSoundProvider> diff --git a/apps/meteor/client/providers/RouterProvider.tsx b/apps/meteor/client/providers/RouterProvider.tsx index b6d4b83cf26..91f9711b458 100644 --- a/apps/meteor/client/providers/RouterProvider.tsx +++ b/apps/meteor/client/providers/RouterProvider.tsx @@ -2,13 +2,11 @@ import { RouterContext, RouterContextValue } from '@rocket.chat/ui-contexts'; import { FlowRouter } from 'meteor/kadira:flow-router'; import { Tracker } from 'meteor/tracker'; import React, { FC } from 'react'; -import { Subscription, Unsubscribe } from 'use-subscription'; -const createSubscription = function <T>(getValue: () => T): Subscription<T> { +const createSubscription = function <T>(getValue: () => T): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => T] { let currentValue = Tracker.nonreactive(getValue); - return { - getCurrentValue: (): T => currentValue, - subscribe: (callback: () => void): Unsubscribe => { + return [ + (callback: () => void): (() => void) => { const computation = Tracker.autorun(() => { currentValue = getValue(); callback(); @@ -18,7 +16,8 @@ const createSubscription = function <T>(getValue: () => T): Subscription<T> { computation.stop(); }; }, - }; + (): T => currentValue, + ]; }; const queryRoutePath = ( diff --git a/apps/meteor/client/providers/createReactiveSubscriptionFactory.ts b/apps/meteor/client/providers/createReactiveSubscriptionFactory.ts index cf9e92695b2..4dead8e2b29 100644 --- a/apps/meteor/client/providers/createReactiveSubscriptionFactory.ts +++ b/apps/meteor/client/providers/createReactiveSubscriptionFactory.ts @@ -1,16 +1,15 @@ import { Tracker } from 'meteor/tracker'; -import { Subscription, Unsubscribe } from 'use-subscription'; interface ISubscriptionFactory<T> { - (...args: any[]): Subscription<T>; + (...args: any[]): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => T]; } export const createReactiveSubscriptionFactory = <T>(computeCurrentValueWith: (...args: any[]) => T): ISubscriptionFactory<T> => - (...args: any[]): Subscription<T> => { + (...args: any[]): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => T] => { const computeCurrentValue = (): T => computeCurrentValueWith(...args); - const callbacks = new Set<Unsubscribe>(); + const callbacks = new Set<() => void>(); let currentValue = computeCurrentValue(); @@ -24,9 +23,8 @@ export const createReactiveSubscriptionFactory = }); }, 0); - return { - getCurrentValue: (): T => currentValue, - subscribe: (callback): Unsubscribe => { + return [ + (callback): (() => void) => { callbacks.add(callback); return (): void => { @@ -39,5 +37,6 @@ export const createReactiveSubscriptionFactory = } }; }, - }; + (): T => currentValue, + ]; }; diff --git a/apps/meteor/client/sidebar/RoomList/SideBarItemTemplateWithData.tsx b/apps/meteor/client/sidebar/RoomList/SideBarItemTemplateWithData.tsx index 8a01dfce72c..104a0b97496 100644 --- a/apps/meteor/client/sidebar/RoomList/SideBarItemTemplateWithData.tsx +++ b/apps/meteor/client/sidebar/RoomList/SideBarItemTemplateWithData.tsx @@ -66,9 +66,9 @@ type RoomListRowProps = { /* @deprecated */ style?: AllHTMLAttributes<HTMLElement>['style']; - selected: boolean; + selected?: boolean; - sidebarViewMode: unknown; + sidebarViewMode?: unknown; }; function SideBarItemTemplateWithData({ diff --git a/apps/meteor/client/sidebar/RoomMenu.js b/apps/meteor/client/sidebar/RoomMenu.tsx similarity index 76% rename from apps/meteor/client/sidebar/RoomMenu.js rename to apps/meteor/client/sidebar/RoomMenu.tsx index 5e703f011e2..0457fb5096b 100644 --- a/apps/meteor/client/sidebar/RoomMenu.js +++ b/apps/meteor/client/sidebar/RoomMenu.tsx @@ -1,3 +1,4 @@ +import { RoomType } from '@rocket.chat/core-typings'; import { Option, Menu } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { @@ -9,8 +10,10 @@ import { usePermission, useMethod, useTranslation, + TranslationKey, } from '@rocket.chat/ui-contexts'; -import React, { memo, useMemo } from 'react'; +import { Fields } from '@rocket.chat/ui-contexts/dist/UserContext'; +import React, { memo, ReactElement, useMemo } from 'react'; import { RoomManager } from '../../app/ui-utils/client/lib/RoomManager'; import { UiTextContext } from '../../definition/IRoomTypeConfig'; @@ -19,13 +22,24 @@ import { useDontAskAgain } from '../hooks/useDontAskAgain'; import { roomCoordinator } from '../lib/rooms/roomCoordinator'; import WarningModal from '../views/admin/apps/WarningModal'; -const fields = { - f: 1, - t: 1, - name: 1, +const fields: Fields = { + f: true, + t: true, + name: true, }; -const RoomMenu = ({ rid, unread, threadUnread, alert, roomOpen, type, cl, name = '' }) => { +type RoomMenuProps = { + rid: string; + unread?: boolean; + threadUnread?: boolean; + alert?: boolean; + roomOpen?: boolean; + type: RoomType; + cl?: boolean; + name?: string; +}; + +const RoomMenu = ({ rid, unread, threadUnread, alert, roomOpen, type, cl, name = '' }: RoomMenuProps): ReactElement => { const t = useTranslation(); const dispatchToastMessage = useToastMessageDispatch(); const setModal = useSetModal(); @@ -36,7 +50,7 @@ const RoomMenu = ({ rid, unread, threadUnread, alert, roomOpen, type, cl, name = const subscription = useUserSubscription(rid, fields); const canFavorite = useSetting('Favorite_Rooms'); - const isFavorite = (subscription != null ? subscription.f : undefined) != null && subscription.f; + const isFavorite = Boolean(subscription?.f); const dontAskHideRoom = useDontAskAgain('hideRoom'); @@ -51,7 +65,7 @@ const RoomMenu = ({ rid, unread, threadUnread, alert, roomOpen, type, cl, name = const canLeaveChannel = usePermission('leave-c'); const canLeavePrivate = usePermission('leave-p'); - const canLeave = (() => { + const canLeave = ((): boolean => { if (type === 'c' && !canLeaveChannel) { return false; } @@ -62,7 +76,7 @@ const RoomMenu = ({ rid, unread, threadUnread, alert, roomOpen, type, cl, name = })(); const handleLeave = useMutableCallback(() => { - const leave = async () => { + const leave = async (): Promise<void> => { try { await leaveRoom(rid); if (roomOpen) { @@ -70,7 +84,7 @@ const RoomMenu = ({ rid, unread, threadUnread, alert, roomOpen, type, cl, name = } RoomManager.close(rid); } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); + dispatchToastMessage({ type: 'error', message: String(error) }); } closeModal(); }; @@ -79,7 +93,7 @@ const RoomMenu = ({ rid, unread, threadUnread, alert, roomOpen, type, cl, name = setModal( <WarningModal - text={t(warnText, name)} + text={t(warnText as TranslationKey, name)} confirmText={t('Leave_room')} close={closeModal} cancel={closeModal} @@ -90,11 +104,11 @@ const RoomMenu = ({ rid, unread, threadUnread, alert, roomOpen, type, cl, name = }); const handleHide = useMutableCallback(async () => { - const hide = async () => { + const hide = async (): Promise<void> => { try { await hideRoom(rid); } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); + dispatchToastMessage({ type: 'error', message: String(error) }); } closeModal(); }; @@ -118,7 +132,7 @@ const RoomMenu = ({ rid, unread, threadUnread, alert, roomOpen, type, cl, name = label: t('Hide_room'), }} > - {t(warnText, name)} + {t(warnText as TranslationKey, name)} </GenericModalDoNotAskAgain>, ); }); @@ -137,7 +151,7 @@ const RoomMenu = ({ rid, unread, threadUnread, alert, roomOpen, type, cl, name = router.push({}); } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); + dispatchToastMessage({ type: 'error', message: String(error) }); } }); @@ -145,7 +159,7 @@ const RoomMenu = ({ rid, unread, threadUnread, alert, roomOpen, type, cl, name = try { await toggleFavorite(rid, !isFavorite); } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); + dispatchToastMessage({ type: 'error', message: String(error) }); } }); @@ -159,15 +173,17 @@ const RoomMenu = ({ rid, unread, threadUnread, alert, roomOpen, type, cl, name = label: { label: isUnread ? t('Mark_read') : t('Mark_unread'), icon: 'flag' }, action: handleToggleRead, }, - ...(canFavorite && { - toggleFavorite: { - label: { - label: isFavorite ? t('Unfavorite') : t('Favorite'), - icon: isFavorite ? 'star-filled' : 'star', - }, - action: handleToggleFavorite, - }, - }), + ...(canFavorite + ? { + toggleFavorite: { + label: { + label: isFavorite ? t('Unfavorite') : t('Favorite'), + icon: isFavorite ? 'star-filled' : 'star', + }, + action: handleToggleFavorite, + }, + } + : {}), ...(canLeave && { leaveRoom: { label: { label: t('Leave_room'), icon: 'sign-out' }, diff --git a/apps/meteor/client/sidebar/Sidebar.stories.tsx b/apps/meteor/client/sidebar/Sidebar.stories.tsx index 8a9e70d9c20..6db4e424351 100644 --- a/apps/meteor/client/sidebar/Sidebar.stories.tsx +++ b/apps/meteor/client/sidebar/Sidebar.stories.tsx @@ -31,14 +31,8 @@ const settings: Record<string, ISetting> = { const settingContextValue: ContextType<typeof SettingsContext> = { hasPrivateAccess: true, isLoading: false, - querySetting: (_id) => ({ - getCurrentValue: () => settings[_id], - subscribe: () => () => undefined, - }), - querySettings: () => ({ - getCurrentValue: () => [], - subscribe: () => () => undefined, - }), + querySetting: (_id) => [() => () => undefined, () => settings[_id]], + querySettings: () => [() => () => undefined, () => []], dispatch: async () => undefined, }; @@ -87,24 +81,15 @@ const userContextValue: ContextType<typeof UserContext> = { roles: ['admin'], type: 'user', }, - queryPreference: <T,>(pref: string | ObjectId, defaultValue: T) => ({ - getCurrentValue: () => (typeof pref === 'string' ? (userPreferences[pref] as T) : defaultValue), - subscribe: () => () => undefined, - }), - querySubscriptions: () => ({ - getCurrentValue: () => subscriptions, - subscribe: () => () => undefined, - }), - querySubscription: () => ({ - getCurrentValue: () => undefined, - subscribe: () => () => undefined, - }), + queryPreference: <T,>(pref: string | ObjectId, defaultValue: T) => [ + () => () => undefined, + () => (typeof pref === 'string' ? (userPreferences[pref] as T) : defaultValue), + ], + querySubscriptions: () => [() => () => undefined, () => subscriptions], + querySubscription: () => [() => () => undefined, () => undefined], loginWithPassword: () => Promise.resolve(undefined), logout: () => Promise.resolve(undefined), - queryRoom: () => ({ - getCurrentValue: () => undefined, - subscribe: () => () => undefined, - }), + queryRoom: () => [() => () => undefined, () => undefined], }; export const Sidebar: Story = () => ( diff --git a/apps/meteor/client/sidebar/search/Row.js b/apps/meteor/client/sidebar/search/Row.tsx similarity index 73% rename from apps/meteor/client/sidebar/search/Row.js rename to apps/meteor/client/sidebar/search/Row.tsx index c1134cd5e45..475eae9e2a4 100644 --- a/apps/meteor/client/sidebar/search/Row.js +++ b/apps/meteor/client/sidebar/search/Row.tsx @@ -1,9 +1,15 @@ -import React, { memo } from 'react'; +import { IRoom, ISubscription } from '@rocket.chat/core-typings'; +import React, { memo, ReactElement } from 'react'; import SideBarItemTemplateWithData from '../RoomList/SideBarItemTemplateWithData'; import UserItem from './UserItem'; -const Row = ({ item, data }) => { +type RowProps = { + item: ISubscription & IRoom; + data: Record<string, any>; +}; + +const Row = ({ item, data }: RowProps): ReactElement => { const { t, SideBarItemTemplate, avatarTemplate: AvatarTemplate, useRealName, extended } = data; if (item.t === 'd' && !item.u) { @@ -21,7 +27,6 @@ const Row = ({ item, data }) => { return ( <SideBarItemTemplateWithData id={`search-${item._id}`} - tabIndex={-1} extended={extended} t={t} room={item} diff --git a/apps/meteor/client/sidebar/search/ScrollerWithCustomProps.js b/apps/meteor/client/sidebar/search/ScrollerWithCustomProps.js deleted file mode 100644 index f71e4397b6a..00000000000 --- a/apps/meteor/client/sidebar/search/ScrollerWithCustomProps.js +++ /dev/null @@ -1,16 +0,0 @@ -import React, { forwardRef } from 'react'; - -import ScrollableContentWrapper from '../../components/ScrollableContentWrapper'; - -const ScrollerWithCustomProps = forwardRef(function ScrollerWithCustomProps(props, ref) { - return ( - <ScrollableContentWrapper - {...props} - ref={ref} - renderView={({ style, ...props }) => <div {...props} style={{ ...style }} />} - renderTrackHorizontal={(props) => <div {...props} style={{ display: 'none' }} className='track-horizontal' />} - /> - ); -}); - -export default ScrollerWithCustomProps; diff --git a/apps/meteor/client/sidebar/search/ScrollerWithCustomProps.tsx b/apps/meteor/client/sidebar/search/ScrollerWithCustomProps.tsx new file mode 100644 index 00000000000..3066c0d218e --- /dev/null +++ b/apps/meteor/client/sidebar/search/ScrollerWithCustomProps.tsx @@ -0,0 +1,16 @@ +import React, { forwardRef, ReactElement } from 'react'; + +import ScrollableContentWrapper from '../../components/ScrollableContentWrapper'; + +const ScrollerWithCustomProps = forwardRef(function ScrollerWithCustomProps(props, ref: React.Ref<HTMLElement>) { + return ( + <ScrollableContentWrapper + {...props} + ref={ref} + renderView={({ style, ...props }): ReactElement => <div {...props} style={{ ...style }} />} + renderTrackHorizontal={(props): ReactElement => <div {...props} style={{ display: 'none' }} className='track-horizontal' />} + /> + ); +}); + +export default ScrollerWithCustomProps; diff --git a/apps/meteor/client/sidebar/search/SearchList.js b/apps/meteor/client/sidebar/search/SearchList.tsx similarity index 60% rename from apps/meteor/client/sidebar/search/SearchList.js rename to apps/meteor/client/sidebar/search/SearchList.tsx index 33f677ed8b3..80be10d7ac6 100644 --- a/apps/meteor/client/sidebar/search/SearchList.js +++ b/apps/meteor/client/sidebar/search/SearchList.tsx @@ -1,11 +1,31 @@ +import { RoomType } from '@rocket.chat/core-typings'; import { css } from '@rocket.chat/css-in-js'; import { Sidebar, TextInput, Box, Icon } from '@rocket.chat/fuselage'; -import { useMutableCallback, useDebouncedValue, useStableArray, useAutoFocus, useUniqueId } from '@rocket.chat/fuselage-hooks'; +import { + useMutableCallback, + useDebouncedValue, + useStableArray, + useAutoFocus, + useUniqueId, + useMergedRefs, +} from '@rocket.chat/fuselage-hooks'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import { useUserPreference, useUserSubscriptions, useSetting, useTranslation } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; -import React, { forwardRef, useState, useMemo, useEffect, useRef } from 'react'; -import { Virtuoso } from 'react-virtuoso'; +import React, { + forwardRef, + useState, + useMemo, + useEffect, + useRef, + ReactElement, + MutableRefObject, + SetStateAction, + Dispatch, + FormEventHandler, + Ref, +} from 'react'; +import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'; import tinykeys from 'tinykeys'; import { AsyncStatePhase } from '../../hooks/useAsyncState'; @@ -15,8 +35,8 @@ import { useTemplateByViewMode } from '../hooks/useTemplateByViewMode'; import Row from './Row'; import ScrollerWithCustomProps from './ScrollerWithCustomProps'; -const shortcut = (() => { - if (!Meteor.Device.isDesktop()) { +const shortcut = ((): string => { + if (!(Meteor as any).Device.isDesktop()) { return ''; } if (window.navigator.platform.toLowerCase().includes('mac')) { @@ -25,9 +45,9 @@ const shortcut = (() => { return '(\u2303+K)'; })(); -const useSpotlight = (filterText = '', usernames) => { +const useSpotlight = (filterText: string, usernames: string[]) => { const expression = /(@|#)?(.*)/i; - const [, mention, name] = filterText.match(expression); + const [, mention, name] = filterText.match(expression) || []; const searchForChannels = mention === '#'; const searchForDMs = mention === '@'; @@ -41,9 +61,10 @@ const useSpotlight = (filterText = '', usernames) => { } return { users: true, rooms: true }; }, [searchForChannels, searchForDMs]); + const args = useMemo(() => [name, usernames, type], [type, name, usernames]); - const { value: data = { users: [], rooms: [] }, phase: status } = useMethodData('spotlight', args); + const { value: data, phase: status } = useMethodData('spotlight', args); return useMemo(() => { if (!data) { @@ -60,11 +81,10 @@ const options = { }, }; -const useSearchItems = (filterText) => { +const useSearchItems = (filterText: string): any => { const expression = /(@|#)?(.*)/i; - const teste = filterText.match(expression); + const [, type, name] = filterText.match(expression) || []; - const [, type, name] = teste; const query = useMemo(() => { const filterRegex = new RegExp(escapeRegExp(name), 'i'); @@ -76,23 +96,36 @@ const useSearchItems = (filterText) => { }; }, [name, type]); - const localRooms = useUserSubscriptions(query, options); + const localRooms: { rid: string; t: RoomType; _id: string; name: string; uids?: string }[] = useUserSubscriptions(query, options); - const usernamesFromClient = useStableArray([...localRooms?.map(({ t, name }) => (t === 'd' ? name : null))].filter(Boolean)); + const usernamesFromClient = useStableArray([...localRooms?.map(({ t, name }) => (t === 'd' ? name : null))].filter(Boolean)) as string[]; const { data: spotlight, status } = useSpotlight(filterText, usernamesFromClient); return useMemo(() => { - const resultsFromServer = []; + const filterUsersUnique = ({ _id }: { _id: string }, index: number, arr: { _id: string }[]): boolean => + index === arr.findIndex((user) => _id === user._id); - const filterUsersUnique = ({ _id }, index, arr) => index === arr.findIndex((user) => _id === user._id); - const roomFilter = (room) => + const roomFilter = (room: { t: string; uids?: string[]; _id: string; name?: string }): boolean => !localRooms.find( - (item) => (room.t === 'd' && room.uids?.length > 1 && room.uids.includes(item._id)) || [item.rid, item._id].includes(room._id), + (item) => + (room.t === 'd' && room.uids && room.uids.length > 1 && room.uids?.includes(item._id)) || [item.rid, item._id].includes(room._id), ); - const usersfilter = (user) => !localRooms.find((room) => room.t === 'd' && room.uids?.length === 2 && room.uids.includes(user._id)); - - const userMap = (user) => ({ + const usersfilter = (user: { _id: string }): boolean => + !localRooms.find((room) => room.t === 'd' && room.uids && room.uids?.length === 2 && room.uids.includes(user._id)); + + const userMap = (user: { + _id: string; + name: string; + username: string; + avatarETag?: string; + }): { + _id: string; + t: string; + name: string; + fname: string; + avatarETag?: string; + } => ({ _id: user._id, t: 'd', name: user.username, @@ -100,17 +133,27 @@ const useSearchItems = (filterText) => { avatarETag: user.avatarETag, }); - const exact = resultsFromServer.filter((item) => [item.usernamame, item.name, item.fname].includes(name)); + type resultsFromServerType = { + _id: string; + t: string; + name: string; + fname?: string; + avatarETag?: string | undefined; + uids?: string[] | undefined; + }[]; + const resultsFromServer: resultsFromServerType = []; resultsFromServer.push(...spotlight.users.filter(filterUsersUnique).filter(usersfilter).map(userMap)); resultsFromServer.push(...spotlight.rooms.filter(roomFilter)); + const exact = resultsFromServer?.filter((item) => [item.name, item.fname].includes(name)); + return { data: Array.from(new Set([...exact, ...localRooms, ...resultsFromServer])), status }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [localRooms, name, spotlight]); }; -const useInput = (initial) => { +const useInput = (initial: string): { value: string; onChange: FormEventHandler; setValue: Dispatch<SetStateAction<string>> } => { const [value, setValue] = useState(initial); const onChange = useMutableCallback((e) => { setValue(e.currentTarget.value); @@ -118,12 +161,12 @@ const useInput = (initial) => { return { value, onChange, setValue }; }; -const toggleSelectionState = (next, current, input) => { - input.setAttribute('aria-activedescendant', next.id); - next.setAttribute('aria-selected', true); +const toggleSelectionState = (next: HTMLElement, current: HTMLElement | undefined, input: HTMLElement | undefined): void => { + input?.setAttribute('aria-activedescendant', next.id); + next.setAttribute('aria-selected', 'true'); next.classList.add('rcx-sidebar-item--selected'); if (current) { - current.setAttribute('aria-selected', false); + current.removeAttribute('aria-selected'); current.classList.remove('rcx-sidebar-item--selected'); } }; @@ -131,17 +174,23 @@ const toggleSelectionState = (next, current, input) => { /** * @type import('react').ForwardRefExoticComponent<{ onClose: unknown } & import('react').RefAttributes<HTMLElement>> */ -const SearchList = forwardRef(function SearchList({ onClose }, ref) { + +type SearchListProps = { + onClose: () => void; +}; + +const SearchList = forwardRef(function SearchList({ onClose }: SearchListProps, ref): ReactElement { const listId = useUniqueId(); const t = useTranslation(); const { setValue: setFilterValue, ...filter } = useInput(''); - const autofocus = useAutoFocus(); + const cursorRef = useRef<HTMLInputElement>(null); + const autofocus: Ref<HTMLInputElement> = useMergedRefs(useAutoFocus<HTMLInputElement>(), cursorRef); - const listRef = useRef(); - const boxRef = useRef(); + const listRef = useRef<VirtuosoHandle>(null); + const boxRef = useRef<HTMLDivElement>(null); - const selectedElement = useRef(); + const selectedElement: MutableRefObject<HTMLElement | null | undefined> = useRef(null); const itemIndexRef = useRef(0); const sidebarViewMode = useUserPreference('sidebarViewMode'); @@ -175,13 +224,13 @@ const SearchList = forwardRef(function SearchList({ onClose }, ref) { let nextSelectedElement = null; if (dir === 'up') { - nextSelectedElement = selectedElement.current.parentElement.previousSibling.querySelector('a'); + nextSelectedElement = (selectedElement.current?.parentElement?.previousSibling as HTMLElement).querySelector('a'); } else { - nextSelectedElement = selectedElement.current.parentElement.nextSibling.querySelector('a'); + nextSelectedElement = (selectedElement.current?.parentElement?.nextSibling as HTMLElement).querySelector('a'); } if (nextSelectedElement) { - toggleSelectionState(nextSelectedElement, selectedElement.current, autofocus.current); + toggleSelectionState(nextSelectedElement, selectedElement.current || undefined, cursorRef?.current || undefined); return nextSelectedElement; } return selectedElement.current; @@ -189,12 +238,12 @@ const SearchList = forwardRef(function SearchList({ onClose }, ref) { const resetCursor = useMutableCallback(() => { itemIndexRef.current = 0; - listRef.current.scrollToIndex({ index: itemIndexRef.current }); + listRef.current?.scrollToIndex({ index: itemIndexRef.current }); selectedElement.current = boxRef.current?.querySelector('a.rcx-sidebar-item'); if (selectedElement.current) { - toggleSelectionState(selectedElement.current, undefined, autofocus.current); + toggleSelectionState(selectedElement.current, undefined, cursorRef?.current || undefined); } }); @@ -207,10 +256,10 @@ const SearchList = forwardRef(function SearchList({ onClose }, ref) { }, [filterText, resetCursor]); useEffect(() => { - if (!autofocus.current) { + if (!cursorRef?.current) { return; } - const unsubscribe = tinykeys(autofocus.current, { + const unsubscribe = tinykeys(cursorRef?.current, { Escape: (event) => { event.preventDefault(); setFilterValue((value) => { @@ -225,13 +274,13 @@ const SearchList = forwardRef(function SearchList({ onClose }, ref) { ArrowUp: () => { const currentElement = changeSelection('up'); itemIndexRef.current = Math.max(itemIndexRef.current - 1, 0); - listRef.current.scrollToIndex({ index: itemIndexRef.current }); + listRef.current?.scrollToIndex({ index: itemIndexRef.current }); selectedElement.current = currentElement; }, ArrowDown: () => { const currentElement = changeSelection('down'); itemIndexRef.current = Math.min(itemIndexRef.current + 1, items?.length + 1); - listRef.current.scrollToIndex({ index: itemIndexRef.current }); + listRef.current?.scrollToIndex({ index: itemIndexRef.current }); selectedElement.current = currentElement; }, Enter: () => { @@ -240,10 +289,10 @@ const SearchList = forwardRef(function SearchList({ onClose }, ref) { } }, }); - return () => { + return (): void => { unsubscribe(); }; - }, [autofocus, changeSelection, items.length, onClose, resetCursor, setFilterValue]); + }, [cursorRef, changeSelection, items.length, onClose, resetCursor, setFilterValue]); return ( <Box @@ -260,7 +309,7 @@ const SearchList = forwardRef(function SearchList({ onClose }, ref) { `} ref={ref} > - <Sidebar.TopBar.Section role='search' is='form'> + <Sidebar.TopBar.Section {...({ role: 'search' } as any)} is='form'> <TextInput aria-owns={listId} data-qa='sidebar-search-input' @@ -288,7 +337,7 @@ const SearchList = forwardRef(function SearchList({ onClose }, ref) { totalCount={items?.length} data={items} components={{ Scroller: ScrollerWithCustomProps }} - itemContent={(index, data) => <Row data={itemData} item={data} />} + itemContent={(_, data): ReactElement => <Row data={itemData} item={data} />} ref={listRef} /> </Box> diff --git a/apps/meteor/client/sidebar/search/UserItem.js b/apps/meteor/client/sidebar/search/UserItem.tsx similarity index 59% rename from apps/meteor/client/sidebar/search/UserItem.js rename to apps/meteor/client/sidebar/search/UserItem.tsx index febaa41717c..21840bcc414 100644 --- a/apps/meteor/client/sidebar/search/UserItem.js +++ b/apps/meteor/client/sidebar/search/UserItem.tsx @@ -1,13 +1,28 @@ +import { IUser } from '@rocket.chat/core-typings'; import { Sidebar } from '@rocket.chat/fuselage'; -import React, { memo } from 'react'; +import React, { memo, ReactElement } from 'react'; import { ReactiveUserStatus } from '../../components/UserStatus'; import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; -const UserItem = ({ item, id, style, t, SideBarItemTemplate, AvatarTemplate, useRealName }) => { +type UserItemProps = { + item: { + name?: string; + fname?: string; + _id: IUser['_id']; + t: string; + }; + t: (value: string) => string; + SideBarItemTemplate: any; + AvatarTemplate: any; + id: string; + style?: CSSStyleRule; + useRealName?: boolean; +}; +const UserItem = ({ item, id, style, t, SideBarItemTemplate, AvatarTemplate, useRealName }: UserItemProps): ReactElement => { const title = useRealName ? item.fname || item.name : item.name || item.fname; const icon = ( - <Sidebar.Item.Icon> + <Sidebar.Item.Icon icon={'' as any}> <ReactiveUserStatus uid={item._id} /> </Sidebar.Item.Icon> ); @@ -16,14 +31,13 @@ const UserItem = ({ item, id, style, t, SideBarItemTemplate, AvatarTemplate, use return ( <SideBarItemTemplate is='a' - style={{ height: '100%' }} + style={{ height: '100%', ...style }} id={id} href={href} title={title} subtitle={t('No_messages_yet')} avatar={AvatarTemplate && <AvatarTemplate {...item} />} icon={icon} - style={style} /> ); }; diff --git a/apps/meteor/client/startup/unread.ts b/apps/meteor/client/startup/unread.ts index 15c40de2d9c..6c076e4ba10 100644 --- a/apps/meteor/client/startup/unread.ts +++ b/apps/meteor/client/startup/unread.ts @@ -1,9 +1,9 @@ import type { ISubscription } from '@rocket.chat/core-typings'; +import { manageFavicon } from '@rocket.chat/favicon'; import { Meteor } from 'meteor/meteor'; import { Session } from 'meteor/session'; import { Tracker } from 'meteor/tracker'; -import { Favico } from '../../app/favico/client'; import { ChatSubscription, ChatRoom } from '../../app/models/client'; import { settings } from '../../app/settings/client'; import { getUserPreference } from '../../app/utils/client'; @@ -75,12 +75,7 @@ Meteor.startup(() => { }); Meteor.startup(() => { - const favicon = new (Favico as any)({ - position: 'up', - animation: 'none', - }); - - window.favico = favicon; + const updateFavicon = manageFavicon(); Tracker.autorun(() => { const siteName = settings.get('Site_Name') ?? ''; @@ -88,11 +83,7 @@ Meteor.startup(() => { const unread = Session.get('unread'); fireGlobalEvent('unread-changed', unread); - if (favicon) { - favicon.badge(unread, { - bgColor: typeof unread !== 'number' ? '#3d8a3a' : '#ac1b1b', - }); - } + updateFavicon(unread); document.title = unread === '' ? siteName : `(${unread}) ${siteName}`; }); diff --git a/apps/meteor/client/views/account/AccountSidebar.tsx b/apps/meteor/client/views/account/AccountSidebar.tsx index aff5500e00f..37662e5e8b8 100644 --- a/apps/meteor/client/views/account/AccountSidebar.tsx +++ b/apps/meteor/client/views/account/AccountSidebar.tsx @@ -1,17 +1,17 @@ import { useRoutePath, useCurrentRoute, useTranslation } from '@rocket.chat/ui-contexts'; import React, { memo, ReactElement, useCallback, useEffect } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; -import { itemsSubscription } from '.'; import { menu, SideNav } from '../../../app/ui-utils/client'; import Sidebar from '../../components/Sidebar'; import { isLayoutEmbedded } from '../../lib/utils/isLayoutEmbedded'; import SettingsProvider from '../../providers/SettingsProvider'; +import { getAccountSidebarItems, subscribeToAccountSidebarItems } from './sidebarItems'; const AccountSidebar = (): ReactElement => { const t = useTranslation(); - const items = useSubscription(itemsSubscription); + const items = useSyncExternalStore(subscribeToAccountSidebarItems, getAccountSidebarItems); const closeFlex = useCallback(() => { if (isLayoutEmbedded()) { diff --git a/apps/meteor/client/views/account/index.ts b/apps/meteor/client/views/account/index.ts index 01c25a32a80..8fc7e4da252 100644 --- a/apps/meteor/client/views/account/index.ts +++ b/apps/meteor/client/views/account/index.ts @@ -1,2 +1,2 @@ export { registerAccountRoute } from './routes'; -export { registerAccountSidebarItem, unregisterSidebarItem, itemsSubscription } from './sidebarItems'; +export { registerAccountSidebarItem, unregisterSidebarItem } from './sidebarItems'; diff --git a/apps/meteor/client/views/account/sidebarItems.ts b/apps/meteor/client/views/account/sidebarItems.ts index 8ce98bc85e8..77ed0fa0126 100644 --- a/apps/meteor/client/views/account/sidebarItems.ts +++ b/apps/meteor/client/views/account/sidebarItems.ts @@ -5,7 +5,8 @@ import { createSidebarItems } from '../../lib/createSidebarItems'; export const { registerSidebarItem: registerAccountSidebarItem, unregisterSidebarItem, - itemsSubscription, + getSidebarItems: getAccountSidebarItems, + subscribeToSidebarItems: subscribeToAccountSidebarItems, } = createSidebarItems([ { href: 'preferences', diff --git a/apps/meteor/client/views/admin/EditableSettingsContext.ts b/apps/meteor/client/views/admin/EditableSettingsContext.ts index 2c74b679fc9..c8898f09a93 100644 --- a/apps/meteor/client/views/admin/EditableSettingsContext.ts +++ b/apps/meteor/client/views/admin/EditableSettingsContext.ts @@ -1,7 +1,7 @@ import { ISettingBase, SectionName, SettingId, GroupId, TabId, ISettingColor } from '@rocket.chat/core-typings'; import { SettingsContextQuery } from '@rocket.chat/ui-contexts'; import { createContext, useContext, useMemo } from 'react'; -import { useSubscription, Subscription, Unsubscribe } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; export type EditableSetting = (ISettingBase | ISettingColor) & { disabled: boolean; @@ -14,58 +14,53 @@ export type EditableSettingsContextQuery = SettingsContextQuery & { }; export type EditableSettingsContextValue = { - readonly queryEditableSetting: (_id: SettingId) => Subscription<EditableSetting | undefined>; - readonly queryEditableSettings: (query: EditableSettingsContextQuery) => Subscription<EditableSetting[]>; - readonly queryGroupSections: (_id: GroupId, tab?: TabId) => Subscription<SectionName[]>; - readonly queryGroupTabs: (_id: GroupId) => Subscription<TabId[]>; + readonly queryEditableSetting: ( + _id: SettingId, + ) => [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => EditableSetting | undefined]; + readonly queryEditableSettings: ( + query: EditableSettingsContextQuery, + ) => [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => EditableSetting[]]; + readonly queryGroupSections: ( + _id: GroupId, + tab?: TabId, + ) => [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => SectionName[]]; + readonly queryGroupTabs: (_id: GroupId) => [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => TabId[]]; readonly dispatch: (changes: Partial<EditableSetting>[]) => void; }; export const EditableSettingsContext = createContext<EditableSettingsContextValue>({ - queryEditableSetting: () => ({ - getCurrentValue: (): undefined => undefined, - subscribe: (): Unsubscribe => (): void => undefined, - }), - queryEditableSettings: () => ({ - getCurrentValue: (): EditableSetting[] => [], - subscribe: (): Unsubscribe => (): void => undefined, - }), - queryGroupSections: () => ({ - getCurrentValue: (): SectionName[] => [], - subscribe: (): Unsubscribe => (): void => undefined, - }), - queryGroupTabs: () => ({ - getCurrentValue: (): TabId[] => [], - subscribe: (): Unsubscribe => (): void => undefined, - }), + queryEditableSetting: () => [(): (() => void) => (): void => undefined, (): undefined => undefined], + queryEditableSettings: () => [(): (() => void) => (): void => undefined, (): EditableSetting[] => []], + queryGroupSections: () => [(): (() => void) => (): void => undefined, (): SectionName[] => []], + queryGroupTabs: () => [(): (() => void) => (): void => undefined, (): TabId[] => []], dispatch: () => undefined, }); export const useEditableSetting = (_id: SettingId): EditableSetting | undefined => { const { queryEditableSetting } = useContext(EditableSettingsContext); - const subscription = useMemo(() => queryEditableSetting(_id), [queryEditableSetting, _id]); - return useSubscription(subscription); + const [subscribe, getSnapshot] = useMemo(() => queryEditableSetting(_id), [queryEditableSetting, _id]); + return useSyncExternalStore(subscribe, getSnapshot); }; export const useEditableSettings = (query?: EditableSettingsContextQuery): EditableSetting[] => { const { queryEditableSettings } = useContext(EditableSettingsContext); - const subscription = useMemo(() => queryEditableSettings(query ?? {}), [queryEditableSettings, query]); - return useSubscription(subscription); + const [subscribe, getSnapshot] = useMemo(() => queryEditableSettings(query ?? {}), [queryEditableSettings, query]); + return useSyncExternalStore(subscribe, getSnapshot); }; export const useEditableSettingsGroupSections = (_id: SettingId, tab?: TabId): SectionName[] => { const { queryGroupSections } = useContext(EditableSettingsContext); - const subscription = useMemo(() => queryGroupSections(_id, tab), [queryGroupSections, _id, tab]); - return useSubscription(subscription); + const [subscribe, getSnapshot] = useMemo(() => queryGroupSections(_id, tab), [queryGroupSections, _id, tab]); + return useSyncExternalStore(subscribe, getSnapshot); }; export const useEditableSettingsGroupTabs = (_id: SettingId): TabId[] => { const { queryGroupTabs } = useContext(EditableSettingsContext); - const subscription = useMemo(() => queryGroupTabs(_id), [queryGroupTabs, _id]); - return useSubscription(subscription); + const [subscribe, getSnapshot] = useMemo(() => queryGroupTabs(_id), [queryGroupTabs, _id]); + return useSyncExternalStore(subscribe, getSnapshot); }; export const useEditableSettingsDispatch = (): ((changes: Partial<EditableSetting>[]) => void) => diff --git a/apps/meteor/client/views/admin/apps/AppMenu.js b/apps/meteor/client/views/admin/apps/AppMenu.js index 7daf4505c9b..ed4bbf9b0e7 100644 --- a/apps/meteor/client/views/admin/apps/AppMenu.js +++ b/apps/meteor/client/views/admin/apps/AppMenu.js @@ -1,5 +1,5 @@ import { Box, Icon, Menu } from '@rocket.chat/fuselage'; -import { useSetModal, useMethod, useEndpoint, useTranslation } from '@rocket.chat/ui-contexts'; +import { useSetModal, useMethod, useEndpoint, useTranslation, useRoute, useRouteParameter } from '@rocket.chat/ui-contexts'; import React, { useMemo, useCallback } from 'react'; import CloudLoginModal from './CloudLoginModal'; @@ -11,6 +11,8 @@ function AppMenu({ app, ...props }) { const t = useTranslation(); const setModal = useSetModal(); const checkUserLoggedIn = useMethod('cloud:checkUserLoggedIn'); + const appsRoute = useRoute('admin-apps'); + const context = useRouteParameter('context'); const setAppStatus = useEndpoint('POST', `/apps/${app.id}/status`); const buildExternalUrl = useEndpoint('GET', '/apps'); @@ -64,6 +66,10 @@ function AppMenu({ app, ...props }) { setModal(<IframeModal url={data.url} confirm={confirm} cancel={closeModal} />); }, [checkUserLoggedIn, setModal, closeModal, buildExternalUrl, app.id, app.purchaseType, syncApp]); + const handleViewLogs = useCallback(() => { + appsRoute.push({ context: 'details', id: app.id, version: app.version, tab: 'logs' }); + }, [app.id, app.version, appsRoute]); + const handleDisable = useCallback(() => { const confirm = async () => { closeModal(); @@ -124,6 +130,17 @@ function AppMenu({ app, ...props }) { action: handleSubscription, }, }), + ...(context !== 'details' && { + viewLogs: { + label: ( + <Box> + <Icon name='list-alt' size='x16' marginInlineEnd='x4' /> + {t('View_Logs')} + </Box> + ), + action: handleViewLogs, + }, + }), ...(app.installed && isAppEnabled && { disable: { @@ -160,7 +177,18 @@ function AppMenu({ app, ...props }) { }, }), }), - [canAppBeSubscribed, t, handleSubscription, app.installed, isAppEnabled, handleDisable, handleEnable, handleUninstall], + [ + canAppBeSubscribed, + t, + handleSubscription, + context, + handleViewLogs, + app.installed, + isAppEnabled, + handleDisable, + handleEnable, + handleUninstall, + ], ); return <Menu options={menuOptions} placement='bottom-start' {...props} />; diff --git a/apps/meteor/client/views/admin/sidebar/AdminSidebarPages.tsx b/apps/meteor/client/views/admin/sidebar/AdminSidebarPages.tsx index e188e396bd7..664c18838c6 100644 --- a/apps/meteor/client/views/admin/sidebar/AdminSidebarPages.tsx +++ b/apps/meteor/client/views/admin/sidebar/AdminSidebarPages.tsx @@ -1,10 +1,10 @@ import { Box } from '@rocket.chat/fuselage'; import React, { memo, FC } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import SidebarItemsAssembler from '../../../components/Sidebar/SidebarItemsAssembler'; import { useUpgradeTabParams } from '../../hooks/useUpgradeTabParams'; -import { itemsSubscription } from '../sidebarItems'; +import { subscribeToAdminSidebarItems, getAdminSidebarItems } from '../sidebarItems'; import UpgradeTab from './UpgradeTab'; type AdminSidebarPagesProps = { @@ -12,7 +12,8 @@ type AdminSidebarPagesProps = { }; const AdminSidebarPages: FC<AdminSidebarPagesProps> = ({ currentPath }) => { - const items = useSubscription(itemsSubscription); + const items = useSyncExternalStore(subscribeToAdminSidebarItems, getAdminSidebarItems); + const { tabType, trialEndDate, isLoading } = useUpgradeTabParams(); return ( diff --git a/apps/meteor/client/views/admin/sidebarItems.ts b/apps/meteor/client/views/admin/sidebarItems.ts index a9dc4bf5351..d3f19506964 100644 --- a/apps/meteor/client/views/admin/sidebarItems.ts +++ b/apps/meteor/client/views/admin/sidebarItems.ts @@ -4,7 +4,8 @@ import { createSidebarItems } from '../../lib/createSidebarItems'; export const { registerSidebarItem: registerAdminSidebarItem, unregisterSidebarItem, - itemsSubscription, + getSidebarItems: getAdminSidebarItems, + subscribeToSidebarItems: subscribeToAdminSidebarItems, } = createSidebarItems([ { href: 'admin-info', diff --git a/apps/meteor/client/views/admin/users/EditUser.js b/apps/meteor/client/views/admin/users/EditUser.js index a8130ca6448..9c5ccbf6807 100644 --- a/apps/meteor/client/views/admin/users/EditUser.js +++ b/apps/meteor/client/views/admin/users/EditUser.js @@ -14,7 +14,6 @@ const getInitialValue = (data) => ({ name: data.name ?? '', password: '', username: data.username, - status: data.status, bio: data.bio ?? '', nickname: data.nickname ?? '', email: (data.emails && data.emails.length && data.emails[0].address) || '', diff --git a/apps/meteor/client/views/banners/BannerRegion.tsx b/apps/meteor/client/views/banners/BannerRegion.tsx index c87667e5f3d..a840540482b 100644 --- a/apps/meteor/client/views/banners/BannerRegion.tsx +++ b/apps/meteor/client/views/banners/BannerRegion.tsx @@ -1,12 +1,12 @@ import React, { FC } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import * as banners from '../../lib/banners'; import LegacyBanner from './LegacyBanner'; import UiKitBanner from './UiKitBanner'; const BannerRegion: FC = () => { - const payload = useSubscription(banners.firstSubscription); + const payload = useSyncExternalStore(...banners.firstSubscription); if (!payload) { return null; diff --git a/apps/meteor/client/views/hooks/useActionSpread.ts b/apps/meteor/client/views/hooks/useActionSpread.ts index 21a089ba144..b1c2fddc9e9 100644 --- a/apps/meteor/client/views/hooks/useActionSpread.ts +++ b/apps/meteor/client/views/hooks/useActionSpread.ts @@ -1,14 +1,14 @@ -import { useMemo } from 'react'; +import { useMemo, ReactNode } from 'react'; -type Action = { - label: string; - icon: string; - action: () => any; +export type Action = { + label: ReactNode; + icon?: string; + action: () => void; }; type MenuOption = { - label: { label: string; icon: string }; - action: Function; + label: { label: ReactNode; icon?: string }; + action: () => void; }; const mapOptions = ([key, { action, label, icon }]: [string, Action]): [string, MenuOption] => [ diff --git a/apps/meteor/client/views/omnichannel/additionalForms.tsx b/apps/meteor/client/views/omnichannel/additionalForms.tsx index a8fab3c41a2..800458471eb 100644 --- a/apps/meteor/client/views/omnichannel/additionalForms.tsx +++ b/apps/meteor/client/views/omnichannel/additionalForms.tsx @@ -1,28 +1,29 @@ -/* eslint-disable @typescript-eslint/no-empty-interface */ import { ReactElement } from 'react'; -import { Unsubscribe, useSubscription, Subscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; -// eslint-disable-next-line @typescript-eslint/interface-name-prefix +/* eslint-disable @typescript-eslint/no-empty-interface */ +/* eslint-disable @typescript-eslint/interface-name-prefix */ export interface EEFormHooks {} const createFormSubscription = (): { registerForm: (form: EEFormHooks) => void; unregisterForm: (form: keyof EEFormHooks) => void; - formsSubscription: Subscription<EEFormHooks>; + formsSubscription: readonly [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => EEFormHooks]; getForm: (form: keyof EEFormHooks) => () => ReactElement; } => { let forms = {} as EEFormHooks; let updateCb = (): void => undefined; - const formsSubscription: Subscription<EEFormHooks> = { - subscribe: (cb: () => void): Unsubscribe => { + const formsSubscription = [ + (cb: () => void): (() => void) => { updateCb = cb; return (): void => { updateCb = (): void => undefined; }; }, - getCurrentValue: (): EEFormHooks => forms, - }; + (): EEFormHooks => forms, + ] as const; + const registerForm = (newForm: EEFormHooks): void => { forms = { ...forms, ...newForm }; updateCb(); @@ -37,6 +38,8 @@ const createFormSubscription = (): { return { registerForm, unregisterForm, formsSubscription, getForm }; }; -export const { registerForm, unregisterForm, formsSubscription, getForm } = createFormSubscription(); +const { registerForm, unregisterForm, formsSubscription, getForm } = createFormSubscription(); + +export { registerForm, unregisterForm, getForm }; -export const useFormsSubscription = (): EEFormHooks => useSubscription(formsSubscription); +export const useFormsSubscription = (): EEFormHooks => useSyncExternalStore(...formsSubscription); diff --git a/apps/meteor/client/views/omnichannel/agents/AgentEdit.tsx b/apps/meteor/client/views/omnichannel/agents/AgentEdit.tsx index b9ed52e54b9..cb3721baec7 100644 --- a/apps/meteor/client/views/omnichannel/agents/AgentEdit.tsx +++ b/apps/meteor/client/views/omnichannel/agents/AgentEdit.tsx @@ -3,13 +3,12 @@ import { Field, TextInput, Button, Margins, Box, MultiSelect, Icon, Select } fro import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useToastMessageDispatch, useRoute, useSetting, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; import React, { useMemo, useRef, useState, FC, ReactElement } from 'react'; -import { useSubscription } from 'use-subscription'; import { getUserEmailAddress } from '../../../../lib/getUserEmailAddress'; import VerticalBar from '../../../components/VerticalBar'; import { useForm } from '../../../hooks/useForm'; import UserInfo from '../../room/contextualBar/UserInfo'; -import { formsSubscription } from '../additionalForms'; +import { useFormsSubscription } from '../additionalForms'; // TODO: TYPE: // Department @@ -46,7 +45,7 @@ const AgentEdit: FC<AgentEditProps> = ({ data, userDepartments, availableDepartm () => (userDepartments.departments ? userDepartments.departments.map(({ departmentId }) => departmentId) : []), [userDepartments], ); - const eeForms = useSubscription(formsSubscription); + const eeForms = useFormsSubscription(); const saveRef = useRef({ values: {}, diff --git a/apps/meteor/client/views/omnichannel/analytics/InterchangeableChart.js b/apps/meteor/client/views/omnichannel/analytics/InterchangeableChart.js index 59c2cc3fadd..524fe89fdb5 100644 --- a/apps/meteor/client/views/omnichannel/analytics/InterchangeableChart.js +++ b/apps/meteor/client/views/omnichannel/analytics/InterchangeableChart.js @@ -15,11 +15,13 @@ const getChartTooltips = (chartName) => { case 'Avg_reaction_time': return { callbacks: { - title(tooltipItem, data) { - return data.labels[tooltipItem[0].index]; + title([ctx]) { + const { dataset } = ctx; + return dataset.label; }, - label(tooltipItem, data) { - return secondsToHHMMSS(data.datasets[0].data[tooltipItem.index]); + label(ctx) { + const { dataset, dataIndex } = ctx; + return secondsToHHMMSS(dataset.data[dataIndex]); }, }, }; @@ -53,7 +55,7 @@ const InterchangeableChart = ({ departmentId, dateRange, chartName, ...props }) tooltipCallbacks, }); } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); + dispatchToastMessage({ type: 'error', message: error.message }); } }); diff --git a/apps/meteor/client/views/omnichannel/businessHours/BusinessHoursFormContainer.js b/apps/meteor/client/views/omnichannel/businessHours/BusinessHoursFormContainer.js index 7eae87457fb..c274ebee272 100644 --- a/apps/meteor/client/views/omnichannel/businessHours/BusinessHoursFormContainer.js +++ b/apps/meteor/client/views/omnichannel/businessHours/BusinessHoursFormContainer.js @@ -1,12 +1,11 @@ import { FieldGroup, Box } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import React, { useEffect, useState } from 'react'; -import { useSubscription } from 'use-subscription'; import { businessHourManager } from '../../../../app/livechat/client/views/app/business-hours/BusinessHours'; import { useForm } from '../../../hooks/useForm'; import { useReactiveValue } from '../../../hooks/useReactiveValue'; -import { formsSubscription } from '../additionalForms'; +import { useFormsSubscription } from '../additionalForms'; import BusinessHourForm from './BusinessHoursForm'; const useChangeHandler = (name, ref) => @@ -25,7 +24,7 @@ const getInitalData = ({ workHours }) => ({ const cleanFunc = () => {}; const BusinessHoursFormContainer = ({ data, saveRef, onChange = () => {} }) => { - const forms = useSubscription(formsSubscription); + const forms = useFormsSubscription(); const [hasChangesMultiple, setHasChangesMultiple] = useState(false); const [hasChangesTimeZone, setHasChangesTimeZone] = useState(false); diff --git a/apps/meteor/client/views/omnichannel/currentChats/FilterByText.tsx b/apps/meteor/client/views/omnichannel/currentChats/FilterByText.tsx index d7b92cb3463..d5432f10eb2 100644 --- a/apps/meteor/client/views/omnichannel/currentChats/FilterByText.tsx +++ b/apps/meteor/client/views/omnichannel/currentChats/FilterByText.tsx @@ -3,13 +3,12 @@ import { useMutableCallback, useLocalStorage } from '@rocket.chat/fuselage-hooks import { useSetModal, useToastMessageDispatch, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; import moment from 'moment'; import React, { Dispatch, FC, SetStateAction, useEffect, useMemo } from 'react'; -import { useSubscription } from 'use-subscription'; import AutoCompleteAgent from '../../../components/AutoCompleteAgent'; import AutoCompleteDepartment from '../../../components/AutoCompleteDepartment'; import GenericModal from '../../../components/GenericModal'; import { useEndpointData } from '../../../hooks/useEndpointData'; -import { formsSubscription } from '../additionalForms'; +import { useFormsSubscription } from '../additionalForms'; import Label from './Label'; import RemoveAllClosed from './RemoveAllClosed'; @@ -64,7 +63,7 @@ const FilterByText: FilterByTextType = ({ setFilter, reload, ...props }) => { setCustomFields([]); }); - const forms = useSubscription<any>(formsSubscription); + const forms = useFormsSubscription() as any; // TODO: Refactor the formsSubscription to use components instead of hooks (since the only thing the hook does is return a component) // Conditional hook was required since the whole formSubscription uses hooks in an incorrect manner diff --git a/apps/meteor/client/views/omnichannel/customFields/EditCustomFieldsPage.js b/apps/meteor/client/views/omnichannel/customFields/EditCustomFieldsPage.js index 852d31215bf..89fdf1cff01 100644 --- a/apps/meteor/client/views/omnichannel/customFields/EditCustomFieldsPage.js +++ b/apps/meteor/client/views/omnichannel/customFields/EditCustomFieldsPage.js @@ -2,11 +2,10 @@ import { Box, Button, Icon, ButtonGroup, FieldGroup } from '@rocket.chat/fuselag import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useToastMessageDispatch, useRoute, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; import React, { useCallback, useState } from 'react'; -import { useSubscription } from 'use-subscription'; import Page from '../../../components/Page'; import { useForm } from '../../../hooks/useForm'; -import { formsSubscription } from '../additionalForms'; +import { useFormsSubscription } from '../additionalForms'; import CustomFieldsForm from './CustomFieldsForm'; const getInitialValues = (cf) => ({ @@ -24,7 +23,7 @@ const EditCustomFieldsPage = ({ customField, id, reload }) => { const [additionalValues, setAdditionalValues] = useState({}); - const { useCustomFieldsAdditionalForm = () => {} } = useSubscription(formsSubscription); + const { useCustomFieldsAdditionalForm = () => {} } = useFormsSubscription(); const AdditionalForm = useCustomFieldsAdditionalForm(); const router = useRoute('omnichannel-customfields'); diff --git a/apps/meteor/client/views/omnichannel/customFields/NewCustomFieldsPage.js b/apps/meteor/client/views/omnichannel/customFields/NewCustomFieldsPage.js index 65b02030cfe..b4d4dcd4a44 100644 --- a/apps/meteor/client/views/omnichannel/customFields/NewCustomFieldsPage.js +++ b/apps/meteor/client/views/omnichannel/customFields/NewCustomFieldsPage.js @@ -2,11 +2,10 @@ import { Box, Button, Icon, FieldGroup, ButtonGroup } from '@rocket.chat/fuselag import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useToastMessageDispatch, useRoute, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; import React, { useCallback, useState } from 'react'; -import { useSubscription } from 'use-subscription'; import Page from '../../../components/Page'; import { useForm } from '../../../hooks/useForm'; -import { formsSubscription } from '../additionalForms'; +import { useFormsSubscription } from '../additionalForms'; import CustomFieldsForm from './CustomFieldsForm'; const initialValues = { @@ -23,7 +22,7 @@ const NewCustomFieldsPage = ({ reload }) => { const [additionalValues, setAdditionalValues] = useState({}); - const { useCustomFieldsAdditionalForm = () => {} } = useSubscription(formsSubscription); + const { useCustomFieldsAdditionalForm = () => {} } = useFormsSubscription(); const AdditionalForm = useCustomFieldsAdditionalForm(); const router = useRoute('omnichannel-customfields'); diff --git a/apps/meteor/client/views/omnichannel/departments/EditDepartment.js b/apps/meteor/client/views/omnichannel/departments/EditDepartment.js index ccde330932d..25a62e66426 100644 --- a/apps/meteor/client/views/omnichannel/departments/EditDepartment.js +++ b/apps/meteor/client/views/omnichannel/departments/EditDepartment.js @@ -15,7 +15,6 @@ import { import { useMutableCallback, useUniqueId } from '@rocket.chat/fuselage-hooks'; import { useToastMessageDispatch, useRoute, useMethod, useEndpoint, useTranslation } from '@rocket.chat/ui-contexts'; import React, { useMemo, useState, useRef } from 'react'; -import { useSubscription } from 'use-subscription'; import { validateEmail } from '../../../../lib/emailValidator'; import Page from '../../../components/Page'; @@ -24,7 +23,7 @@ import { useRecordList } from '../../../hooks/lists/useRecordList'; import { useComponentDidUpdate } from '../../../hooks/useComponentDidUpdate'; import { useForm } from '../../../hooks/useForm'; import { AsyncStatePhase } from '../../../lib/asyncState'; -import { formsSubscription } from '../additionalForms'; +import { useFormsSubscription } from '../additionalForms'; import DepartmentsAgentsTable from './DepartmentsAgentsTable'; function withDefault(key, defaultValue) { @@ -42,7 +41,7 @@ function EditDepartment({ data, id, title, reload, allowedToForwardData }) { useDepartmentForwarding = () => {}, useDepartmentBusinessHours = () => {}, useSelectForwardDepartment = () => {}, - } = useSubscription(formsSubscription); + } = useFormsSubscription(); const initialAgents = useRef((data && data.agents) || []); diff --git a/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/RoomEdit.js b/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/RoomEdit.js index 0f4a5578c4b..fdcdeeaabc6 100644 --- a/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/RoomEdit.js +++ b/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/RoomEdit.js @@ -2,7 +2,6 @@ import { Field, TextInput, ButtonGroup, Button } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useToastMessageDispatch, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; import React, { useState, useMemo } from 'react'; -import { useSubscription } from 'use-subscription'; import { hasAtLeastOnePermission } from '../../../../../../app/authorization/client'; import CustomFieldsForm from '../../../../../components/CustomFieldsForm'; @@ -11,7 +10,7 @@ import VerticalBar from '../../../../../components/VerticalBar'; import { AsyncStatePhase } from '../../../../../hooks/useAsyncState'; import { useEndpointData } from '../../../../../hooks/useEndpointData'; import { useForm } from '../../../../../hooks/useForm'; -import { formsSubscription } from '../../../additionalForms'; +import { useFormsSubscription } from '../../../additionalForms'; import { FormSkeleton } from '../../Skeleton'; const initialValuesRoom = { @@ -45,7 +44,7 @@ function RoomEdit({ room, visitor, reload, reloadInfo, close }) { const { handleTopic, handleTags, handlePriorityId } = handlersRoom; const { topic, tags, priorityId } = valuesRoom; - const forms = useSubscription(formsSubscription); + const forms = useFormsSubscription(); const { usePrioritiesSelect = () => {} } = forms; diff --git a/apps/meteor/client/views/omnichannel/directory/contacts/contextualBar/ContactNewEdit.js b/apps/meteor/client/views/omnichannel/directory/contacts/contextualBar/ContactNewEdit.js index 6380f16ab56..fda1dc2e8dc 100644 --- a/apps/meteor/client/views/omnichannel/directory/contacts/contextualBar/ContactNewEdit.js +++ b/apps/meteor/client/views/omnichannel/directory/contacts/contextualBar/ContactNewEdit.js @@ -2,7 +2,6 @@ import { Field, TextInput, ButtonGroup, Button } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useToastMessageDispatch, useEndpoint, useTranslation } from '@rocket.chat/ui-contexts'; import React, { useState, useMemo } from 'react'; -import { useSubscription } from 'use-subscription'; import { hasAtLeastOnePermission } from '../../../../../../app/authorization/client'; import { validateEmail } from '../../../../../../lib/emailValidator'; @@ -13,7 +12,7 @@ import { useComponentDidUpdate } from '../../../../../hooks/useComponentDidUpdat import { useEndpointData } from '../../../../../hooks/useEndpointData'; import { useForm } from '../../../../../hooks/useForm'; import { createToken } from '../../../../../lib/utils/createToken'; -import { formsSubscription } from '../../../additionalForms'; +import { useFormsSubscription } from '../../../additionalForms'; import { FormSkeleton } from '../../Skeleton'; const initialValues = { @@ -50,7 +49,7 @@ function ContactNewEdit({ id, data, close }) { const { values, handlers, hasUnsavedChanges: hasUnsavedChangesContact } = useForm(getInitialValues(data)); - const eeForms = useSubscription(formsSubscription); + const eeForms = useFormsSubscription(); const { useContactManager = () => {} } = eeForms; diff --git a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/AgentStatusChart.js b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/AgentStatusChart.js index 71c2edee9c5..c1cf1b438f8 100644 --- a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/AgentStatusChart.js +++ b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/AgentStatusChart.js @@ -52,7 +52,7 @@ const AgentStatusChart = ({ params, reloadRef, ...props }) => { }, [t]); useEffect(() => { - if (state === AsyncStatePhase.RESOLVED) { + if (state === AsyncStatePhase.RESOLVED && context.current) { updateChartData(t('Offline'), [offline]); updateChartData(t('Available'), [available]); updateChartData(t('Away'), [away]); diff --git a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ChatDurationChart.js b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ChatDurationChart.js index f1dc8f1072d..f049480b789 100644 --- a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ChatDurationChart.js +++ b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ChatDurationChart.js @@ -13,13 +13,13 @@ import { useUpdateChartData } from './useUpdateChartData'; const [labels, initialData] = getMomentChartLabelsAndData(); const tooltipCallbacks = { callbacks: { - title(tooltipItem, data) { - return data.labels[tooltipItem[0].index]; + title([ctx]) { + const { dataset } = ctx; + return dataset.label; }, - label(tooltipItem, data) { - const { datasetIndex, index } = tooltipItem; - const { data: datasetData, label } = data.datasets[datasetIndex]; - return `${label}: ${secondsToHHMMSS(datasetData[index])}`; + label(ctx) { + const { dataset, dataIndex } = ctx; + return `${dataset.label}: ${secondsToHHMMSS(dataset.data[dataIndex])}`; }, }, }; diff --git a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ChatsPerAgentChart.js b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ChatsPerAgentChart.js index 4a2bbccab60..a2e6837e948 100644 --- a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ChatsPerAgentChart.js +++ b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ChatsPerAgentChart.js @@ -9,6 +9,7 @@ import { useUpdateChartData } from './useUpdateChartData'; const initialData = { agents: {}, + success: true, }; const init = (canvas, context, t) => diff --git a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ResponseTimesChart.js b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ResponseTimesChart.js index 04a2cf1af0b..c94b59ee077 100644 --- a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ResponseTimesChart.js +++ b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ResponseTimesChart.js @@ -13,13 +13,13 @@ import { useUpdateChartData } from './useUpdateChartData'; const [labels, initialData] = getMomentChartLabelsAndData(); const tooltipCallbacks = { callbacks: { - title(tooltipItem, data) { - return data.labels[tooltipItem[0].index]; + title([ctx]) { + const { dataset } = ctx; + return dataset.label; }, - label(tooltipItem, data) { - const { datasetIndex, index } = tooltipItem; - const { data: datasetData, label } = data.datasets[datasetIndex]; - return `${label}: ${secondsToHHMMSS(datasetData[index])}`; + label(ctx) { + const { dataset, dataIndex } = ctx; + return `${dataset.label}: ${secondsToHHMMSS(dataset.data[dataIndex])}`; }, }, }; diff --git a/apps/meteor/client/views/omnichannel/sidebar/OmnichannelSidebar.tsx b/apps/meteor/client/views/omnichannel/sidebar/OmnichannelSidebar.tsx index ea646b6b745..c7def16dd3b 100644 --- a/apps/meteor/client/views/omnichannel/sidebar/OmnichannelSidebar.tsx +++ b/apps/meteor/client/views/omnichannel/sidebar/OmnichannelSidebar.tsx @@ -1,16 +1,16 @@ import { useRoutePath, useCurrentRoute, useTranslation } from '@rocket.chat/ui-contexts'; import React, { useCallback, useEffect, FC, memo } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { menu, SideNav } from '../../../../app/ui-utils/client'; import Sidebar from '../../../components/Sidebar'; import SidebarItemsAssemblerProps from '../../../components/Sidebar/SidebarItemsAssembler'; import { isLayoutEmbedded } from '../../../lib/utils/isLayoutEmbedded'; import SettingsProvider from '../../../providers/SettingsProvider'; -import { itemsSubscription } from '../sidebarItems'; +import { getOmnichannelSidebarItems, subscribeToOmnichannelSidebarItems } from '../sidebarItems'; const OmnichannelSidebar: FC = () => { - const items = useSubscription(itemsSubscription); + const items = useSyncExternalStore(subscribeToOmnichannelSidebarItems, getOmnichannelSidebarItems); const t = useTranslation(); const closeOmnichannelFlex = useCallback(() => { diff --git a/apps/meteor/client/views/omnichannel/sidebarItems.ts b/apps/meteor/client/views/omnichannel/sidebarItems.ts index 34e815a3eff..2cda8c2c9e3 100644 --- a/apps/meteor/client/views/omnichannel/sidebarItems.ts +++ b/apps/meteor/client/views/omnichannel/sidebarItems.ts @@ -4,7 +4,8 @@ import { createSidebarItems } from '../../lib/createSidebarItems'; export const { registerSidebarItem: registerOmnichannelSidebarItem, unregisterSidebarItem, - itemsSubscription, + getSidebarItems: getOmnichannelSidebarItems, + subscribeToSidebarItems: subscribeToOmnichannelSidebarItems, } = createSidebarItems([ { href: 'omnichannel/current', diff --git a/apps/meteor/client/views/room/MessageList/contexts/SelectedMessagesContext.tsx b/apps/meteor/client/views/room/MessageList/contexts/SelectedMessagesContext.tsx index c96c6a92075..f9fda919d88 100644 --- a/apps/meteor/client/views/room/MessageList/contexts/SelectedMessagesContext.tsx +++ b/apps/meteor/client/views/room/MessageList/contexts/SelectedMessagesContext.tsx @@ -1,6 +1,5 @@ -import { OffCallbackHandler } from '@rocket.chat/emitter'; -import { createContext, useCallback, useContext, useMemo } from 'react'; -import { useSubscription } from 'use-subscription'; +import { createContext, useCallback, useContext } from 'react'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { selectedMessageStore } from '../../providers/SelectedMessagesProvider'; @@ -14,28 +13,28 @@ export const SelectedMessageContext = createContext({ export const useIsSelectedMessage = (mid: string): boolean => { const { selectedMessageStore } = useContext(SelectedMessageContext); - const subscription = useMemo( - () => ({ - getCurrentValue: (): boolean => selectedMessageStore.isSelected(mid), - subscribe: (callback: () => void): OffCallbackHandler => selectedMessageStore.on(mid, callback), - }), - [mid, selectedMessageStore], + + const subscribe = useCallback( + (callback: () => void): (() => void) => selectedMessageStore.on(mid, callback), + [selectedMessageStore, mid], ); - return useSubscription(subscription); + + const getSnapshot = (): boolean => selectedMessageStore.isSelected(mid); + + return useSyncExternalStore(subscribe, getSnapshot); }; export const useIsSelecting = (): boolean => { const { selectedMessageStore } = useContext(SelectedMessageContext); - return useSubscription( - useMemo( - () => ({ - getCurrentValue: (): boolean => selectedMessageStore.getIsSelecting(), - subscribe: (callback: () => void): OffCallbackHandler => selectedMessageStore.on('toggleIsSelecting', callback), - }), - [selectedMessageStore], - ), + const subscribe = useCallback( + (callback: () => void): (() => void) => selectedMessageStore.on('toggleIsSelecting', callback), + [selectedMessageStore], ); + + const getSnapshot = (): boolean => selectedMessageStore.getIsSelecting(); + + return useSyncExternalStore(subscribe, getSnapshot); }; export const useToggleSelect = (mid: string): (() => void) => { @@ -48,13 +47,12 @@ export const useToggleSelect = (mid: string): (() => void) => { export const useCountSelected = (): number => { const { selectedMessageStore } = useContext(SelectedMessageContext); - return useSubscription( - useMemo( - () => ({ - getCurrentValue: (): number => selectedMessageStore.count(), - subscribe: (callback: () => void): OffCallbackHandler => selectedMessageStore.on('change', callback), - }), - [selectedMessageStore], - ), + const subscribe = useCallback( + (callback: () => void): (() => void) => selectedMessageStore.on('change', callback), + [selectedMessageStore], ); + + const getSnapshot = (): number => selectedMessageStore.count(); + + return useSyncExternalStore(subscribe, getSnapshot); }; diff --git a/apps/meteor/client/views/room/MessageList/providers/MessageHighlightProvider.tsx b/apps/meteor/client/views/room/MessageList/providers/MessageHighlightProvider.tsx index 3d23ca9972e..a9aec5439b6 100644 --- a/apps/meteor/client/views/room/MessageList/providers/MessageHighlightProvider.tsx +++ b/apps/meteor/client/views/room/MessageList/providers/MessageHighlightProvider.tsx @@ -1,11 +1,11 @@ import React, { ReactElement, ContextType, useMemo, ReactNode } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import MessageHighlightContext from '../contexts/MessageHighlightContext'; -import { messageHighlightSubscription } from './messageHighlightSubscription'; +import * as messageHighlightSubscription from './messageHighlightSubscription'; const MessageHighlightProvider = ({ children }: { children: ReactNode }): ReactElement => { - const highlightMessageId = useSubscription(messageHighlightSubscription); + const highlightMessageId = useSyncExternalStore(messageHighlightSubscription.subscribe, messageHighlightSubscription.getSnapshot); const contextValue = useMemo<ContextType<typeof MessageHighlightContext>>( () => ({ diff --git a/apps/meteor/client/views/room/MessageList/providers/messageHighlightSubscription.ts b/apps/meteor/client/views/room/MessageList/providers/messageHighlightSubscription.ts index 31fcb3bab26..dff96e0b010 100644 --- a/apps/meteor/client/views/room/MessageList/providers/messageHighlightSubscription.ts +++ b/apps/meteor/client/views/room/MessageList/providers/messageHighlightSubscription.ts @@ -1,31 +1,29 @@ import { IMessage } from '@rocket.chat/core-typings'; -import { Subscription, Unsubscribe } from 'use-subscription'; type SetHighlightFn = (_id: IMessage['_id']) => void; type ClearHighlightFn = (_id: IMessage['_id']) => void; type MessageHighlightSubscription = { - subscription: Subscription<IMessage['_id'] | undefined>; + subscribe: (callback: () => void) => () => void; + getSnapshot: () => IMessage['_id'] | undefined; setHighlight: SetHighlightFn; clearHighlight: ClearHighlightFn; }; const createMessageHighlightSubscription = (): MessageHighlightSubscription => { - let updateCb: Unsubscribe = () => undefined; + let updateCb: () => void = () => undefined; let highlightMessageId: IMessage['_id'] | undefined; - const subscription: Subscription<typeof highlightMessageId> = { - subscribe: (cb) => { - updateCb = cb; - return (): void => { - updateCb = (): void => undefined; - }; - }, - - getCurrentValue: (): typeof highlightMessageId => highlightMessageId, + const subscribe = (cb: () => void): (() => void) => { + updateCb = cb; + return (): void => { + updateCb = (): void => undefined; + }; }; + const getSnapshot = (): typeof highlightMessageId => highlightMessageId; + const setHighlight = (_id: IMessage['_id']): void => { highlightMessageId = _id; updateCb(); @@ -36,11 +34,12 @@ const createMessageHighlightSubscription = (): MessageHighlightSubscription => { updateCb(); }; - return { subscription, setHighlight, clearHighlight }; + return { subscribe, getSnapshot, setHighlight, clearHighlight }; }; export const { - subscription: messageHighlightSubscription, + getSnapshot, + subscribe, setHighlight: setHighlightMessage, clearHighlight: clearHighlightMessage, } = createMessageHighlightSubscription(); diff --git a/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsers.stories.tsx b/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsers.stories.tsx index 0ee902a3268..e45f0f55158 100644 --- a/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsers.stories.tsx +++ b/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsers.stories.tsx @@ -17,5 +17,5 @@ export default { export const Default: ComponentStory<typeof AddUsers> = (args) => <AddUsers {...args} />; Default.storyName = 'AddUsers'; Default.args = { - value: 'rocket.cat', + users: ['rocket.cat'], }; diff --git a/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsers.js b/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsers.tsx similarity index 61% rename from apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsers.js rename to apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsers.tsx index a6f3db83a52..ae39742aac6 100644 --- a/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsers.js +++ b/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsers.tsx @@ -1,11 +1,20 @@ +import { IUser } from '@rocket.chat/core-typings'; import { Field, Button } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; -import React from 'react'; +import React, { ReactElement } from 'react'; import UserAutoCompleteMultiple from '../../../../../components/UserAutoCompleteMultiple'; import VerticalBar from '../../../../../components/VerticalBar'; -const AddUsers = ({ onClickClose, onClickBack, onClickSave, value, onChange }) => { +type AddUsersProps = { + onClickClose?: () => void; + onClickBack?: () => void; + onClickSave: () => Promise<void>; + users: IUser['username'][]; + onChange: (value: IUser['username'][], action?: string) => void; +}; + +const AddUsers = ({ onClickClose, onClickBack, onClickSave, users, onChange }: AddUsersProps): ReactElement => { const t = useTranslation(); return ( @@ -18,11 +27,11 @@ const AddUsers = ({ onClickClose, onClickBack, onClickSave, value, onChange }) = <VerticalBar.ScrollableContent> <Field> <Field.Label flexGrow={0}>{t('Choose_users')}</Field.Label> - <UserAutoCompleteMultiple value={value} onChange={onChange} placeholder={t('Choose_users')} /> + <UserAutoCompleteMultiple value={users} onChange={onChange} placeholder={t('Choose_users')} /> </Field> </VerticalBar.ScrollableContent> <VerticalBar.Footer> - <Button primary disabled={!value || value.length === 0} onClick={onClickSave}> + <Button primary disabled={!users || users.length === 0} onClick={onClickSave}> {t('Add_users')} </Button> </VerticalBar.Footer> diff --git a/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsersWithData.js b/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsersWithData.tsx similarity index 67% rename from apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsersWithData.js rename to apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsersWithData.tsx index 90bc58d23df..e088874417c 100644 --- a/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsersWithData.js +++ b/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsersWithData.tsx @@ -1,20 +1,31 @@ +import { IRoom, IUser } from '@rocket.chat/core-typings'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useToastMessageDispatch, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; -import React from 'react'; +import React, { ReactElement } from 'react'; import { useForm } from '../../../../../hooks/useForm'; import { useTabBarClose } from '../../../providers/ToolboxProvider'; import AddUsers from './AddUsers'; -const AddUsersWithData = ({ rid, onClickBack, reload }) => { +type AddUsersWithDataProps = { + rid: IRoom['_id']; + onClickBack: () => void; + reload: () => void; +}; + +type AddUsersInitialProps = { + users: IUser['username'][]; +}; + +const AddUsersWithData = ({ rid, onClickBack, reload }: AddUsersWithDataProps): ReactElement => { const t = useTranslation(); const dispatchToastMessage = useToastMessageDispatch(); const onClickClose = useTabBarClose(); const saveAction = useMethod('addUsersToRoom'); - const { values, handlers } = useForm({ users: [] }); - const { users } = values; + const { values, handlers } = useForm({ users: [] as IUser['username'][] }); + const { users } = values as AddUsersInitialProps; const { handleUsers } = handlers; const onChangeUsers = useMutableCallback((value, action) => { @@ -38,7 +49,7 @@ const AddUsersWithData = ({ rid, onClickBack, reload }) => { } }); - return <AddUsers onClickClose={onClickClose} onClickBack={onClickBack} onClickSave={handleSave} value={users} onChange={onChangeUsers} />; + return <AddUsers onClickClose={onClickClose} onClickBack={onClickBack} onClickSave={handleSave} users={users} onChange={onChangeUsers} />; }; export default AddUsersWithData; diff --git a/apps/meteor/client/views/room/hooks/useUserHasRoomRole.ts b/apps/meteor/client/views/room/hooks/useUserHasRoomRole.ts new file mode 100644 index 00000000000..cc9e93bdf31 --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserHasRoomRole.ts @@ -0,0 +1,8 @@ +import { IRole, IRoom, IUser } from '@rocket.chat/core-typings'; +import { useCallback } from 'react'; + +import { RoomRoles } from '../../../../app/models/client'; +import { useReactiveValue } from '../../../hooks/useReactiveValue'; + +export const useUserHasRoomRole = (uid: IUser['_id'], rid: IRoom['_id'], role: IRole['name']): boolean => + useReactiveValue(useCallback(() => !!RoomRoles.findOne({ rid, 'u._id': uid, 'roles': role }), [uid, rid, role])); diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions.js b/apps/meteor/client/views/room/hooks/useUserInfoActions.js deleted file mode 100644 index fc6fdabd4a1..00000000000 --- a/apps/meteor/client/views/room/hooks/useUserInfoActions.js +++ /dev/null @@ -1,415 +0,0 @@ -import { Button, ButtonGroup, Icon, Modal, Box } from '@rocket.chat/fuselage'; -import { useAutoFocus, useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { escapeHTML } from '@rocket.chat/string-helpers'; -import { - useSetModal, - useToastMessageDispatch, - useRoute, - useUserId, - useUserSubscription, - useUserRoom, - useUserSubscriptionByName, - usePermission, - useAllPermissions, - useMethod, - useTranslation, -} from '@rocket.chat/ui-contexts'; -import React, { useCallback, useMemo } from 'react'; - -import { RoomRoles } from '../../../../app/models/client'; -import { RoomMemberActions } from '../../../../definition/IRoomTypeConfig'; -import { useEndpointActionExperimental } from '../../../hooks/useEndpointActionExperimental'; -import { useReactiveValue } from '../../../hooks/useReactiveValue'; -import { roomCoordinator } from '../../../lib/rooms/roomCoordinator'; -import RemoveUsersModal from '../../teams/contextualBar/members/RemoveUsersModal'; -import { useWebRTC } from './useWebRTC'; - -const useUserHasRoomRole = (uid, rid, role) => - useReactiveValue(useCallback(() => !!RoomRoles.findOne({ rid, 'u._id': uid, 'roles': role }), [uid, rid, role])); - -const getShouldOpenDirectMessage = (currentSubscription, usernameSubscription, canOpenDirectMessage, username) => { - const canOpenDm = canOpenDirectMessage || usernameSubscription; - const directMessageIsNotAlreadyOpen = currentSubscription && currentSubscription.name !== username; - return canOpenDm && directMessageIsNotAlreadyOpen; -}; - -const getUserIsMuted = (room, user, userCanPostReadonly) => { - if (room && room.ro) { - if (Array.isArray(room.unmuted) && room.unmuted.indexOf(user && user.username) !== -1) { - return false; - } - - if (userCanPostReadonly) { - return Array.isArray(room.muted) && room.muted.indexOf(user && user.username) !== -1; - } - - return true; - } - - return room && Array.isArray(room.muted) && room.muted.indexOf(user && user.username) > -1; -}; - -const WarningModal = ({ text, confirmText, close, confirm, ...props }) => { - const refAutoFocus = useAutoFocus(true); - const t = useTranslation(); - return ( - <Modal {...props}> - <Modal.Header> - <Icon color='warning' name='modal-warning' size={20} /> - <Modal.Title>{t('Are_you_sure')}</Modal.Title> - <Modal.Close onClick={close} /> - </Modal.Header> - <Modal.Content fontScale='p2'>{text}</Modal.Content> - <Modal.Footer> - <ButtonGroup align='end'> - <Button ghost onClick={close}> - {t('Cancel')} - </Button> - <Button ref={refAutoFocus} primary danger onClick={confirm}> - {confirmText} - </Button> - </ButtonGroup> - </Modal.Footer> - </Modal> - ); -}; -// TODO: Remove endpoint concatenation -export const useUserInfoActions = (user = {}, rid, reload) => { - const t = useTranslation(); - const dispatchToastMessage = useToastMessageDispatch(); - const directRoute = useRoute('direct'); - - const setModal = useSetModal(); - - const { _id: uid } = user; - const ownUserId = useUserId(); - - const closeModal = useMutableCallback(() => setModal(null)); - - const room = useUserRoom(rid); - const currentSubscription = useUserSubscription(rid); - const usernameSubscription = useUserSubscriptionByName(user.username); - - const isLeader = useUserHasRoomRole(uid, rid, 'leader'); - const isModerator = useUserHasRoomRole(uid, rid, 'moderator'); - const isOwner = useUserHasRoomRole(uid, rid, 'owner'); - - const otherUserCanPostReadonly = useAllPermissions('post-readonly', rid); - - const isIgnored = currentSubscription && currentSubscription.ignored && currentSubscription.ignored.indexOf(uid) > -1; - const isMuted = getUserIsMuted(room, user, otherUserCanPostReadonly); - - const endpointPrefix = room.t === 'p' ? '/v1/groups' : '/v1/channels'; - - const roomDirectives = room && room.t && roomCoordinator.getRoomDirectives(room.t); - - const [roomCanSetOwner, roomCanSetLeader, roomCanSetModerator, roomCanIgnore, roomCanBlock, roomCanMute, roomCanRemove] = [ - ...(roomDirectives && [ - roomDirectives.allowMemberAction(room, RoomMemberActions.SET_AS_OWNER), - roomDirectives.allowMemberAction(room, RoomMemberActions.SET_AS_LEADER), - roomDirectives.allowMemberAction(room, RoomMemberActions.SET_AS_MODERATOR), - roomDirectives.allowMemberAction(room, RoomMemberActions.IGNORE), - roomDirectives.allowMemberAction(room, RoomMemberActions.BLOCK), - roomDirectives.allowMemberAction(room, RoomMemberActions.MUTE), - roomDirectives.allowMemberAction(room, RoomMemberActions.REMOVE_USER), - ]), - ]; - - const roomName = room && room.t && escapeHTML(roomCoordinator.getRoomName(room.t, room)); - - const userCanSetOwner = usePermission('set-owner', rid); - const userCanSetLeader = usePermission('set-leader', rid); - const userCanSetModerator = usePermission('set-moderator', rid); - const userCanMute = usePermission('mute-user', rid); - const userCanRemove = usePermission('remove-user', rid); - const userCanDirectMessage = usePermission('create-d'); - const { shouldAllowCalls, callInProgress, joinCall, startCall } = useWebRTC(rid); - - const shouldOpenDirectMessage = getShouldOpenDirectMessage( - currentSubscription, - usernameSubscription, - userCanDirectMessage, - user.username, - ); - - const openDirectDm = useMutableCallback(() => - directRoute.push({ - rid: user.username, - }), - ); - - const openDirectMessageOption = useMemo( - () => - shouldOpenDirectMessage && { - label: t('Direct_Message'), - icon: 'balloon', - action: openDirectDm, - }, - [openDirectDm, shouldOpenDirectMessage, t], - ); - - const videoCallOption = useMemo(() => { - const handleJoinCall = () => { - joinCall({ audio: true, video: true }); - }; - const handleStartCall = () => { - startCall({ audio: true, video: true }); - }; - const action = callInProgress ? handleJoinCall : handleStartCall; - - return ( - shouldAllowCalls && { - label: t(callInProgress ? 'Join_video_call' : 'Start_video_call'), - icon: 'video', - action, - } - ); - }, [callInProgress, shouldAllowCalls, t, joinCall, startCall]); - - const audioCallOption = useMemo(() => { - const handleJoinCall = () => { - joinCall({ audio: true, video: false }); - }; - const handleStartCall = () => { - startCall({ audio: true, video: false }); - }; - const action = callInProgress ? handleJoinCall : handleStartCall; - - return ( - shouldAllowCalls && { - label: t(callInProgress ? 'Join_audio_call' : 'Start_audio_call'), - icon: 'mic', - action, - } - ); - }, [callInProgress, shouldAllowCalls, t, joinCall, startCall]); - - const changeOwnerEndpoint = isOwner ? 'removeOwner' : 'addOwner'; - const changeOwnerMessage = isOwner ? 'User__username__removed_from__room_name__owners' : 'User__username__is_now_a_owner_of__room_name_'; - const changeOwner = useEndpointActionExperimental( - 'POST', - `${endpointPrefix}.${changeOwnerEndpoint}`, - t(changeOwnerMessage, { username: user.username, room_name: roomName }), - ); - const changeOwnerAction = useMutableCallback(async () => changeOwner({ roomId: rid, userId: uid })); - const changeOwnerOption = useMemo( - () => - roomCanSetOwner && - userCanSetOwner && { - label: t(isOwner ? 'Remove_as_owner' : 'Set_as_owner'), - icon: 'shield-check', - action: changeOwnerAction, - }, - [changeOwnerAction, isOwner, t, roomCanSetOwner, userCanSetOwner], - ); - - const changeLeaderEndpoint = isLeader ? 'removeLeader' : 'addLeader'; - const changeLeaderMessage = isLeader - ? 'User__username__removed_from__room_name__leaders' - : 'User__username__is_now_a_leader_of__room_name_'; - const changeLeader = useEndpointActionExperimental( - 'POST', - `${endpointPrefix}.${changeLeaderEndpoint}`, - t(changeLeaderMessage, { username: user.username, room_name: roomName }), - ); - const changeLeaderAction = useMutableCallback(() => changeLeader({ roomId: rid, userId: uid })); - const changeLeaderOption = useMemo( - () => - roomCanSetLeader && - userCanSetLeader && { - label: t(isLeader ? 'Remove_as_leader' : 'Set_as_leader'), - icon: 'shield-alt', - action: changeLeaderAction, - }, - [isLeader, roomCanSetLeader, t, userCanSetLeader, changeLeaderAction], - ); - - const changeModeratorEndpoint = isModerator ? 'removeModerator' : 'addModerator'; - const changeModeratorMessage = isModerator - ? 'User__username__removed_from__room_name__moderators' - : 'User__username__is_now_a_moderator_of__room_name_'; - const changeModerator = useEndpointActionExperimental( - 'POST', - `${endpointPrefix}.${changeModeratorEndpoint}`, - t(changeModeratorMessage, { username: user.username, room_name: roomName }), - ); - const changeModeratorAction = useMutableCallback(() => changeModerator({ roomId: rid, userId: uid })); - const changeModeratorOption = useMemo( - () => - roomCanSetModerator && - userCanSetModerator && { - label: t(isModerator ? 'Remove_as_moderator' : 'Set_as_moderator'), - icon: 'shield', - action: changeModeratorAction, - }, - [changeModeratorAction, isModerator, roomCanSetModerator, t, userCanSetModerator], - ); - - const ignoreUser = useMethod('ignoreUser'); - const ignoreUserAction = useMutableCallback(async () => { - try { - await ignoreUser({ rid, userId: uid, ignore: !isIgnored }); - if (isIgnored) { - dispatchToastMessage({ type: 'success', message: t('User_has_been_unignored') }); - } else { - dispatchToastMessage({ type: 'success', message: t('User_has_been_ignored') }); - } - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } - }); - const ignoreUserOption = useMemo( - () => - roomCanIgnore && - uid !== ownUserId && { - label: t(isIgnored ? 'Unignore' : 'Ignore'), - icon: 'ban', - action: ignoreUserAction, - }, - [ignoreUserAction, isIgnored, ownUserId, roomCanIgnore, t, uid], - ); - - const isUserBlocked = currentSubscription && currentSubscription.blocker; - const toggleBlock = useMethod(isUserBlocked ? 'unblockUser' : 'blockUser'); - const toggleBlockUserAction = useMutableCallback(async () => { - try { - await toggleBlock({ rid, blocked: uid }); - dispatchToastMessage({ - type: 'success', - message: t(isUserBlocked ? 'User_is_unblocked' : 'User_is_blocked'), - }); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } - }); - const toggleBlockUserOption = useMemo( - () => - roomCanBlock && - uid !== ownUserId && { - label: t(isUserBlocked ? 'Unblock' : 'Block'), - icon: 'ban', - action: toggleBlockUserAction, - }, - [isUserBlocked, ownUserId, roomCanBlock, t, toggleBlockUserAction, uid], - ); - - const muteFn = useMethod(isMuted ? 'unmuteUserInRoom' : 'muteUserInRoom'); - const muteUserOption = useMemo(() => { - const action = () => { - const onConfirm = async () => { - try { - await muteFn({ rid, username: user.username }); - closeModal(); - dispatchToastMessage({ - type: 'success', - message: t(isMuted ? 'User__username__unmuted_in_room__roomName__' : 'User__username__muted_in_room__roomName__', { - username: user.username, - roomName, - }), - }); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } - }; - - if (isMuted) { - return onConfirm(); - } - - setModal( - <WarningModal - text={t('The_user_wont_be_able_to_type_in_s', roomName)} - close={closeModal} - confirmText={t('Yes_mute_user')} - confirm={onConfirm} - />, - ); - }; - - return ( - roomCanMute && - userCanMute && { - label: t(isMuted ? 'Unmute_user' : 'Mute_user'), - icon: isMuted ? 'mic' : 'mic-off', - action, - } - ); - }, [closeModal, dispatchToastMessage, isMuted, muteFn, rid, roomCanMute, roomName, setModal, t, user.username, userCanMute]); - - const removeFromTeam = useEndpointActionExperimental('POST', 'teams.removeMember', t('User_has_been_removed_from_team')); - - const removeUserAction = useEndpointActionExperimental('POST', `${endpointPrefix}.kick`, t('User_has_been_removed_from_s', roomName)); - const removeUserOptionAction = useMutableCallback(() => { - if (room.teamMain && room.teamId) { - return setModal( - <RemoveUsersModal - teamId={room?.teamId} - userId={uid} - onClose={closeModal} - onCancel={closeModal} - onConfirm={async (rooms) => { - const roomKeys = Object.keys(rooms); - await removeFromTeam({ - teamId: room.teamId, - userId: uid, - ...(roomKeys.length && { rooms: roomKeys }), - }); - closeModal(); - reload && reload(); - }} - />, - ); - } - - setModal( - <WarningModal - text={t('The_user_will_be_removed_from_s', roomName)} - close={closeModal} - confirmText={t('Yes_remove_user')} - confirm={async () => { - await removeUserAction({ roomId: rid, userId: uid }); - closeModal(); - reload && reload(); - }} - />, - ); - }); - - const removeUserOption = useMemo( - () => - roomCanRemove && - userCanRemove && { - label: <Box color='danger'>{room.teamMain ? t('Remove_from_team') : t('Remove_from_room')}</Box>, - icon: 'sign-out', - action: removeUserOptionAction, - }, - [room, roomCanRemove, userCanRemove, removeUserOptionAction, t], - ); - - return useMemo( - () => ({ - ...(openDirectMessageOption && { openDirectMessage: openDirectMessageOption }), - ...(videoCallOption && { video: videoCallOption }), - ...(audioCallOption && { audio: audioCallOption }), - ...(changeOwnerOption && { changeOwner: changeOwnerOption }), - ...(changeLeaderOption && { changeLeader: changeLeaderOption }), - ...(changeModeratorOption && { changeModerator: changeModeratorOption }), - ...(ignoreUserOption && { ignoreUser: ignoreUserOption }), - ...(muteUserOption && { muteUser: muteUserOption }), - ...(removeUserOption && { removeUser: removeUserOption }), - ...(toggleBlockUserOption && { toggleBlock: toggleBlockUserOption }), - }), - [ - audioCallOption, - changeLeaderOption, - changeModeratorOption, - changeOwnerOption, - ignoreUserOption, - muteUserOption, - openDirectMessageOption, - removeUserOption, - videoCallOption, - toggleBlockUserOption, - ], - ); -}; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useAudioCallAction.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useAudioCallAction.ts new file mode 100644 index 00000000000..db92ce2f689 --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useAudioCallAction.ts @@ -0,0 +1,33 @@ +import { IRoom } from '@rocket.chat/core-typings'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; + +import { Action } from '../../../../hooks/useActionSpread'; +import { useWebRTC } from '../../useWebRTC'; + +export const useAudioCallAction = (rid: IRoom['_id']): Action | undefined => { + const t = useTranslation(); + const { shouldAllowCalls, callInProgress, joinCall, startCall } = useWebRTC(rid); + + const audioCallOption = useMemo(() => { + const handleJoinCall = (): void => { + joinCall({ audio: true, video: false }); + }; + + const handleStartCall = (): void => { + startCall({ audio: true, video: false }); + }; + + const action = callInProgress ? handleJoinCall : handleStartCall; + + return shouldAllowCalls + ? { + label: t(callInProgress ? 'Join_audio_call' : 'Start_audio_call'), + icon: 'mic', + action, + } + : undefined; + }, [callInProgress, shouldAllowCalls, t, joinCall, startCall]); + + return audioCallOption; +}; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useBlockUserAction.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useBlockUserAction.ts new file mode 100644 index 00000000000..0a0574d6b76 --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useBlockUserAction.ts @@ -0,0 +1,51 @@ +import { IRoom, IUser } from '@rocket.chat/core-typings'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { useTranslation, useMethod, useToastMessageDispatch, useUserId, useUserSubscription, useUserRoom } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; + +import { Action } from '../../../../hooks/useActionSpread'; +import { getRoomDirectives } from '../../../lib/getRoomDirectives'; + +export const useBlockUserAction = (user: Pick<IUser, '_id' | 'username'>, rid: IRoom['_id']): Action | undefined => { + const t = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + const currentSubscription = useUserSubscription(rid); + const ownUserId = useUserId(); + const { _id: uid } = user; + const room = useUserRoom(rid); + + if (!room) { + throw Error('Room not provided'); + } + + const { roomCanBlock } = getRoomDirectives(room); + + const isUserBlocked = currentSubscription?.blocker; + const toggleBlock = useMethod(isUserBlocked ? 'unblockUser' : 'blockUser'); + + const toggleBlockUserAction = useMutableCallback(async () => { + try { + await toggleBlock({ rid, blocked: uid }); + dispatchToastMessage({ + type: 'success', + message: t(isUserBlocked ? 'User_is_unblocked' : 'User_is_blocked'), + }); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }); + + const toggleBlockUserOption = useMemo( + () => + roomCanBlock && uid !== ownUserId + ? { + label: t(isUserBlocked ? 'Unblock' : 'Block'), + icon: 'ban', + action: toggleBlockUserAction, + } + : undefined, + [isUserBlocked, ownUserId, roomCanBlock, t, toggleBlockUserAction, uid], + ); + + return toggleBlockUserOption; +}; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeLeaderAction.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeLeaderAction.ts new file mode 100644 index 00000000000..dddfc28397e --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeLeaderAction.ts @@ -0,0 +1,53 @@ +import { IRoom, IUser } from '@rocket.chat/core-typings'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { escapeHTML } from '@rocket.chat/string-helpers'; +import { useTranslation, usePermission, useUserRoom } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; + +import { useEndpointActionExperimental } from '../../../../../hooks/useEndpointActionExperimental'; +import { roomCoordinator } from '../../../../../lib/rooms/roomCoordinator'; +import { Action } from '../../../../hooks/useActionSpread'; +import { getRoomDirectives } from '../../../lib/getRoomDirectives'; +import { useUserHasRoomRole } from '../../useUserHasRoomRole'; + +// TODO: Remove endpoint concatenation +export const useChangeLeaderAction = (user: Pick<IUser, '_id' | 'username'>, rid: IRoom['_id']): Action | undefined => { + const t = useTranslation(); + const room = useUserRoom(rid); + const { _id: uid } = user; + const userCanSetLeader = usePermission('set-leader', rid); + + if (!room) { + throw Error('Room not provided'); + } + + const endpointPrefix = room.t === 'p' ? '/v1/groups' : '/v1/channels'; + const { roomCanSetLeader } = getRoomDirectives(room); + const isLeader = useUserHasRoomRole(uid, rid, 'leader'); + const roomName = room?.t && escapeHTML(roomCoordinator.getRoomName(room.t, room)); + + const changeLeaderEndpoint = isLeader ? 'removeLeader' : 'addLeader'; + const changeLeaderMessage = isLeader + ? 'User__username__removed_from__room_name__leaders' + : 'User__username__is_now_a_leader_of__room_name_'; + const changeLeader = useEndpointActionExperimental( + 'POST', + `${endpointPrefix}.${changeLeaderEndpoint}`, + // eslint-disable-next-line @typescript-eslint/camelcase + t(changeLeaderMessage, { username: user.username, room_name: roomName }), + ); + const changeLeaderAction = useMutableCallback(() => changeLeader({ roomId: rid, userId: uid })); + const changeLeaderOption = useMemo( + () => + roomCanSetLeader && userCanSetLeader + ? { + label: t(isLeader ? 'Remove_as_leader' : 'Set_as_leader'), + icon: 'shield-alt', + action: changeLeaderAction, + } + : undefined, + [isLeader, roomCanSetLeader, t, userCanSetLeader, changeLeaderAction], + ); + + return changeLeaderOption; +}; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeModeratorAction.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeModeratorAction.ts new file mode 100644 index 00000000000..0b32928789a --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeModeratorAction.ts @@ -0,0 +1,54 @@ +import { IRoom, IUser } from '@rocket.chat/core-typings'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { escapeHTML } from '@rocket.chat/string-helpers'; +import { useTranslation, usePermission, useUserRoom } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; + +import { useEndpointActionExperimental } from '../../../../../hooks/useEndpointActionExperimental'; +import { roomCoordinator } from '../../../../../lib/rooms/roomCoordinator'; +import { Action } from '../../../../hooks/useActionSpread'; +import { getRoomDirectives } from '../../../lib/getRoomDirectives'; +import { useUserHasRoomRole } from '../../useUserHasRoomRole'; + +// TODO: Remove endpoint concatenation +export const useChangeModeratorAction = (user: Pick<IUser, '_id' | 'username'>, rid: IRoom['_id']): Action | undefined => { + const t = useTranslation(); + const room = useUserRoom(rid); + const { _id: uid } = user; + + const userCanSetModerator = usePermission('set-moderator', rid); + const isModerator = useUserHasRoomRole(uid, rid, 'moderator'); + + if (!room) { + throw Error('Room not provided'); + } + + const endpointPrefix = room.t === 'p' ? '/v1/groups' : '/v1/channels'; + const { roomCanSetModerator } = getRoomDirectives(room); + const roomName = room?.t && escapeHTML(roomCoordinator.getRoomName(room.t, room)); + + const changeModeratorEndpoint = isModerator ? 'removeModerator' : 'addModerator'; + const changeModeratorMessage = isModerator + ? 'User__username__removed_from__room_name__moderators' + : 'User__username__is_now_a_moderator_of__room_name_'; + const changeModerator = useEndpointActionExperimental( + 'POST', + `${endpointPrefix}.${changeModeratorEndpoint}`, + // eslint-disable-next-line @typescript-eslint/camelcase + t(changeModeratorMessage, { username: user.username, room_name: roomName }), + ); + const changeModeratorAction = useMutableCallback(() => changeModerator({ roomId: rid, userId: uid })); + const changeModeratorOption = useMemo( + () => + roomCanSetModerator && userCanSetModerator + ? { + label: t(isModerator ? 'Remove_as_moderator' : 'Set_as_moderator'), + icon: 'shield-blank', + action: changeModeratorAction, + } + : undefined, + [changeModeratorAction, isModerator, roomCanSetModerator, t, userCanSetModerator], + ); + + return changeModeratorOption; +}; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeOwnerAction.tsx b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeOwnerAction.tsx new file mode 100644 index 00000000000..4e027c0c150 --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeOwnerAction.tsx @@ -0,0 +1,53 @@ +import { IRoom, IUser } from '@rocket.chat/core-typings'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { escapeHTML } from '@rocket.chat/string-helpers'; +import { useTranslation, usePermission, useUserRoom } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; + +import { useEndpointActionExperimental } from '../../../../../hooks/useEndpointActionExperimental'; +import { roomCoordinator } from '../../../../../lib/rooms/roomCoordinator'; +import { Action } from '../../../../hooks/useActionSpread'; +import { getRoomDirectives } from '../../../lib/getRoomDirectives'; +import { useUserHasRoomRole } from '../../useUserHasRoomRole'; + +// TODO: Remove endpoint concatenation +export const useChangeOwnerAction = (user: Pick<IUser, '_id' | 'username'>, rid: IRoom['_id']): Action | undefined => { + const t = useTranslation(); + const room = useUserRoom(rid); + const { _id: uid } = user; + const userCanSetOwner = usePermission('set-owner', rid); + const isOwner = useUserHasRoomRole(uid, rid, 'owner'); + + if (!room) { + throw Error('Room not provided'); + } + + const endpointPrefix = room.t === 'p' ? '/v1/groups' : '/v1/channels'; + const { roomCanSetOwner } = getRoomDirectives(room); + const roomName = room?.t && escapeHTML(roomCoordinator.getRoomName(room.t, room)); + + const changeOwnerEndpoint = isOwner ? 'removeOwner' : 'addOwner'; + const changeOwnerMessage = isOwner ? 'User__username__removed_from__room_name__owners' : 'User__username__is_now_a_owner_of__room_name_'; + + const changeOwner = useEndpointActionExperimental( + 'POST', + `${endpointPrefix}.${changeOwnerEndpoint}`, + // eslint-disable-next-line @typescript-eslint/camelcase + t(changeOwnerMessage, { username: user.username, room_name: roomName }), + ); + + const changeOwnerAction = useMutableCallback(async () => changeOwner({ roomId: rid, userId: uid })); + const changeOwnerOption = useMemo( + () => + roomCanSetOwner && userCanSetOwner + ? { + label: t(isOwner ? 'Remove_as_owner' : 'Set_as_owner'), + icon: 'shield-check', + action: changeOwnerAction, + } + : undefined, + [changeOwnerAction, roomCanSetOwner, userCanSetOwner, isOwner, t], + ); + + return changeOwnerOption; +}; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useDirectMessageAction.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useDirectMessageAction.ts new file mode 100644 index 00000000000..07f07a78d32 --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useDirectMessageAction.ts @@ -0,0 +1,54 @@ +import { IRoom, IUser, ISubscription } from '@rocket.chat/core-typings'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { useTranslation, usePermission, useRoute, useUserSubscription, useUserSubscriptionByName } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; + +import { Action } from '../../../../hooks/useActionSpread'; + +const getShouldOpenDirectMessage = ( + currentSubscription?: ISubscription, + usernameSubscription?: ISubscription, + canOpenDirectMessage?: boolean, + username?: IUser['username'], +): boolean => { + const canOpenDm = canOpenDirectMessage || usernameSubscription; + const directMessageIsNotAlreadyOpen = currentSubscription && currentSubscription.name !== username; + return (canOpenDm && directMessageIsNotAlreadyOpen) ?? false; +}; + +export const useDirectMessageAction = (user: Pick<IUser, '_id' | 'username'>, rid: IRoom['_id']): Action | undefined => { + const t = useTranslation(); + const usernameSubscription = useUserSubscriptionByName(user.username ?? ''); + const currentSubscription = useUserSubscription(rid); + const canOpenDirectMessage = usePermission('create-d'); + const directRoute = useRoute('direct'); + + const shouldOpenDirectMessage = getShouldOpenDirectMessage( + currentSubscription, + usernameSubscription, + canOpenDirectMessage, + user.username, + ); + + const openDirectMessage = useMutableCallback( + () => + user.username && + directRoute.push({ + rid: user.username, + }), + ); + + const openDirectMessageOption = useMemo( + () => + shouldOpenDirectMessage + ? { + label: t('Direct_Message'), + icon: 'balloon', + action: openDirectMessage, + } + : undefined, + [openDirectMessage, shouldOpenDirectMessage, t], + ); + + return openDirectMessageOption; +}; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useIgnoreUserAction.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useIgnoreUserAction.ts new file mode 100644 index 00000000000..8147c461ef8 --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useIgnoreUserAction.ts @@ -0,0 +1,52 @@ +import { IRoom, IUser } from '@rocket.chat/core-typings'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { useTranslation, useMethod, useUserSubscription, useUserRoom, useUserId, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; + +import { Action } from '../../../../hooks/useActionSpread'; +import { getRoomDirectives } from '../../../lib/getRoomDirectives'; + +export const useIgnoreUserAction = (user: Pick<IUser, '_id' | 'username'>, rid: IRoom['_id']): Action | undefined => { + const t = useTranslation(); + const room = useUserRoom(rid); + const { _id: uid } = user; + const ownUserId = useUserId(); + const dispatchToastMessage = useToastMessageDispatch(); + const currentSubscription = useUserSubscription(rid); + const ignoreUser = useMethod('ignoreUser'); + + const isIgnored = currentSubscription?.ignored && currentSubscription.ignored.indexOf(uid) > -1; + + if (!room) { + throw Error('Room not provided'); + } + + const { roomCanIgnore } = getRoomDirectives(room); + + const ignoreUserAction = useMutableCallback(async () => { + try { + await ignoreUser({ rid, userId: uid, ignore: !isIgnored }); + if (isIgnored) { + dispatchToastMessage({ type: 'success', message: t('User_has_been_unignored') }); + } else { + dispatchToastMessage({ type: 'success', message: t('User_has_been_ignored') }); + } + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }); + + const ignoreUserOption = useMemo( + () => + roomCanIgnore && uid !== ownUserId + ? { + label: t(isIgnored ? 'Unignore' : 'Ignore'), + icon: 'ban', + action: ignoreUserAction, + } + : undefined, + [ignoreUserAction, isIgnored, ownUserId, roomCanIgnore, t, uid], + ); + + return ignoreUserOption; +}; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useMuteUserAction.tsx b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useMuteUserAction.tsx new file mode 100644 index 00000000000..b3fc6fe926b --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useMuteUserAction.tsx @@ -0,0 +1,119 @@ +import { IRoom, IUser } from '@rocket.chat/core-typings'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { escapeHTML } from '@rocket.chat/string-helpers'; +import { + useAllPermissions, + usePermission, + useSetModal, + useMethod, + useToastMessageDispatch, + useTranslation, + useUserRoom, +} from '@rocket.chat/ui-contexts'; +import React, { useMemo } from 'react'; + +import GenericModal from '../../../../../components/GenericModal'; +import { roomCoordinator } from '../../../../../lib/rooms/roomCoordinator'; +import { Action } from '../../../../hooks/useActionSpread'; +import { getRoomDirectives } from '../../../lib/getRoomDirectives'; + +const getUserIsMuted = ( + user: Pick<IUser, '_id' | 'username'>, + room: IRoom | undefined, + userCanPostReadonly: boolean, +): boolean | undefined => { + if (room?.ro) { + if (Array.isArray(room.unmuted) && room.unmuted.indexOf(user.username ?? '') !== -1) { + return false; + } + + if (userCanPostReadonly) { + return Array.isArray(room.muted) && room.muted.indexOf(user.username ?? '') !== -1; + } + + return true; + } + + return room && Array.isArray(room.muted) && room.muted.indexOf(user.username ?? '') > -1; +}; + +export const useMuteUserAction = (user: Pick<IUser, '_id' | 'username'>, rid: IRoom['_id']): Action | undefined => { + const t = useTranslation(); + const room = useUserRoom(rid); + const userCanMute = usePermission('mute-user', rid); + const dispatchToastMessage = useToastMessageDispatch(); + const setModal = useSetModal(); + const closeModal = useMutableCallback(() => setModal(null)); + const otherUserCanPostReadonly = useAllPermissions( + useMemo(() => ['post-readonly'], []), + rid, + ); + + const isMuted = getUserIsMuted(user, room, otherUserCanPostReadonly); + const roomName = room?.t && escapeHTML(roomCoordinator.getRoomName(room.t, room)); + + if (!room) { + throw Error('Room not provided'); + } + + const { roomCanMute } = getRoomDirectives(room); + + const mutedMessage = isMuted ? 'User__username__unmuted_in_room__roomName__' : 'User__username__muted_in_room__roomName__'; + + const muteUser = useMethod(isMuted ? 'unmuteUserInRoom' : 'muteUserInRoom'); + + const muteUserOption = useMemo(() => { + const action = (): Promise<void> | void => { + const onConfirm = async (): Promise<void> => { + try { + await muteUser({ rid, username: user.username }); + + return dispatchToastMessage({ + type: 'success', + message: t(mutedMessage, { + username: user.username, + roomName, + }), + }); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } finally { + closeModal(); + } + }; + + if (isMuted) { + return onConfirm(); + } + + return setModal( + <GenericModal variant='danger' confirmText={t('Yes_mute_user')} onClose={closeModal} onCancel={closeModal} onConfirm={onConfirm}> + {t('The_user_wont_be_able_to_type_in_s', roomName)} + </GenericModal>, + ); + }; + + return roomCanMute && userCanMute + ? { + label: t(isMuted ? 'Unmute_user' : 'Mute_user'), + icon: isMuted ? 'mic' : 'mic-off', + action, + } + : undefined; + }, [ + closeModal, + mutedMessage, + dispatchToastMessage, + isMuted, + muteUser, + rid, + roomCanMute, + roomName, + setModal, + t, + user.username, + userCanMute, + ]); + + return muteUserOption; +}; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useRemoveUserAction.tsx b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useRemoveUserAction.tsx new file mode 100644 index 00000000000..6bc41e267db --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useRemoveUserAction.tsx @@ -0,0 +1,92 @@ +import { IRoom, IUser } from '@rocket.chat/core-typings'; +import { Box, Icon } from '@rocket.chat/fuselage'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { escapeHTML } from '@rocket.chat/string-helpers'; +import { usePermission, useSetModal, useTranslation, useUserRoom } from '@rocket.chat/ui-contexts'; +import React, { useMemo } from 'react'; + +import GenericModal from '../../../../../components/GenericModal'; +import { useEndpointActionExperimental } from '../../../../../hooks/useEndpointActionExperimental'; +import { roomCoordinator } from '../../../../../lib/rooms/roomCoordinator'; +import { Action } from '../../../../hooks/useActionSpread'; +import RemoveUsersModal from '../../../../teams/contextualBar/members/RemoveUsersModal'; +import { getRoomDirectives } from '../../../lib/getRoomDirectives'; + +// TODO: Remove endpoint concatenation +export const useRemoveUserAction = (user: Pick<IUser, '_id' | 'username'>, rid: IRoom['_id'], reload?: () => void): Action | undefined => { + const t = useTranslation(); + const room = useUserRoom(rid); + const { _id: uid } = user; + + const userCanRemove = usePermission('remove-user', rid); + const setModal = useSetModal(); + const closeModal = useMutableCallback(() => setModal(null)); + const roomName = room?.t && escapeHTML(roomCoordinator.getRoomName(room.t, room)); + + if (!room) { + throw Error('Room not provided'); + } + + const endpointPrefix = room.t === 'p' ? '/v1/groups' : '/v1/channels'; + const { roomCanRemove } = getRoomDirectives(room); + + const removeFromTeam = useEndpointActionExperimental('POST', '/v1/teams.removeMember', t('User_has_been_removed_from_team')); + const removeFromRoom = useEndpointActionExperimental('POST', `${endpointPrefix}.kick`, t('User_has_been_removed_from_s', roomName)); + + const removeUserOptionAction = useMutableCallback(() => { + const handleRemoveFromTeam = async (rooms: IRoom[]): Promise<void> => { + if (room.teamId) { + const roomKeys = Object.keys(rooms); + await removeFromTeam({ + teamId: room.teamId, + userId: uid, + ...(roomKeys.length && { rooms: roomKeys }), + }); + closeModal(); + reload?.(); + } + }; + + const handleRemoveFromRoom = async (rid: IRoom['_id'], uid: IUser['_id']): Promise<void> => { + await removeFromRoom({ roomId: rid, userId: uid }); + closeModal(); + reload?.(); + }; + + if (room.teamMain && room.teamId) { + return setModal( + <RemoveUsersModal teamId={room?.teamId} userId={uid} onClose={closeModal} onCancel={closeModal} onConfirm={handleRemoveFromTeam} />, + ); + } + + setModal( + <GenericModal + variant='danger' + confirmText={t('Yes_remove_user')} + onClose={closeModal} + onCancel={closeModal} + onConfirm={(): Promise<void> => handleRemoveFromRoom(rid, uid)} + > + {t('The_user_will_be_removed_from_s', roomName)} + </GenericModal>, + ); + }); + + const removeUserOption = useMemo( + () => + roomCanRemove && userCanRemove + ? { + label: ( + <Box color='danger'> + <Icon mie='x4' name='cross' size='x20' /> + {room?.teamMain ? t('Remove_from_team') : t('Remove_from_room')} + </Box> + ), + action: removeUserOptionAction, + } + : undefined, + [room, roomCanRemove, userCanRemove, removeUserOptionAction, t], + ); + + return removeUserOption; +}; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useVideoCallAction.tsx b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useVideoCallAction.tsx new file mode 100644 index 00000000000..6a3fafcd8db --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useVideoCallAction.tsx @@ -0,0 +1,33 @@ +import { IRoom } from '@rocket.chat/core-typings'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; + +import { Action } from '../../../../hooks/useActionSpread'; +import { useWebRTC } from '../../useWebRTC'; + +export const useVideoCallAction = (rid: IRoom['_id']): Action | undefined => { + const t = useTranslation(); + const { shouldAllowCalls, callInProgress, joinCall, startCall } = useWebRTC(rid); + + const videoCallOption = useMemo(() => { + const handleJoinCall = (): void => { + joinCall({ audio: true, video: true }); + }; + + const handleStartCall = (): void => { + startCall({ audio: true, video: true }); + }; + + const action = callInProgress ? handleJoinCall : handleStartCall; + + return shouldAllowCalls + ? { + label: t(callInProgress ? 'Join_video_call' : 'Start_video_call'), + icon: 'video', + action, + } + : undefined; + }, [callInProgress, shouldAllowCalls, t, joinCall, startCall]); + + return videoCallOption; +}; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/index.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/index.ts new file mode 100644 index 00000000000..44ce5ef6c2d --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/index.ts @@ -0,0 +1 @@ +export { useUserInfoActions } from './useUserInfoActions'; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts new file mode 100644 index 00000000000..f54586c0a18 --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts @@ -0,0 +1,60 @@ +import { IRoom, IUser } from '@rocket.chat/core-typings'; +import { useMemo } from 'react'; + +import { Action } from '../../../hooks/useActionSpread'; +import { useAudioCallAction } from './actions/useAudioCallAction'; +import { useBlockUserAction } from './actions/useBlockUserAction'; +import { useChangeLeaderAction } from './actions/useChangeLeaderAction'; +import { useChangeModeratorAction } from './actions/useChangeModeratorAction'; +import { useChangeOwnerAction } from './actions/useChangeOwnerAction'; +import { useDirectMessageAction } from './actions/useDirectMessageAction'; +import { useIgnoreUserAction } from './actions/useIgnoreUserAction'; +import { useMuteUserAction } from './actions/useMuteUserAction'; +import { useRemoveUserAction } from './actions/useRemoveUserAction'; +import { useVideoCallAction } from './actions/useVideoCallAction'; + +export const useUserInfoActions = ( + user: Pick<IUser, '_id' | 'username'>, + rid: IRoom['_id'], + reload?: () => void, +): { + [key: string]: Action; +} => { + const audioCallOption = useAudioCallAction(rid); + const blockUserOption = useBlockUserAction(user, rid); + const changeLeaderOption = useChangeLeaderAction(user, rid); + const changeModeratorOption = useChangeModeratorAction(user, rid); + const changeOwnerOption = useChangeOwnerAction(user, rid); + const openDirectMessageOption = useDirectMessageAction(user, rid); + const ignoreUserOption = useIgnoreUserAction(user, rid); + const muteUserOption = useMuteUserAction(user, rid); + const removeUserOption = useRemoveUserAction(user, rid, reload); + const videoCallOption = useVideoCallAction(rid); + + return useMemo( + () => ({ + ...(openDirectMessageOption && { openDirectMessage: openDirectMessageOption }), + ...(videoCallOption && { video: videoCallOption }), + ...(audioCallOption && { audio: audioCallOption }), + ...(changeOwnerOption && { changeOwner: changeOwnerOption }), + ...(changeLeaderOption && { changeLeader: changeLeaderOption }), + ...(changeModeratorOption && { changeModerator: changeModeratorOption }), + ...(ignoreUserOption && { ignoreUser: ignoreUserOption }), + ...(muteUserOption && { muteUser: muteUserOption }), + ...(blockUserOption && { toggleBlock: blockUserOption }), + ...(removeUserOption && { removeUser: removeUserOption }), + }), + [ + audioCallOption, + changeLeaderOption, + changeModeratorOption, + changeOwnerOption, + ignoreUserOption, + muteUserOption, + openDirectMessageOption, + removeUserOption, + videoCallOption, + blockUserOption, + ], + ); +}; diff --git a/apps/meteor/client/views/room/lib/getRoomDirectives.ts b/apps/meteor/client/views/room/lib/getRoomDirectives.ts new file mode 100644 index 00000000000..2f5602f02d2 --- /dev/null +++ b/apps/meteor/client/views/room/lib/getRoomDirectives.ts @@ -0,0 +1,33 @@ +import { IRoom } from '@rocket.chat/core-typings'; + +import { RoomMemberActions } from '../../../../definition/IRoomTypeConfig'; +import { roomCoordinator } from '../../../lib/rooms/roomCoordinator'; + +type getRoomDirectiesType = { + roomCanSetOwner: boolean; + roomCanSetLeader: boolean; + roomCanSetModerator: boolean; + roomCanIgnore: boolean; + roomCanBlock: boolean; + roomCanMute: boolean; + roomCanRemove: boolean; +}; + +export const getRoomDirectives = (room: IRoom): getRoomDirectiesType => { + const roomDirectives = room?.t && roomCoordinator.getRoomDirectives(room.t); + + const [roomCanSetOwner, roomCanSetLeader, roomCanSetModerator, roomCanIgnore, roomCanBlock, roomCanMute, roomCanRemove] = [ + ...((roomDirectives && [ + roomDirectives.allowMemberAction(room, RoomMemberActions.SET_AS_OWNER), + roomDirectives.allowMemberAction(room, RoomMemberActions.SET_AS_LEADER), + roomDirectives.allowMemberAction(room, RoomMemberActions.SET_AS_MODERATOR), + roomDirectives.allowMemberAction(room, RoomMemberActions.IGNORE), + roomDirectives.allowMemberAction(room, RoomMemberActions.BLOCK), + roomDirectives.allowMemberAction(room, RoomMemberActions.MUTE), + roomDirectives.allowMemberAction(room, RoomMemberActions.REMOVE_USER), + ]) ?? + []), + ]; + + return { roomCanSetOwner, roomCanSetLeader, roomCanSetModerator, roomCanIgnore, roomCanBlock, roomCanMute, roomCanRemove }; +}; diff --git a/apps/meteor/client/views/root/AppLayout.tsx b/apps/meteor/client/views/root/AppLayout.tsx index f883e61f1da..137b542fcdb 100644 --- a/apps/meteor/client/views/root/AppLayout.tsx +++ b/apps/meteor/client/views/root/AppLayout.tsx @@ -1,5 +1,5 @@ import React, { FC, Fragment, Suspense } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { appLayout } from '../../lib/appLayout'; import { blazePortals } from '../../lib/portals/blazePortals'; @@ -9,8 +9,8 @@ import { useTooltipHandling } from './useTooltipHandling'; const AppLayout: FC = () => { useTooltipHandling(); - const layout = useSubscription(appLayout); - const portals = useSubscription(blazePortals); + const layout = useSyncExternalStore(appLayout.subscribe, appLayout.getSnapshot); + const portals = useSyncExternalStore(blazePortals.subscribe, blazePortals.getSnapshot); return ( <> diff --git a/apps/meteor/client/views/root/PortalsWrapper.tsx b/apps/meteor/client/views/root/PortalsWrapper.tsx index 99c51a455a1..cc31f762e30 100644 --- a/apps/meteor/client/views/root/PortalsWrapper.tsx +++ b/apps/meteor/client/views/root/PortalsWrapper.tsx @@ -1,11 +1,11 @@ import React, { FC } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { portalsSubscription } from '../../lib/portals/portalsSubscription'; import PortalWrapper from './PortalWrapper'; const PortalsWrapper: FC = () => { - const portals = useSubscription(portalsSubscription); + const portals = useSyncExternalStore(portalsSubscription.subscribe, portalsSubscription.getSnapshot); return ( <> diff --git a/apps/meteor/client/views/teams/CreateTeamModal/CreateTeamModal.tsx b/apps/meteor/client/views/teams/CreateTeamModal/CreateTeamModal.tsx index c035fa4a713..d131b403d92 100644 --- a/apps/meteor/client/views/teams/CreateTeamModal/CreateTeamModal.tsx +++ b/apps/meteor/client/views/teams/CreateTeamModal/CreateTeamModal.tsx @@ -1,207 +1,13 @@ -import type { IUser } from '@rocket.chat/core-typings'; import { Box, Modal, ButtonGroup, Button, TextInput, Field, ToggleSwitch, FieldGroup } from '@rocket.chat/fuselage'; -import { useMutableCallback, useDebouncedCallback, useAutoFocus } from '@rocket.chat/fuselage-hooks'; -import { useSetting, usePermission, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; -import React, { FC, memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { useAutoFocus } from '@rocket.chat/fuselage-hooks'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import React, { memo, ReactElement } from 'react'; -import { useEndpointActionExperimental } from '../../../hooks/useEndpointActionExperimental'; -import { useForm } from '../../../hooks/useForm'; -import { goToRoomById } from '../../../lib/utils/goToRoomById'; +import UserAutoCompleteMultiple from '../../../components/UserAutoCompleteMultiple'; import TeamNameInput from './TeamNameInput'; -import UsersInput from './UsersInput'; +import { useCreateTeamModalState } from './useCreateTeamModalState'; -type CreateTeamModalState = { - name: any; - nameError: any; - onChangeName: any; - description: any; - onChangeDescription: any; - type: any; - onChangeType: any; - readOnly: any; - canChangeReadOnly: any; - onChangeReadOnly: any; - encrypted: any; - canChangeEncrypted: any; - onChangeEncrypted: any; - broadcast: any; - onChangeBroadcast: any; - members: any; - onChangeMembers: any; - hasUnsavedChanges: any; - isCreateButtonEnabled: any; - onCreate: any; -}; - -const useCreateTeamModalState = (onClose: () => void): CreateTeamModalState => { - const e2eEnabled = useSetting('E2E_Enable'); - const e2eEnabledForPrivateByDefault = useSetting('E2E_Enabled_Default_PrivateRooms'); - const namesValidation = useSetting('UTF8_Channel_Names_Validation'); - const allowSpecialNames = useSetting('UI_Allow_room_names_with_special_chars'); - - const { values, handlers, hasUnsavedChanges } = useForm({ - members: [], - name: '', - description: '', - type: true, - readOnly: false, - encrypted: e2eEnabledForPrivateByDefault ?? false, - broadcast: false, - }); - - const { name, description, type, readOnly, broadcast, encrypted, members } = values as { - name: string; - description: string; - type: boolean; - readOnly: boolean; - broadcast: boolean; - encrypted: boolean; - members: Exclude<IUser['username'], undefined>[]; - }; - - const { handleMembers, handleEncrypted, handleType, handleBroadcast, handleReadOnly } = handlers; - - const t = useTranslation(); - - const teamNameRegex = useMemo(() => { - if (allowSpecialNames) { - return null; - } - - return new RegExp(`^${namesValidation}$`); - }, [allowSpecialNames, namesValidation]); - - const [nameError, setNameError] = useState<string>(); - - const teamNameExists = useMethod('roomNameExists'); - - const checkName = useDebouncedCallback( - async (name: string) => { - setNameError(undefined); - - if (!hasUnsavedChanges) { - return; - } - - if (!name || name.length === 0) { - setNameError(t('Field_required')); - return; - } - - if (teamNameRegex && !teamNameRegex.test(name)) { - setNameError(t('error-invalid-name')); - return; - } - - const isNotAvailable = await teamNameExists(name); - if (isNotAvailable) { - setNameError(t('Teams_Errors_team_name', { name })); - } - }, - 230, - [name], - ); - - useEffect(() => { - checkName(name); - }, [checkName, name]); - - const canChangeReadOnly = !broadcast; - - const canChangeEncrypted = type && !broadcast && e2eEnabled && !e2eEnabledForPrivateByDefault; - - const onChangeName = handlers.handleName; - - const onChangeDescription = handlers.handleDescription; - - const onChangeType = useMutableCallback((value) => { - handleEncrypted(!value); - return handleType(value); - }); - - const onChangeReadOnly = handlers.handleReadOnly; - - const onChangeEncrypted = handlers.handleEncrypted; - - const onChangeBroadcast = useCallback( - (value) => { - handleEncrypted(!value); - handleReadOnly(value); - return handleBroadcast(value); - }, - [handleBroadcast, handleEncrypted, handleReadOnly], - ); - - const onChangeMembers = useCallback( - (value, action) => { - if (!action) { - if (members.includes(value)) { - return; - } - return handleMembers([...members, value]); - } - handleMembers(members.filter((current) => current !== value)); - }, - [handleMembers, members], - ); - - const canSave = hasUnsavedChanges && !nameError; - const canCreateTeam = usePermission('create-team'); - const isCreateButtonEnabled = canSave && canCreateTeam; - - const createTeam = useEndpointActionExperimental('POST', '/v1/teams.create'); - - const onCreate = useCallback(async () => { - const params = { - name, - members, - type: type ? 1 : 0, - room: { - readOnly, - extraData: { - description, - broadcast, - encrypted, - }, - }, - }; - - const data = await createTeam(params); - - goToRoomById(data.team.roomId); - - onClose(); - }, [name, members, type, readOnly, description, broadcast, encrypted, createTeam, onClose]); - - return { - name, - nameError, - onChangeName, - description, - onChangeDescription, - type, - onChangeType, - readOnly, - canChangeReadOnly, - onChangeReadOnly, - encrypted, - canChangeEncrypted, - onChangeEncrypted, - broadcast, - onChangeBroadcast, - members, - onChangeMembers, - hasUnsavedChanges, - isCreateButtonEnabled, - onCreate, - }; -}; - -type CreateTeamModalProps = { - onClose: () => void; -}; - -const CreateTeamModal: FC<CreateTeamModalProps> = ({ onClose }) => { +const CreateTeamModal = ({ onClose }: { onClose: () => void }): ReactElement => { const { name, nameError, @@ -226,14 +32,13 @@ const CreateTeamModal: FC<CreateTeamModalProps> = ({ onClose }) => { } = useCreateTeamModalState(onClose); const t = useTranslation(); - const focusRef = useAutoFocus<HTMLInputElement>(); return ( <Modal> <Modal.Header> <Modal.Title>{t('Teams_New_Title')}</Modal.Title> - <Modal.Close onClick={onClose} /> + <Modal.Close title={t('Close')} onClick={onClose} /> </Modal.Header> <Modal.Content> <FieldGroup> @@ -310,7 +115,7 @@ const CreateTeamModal: FC<CreateTeamModalProps> = ({ onClose }) => { ({t('optional')}) </Box> </Field.Label> - <UsersInput value={members} onChange={onChangeMembers} /> + <UserAutoCompleteMultiple value={members} onChange={onChangeMembers} /> </Field> </FieldGroup> </Modal.Content> diff --git a/apps/meteor/client/views/teams/CreateTeamModal/UsersInput.tsx b/apps/meteor/client/views/teams/CreateTeamModal/UsersInput.tsx deleted file mode 100644 index 13126e2c683..00000000000 --- a/apps/meteor/client/views/teams/CreateTeamModal/UsersInput.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { AutoComplete, Box, Option, Options, Chip, AutoCompleteProps } from '@rocket.chat/fuselage'; -import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; -import React, { FC, memo, useCallback, useMemo, useState } from 'react'; - -import UserAvatar from '../../../components/avatar/UserAvatar'; -import { useEndpointData } from '../../../hooks/useEndpointData'; - -type UsersInputProps = { - value: unknown[]; - onChange: (value: unknown, action: 'remove' | undefined) => void; -}; - -type AutocompleteData = [AutoCompleteProps['options'], { [key: string]: string | undefined }]; - -const useUsersAutoComplete = (term: string): AutocompleteData => { - const params = useMemo( - () => ({ - selector: JSON.stringify({ term }), - }), - [term], - ); - const { value: data } = useEndpointData('/v1/users.autocomplete', params); - - return useMemo<AutocompleteData>(() => { - if (!data) { - return [[], {}]; - } - - const options = - data.items.map((user) => ({ - label: user.name ?? '', - value: user._id ?? '', - })) || []; - - const labelData = Object.fromEntries(data.items.map((user) => [user._id, user.username]) || []); - - return [options, labelData]; - }, [data]); -}; - -const UsersInput: FC<UsersInputProps> = ({ onChange, ...props }) => { - const [filter, setFilter] = useState(''); - const [options, labelData] = useUsersAutoComplete(useDebouncedValue(filter, 1000)); - - const onClickSelected = useCallback( - (e) => { - e.stopPropagation(); - e.preventDefault(); - onChange(e.currentTarget.value, 'remove'); - }, - [onChange], - ); - - const renderSelected = useCallback<FC<{ value?: string[] }>>( - ({ value: selected }) => ( - <> - {selected?.map((value) => ( - <Chip key={value} {...props} height='x20' value={value} onClick={onClickSelected} mie='x4'> - <UserAvatar size='x20' username={labelData[value] as string} /> - <Box is='span' margin='none' mis='x4'> - {labelData[value]} - </Box> - </Chip> - ))} - </> - ), - [onClickSelected, props, labelData], - ); - - const renderItem = useCallback<FC<{ value: string }>>( - ({ value, ...props }) => ( - <Option key={value} {...props} avatar={<UserAvatar size={Options.AvatarSize} username={labelData[value] as string} />} /> - ), - [labelData], - ); - - return ( - <AutoComplete - {...props} - filter={filter} - options={options} - renderSelected={renderSelected} - renderItem={renderItem} - setFilter={setFilter} - onChange={onChange} - /> - ); -}; - -export default memo(UsersInput); diff --git a/apps/meteor/client/views/teams/CreateTeamModal/useCreateTeamModalState.ts b/apps/meteor/client/views/teams/CreateTeamModal/useCreateTeamModalState.ts new file mode 100644 index 00000000000..bd84a2173b4 --- /dev/null +++ b/apps/meteor/client/views/teams/CreateTeamModal/useCreateTeamModalState.ts @@ -0,0 +1,193 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { useMutableCallback, useDebouncedCallback } from '@rocket.chat/fuselage-hooks'; +import { useSetting, usePermission, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { useEndpointActionExperimental } from '../../../hooks/useEndpointActionExperimental'; +import { useForm } from '../../../hooks/useForm'; +import { goToRoomById } from '../../../lib/utils/goToRoomById'; + +// TODO: Type it correctly when rewrit it using react-hook-form +type CreateTeamModalState = { + name: any; + nameError: any; + onChangeName: any; + description: any; + onChangeDescription: any; + type: any; + onChangeType: any; + readOnly: any; + canChangeReadOnly: any; + onChangeReadOnly: any; + encrypted: any; + canChangeEncrypted: any; + onChangeEncrypted: any; + broadcast: any; + onChangeBroadcast: any; + members: any; + onChangeMembers: any; + hasUnsavedChanges: any; + isCreateButtonEnabled: any; + onCreate: any; +}; + +export const useCreateTeamModalState = (onClose: () => void): CreateTeamModalState => { + const t = useTranslation(); + const e2eEnabled = useSetting('E2E_Enable'); + const e2eEnabledForPrivateByDefault = useSetting('E2E_Enabled_Default_PrivateRooms'); + const namesValidation = useSetting('UTF8_Channel_Names_Validation'); + const allowSpecialNames = useSetting('UI_Allow_room_names_with_special_chars'); + + const { values, handlers, hasUnsavedChanges } = useForm({ + members: [], + name: '', + description: '', + type: true, + readOnly: false, + encrypted: e2eEnabledForPrivateByDefault ?? false, + broadcast: false, + }); + + const { name, description, type, readOnly, broadcast, encrypted, members } = values as { + name: string; + description: string; + type: boolean; + readOnly: boolean; + broadcast: boolean; + encrypted: boolean; + members: Exclude<IUser['username'], undefined>[]; + }; + + const { handleMembers, handleEncrypted, handleType, handleBroadcast, handleReadOnly } = handlers; + + const teamNameRegex = useMemo(() => { + if (allowSpecialNames) { + return null; + } + + return new RegExp(`^${namesValidation}$`); + }, [allowSpecialNames, namesValidation]); + + const [nameError, setNameError] = useState<string>(); + + const teamNameExists = useMethod('roomNameExists'); + + const checkName = useDebouncedCallback( + async (name: string) => { + setNameError(undefined); + + if (!hasUnsavedChanges) { + return; + } + + if (!name || name.length === 0) { + setNameError(t('Field_required')); + return; + } + + if (teamNameRegex && !teamNameRegex.test(name)) { + setNameError(t('error-invalid-name')); + return; + } + + const isNotAvailable = await teamNameExists(name); + if (isNotAvailable) { + setNameError(t('Teams_Errors_team_name', { name })); + } + }, + 230, + [name], + ); + + useEffect(() => { + checkName(name); + }, [checkName, name]); + + const canChangeReadOnly = !broadcast; + + const canChangeEncrypted = type && !broadcast && e2eEnabled && !e2eEnabledForPrivateByDefault; + + const onChangeName = handlers.handleName; + + const onChangeDescription = handlers.handleDescription; + + const onChangeType = useMutableCallback((value) => { + handleEncrypted(!value); + return handleType(value); + }); + + const onChangeReadOnly = handlers.handleReadOnly; + + const onChangeEncrypted = handlers.handleEncrypted; + + const onChangeBroadcast = useCallback( + (value) => { + handleEncrypted(!value); + handleReadOnly(value); + return handleBroadcast(value); + }, + [handleBroadcast, handleEncrypted, handleReadOnly], + ); + + const onChangeMembers = useCallback( + (value, action) => { + if (!action) { + if (members.includes(value)) { + return; + } + return handleMembers([...members, value]); + } + handleMembers(members.filter((current) => current !== value)); + }, + [handleMembers, members], + ); + + const canSave = hasUnsavedChanges && !nameError; + const canCreateTeam = usePermission('create-team'); + const isCreateButtonEnabled = canSave && canCreateTeam; + + const createTeam = useEndpointActionExperimental('POST', '/v1/teams.create'); + + const onCreate = useCallback(async () => { + const params = { + name, + members, + type: type ? 1 : 0, + room: { + readOnly, + extraData: { + description, + broadcast, + encrypted, + }, + }, + }; + + const data = await createTeam(params); + goToRoomById(data.team.roomId); + onClose(); + }, [name, members, type, readOnly, description, broadcast, encrypted, createTeam, onClose]); + + return { + name, + nameError, + onChangeName, + description, + onChangeDescription, + type, + onChangeType, + readOnly, + canChangeReadOnly, + onChangeReadOnly, + encrypted, + canChangeEncrypted, + onChangeEncrypted, + broadcast, + onChangeBroadcast, + members, + onChangeMembers, + hasUnsavedChanges, + isCreateButtonEnabled, + onCreate, + }; +}; diff --git a/apps/meteor/client/views/teams/index.js b/apps/meteor/client/views/teams/index.ts similarity index 100% rename from apps/meteor/client/views/teams/index.js rename to apps/meteor/client/views/teams/index.ts diff --git a/apps/meteor/definition/externals/meteor/meteor.d.ts b/apps/meteor/definition/externals/meteor/meteor.d.ts index 696c893c4bc..f797375ed72 100644 --- a/apps/meteor/definition/externals/meteor/meteor.d.ts +++ b/apps/meteor/definition/externals/meteor/meteor.d.ts @@ -12,6 +12,10 @@ declare module 'meteor/meteor' { reason?: string; } + interface Device { + isDesktop: () => boolean; + } + const server: any; const runAsUser: (userId: string, scope: Function) => any; diff --git a/apps/meteor/definition/externals/meteor/webapp-hashing.d.ts b/apps/meteor/definition/externals/meteor/webapp-hashing.d.ts new file mode 100644 index 00000000000..845923093f2 --- /dev/null +++ b/apps/meteor/definition/externals/meteor/webapp-hashing.d.ts @@ -0,0 +1,5 @@ +declare module 'meteor/webapp-hashing' { + namespace WebAppHashing { + function calculateClientHash(manifest: Record<string, any>, includeFilter: Function, runtimeConfigOverride: any): string; + } +} diff --git a/apps/meteor/package.json b/apps/meteor/package.json index b4490100493..64e8c9cd11b 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -16,6 +16,7 @@ "start": "meteor", "build:ci": "meteor build --server-only", "dev": "meteor --exclude-archs \"web.browser.legacy, web.cordova\"", + "dsv": "meteor --exclude-archs \"web.browser.legacy, web.cordova\"", "ha": "meteor npm run ha:start", "ha:start": "ts-node .scripts/run-ha.ts main", "ha:add": "ts-node .scripts/run-ha.ts instance", @@ -31,9 +32,7 @@ "typecheck": "cross-env NODE_OPTIONS=\"--max-old-space-size=8184\" tsc --noEmit --skipLibCheck", "deploy": "npm run build && pm2 startOrRestart pm2.json", "coverage": "nyc -r html mocha --config ./.mocharc.js", - "testci": "node .scripts/start.js", "test:playwright": "playwright test", - "test:playwright:ee": "cross-env ENTERPRISE=true yarn test:playwright", "testapi": "mocha --config ./.mocharc.api.js", "testunit": "npm run .testunit:definition && npm run .testunit:client && npm run .testunit:server", ".testunit:server": "mocha --config ./.mocharc.js", @@ -132,7 +131,7 @@ "@types/supertest": "^2.0.11", "@types/ua-parser-js": "^0.7.36", "@types/underscore.string": "0.0.38", - "@types/use-subscription": "^1.0.0", + "@types/use-sync-external-store": "^0.0.3", "@types/uuid": "^8.3.4", "@types/xml-crypto": "^1.4.1", "@types/xmldom": "^0.1.30", @@ -195,10 +194,11 @@ "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/css-in-js": "~0.31.12", "@rocket.chat/emitter": "~0.31.12", + "@rocket.chat/favicon": "workspace:^", "@rocket.chat/forked-matrix-appservice-bridge": "^4.0.1", "@rocket.chat/forked-matrix-bot-sdk": "^0.6.0-beta.2", "@rocket.chat/fuselage": "0.32.0-dev.49", - "@rocket.chat/fuselage-hooks": "~0.31.12", + "@rocket.chat/fuselage-hooks": "~0.31.14-dev.9", "@rocket.chat/fuselage-polyfills": "~0.31.12", "@rocket.chat/fuselage-toastbar": "^0.32.0-dev.22", "@rocket.chat/fuselage-tokens": "~0.31.12", @@ -352,7 +352,7 @@ "underscore.string": "^3.3.6", "universal-perf-hooks": "^1.0.1", "url-polyfill": "^1.1.12", - "use-subscription": "~1.6.0", + "use-sync-external-store": "^1.2.0", "uuid": "^8.3.2", "vm2": "^3.9.9", "webdav": "^4.9.0", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json index 4ce37c261e9..4222e81bdc9 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json @@ -670,6 +670,7 @@ "Better": "Better", "Bio": "Bio", "Bio_Placeholder": "Bio Placeholder", + "Block": "Block", "Block_Multiple_Failed_Logins_Attempts_Until_Block_By_Ip": "How many failed attempts until block by IP", "Block_Multiple_Failed_Logins_Attempts_Until_Block_by_User": "How many failed attempts until block by User", "Block_Multiple_Failed_Logins_By_Ip": "Block failed login attempts by IP", @@ -4554,6 +4555,7 @@ "unarchive-room_description": "Permission to unarchive channels", "Unassigned": "Unassigned", "Unavailable": "Unavailable", + "Unblock": "Unblock", "Unblock_User": "Unblock User", "Uncheck_All": "Uncheck All", "Uncollapse": "Uncollapse", diff --git a/apps/meteor/server/sdk/types/ITeamService.ts b/apps/meteor/server/sdk/types/ITeamService.ts index 1564ae5c6cd..607413f8288 100644 --- a/apps/meteor/server/sdk/types/ITeamService.ts +++ b/apps/meteor/server/sdk/types/ITeamService.ts @@ -112,4 +112,5 @@ export interface ITeamService { removeAllMembersFromTeam(teamId: string): Promise<void>; removeRolesFromMember(teamId: string, userId: string, roles: Array<IRole['_id']>): Promise<boolean>; getStatistics(): Promise<ITeamStats>; + findBySubscribedUserIds(userId: string, callerId?: string): Promise<ITeam[]>; } diff --git a/apps/meteor/server/services/team/service.ts b/apps/meteor/server/services/team/service.ts index bcc44b0a9be..9ed9fce67d4 100644 --- a/apps/meteor/server/services/team/service.ts +++ b/apps/meteor/server/services/team/service.ts @@ -73,10 +73,11 @@ export class TeamService extends ServiceClassInternal implements ITeamService { // TODO add validations to `data` and `members` - const membersResult = await this.Users.findActiveByIds(members, { - projection: { username: 1, _id: 0 }, + const membersResult = await this.Users.findActiveByIdsOrUsernames(members, { + projection: { username: 1, _id: 1 }, }).toArray(); const memberUsernames = membersResult.map(({ username }) => username); + const memberIds = membersResult.map(({ _id }) => _id); const teamData = { ...team, @@ -96,7 +97,7 @@ export class TeamService extends ServiceClassInternal implements ITeamService { // filter empty strings and falsy values from members list const membersList: Array<InsertionModel<ITeamMember>> = - members + memberIds ?.filter(Boolean) .filter((memberId) => !excludeFromMembers.includes(memberId)) .map((memberId) => ({ @@ -800,13 +801,10 @@ export class TeamService extends ServiceClassInternal implements ITeamService { } async removeAllMembersFromTeam(teamId: string): Promise<void> { - const team = await this.TeamModel.findOneById(teamId); - - if (!team) { - return; + if (!teamId) { + throw new Error('missing-teamId'); } - - await this.TeamMembersModel.deleteByTeamId(team._id); + await this.TeamMembersModel.deleteByTeamId(teamId); } async addMember(inviter: Pick<IUser, '_id' | 'username'>, userId: string, teamId: string): Promise<boolean> { diff --git a/apps/meteor/tests/e2e/utils/configs/verifyTestBaseUrl.ts b/apps/meteor/tests/e2e/utils/configs/verifyTestBaseUrl.ts index 32e42d34056..6e2cb09c199 100644 --- a/apps/meteor/tests/e2e/utils/configs/verifyTestBaseUrl.ts +++ b/apps/meteor/tests/e2e/utils/configs/verifyTestBaseUrl.ts @@ -2,9 +2,6 @@ const getBaseUrl = (): string => { if (process.env.BASE_URL) { return process.env.BASE_URL; } - if (process.env.ENTERPRISE) { - return 'http://localhost:4000'; - } return 'http://localhost:3000'; }; diff --git a/apps/meteor/tests/end-to-end/api/01-users.js b/apps/meteor/tests/end-to-end/api/01-users.js index 6efd6989c96..77949612333 100644 --- a/apps/meteor/tests/end-to-end/api/01-users.js +++ b/apps/meteor/tests/end-to-end/api/01-users.js @@ -328,6 +328,7 @@ describe('[Users]', function () { email, name: 'name', username, + pass: 'test', }) .expect('Content-Type', 'application/json') .expect(400) @@ -3117,7 +3118,6 @@ describe('[Users]', function () { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body.error).to.be.equal("The 'selector' param is required"); }) .end(done); }); diff --git a/apps/meteor/tests/end-to-end/api/25-teams.js b/apps/meteor/tests/end-to-end/api/25-teams.js index 41722083e60..dd4419432dd 100644 --- a/apps/meteor/tests/end-to-end/api/25-teams.js +++ b/apps/meteor/tests/end-to-end/api/25-teams.js @@ -3,7 +3,7 @@ import { expect } from 'chai'; import { getCredentials, api, request, credentials, methodCall } from '../../data/api-data'; import { updatePermission } from '../../data/permissions.helper.js'; import { createUser, login } from '../../data/users.helper'; -import { password } from '../../data/user'; +import { adminUsername, password } from '../../data/user'; describe('[Teams]', () => { before((done) => getCredentials(done)); @@ -78,6 +78,45 @@ describe('[Teams]', () => { .end(done); }); + it('should create a public team with a member', (done) => { + request + .post(api('teams.create')) + .set(credentials) + .send({ + name: `test-team-${Date.now()}`, + type: 0, + members: [testUser.username], + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('team'); + expect(res.body).to.have.nested.property('team._id'); + publicTeam = res.body.team; + }) + .then((response) => { + const teamId = response.body.team._id; + return request + .get(api('teams.members')) + .set(credentials) + .query({ teamId }) + .expect(200) + .expect((response) => { + expect(response.body).to.have.property('success', true); + expect(response.body).to.have.property('members'); + + // remove admin user from members because it's added automatically as owner + const members = response.body.members.filter(({ user }) => user.username !== adminUsername); + + const [member] = members; + expect(member.user.username).to.be.equal(testUser.username); + }); + }) + .then(() => done()) + .catch(done); + }); + it('should create private team with a defined owner', (done) => { request .post(api('teams.create')) diff --git a/apps/meteor/tests/mocks/client/RouterContextMock.tsx b/apps/meteor/tests/mocks/client/RouterContextMock.tsx index 579d1e7367f..ec99fa8eec9 100644 --- a/apps/meteor/tests/mocks/client/RouterContextMock.tsx +++ b/apps/meteor/tests/mocks/client/RouterContextMock.tsx @@ -1,5 +1,4 @@ import React, { ContextType, ReactElement, ReactNode, useMemo } from 'react'; -import { Subscription } from 'use-subscription'; import { RouterContext } from '@rocket.chat/ui-contexts'; type RouterContextMockProps = { @@ -11,28 +10,28 @@ type RouterContextMockProps = { const RouterContextMock = ({ children, pushRoute, replaceRoute }: RouterContextMockProps): ReactElement => { const value = useMemo<ContextType<typeof RouterContext>>( () => ({ - queryRoutePath: (): Subscription<undefined> => ({ - getCurrentValue: (): undefined => undefined, - subscribe: () => (): void => undefined, - }), - queryRouteUrl: (): Subscription<undefined> => ({ - getCurrentValue: (): undefined => undefined, - subscribe: () => (): void => undefined, - }), + queryRoutePath: (): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => undefined] => [ + () => (): void => undefined, + (): undefined => undefined, + ], + queryRouteUrl: (): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => undefined] => [ + () => (): void => undefined, + (): undefined => undefined, + ], pushRoute: pushRoute ?? ((): void => undefined), replaceRoute: replaceRoute ?? ((): void => undefined), - queryRouteParameter: (): Subscription<undefined> => ({ - getCurrentValue: (): undefined => undefined, - subscribe: () => (): void => undefined, - }), - queryQueryStringParameter: (): Subscription<undefined> => ({ - getCurrentValue: (): undefined => undefined, - subscribe: () => (): void => undefined, - }), - queryCurrentRoute: (): Subscription<[undefined, {}, {}, undefined]> => ({ - getCurrentValue: (): [undefined, {}, {}, undefined] => [undefined, {}, {}, undefined], - subscribe: () => (): void => undefined, - }), + queryRouteParameter: (): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => undefined] => [ + () => (): void => undefined, + (): undefined => undefined, + ], + queryQueryStringParameter: (): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => undefined] => [ + () => (): void => undefined, + (): undefined => undefined, + ], + queryCurrentRoute: (): [ + subscribe: (onStoreChange: () => void) => () => void, + getSnapshot: () => [undefined?, {}?, {}?, undefined?], + ] => [() => (): void => undefined, (): [undefined, {}, {}, undefined] => [undefined, {}, {}, undefined]], }), [pushRoute, replaceRoute], ); diff --git a/apps/meteor/tests/unit/lib/mimeTypes.tests.ts b/apps/meteor/tests/unit/lib/mimeTypes.tests.ts new file mode 100644 index 00000000000..a3ef408741c --- /dev/null +++ b/apps/meteor/tests/unit/lib/mimeTypes.tests.ts @@ -0,0 +1,15 @@ +import { expect } from 'chai'; + +import { getExtension } from '../../../app/utils/lib/mimeTypes'; + +describe('mimeTypes', () => { + describe('#getExtension()', () => { + it('should return an empty string if the given param is an invalid mime type', () => { + expect(getExtension('invalid-mime')).to.be.equal(''); + }); + + it('should return the correct extension when the mime type is valid', () => { + expect(getExtension('image/png')).to.be.equal('png'); + }); + }); +}); diff --git a/package.json b/package.json index b1b19ff5cf1..c01189159bb 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,13 @@ "build:services": "turbo run build --filter=rocketchat-services...", "build:ci": "turbo run build:ci", "testunit": "turbo run testunit", - "dev": "turbo run dev --filter=@rocket.chat/meteor...", + "dev": "turbo run dev --parallel --filter=@rocket.chat/meteor...", + "dsv": "turbo run dsv --filter=@rocket.chat/meteor...", "lint": "turbo run lint", "storybook": "yarn workspace @rocket.chat/meteor run storybook" }, "devDependencies": { + "@types/chart.js": "^2.9.37", "@types/js-yaml": "^4.0.5", "husky": "^7.0.4", "turbo": "1.2.5" diff --git a/packages/api-client/package.json b/packages/api-client/package.json index 2100aaa8b0e..6cfbc434909 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -14,7 +14,8 @@ "lint": "eslint --ext .js,.jsx,.ts,.tsx .", "lint:fix": "eslint --ext .js,.jsx,.ts,.tsx . --fix", "jest": "jest", - "build": "tsc -p tsconfig.json" + "build": "tsc -p tsconfig.json", + "dev": "tsc --watch --preserveWatchOutput -p tsconfig.json" }, "main": "./dist/index.js", "typings": "./dist/index.d.ts", diff --git a/packages/core-typings/package.json b/packages/core-typings/package.json index 335efb7de7b..048eb5ca8f9 100644 --- a/packages/core-typings/package.json +++ b/packages/core-typings/package.json @@ -13,6 +13,7 @@ "lint": "eslint --ext .js,.jsx,.ts,.tsx .", "lint:fix": "eslint --ext .js,.jsx,.ts,.tsx . --fix", "test": "echo \"Error: no test specified\" && exit 1", + "dev": "tsc --watch --preserveWatchOutput -p tsconfig.json", "build": "rm -rf dist && tsc -p tsconfig.json" }, "main": "./dist/index.js", diff --git a/packages/core-typings/src/IRocketChatAssets.ts b/packages/core-typings/src/IRocketChatAssets.ts new file mode 100644 index 00000000000..b0a3c50f3ec --- /dev/null +++ b/packages/core-typings/src/IRocketChatAssets.ts @@ -0,0 +1,54 @@ +export interface IRocketChatAssetConstraint { + type: string; + extensions: string[]; + width?: number; + height?: number; +} + +export interface IRocketChatAssetWizard { + step: number; + order: number; +} + +export interface IRocketChatAssetCache { + path: string; + cacheable: boolean; + where: string; + type: string; + content: Buffer; + extension: string; + url: string; + size: number; + uploadDate: Date; + contentType: string; + hash: string; + sourceMapUrl?: string; +} + +export interface IRocketChatAsset { + label: string; + constraints: IRocketChatAssetConstraint; + defaultUrl?: string; + url?: string; + wizard?: IRocketChatAssetWizard; + cache?: IRocketChatAssetCache; +} + +export interface IRocketChatAssets { + logo: IRocketChatAsset; + background: IRocketChatAsset; + favicon_ico: IRocketChatAsset; + favicon: IRocketChatAsset; + favicon_16: IRocketChatAsset; + favicon_32: IRocketChatAsset; + favicon_192: IRocketChatAsset; + favicon_512: IRocketChatAsset; + touchicon_180: IRocketChatAsset; + touchicon_180_pre: IRocketChatAsset; + tile_70: IRocketChatAsset; + tile_144: IRocketChatAsset; + tile_150: IRocketChatAsset; + tile_310_square: IRocketChatAsset; + tile_310_wide: IRocketChatAsset; + safari_pinned: IRocketChatAsset; +} diff --git a/packages/core-typings/src/ISetting.ts b/packages/core-typings/src/ISetting.ts index cdde985ff0c..63236e56194 100644 --- a/packages/core-typings/src/ISetting.ts +++ b/packages/core-typings/src/ISetting.ts @@ -1,5 +1,7 @@ import type { FilterQuery } from 'mongodb'; +import type { IRocketChatAssetConstraint } from './IRocketChatAssets'; + export type SettingId = string; export type GroupId = SettingId; export type TabId = SettingId; @@ -19,7 +21,7 @@ export interface ISettingSelectOption { i18nLabel: string; } -export type ISetting = ISettingBase | ISettingEnterprise | ISettingColor | ISettingCode | ISettingAction; +export type ISetting = ISettingBase | ISettingEnterprise | ISettingColor | ISettingCode | ISettingAction | ISettingAsset; export interface ISettingBase { _id: SettingId; @@ -119,6 +121,8 @@ export interface ISettingAction extends ISettingBase { export interface ISettingAsset extends ISettingBase { type: 'asset'; value: AssetValue; + fileConstraints: IRocketChatAssetConstraint; + asset: string; } export interface ISettingDate extends ISettingBase { diff --git a/packages/core-typings/src/ISubscription.ts b/packages/core-typings/src/ISubscription.ts index 3dcdccd2fa9..7b140a775e3 100644 --- a/packages/core-typings/src/ISubscription.ts +++ b/packages/core-typings/src/ISubscription.ts @@ -56,7 +56,7 @@ export interface ISubscription extends IRocketChatRecord { autoTranslateLanguage?: string; disableNotifications?: boolean; muteGroupMentions?: boolean; - ignored?: unknown; + ignored?: IUser['_id'][]; department?: unknown; diff --git a/packages/core-typings/src/IUser.ts b/packages/core-typings/src/IUser.ts index 0dde24a2d7b..15cb0d6bf9e 100644 --- a/packages/core-typings/src/IUser.ts +++ b/packages/core-typings/src/IUser.ts @@ -54,6 +54,7 @@ export interface IUserServices { resume?: { loginTokens?: LoginToken[]; }; + cloud?: unknown; google?: any; facebook?: any; github?: any; diff --git a/packages/core-typings/src/index.ts b/packages/core-typings/src/index.ts index 44045c4613d..785e203163a 100644 --- a/packages/core-typings/src/index.ts +++ b/packages/core-typings/src/index.ts @@ -25,6 +25,7 @@ export * from './ICustomSound'; export * from './ICloud'; export * from './IServerEvent'; export * from './ICronJobs'; +export * from './IRocketChatAssets'; export * from './IPushToken'; export * from './IPushNotificationConfig'; export * from './SlashCommands'; diff --git a/packages/favicon/.eslintrc b/packages/favicon/.eslintrc new file mode 100644 index 00000000000..3f02f4bbff8 --- /dev/null +++ b/packages/favicon/.eslintrc @@ -0,0 +1,8 @@ +{ + "extends": ["@rocket.chat/eslint-config"], + "ignorePatterns": ["**/dist"], + "rules": { + "@typescript-eslint/interface-name-prefix": "off", + "@typescript-eslint/explicit-function-return-type": "off" + } +} diff --git a/packages/favicon/package.json b/packages/favicon/package.json new file mode 100644 index 00000000000..359853747b2 --- /dev/null +++ b/packages/favicon/package.json @@ -0,0 +1,20 @@ +{ + "name": "@rocket.chat/favicon", + "version": "0.0.1", + "private": true, + "devDependencies": { + "eslint": "^8.12.0", + "typescript": "~4.3.4" + }, + "scripts": { + "lint": "eslint --ext .js,.jsx,.ts,.tsx .", + "lint:fix": "eslint --ext .js,.jsx,.ts,.tsx . --fix", + "build": "rm -rf dist && tsc -p tsconfig.json", + "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput" + }, + "main": "./dist/index.js", + "typings": "./dist/index.d.ts", + "files": [ + "/dist" + ] +} diff --git a/packages/favicon/src/badge.ts b/packages/favicon/src/badge.ts new file mode 100644 index 00000000000..a566f7fdac1 --- /dev/null +++ b/packages/favicon/src/badge.ts @@ -0,0 +1,87 @@ +export type Badge = number | string | null | undefined; + +const getBadgeText = (badge: NonNullable<Badge>) => { + if (typeof badge === 'number') { + badge = Math.abs(badge | 0); + + if (badge > 999) { + return `${badge > 9999 ? 9 : Math.floor(badge / 1000)}k+`; + } + + return String(badge); + } + + return String(badge); +}; + +const getBadgeStyle = (badge: NonNullable<Badge>) => { + if (typeof badge === 'number' && badge > 0) { + return { bgColor: '#ac1b1b', textColor: '#fff', fontFamily: 'sans-serif', fontStyle: 'bold' }; + } + + if (typeof badge === 'string' && badge !== '') { + return { bgColor: '#3d8a3a', textColor: '#fff', fontFamily: 'sans-serif', fontStyle: 'bold' }; + } + + throw new Error('Invalid badge type'); +}; + +export const drawBadge = (badge: Badge, context: CanvasRenderingContext2D) => { + if (!badge) { + return; + } + + const text = getBadgeText(badge); + const { fontFamily, fontStyle, bgColor, textColor } = getBadgeStyle(badge); + + let w = 0.6; + const h = 0.6; + + let x = 0.4; + const y = 0; + + if (text.length === 2) { + x -= w * 0.4; + w *= 1.4; + } else if (text.length >= 3) { + x -= w * 0.65; + w *= 1.65; + } + + context.beginPath(); + + if (text.length > 1) { + context.moveTo(x + w / 2, y); + context.lineTo(x + w - h / 2, y); + context.quadraticCurveTo(x + w, y, x + w, y + h / 2); + context.lineTo(x + w, y + h - h / 2); + context.quadraticCurveTo(x + w, y + h, x + w - h / 2, y + h); + context.lineTo(x + h / 2, y + h); + context.quadraticCurveTo(x, y + h, x, y + h - h / 2); + context.lineTo(x, y + h / 2); + context.quadraticCurveTo(x, y, x + h / 2, y); + } else { + context.arc(x + w / 2, y + h / 2, h / 2, 0, 2 * Math.PI); + } + + context.fillStyle = bgColor; + context.fill(); + + context.closePath(); + + context.font = `${fontStyle} ${h * (text.length > 2 ? 0.85 : 1)}px ${fontFamily}`; + context.textAlign = 'center'; + context.fillStyle = textColor; + + context.beginPath(); + + context.stroke(); + + if (text.length > 3) { + context.fillText(text, x + w / 2, y + h - h * 0.2); + } else { + context.fillText(text, x + w / 2, y + h - h * 0.15); + } + + context.closePath(); +}; diff --git a/packages/favicon/src/index.ts b/packages/favicon/src/index.ts new file mode 100644 index 00000000000..9ebcfb6977f --- /dev/null +++ b/packages/favicon/src/index.ts @@ -0,0 +1,111 @@ +import { Badge, drawBadge } from './badge'; + +const getFavicons = () => { + const favicons = Array.from(document.head.getElementsByTagName('link')).filter((link) => + /(^|\s)icon(\s|$)/i.test(link.getAttribute('rel') ?? ''), + ); + + if (favicons.length === 0) { + const link = document.createElement('link'); + link.setAttribute('rel', 'icon'); + document.head.appendChild(link); + favicons.push(link); + } + + for (const favicon of favicons) { + favicon.setAttribute('type', 'image/png'); + } + + return favicons; +}; + +const fetchFaviconImage = async (url: string | undefined) => { + const img = new Image(); + + if (url) { + img.crossOrigin = 'anonymous'; + img.src = url; + } else { + img.src = ''; + img.width = 32; + img.height = 32; + } + + return new Promise<HTMLImageElement>((resolve, reject) => { + img.onload = () => { + resolve(img); + }; + img.onerror = () => { + reject(new Error('Failed to load image')); + }; + }); +}; + +const renderAndUpdate = ({ + badge, + canvas, + favicons, + context, + img, +}: { + badge: Badge; + canvas: HTMLCanvasElement; + favicons: HTMLLinkElement[]; + context: CanvasRenderingContext2D; + img: HTMLImageElement; +}) => { + context.scale(canvas.width, canvas.height); + + context.clearRect(0, 0, 1, 1); + + context.drawImage(img, 0, 0, 1, 1); + + drawBadge(badge, context); + + context.setTransform(1, 0, 0, 1, 0, 0); + + const url = canvas.toDataURL('image/png'); + + for (const icon of favicons) { + icon.setAttribute('href', url); + } +}; + +export const manageFavicon = () => { + let pendingBadge: Badge; + + let updateOrCollect = (badge: Badge) => { + pendingBadge = badge; + }; + + const init = async () => { + const favicons = getFavicons(); + const lastFavicon = favicons[favicons.length - 1]; + const faviconURL = lastFavicon.getAttribute('href') ?? undefined; + const img = await fetchFaviconImage(faviconURL); + const canvas = document.createElement('canvas'); + canvas.width = img.width > 0 ? img.width : 32; + canvas.height = img.height > 0 ? img.height : 32; + + const context = canvas.getContext('2d'); + + if (!context) { + throw new Error('Failed to create canvas context'); + } + + updateOrCollect = (badge) => { + renderAndUpdate({ badge, canvas, favicons, context, img }); + }; + + if (pendingBadge) { + updateOrCollect(pendingBadge); + pendingBadge = undefined; + } + }; + + init(); + + return (badge: Badge) => { + updateOrCollect(badge); + }; +}; diff --git a/packages/favicon/tsconfig.json b/packages/favicon/tsconfig.json new file mode 100644 index 00000000000..b16dc6107ab --- /dev/null +++ b/packages/favicon/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "lib": ["DOM"], + }, + "include": ["./src/**/*"] +} diff --git a/packages/livechat/package.json b/packages/livechat/package.json index 942c461d5a1..8b9a2a119fc 100644 --- a/packages/livechat/package.json +++ b/packages/livechat/package.json @@ -14,6 +14,7 @@ "scripts": { "clean": "rimraf dist", "build": "webpack-cli --mode production", + "dev": "yarn build", "start": "webpack-dev-server --mode development", "lint": "run-s eslint stylelint", "eslint": "eslint src", diff --git a/packages/rest-typings/package.json b/packages/rest-typings/package.json index ba74b1139fd..ebc24411016 100644 --- a/packages/rest-typings/package.json +++ b/packages/rest-typings/package.json @@ -15,6 +15,7 @@ "lint": "eslint --ext .js,.jsx,.ts,.tsx .", "lint:fix": "eslint --ext .js,.jsx,.ts,.tsx . --fix", "jest": "jest", + "dev": "tsc --watch --preserveWatchOutput -p tsconfig.json", "build": "rm -rf dist && tsc -p tsconfig.json" }, "main": "./dist/index.js", diff --git a/packages/rest-typings/src/index.ts b/packages/rest-typings/src/index.ts index 5719fd7bc7e..8892166683a 100644 --- a/packages/rest-typings/src/index.ts +++ b/packages/rest-typings/src/index.ts @@ -4,6 +4,7 @@ import type { KeyOfEach } from '@rocket.chat/core-typings'; import type { AppsEndpoints } from './apps'; import type { AutoTranslateEndpoints } from './v1/autoTranslate'; import type { ReplacePlaceholders } from './helpers/ReplacePlaceholders'; +import type { AssetsEndpoints } from './v1/assets'; import type { BannersEndpoints } from './v1/banners'; import type { ChannelsEndpoints } from './v1/channels'; import type { ChatEndpoints } from './v1/chat'; @@ -73,6 +74,7 @@ export interface Endpoints VideoConferenceEndpoints, InvitesEndpoints, E2eEndpoints, + AssetsEndpoints, CustomSoundEndpoint, EmailInboxEndpoints, WebdavEndpoints, @@ -146,6 +148,7 @@ export * from './v1/permissions'; export * from './v1/roles'; export * from './v1/settings'; export * from './v1/teams'; +export * from './v1/assets'; export * from './v1/channels/ChannelsAddAllProps'; export * from './v1/channels/ChannelsArchiveProps'; export * from './v1/channels/ChannelsUnarchiveProps'; @@ -177,3 +180,9 @@ export * from './helpers/PaginatedResult'; export * from './helpers/ReplacePlaceholders'; export * from './helpers/WithItemCount'; export * from './v1/emojiCustom'; + +export * from './v1/users'; +export * from './v1/users/UsersSetAvatarParamsPOST'; +export * from './v1/users/UsersSetPreferenceParamsPOST'; +export * from './v1/users/UsersUpdateOwnBasicInfoParamsPOST'; +export * from './v1/users/UsersUpdateParamsPOST'; diff --git a/packages/rest-typings/src/v1/assets.ts b/packages/rest-typings/src/v1/assets.ts new file mode 100644 index 00000000000..fc318f63ba4 --- /dev/null +++ b/packages/rest-typings/src/v1/assets.ts @@ -0,0 +1,28 @@ +import Ajv, { JSONSchemaType } from 'ajv'; +import type { IRocketChatAssets } from '@rocket.chat/core-typings'; + +export type AssetsUnsetAssetProps = { assetName: keyof IRocketChatAssets; refreshAllClients?: boolean }; + +export type AssetsEndpoints = { + 'assets.setAsset': { + POST: (params: AssetsUnsetAssetProps) => void; + }; + + 'assets.unsetAsset': { + POST: (params: AssetsUnsetAssetProps) => void; + }; +}; + +const ajv = new Ajv(); + +const assetsUnsetAssetPropsSchema: JSONSchemaType<AssetsUnsetAssetProps> = { + type: 'object', + properties: { + assetName: { type: 'string' }, + refreshAllClients: { type: 'boolean', nullable: true }, + }, + required: ['assetName'], + additionalProperties: false, +}; + +export const isAssetsUnsetAssetProps = ajv.compile(assetsUnsetAssetPropsSchema); diff --git a/packages/rest-typings/src/v1/groups.ts b/packages/rest-typings/src/v1/groups.ts index 7fc73cbe744..9ab08be043d 100644 --- a/packages/rest-typings/src/v1/groups.ts +++ b/packages/rest-typings/src/v1/groups.ts @@ -381,4 +381,22 @@ export type GroupsEndpoints = { messages: IMessage[]; }>; }; + '/v1/groups.addModerator': { + POST: (params: { roomId: string; userId: string }) => {}; + }; + '/v1/groups.removeModerator': { + POST: (params: { roomId: string; userId: string }) => {}; + }; + '/v1/groups.addOwner': { + POST: (params: { roomId: string; userId: string }) => {}; + }; + '/v1/groups.removeOwner': { + POST: (params: { roomId: string; userId: string }) => {}; + }; + '/v1/groups.addLeader': { + POST: (params: { roomId: string; userId: string }) => {}; + }; + '/v1/groups.removeLeader': { + POST: (params: { roomId: string; userId: string }) => {}; + }; }; diff --git a/packages/rest-typings/src/v1/users.ts b/packages/rest-typings/src/v1/users.ts index 9d9413034dd..3a49e8c38ce 100644 --- a/packages/rest-typings/src/v1/users.ts +++ b/packages/rest-typings/src/v1/users.ts @@ -1,6 +1,14 @@ -import type { ITeam, IUser } from '@rocket.chat/core-typings'; +import type { IExportOperation, ISubscription, ITeam, IUser } from '@rocket.chat/core-typings'; import Ajv from 'ajv'; +import type { UserCreateParamsPOST } from './users/UserCreateParamsPOST'; +import type { UserDeactivateIdleParamsPOST } from './users/UserDeactivateIdleParamsPOST'; +import type { UserLogoutParamsPOST } from './users/UserLogoutParamsPOST'; +import type { UserRegisterParamsPOST } from './users/UserRegisterParamsPOST'; +import type { UsersAutocompleteParamsGET } from './users/UsersAutocompleteParamsGET'; +import type { UserSetActiveStatusParamsPOST } from './users/UserSetActiveStatusParamsPOST'; +import type { UsersInfoParamsGet } from './users/UsersInfoParamsGet'; +import type { UsersListTeamsParamsGET } from './users/UsersListTeamsParamsGET'; import type { PaginatedRequest } from '../helpers/PaginatedRequest'; import type { PaginatedResult } from '../helpers/PaginatedResult'; @@ -43,36 +51,6 @@ const Users2faSendEmailCodeSchema = { export const isUsers2faSendEmailCodeProps = ajv.compile<Users2faSendEmailCode>(Users2faSendEmailCodeSchema); -type UsersAutocomplete = { selector: string }; - -const UsersAutocompleteSchema = { - type: 'object', - properties: { - selector: { - type: 'string', - }, - }, - required: ['selector'], - additionalProperties: false, -}; - -export const isUsersAutocompleteProps = ajv.compile<UsersAutocomplete>(UsersAutocompleteSchema); - -type UsersListTeams = { userId: IUser['_id'] }; - -const UsersListTeamsSchema = { - type: 'object', - properties: { - userId: { - type: 'string', - }, - }, - required: ['userId'], - additionalProperties: false, -}; - -export const isUsersListTeamsProps = ajv.compile<UsersListTeams>(UsersListTeamsSchema); - type UsersSetAvatar = { userId?: IUser['_id']; username?: IUser['username']; avatarUrl?: string }; const UsersSetAvatarSchema = { @@ -127,31 +105,208 @@ export type UserPresence = Readonly< >; export type UsersEndpoints = { - '/v1/users.info': { - GET: (params: UsersInfo) => { - user: IUser; - }; + '/v1/users.2fa.enableEmail': { + POST: () => void; + }; + + '/v1/users.2fa.disableEmail': { + POST: () => void; }; + '/v1/users.2fa.sendEmailCode': { POST: (params: Users2faSendEmailCode) => void; }; + + '/v1/users.listTeams': { + GET: (params: UsersListTeamsParamsGET) => { teams: ITeam[] }; + }; '/v1/users.autocomplete': { - GET: (params: UsersAutocomplete) => { + GET: (params: UsersAutocompleteParamsGET) => { items: Required<Pick<IUser, '_id' | 'name' | 'username' | 'nickname' | 'status' | 'avatarETag'>>[]; }; }; + '/v1/users.list': { GET: (params: PaginatedRequest<{ query: string }>) => PaginatedResult<{ users: Pick<IUser, '_id' | 'username' | 'name' | 'status' | 'roles' | 'emails' | 'active' | 'avatarETag'>[]; }>; }; - '/v1/users.listTeams': { - GET: (params: UsersListTeams) => { teams: Array<ITeam> }; - }; + '/v1/users.setAvatar': { POST: (params: UsersSetAvatar) => void; }; '/v1/users.resetAvatar': { POST: (params: UsersResetAvatar) => void; }; + + '/v1/users.requestDataDownload': { + GET: (params: { fullExport?: 'true' | 'false' }) => { + requested: boolean; + exportOperation: IExportOperation; + }; + }; + '/v1/users.logoutOtherClients': { + POST: () => { + token: string; + tokenExpires: string; + }; + }; + '/v1/users.removeOtherTokens': { + POST: () => void; + }; + '/v1/users.resetE2EKey': { + POST: ( + params: + | { + userId: string; + } + | { + username: string; + } + | { + user: string; + }, + ) => void; + }; + '/v1/users.resetTOTP': { + POST: ( + params: + | { + userId: string; + } + | { + username: string; + } + | { + user: string; + }, + ) => void; + }; + + '/v1/users.presence': { + GET: (params: { from?: string; ids: string | string[] }) => UsersPresencePayload; + }; + + '/v1/users.removePersonalAccessToken': { + POST: (params: { tokenName: string }) => void; + }; + + '/v1/users.getPersonalAccessTokens': { + GET: () => { + tokens: { + name?: string; + createdAt: string; + lastTokenPart: string; + bypassTwoFactor: boolean; + }[]; + }; + }; + '/v1/users.regeneratePersonalAccessToken': { + POST: (params: { tokenName: string }) => { + token: string; + }; + }; + '/v1/users.generatePersonalAccessToken': { + POST: (params: { tokenName: string; bypassTwoFactor: boolean }) => { + token: string; + }; + }; + '/v1/users.getUsernameSuggestion': { + GET: () => { + result: string; + }; + }; + '/v1/users.forgotPassword': { + POST: (params: { email: string }) => void; + }; + '/v1/users.getPreferences': { + GET: () => { + preferences: Required<IUser>['settings']['preferences']; + }; + }; + '/v1/users.createToken': { + POST: () => { + data: { + userId: string; + authToken: string; + }; + }; + }; + + '/v1/users.create': { + POST: (params: UserCreateParamsPOST) => { + user: IUser; + }; + }; + + '/v1/users.setActiveStatus': { + POST: (params: UserSetActiveStatusParamsPOST) => { + user: IUser; + }; + }; + + '/v1/users.deactivateIdle': { + POST: (params: UserDeactivateIdleParamsPOST) => { + count: number; + }; + }; + + '/v1/users.getPresence': { + GET: ( + params: + | { + userId: string; + } + | { + username: string; + } + | { + user: string; + }, + ) => { + presence: 'online' | 'offline' | 'away' | 'busy'; + connectionStatus?: 'online' | 'offline' | 'away' | 'busy'; + lastLogin?: string; + }; + }; + + '/v1/users.setStatus': { + POST: (params: { message?: string; status?: 'online' | 'offline' | 'away' | 'busy' }) => void; + }; + + '/v1/users.getStatus': { + GET: () => { + status: 'online' | 'offline' | 'away' | 'busy'; + message?: string; + _id: string; + connectionStatus?: 'online' | 'offline' | 'away' | 'busy'; + }; + }; + + '/v1/users.info': { + GET: (params: UsersInfoParamsGet) => { + user: IUser & { rooms?: Pick<ISubscription, 'rid' | 'name' | 't' | 'roles' | 'unread'>[] }; + }; + }; + + '/v1/users.register': { + POST: (params: UserRegisterParamsPOST) => { + user: Partial<IUser>; + }; + }; + + '/v1/users.logout': { + POST: (params: UserLogoutParamsPOST) => { + message: string; + }; + }; }; + +export * from './users/UserCreateParamsPOST'; +export * from './users/UserSetActiveStatusParamsPOST'; +export * from './users/UserDeactivateIdleParamsPOST'; +export * from './users/UsersInfoParamsGet'; +export * from './users/UserRegisterParamsPOST'; +export * from './users/UserLogoutParamsPOST'; +export * from './users/UsersListTeamsParamsGET'; +export * from './users/UsersAutocompleteParamsGET'; diff --git a/packages/rest-typings/src/v1/users/UserCreateParamsPOST.ts b/packages/rest-typings/src/v1/users/UserCreateParamsPOST.ts new file mode 100644 index 00000000000..34749899901 --- /dev/null +++ b/packages/rest-typings/src/v1/users/UserCreateParamsPOST.ts @@ -0,0 +1,51 @@ +import Ajv from 'ajv'; + +const ajv = new Ajv({ + coerceTypes: true, +}); + +export type UserCreateParamsPOST = { + email: string; + name: string; + password: string; + username: string; + active?: boolean; + bio?: string; + nickname?: string; + statusText?: string; + roles?: string[]; + joinDefaultChannels?: boolean; + requirePasswordChange?: boolean; + setRandomPassword?: boolean; + sendWelcomeEmail?: boolean; + verified?: boolean; + customFields?: object; + /* @deprecated */ + fields: string; +}; + +const userCreateParamsPostSchema = { + type: 'object', + properties: { + email: { type: 'string' }, + name: { type: 'string' }, + password: { type: 'string' }, + username: { type: 'string' }, + active: { type: 'boolean', nullable: true }, + bio: { type: 'string', nullable: true }, + nickname: { type: 'string', nullable: true }, + statusText: { type: 'string', nullable: true }, + roles: { type: 'array', items: { type: 'string' } }, + joinDefaultChannels: { type: 'boolean', nullable: true }, + requirePasswordChange: { type: 'boolean', nullable: true }, + setRandomPassword: { type: 'boolean', nullable: true }, + sendWelcomeEmail: { type: 'boolean', nullable: true }, + verified: { type: 'boolean', nullable: true }, + customFields: { type: 'object' }, + fields: { type: 'string', nullable: true }, + }, + additionalProperties: false, + required: ['email', 'name', 'password', 'username'], +}; + +export const isUserCreateParamsPOST = ajv.compile<UserCreateParamsPOST>(userCreateParamsPostSchema); diff --git a/packages/rest-typings/src/v1/users/UserDeactivateIdleParamsPOST.ts b/packages/rest-typings/src/v1/users/UserDeactivateIdleParamsPOST.ts new file mode 100644 index 00000000000..0b7fb03ed5d --- /dev/null +++ b/packages/rest-typings/src/v1/users/UserDeactivateIdleParamsPOST.ts @@ -0,0 +1,26 @@ +import Ajv from 'ajv'; + +const ajv = new Ajv({ + coerceTypes: true, +}); + +export type UserDeactivateIdleParamsPOST = { + daysIdle: number; + role?: string; +}; + +const userDeactivateIdleSchema = { + type: 'object', + properties: { + daysIdle: { + type: 'number', + }, + role: { + type: 'string', + }, + }, + required: ['daysIdle'], + additionalProperties: false, +}; + +export const isUserDeactivateIdleParamsPOST = ajv.compile<UserDeactivateIdleParamsPOST>(userDeactivateIdleSchema); diff --git a/packages/rest-typings/src/v1/users/UserLogoutParamsPOST.ts b/packages/rest-typings/src/v1/users/UserLogoutParamsPOST.ts new file mode 100644 index 00000000000..cd51b37495d --- /dev/null +++ b/packages/rest-typings/src/v1/users/UserLogoutParamsPOST.ts @@ -0,0 +1,22 @@ +import Ajv from 'ajv'; + +const ajv = new Ajv({ + coerceTypes: true, +}); + +export type UserLogoutParamsPOST = { + userId?: string; +}; + +const UserLogoutParamsPostSchema = { + type: 'object', + properties: { + userId: { + type: 'string', + nullable: true, + }, + }, + required: [], +}; + +export const isUserLogoutParamsPOST = ajv.compile<UserLogoutParamsPOST>(UserLogoutParamsPostSchema); diff --git a/packages/rest-typings/src/v1/users/UserRegisterParamsPOST.ts b/packages/rest-typings/src/v1/users/UserRegisterParamsPOST.ts new file mode 100644 index 00000000000..0551e783920 --- /dev/null +++ b/packages/rest-typings/src/v1/users/UserRegisterParamsPOST.ts @@ -0,0 +1,47 @@ +import Ajv from 'ajv'; + +const ajv = new Ajv({ + coerceTypes: true, +}); + +export type UserRegisterParamsPOST = { + username: string; + name?: string; + email: string; + pass: string; + secret?: string; + reason?: string; +}; + +const UserRegisterParamsPostSchema = { + type: 'object', + properties: { + username: { + type: 'string', + minLength: 3, + }, + + name: { + type: 'string', + nullable: true, + }, + email: { + type: 'string', + }, + pass: { + type: 'string', + }, + secret: { + type: 'string', + nullable: true, + }, + reason: { + type: 'string', + nullable: true, + }, + }, + required: ['username', 'email', 'pass'], + additionalProperties: false, +}; + +export const isUserRegisterParamsPOST = ajv.compile<UserRegisterParamsPOST>(UserRegisterParamsPostSchema); diff --git a/packages/rest-typings/src/v1/users/UserSetActiveStatusParamsPOST.ts b/packages/rest-typings/src/v1/users/UserSetActiveStatusParamsPOST.ts new file mode 100644 index 00000000000..ad503f5d298 --- /dev/null +++ b/packages/rest-typings/src/v1/users/UserSetActiveStatusParamsPOST.ts @@ -0,0 +1,24 @@ +import Ajv from 'ajv'; + +const ajv = new Ajv({ + coerceTypes: true, +}); + +export type UserSetActiveStatusParamsPOST = { + userId: string; + activeStatus: boolean; + confirmRelinquish?: boolean; +}; + +const UserCreateParamsPostSchema = { + type: 'object', + properties: { + userId: { type: 'string' }, + activeStatus: { type: 'boolean' }, + confirmRelinquish: { type: 'boolean', nullable: true }, + }, + required: ['userId', 'activeStatus'], + additionalProperties: false, +}; + +export const isUserSetActiveStatusParamsPOST = ajv.compile<UserSetActiveStatusParamsPOST>(UserCreateParamsPostSchema); diff --git a/packages/rest-typings/src/v1/users/UsersAutocompleteParamsGET.ts b/packages/rest-typings/src/v1/users/UsersAutocompleteParamsGET.ts new file mode 100644 index 00000000000..bdb6578db24 --- /dev/null +++ b/packages/rest-typings/src/v1/users/UsersAutocompleteParamsGET.ts @@ -0,0 +1,20 @@ +import Ajv from 'ajv'; + +const ajv = new Ajv({ + coerceTypes: true, +}); + +export type UsersAutocompleteParamsGET = { selector: string }; + +const UsersAutocompleteParamsGetSchema = { + type: 'object', + properties: { + selector: { + type: 'string', + }, + }, + required: ['selector'], + additionalProperties: false, +}; + +export const isUsersAutocompleteProps = ajv.compile<UsersAutocompleteParamsGET>(UsersAutocompleteParamsGetSchema); diff --git a/packages/rest-typings/src/v1/users/UsersInfoParamsGet.ts b/packages/rest-typings/src/v1/users/UsersInfoParamsGet.ts new file mode 100644 index 00000000000..ddae73750ea --- /dev/null +++ b/packages/rest-typings/src/v1/users/UsersInfoParamsGet.ts @@ -0,0 +1,44 @@ +import Ajv from 'ajv'; + +const ajv = new Ajv({ + coerceTypes: true, +}); + +export type UsersInfoParamsGet = ({ userId: string } | { username: string }) & { + fields?: string; +}; + +const UsersInfoParamsGetSchema = { + anyOf: [ + { + type: 'object', + properties: { + userId: { + type: 'string', + }, + fields: { + type: 'string', + nullable: true, + }, + }, + required: ['userId'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + username: { + type: 'string', + }, + fields: { + type: 'string', + nullable: true, + }, + }, + required: ['username'], + additionalProperties: false, + }, + ], +}; + +export const isUsersInfoParamsGetProps = ajv.compile<UsersInfoParamsGet>(UsersInfoParamsGetSchema); diff --git a/packages/rest-typings/src/v1/users/UsersListTeamsParamsGET.ts b/packages/rest-typings/src/v1/users/UsersListTeamsParamsGET.ts new file mode 100644 index 00000000000..336fd6b3144 --- /dev/null +++ b/packages/rest-typings/src/v1/users/UsersListTeamsParamsGET.ts @@ -0,0 +1,21 @@ +import Ajv from 'ajv'; +import type { IUser } from '@rocket.chat/core-typings'; + +const ajv = new Ajv({ + coerceTypes: true, +}); + +export type UsersListTeamsParamsGET = { userId: IUser['_id'] }; + +const UsersListTeamsParamsGetSchema = { + type: 'object', + properties: { + userId: { + type: 'string', + }, + }, + required: ['userId'], + additionalProperties: false, +}; + +export const isUsersListTeamsProps = ajv.compile<UsersListTeamsParamsGET>(UsersListTeamsParamsGetSchema); diff --git a/packages/rest-typings/src/v1/users/UsersSetAvatarParamsPOST.ts b/packages/rest-typings/src/v1/users/UsersSetAvatarParamsPOST.ts new file mode 100644 index 00000000000..9a43dc7b07f --- /dev/null +++ b/packages/rest-typings/src/v1/users/UsersSetAvatarParamsPOST.ts @@ -0,0 +1,33 @@ +import Ajv from 'ajv'; + +const ajv = new Ajv({ + coerceTypes: true, +}); + +export type UserSetAvatarParamsPOST = { + avatarUrl?: string; + userId?: string; + username?: string; +}; + +const UserSetAvatarParamsPostSchema = { + type: 'object', + properties: { + avatarUrl: { + type: 'string', + nullable: true, + }, + userId: { + type: 'string', + nullable: true, + }, + username: { + type: 'string', + nullable: true, + }, + }, + required: [], + additionalProperties: false, +}; + +export const isUserSetAvatarParamsPOST = ajv.compile<UserSetAvatarParamsPOST>(UserSetAvatarParamsPostSchema); diff --git a/packages/rest-typings/src/v1/users/UsersSetPreferenceParamsPOST.ts b/packages/rest-typings/src/v1/users/UsersSetPreferenceParamsPOST.ts new file mode 100644 index 00000000000..5379276b4de --- /dev/null +++ b/packages/rest-typings/src/v1/users/UsersSetPreferenceParamsPOST.ts @@ -0,0 +1,190 @@ +import Ajv from 'ajv'; + +const ajv = new Ajv({ + coerceTypes: true, +}); + +export type UsersSetPreferencesParamsPOST = { + userId?: string; + data: { + newRoomNotification?: string; + newMessageNotification?: string; + clockMode?: number; + useEmojis?: boolean; + convertAsciiEmoji?: boolean; + saveMobileBandwidth?: boolean; + collapseMediaByDefault?: boolean; + autoImageLoad?: boolean; + emailNotificationMode?: string; + unreadAlert?: boolean; + notificationsSoundVolume?: number; + desktopNotifications?: string; + pushNotifications?: string; + enableAutoAway?: boolean; + highlights?: string[]; + desktopNotificationRequireInteraction?: boolean; + messageViewMode?: number; + showMessageInMainThread?: boolean; + hideUsernames?: boolean; + hideRoles?: boolean; + displayAvatars?: boolean; + hideFlexTab?: boolean; + sendOnEnter?: string; + language?: string; + sidebarShowFavorites?: boolean; + sidebarShowUnread?: boolean; + sidebarSortby?: string; + sidebarViewMode?: string; + sidebarDisplayAvatar?: boolean; + sidebarGroupByType?: boolean; + muteFocusedConversations?: boolean; + }; +}; + +const UsersSetPreferencesParamsPostSchema = { + type: 'object', + properties: { + userId: { + type: 'string', + nullable: true, + }, + data: { + type: 'object', + properties: { + newRoomNotification: { + type: 'string', + nullable: true, + }, + newMessageNotification: { + type: 'string', + nullable: true, + }, + clockMode: { + type: 'number', + nullable: true, + }, + useEmojis: { + type: 'boolean', + nullable: true, + }, + convertAsciiEmoji: { + type: 'boolean', + nullable: true, + }, + saveMobileBandwidth: { + type: 'boolean', + nullable: true, + }, + collapseMediaByDefault: { + type: 'boolean', + nullable: true, + }, + autoImageLoad: { + type: 'boolean', + nullable: true, + }, + emailNotificationMode: { + type: 'string', + nullable: true, + }, + unreadAlert: { + type: 'boolean', + nullable: true, + }, + notificationsSoundVolume: { + type: 'number', + nullable: true, + }, + desktopNotifications: { + type: 'string', + nullable: true, + }, + pushNotifications: { + type: 'string', + nullable: true, + }, + enableAutoAway: { + type: 'boolean', + nullable: true, + }, + highlights: { + type: 'array', + items: { + type: 'string', + }, + nullable: true, + }, + desktopNotificationRequireInteraction: { + type: 'boolean', + nullable: true, + }, + messageViewMode: { + type: 'number', + nullable: true, + }, + showMessageInMainThread: { + type: 'boolean', + nullable: true, + }, + hideUsernames: { + type: 'boolean', + nullable: true, + }, + hideRoles: { + type: 'boolean', + nullable: true, + }, + displayAvatars: { + type: 'boolean', + nullable: true, + }, + hideFlexTab: { + type: 'boolean', + nullable: true, + }, + sendOnEnter: { + type: 'string', + nullable: true, + }, + language: { + type: 'string', + nullable: true, + }, + sidebarShowFavorites: { + type: 'boolean', + nullable: true, + }, + sidebarShowUnread: { + type: 'boolean', + nullable: true, + }, + sidebarSortby: { + type: 'string', + nullable: true, + }, + sidebarViewMode: { + type: 'string', + nullable: true, + }, + sidebarDisplayAvatar: { + type: 'boolean', + nullable: true, + }, + sidebarGroupByType: { + type: 'boolean', + nullable: true, + }, + muteFocusedConversations: { + type: 'boolean', + nullable: true, + }, + }, + required: [], + additionalProperties: false, + }, + }, + required: ['data'], + additionalProperties: false, +}; + +export const isUsersSetPreferencesParamsPOST = ajv.compile<UsersSetPreferencesParamsPOST>(UsersSetPreferencesParamsPostSchema); diff --git a/packages/rest-typings/src/v1/users/UsersUpdateOwnBasicInfoParamsPOST.ts b/packages/rest-typings/src/v1/users/UsersUpdateOwnBasicInfoParamsPOST.ts new file mode 100644 index 00000000000..90f80dedc13 --- /dev/null +++ b/packages/rest-typings/src/v1/users/UsersUpdateOwnBasicInfoParamsPOST.ts @@ -0,0 +1,67 @@ +import Ajv from 'ajv'; + +const ajv = new Ajv({ + coerceTypes: true, +}); + +export type UsersUpdateOwnBasicInfoParamsPOST = { + data: { + email?: string; + name?: string; + username?: string; + nickname?: string; + statusText?: string; + currentPassword?: string; + newPassword?: string; + }; + customFields?: {}; +}; + +const UsersUpdateOwnBasicInfoParamsPostSchema = { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + email: { + type: 'string', + nullable: true, + }, + name: { + type: 'string', + nullable: true, + }, + username: { + type: 'string', + nullable: true, + }, + nickname: { + type: 'string', + nullable: true, + }, + statusText: { + type: 'string', + nullable: true, + }, + currentPassword: { + type: 'string', + nullable: true, + }, + newPassword: { + type: 'string', + nullable: true, + }, + }, + required: [], + additionalProperties: false, + }, + customFields: { + type: 'object', + nullable: true, + }, + }, + required: ['data'], + additionalProperties: false, +}; + +export const isUsersUpdateOwnBasicInfoParamsPOST = ajv.compile<UsersUpdateOwnBasicInfoParamsPOST>(UsersUpdateOwnBasicInfoParamsPostSchema); diff --git a/packages/rest-typings/src/v1/users/UsersUpdateParamsPOST.ts b/packages/rest-typings/src/v1/users/UsersUpdateParamsPOST.ts new file mode 100644 index 00000000000..b9c60cd91cc --- /dev/null +++ b/packages/rest-typings/src/v1/users/UsersUpdateParamsPOST.ts @@ -0,0 +1,118 @@ +import Ajv from 'ajv'; + +const ajv = new Ajv({ + coerceTypes: true, +}); + +export type UsersUpdateParamsPOST = { + userId: string; + data: { + email?: string; + name?: string; + password?: string; + username?: string; + bio?: string; + nickname?: string; + statusText?: string; + active?: boolean; + roles?: string[]; + joinDefaultChannels?: boolean; + requirePasswordChange?: boolean; + setRandomPassword?: boolean; + sendWelcomeEmail?: boolean; + verified?: boolean; + customFields?: {}; + status?: string; + }; + confirmRelinquish?: boolean; +}; + +const UsersUpdateParamsPostSchema = { + type: 'object', + properties: { + userId: { + type: 'string', + }, + confirmRelinquish: { + type: 'boolean', + }, + data: { + type: 'object', + properties: { + email: { + type: 'string', + nullable: true, + }, + name: { + type: 'string', + nullable: true, + }, + password: { + type: 'string', + nullable: true, + }, + username: { + type: 'string', + nullable: true, + }, + bio: { + type: 'string', + nullable: true, + }, + nickname: { + type: 'string', + nullable: true, + }, + statusText: { + type: 'string', + nullable: true, + }, + active: { + type: 'boolean', + nullable: true, + }, + roles: { + type: 'array', + items: { + type: 'string', + }, + nullable: true, + }, + joinDefaultChannels: { + type: 'boolean', + nullable: true, + }, + requirePasswordChange: { + type: 'boolean', + nullable: true, + }, + setRandomPassword: { + type: 'boolean', + nullable: true, + }, + sendWelcomeEmail: { + type: 'boolean', + nullable: true, + }, + verified: { + type: 'boolean', + nullable: true, + }, + customFields: { + type: 'object', + nullable: true, + }, + status: { + type: 'string', + nullable: true, + }, + }, + required: [], + additionalProperties: false, + }, + }, + required: ['userId', 'data'], + additionalProperties: false, +}; + +export const isUsersUpdateParamsPOST = ajv.compile<UsersUpdateParamsPOST>(UsersUpdateParamsPostSchema); diff --git a/packages/ui-contexts/package.json b/packages/ui-contexts/package.json index 42a0017b89a..6ad0d35404f 100644 --- a/packages/ui-contexts/package.json +++ b/packages/ui-contexts/package.json @@ -5,18 +5,18 @@ "devDependencies": { "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/emitter": "^0.31.11", - "@rocket.chat/fuselage-hooks": "^0.31.11", + "@rocket.chat/fuselage-hooks": "~0.31.14-dev.9", "@rocket.chat/rest-typings": "workspace:^", "@types/jest": "^27.4.1", "@types/mongodb": "~3.6.10", "@types/react": "^17", - "@types/use-subscription": "^1", + "@types/use-sync-external-store": "^0.0.3", "eslint": "^8.12.0", "jest": "^27.5.1", "react": "~17.0.2", "ts-jest": "^27.1.4", "typescript": "~4.3.5", - "use-subscription": "~1.6.0" + "use-sync-external-store": "^1.2.0" }, "peerDependencies": { "@rocket.chat/core-typings": "workspace:^", @@ -24,12 +24,13 @@ "@rocket.chat/fuselage-hooks": "*", "@rocket.chat/rest-typings": "workspace:^", "react": "~17.0.2", - "use-subscription": "*" + "use-sync-external-store": "^1.2.0" }, "scripts": { "lint": "eslint --ext .js,.jsx,.ts,.tsx .", "lint:fix": "eslint --ext .js,.jsx,.ts,.tsx . --fix", "jest": "jest", + "dev": "tsc --watch --preserveWatchOutput -p tsconfig.json", "build": "rm -rf dist && tsc -p tsconfig.json" }, "main": "./dist/index.js", diff --git a/packages/ui-contexts/src/AuthorizationContext.ts b/packages/ui-contexts/src/AuthorizationContext.ts index 5072d00ed56..23583884164 100644 --- a/packages/ui-contexts/src/AuthorizationContext.ts +++ b/packages/ui-contexts/src/AuthorizationContext.ts @@ -1,7 +1,6 @@ import type { IRole } from '@rocket.chat/core-typings'; import type { IEmitter } from '@rocket.chat/emitter'; import { createContext } from 'react'; -import type { Subscription, Unsubscribe } from 'use-subscription'; import type { ObjectId } from 'mongodb'; export type IRoles = { [_id: string]: IRole }; @@ -13,30 +12,27 @@ export type RoleStore = IEmitter<{ }; export type AuthorizationContextValue = { - queryPermission(permission: string | ObjectId, scope?: string | ObjectId): Subscription<boolean>; - queryAtLeastOnePermission(permission: (string | ObjectId)[], scope?: string | ObjectId): Subscription<boolean>; - queryAllPermissions(permission: (string | ObjectId)[], scope?: string | ObjectId): Subscription<boolean>; - queryRole(role: string | ObjectId): Subscription<boolean>; + queryPermission( + permission: string | ObjectId, + scope?: string | ObjectId, + ): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => boolean]; + queryAtLeastOnePermission( + permission: (string | ObjectId)[], + scope?: string | ObjectId, + ): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => boolean]; + queryAllPermissions( + permission: (string | ObjectId)[], + scope?: string | ObjectId, + ): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => boolean]; + queryRole(role: string | ObjectId): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => boolean]; roleStore: RoleStore; }; export const AuthorizationContext = createContext<AuthorizationContextValue>({ - queryPermission: () => ({ - getCurrentValue: (): boolean => false, - subscribe: (): Unsubscribe => (): void => undefined, - }), - queryAtLeastOnePermission: () => ({ - getCurrentValue: (): boolean => false, - subscribe: (): Unsubscribe => (): void => undefined, - }), - queryAllPermissions: () => ({ - getCurrentValue: (): boolean => false, - subscribe: (): Unsubscribe => (): void => undefined, - }), - queryRole: () => ({ - getCurrentValue: (): boolean => false, - subscribe: (): Unsubscribe => (): void => undefined, - }), + queryPermission: () => [() => (): void => undefined, (): boolean => false], + queryAtLeastOnePermission: () => [() => (): void => undefined, (): boolean => false], + queryAllPermissions: () => [() => (): void => undefined, (): boolean => false], + queryRole: () => [() => (): void => undefined, (): boolean => false], roleStore: { roles: {}, emit: (): void => undefined, diff --git a/packages/ui-contexts/src/RouterContext.ts b/packages/ui-contexts/src/RouterContext.ts index 54fa4cd66d8..6fd12b67e41 100644 --- a/packages/ui-contexts/src/RouterContext.ts +++ b/packages/ui-contexts/src/RouterContext.ts @@ -1,5 +1,4 @@ import { createContext } from 'react'; -import type { Subscription } from 'use-subscription'; export type RouteName = string; @@ -14,44 +13,34 @@ export type RouterContextValue = { name: RouteName, parameters: RouteParameters | undefined, queryStringParameters: QueryStringParameters | undefined, - ) => Subscription<string | undefined>; + ) => [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => string | undefined]; queryRouteUrl: ( name: RouteName, parameters: RouteParameters | undefined, queryStringParameters: QueryStringParameters | undefined, - ) => Subscription<string | undefined>; + ) => [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => string | undefined]; pushRoute: (name: RouteName, parameters: RouteParameters | undefined, queryStringParameters: QueryStringParameters | undefined) => void; replaceRoute: ( name: RouteName, parameters: RouteParameters | undefined, queryStringParameters: QueryStringParameters | undefined, ) => void; - queryRouteParameter: (name: string) => Subscription<string | undefined>; - queryQueryStringParameter: (name: string) => Subscription<string | undefined>; - queryCurrentRoute: () => Subscription<[RouteName?, RouteParameters?, QueryStringParameters?, RouteGroupName?]>; + queryRouteParameter: (name: string) => [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => string | undefined]; + queryQueryStringParameter: ( + name: string, + ) => [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => string | undefined]; + queryCurrentRoute: () => [ + subscribe: (onStoreChange: () => void) => () => void, + getSnapshot: () => [RouteName?, RouteParameters?, QueryStringParameters?, RouteGroupName?], + ]; }; export const RouterContext = createContext<RouterContextValue>({ - queryRoutePath: () => ({ - getCurrentValue: (): undefined => undefined, - subscribe: () => (): void => undefined, - }), - queryRouteUrl: () => ({ - getCurrentValue: (): undefined => undefined, - subscribe: () => (): void => undefined, - }), + queryRoutePath: () => [() => (): void => undefined, (): undefined => undefined], + queryRouteUrl: () => [() => (): void => undefined, (): undefined => undefined], pushRoute: () => undefined, replaceRoute: () => undefined, - queryRouteParameter: () => ({ - getCurrentValue: (): undefined => undefined, - subscribe: () => (): void => undefined, - }), - queryQueryStringParameter: () => ({ - getCurrentValue: (): undefined => undefined, - subscribe: () => (): void => undefined, - }), - queryCurrentRoute: () => ({ - getCurrentValue: (): [undefined, {}, {}, undefined] => [undefined, {}, {}, undefined], - subscribe: () => (): void => undefined, - }), + queryRouteParameter: () => [() => (): void => undefined, (): undefined => undefined], + queryQueryStringParameter: () => [() => (): void => undefined, (): undefined => undefined], + queryCurrentRoute: () => [() => (): void => undefined, (): [undefined, {}, {}, undefined] => [undefined, {}, {}, undefined]], }); diff --git a/packages/ui-contexts/src/ServerContext/methods.ts b/packages/ui-contexts/src/ServerContext/methods.ts index 5cdbb326439..825febf0e22 100644 --- a/packages/ui-contexts/src/ServerContext/methods.ts +++ b/packages/ui-contexts/src/ServerContext/methods.ts @@ -127,6 +127,26 @@ export interface ServerMethods { 'livechat:saveAgentInfo': (_id: string, agentData: unknown, agentDepartments: unknown) => unknown; 'autoTranslate.getProviderUiMetadata': () => Record<string, { name: string; displayName: string }>; 'autoTranslate.getSupportedLanguages': (language: string) => ISupportedLanguage[]; + 'spotlight': ( + ...args: ( + | string + | string[] + | { + users: boolean; + rooms: boolean; + } + )[] + ) => { + rooms: { _id: string; name: string; t: string; uids?: string[] }[]; + users: { + _id: string; + status: 'offline' | 'online' | 'busy' | 'away'; + name: string; + username: string; + outside: boolean; + avatarETag?: string; + }[]; + }; } export type ServerMethodName = keyof ServerMethods; diff --git a/packages/ui-contexts/src/SessionContext.ts b/packages/ui-contexts/src/SessionContext.ts index 250953bffae..24cb77f5564 100644 --- a/packages/ui-contexts/src/SessionContext.ts +++ b/packages/ui-contexts/src/SessionContext.ts @@ -1,15 +1,11 @@ import { createContext } from 'react'; -import type { Subscription, Unsubscribe } from 'use-subscription'; export type SessionContextValue = { - query: (name: string) => Subscription<unknown>; + query: (name: string) => [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => unknown]; dispatch: (name: string, value: unknown) => void; }; export const SessionContext = createContext<SessionContextValue>({ - query: () => ({ - getCurrentValue: (): undefined => undefined, - subscribe: (): Unsubscribe => (): void => undefined, - }), + query: () => [() => (): void => undefined, (): undefined => undefined], dispatch: (): void => undefined, }); diff --git a/packages/ui-contexts/src/SettingsContext.ts b/packages/ui-contexts/src/SettingsContext.ts index ed27efa4f4e..d9bb2f2e33d 100644 --- a/packages/ui-contexts/src/SettingsContext.ts +++ b/packages/ui-contexts/src/SettingsContext.ts @@ -1,6 +1,5 @@ import type { SettingId, ISetting, GroupId, SectionName, TabId } from '@rocket.chat/core-typings'; import { createContext } from 'react'; -import type { Subscription, Unsubscribe } from 'use-subscription'; export type SettingsContextQuery = { readonly _id?: SettingId[]; @@ -12,21 +11,19 @@ export type SettingsContextQuery = { export type SettingsContextValue = { readonly hasPrivateAccess: boolean; readonly isLoading: boolean; - readonly querySetting: (_id: SettingId) => Subscription<ISetting | undefined>; - readonly querySettings: (query: SettingsContextQuery) => Subscription<ISetting[]>; + readonly querySetting: ( + _id: SettingId, + ) => [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => ISetting | undefined]; + readonly querySettings: ( + query: SettingsContextQuery, + ) => [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => ISetting[]]; readonly dispatch: (changes: Partial<ISetting>[]) => Promise<void>; }; export const SettingsContext = createContext<SettingsContextValue>({ hasPrivateAccess: false, isLoading: false, - querySetting: () => ({ - getCurrentValue: (): undefined => undefined, - subscribe: (): Unsubscribe => (): void => undefined, - }), - querySettings: () => ({ - getCurrentValue: (): ISetting[] => [], - subscribe: (): Unsubscribe => (): void => undefined, - }), + querySetting: () => [(): (() => void) => (): void => undefined, (): undefined => undefined], + querySettings: () => [(): (() => void) => (): void => undefined, (): ISetting[] => []], dispatch: async () => undefined, }); diff --git a/packages/ui-contexts/src/UserContext.ts b/packages/ui-contexts/src/UserContext.ts index c44e835a170..05071b86b72 100644 --- a/packages/ui-contexts/src/UserContext.ts +++ b/packages/ui-contexts/src/UserContext.ts @@ -1,7 +1,6 @@ import type { IRoom, ISubscription, IUser } from '@rocket.chat/core-typings'; import type { ObjectId, FilterQuery } from 'mongodb'; import { createContext } from 'react'; -import type { Subscription, Unsubscribe } from 'use-subscription'; export type SubscriptionQuery = | { @@ -33,10 +32,24 @@ export type UserContextValue = { user: IUser | null; loginWithPassword: (user: string | object, password: string) => Promise<void>; logout: () => Promise<void>; - queryPreference: <T>(key: string | ObjectId, defaultValue?: T) => Subscription<T | undefined>; - querySubscription: (query: FilterQuery<ISubscription>, fields?: Fields, sort?: Sort) => Subscription<ISubscription | undefined>; - queryRoom: (query: FilterQuery<IRoom>, fields?: Fields, sort?: Sort) => Subscription<IRoom | undefined>; - querySubscriptions: (query: SubscriptionQuery, options?: FindOptions) => Subscription<Array<ISubscription> | []>; + queryPreference: <T>( + key: string | ObjectId, + defaultValue?: T, + ) => [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => T | undefined]; + querySubscription: ( + query: FilterQuery<ISubscription>, + fields?: Fields, + sort?: Sort, + ) => [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => ISubscription | undefined]; + queryRoom: ( + query: FilterQuery<IRoom>, + fields?: Fields, + sort?: Sort, + ) => [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => IRoom | undefined]; + querySubscriptions: ( + query: SubscriptionQuery, + options?: FindOptions, + ) => [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => Array<ISubscription> | []]; }; export const UserContext = createContext<UserContextValue>({ @@ -44,20 +57,8 @@ export const UserContext = createContext<UserContextValue>({ user: null, loginWithPassword: async () => undefined, logout: () => Promise.resolve(), - queryPreference: () => ({ - getCurrentValue: (): undefined => undefined, - subscribe: (): Unsubscribe => (): void => undefined, - }), - querySubscription: () => ({ - getCurrentValue: (): undefined => undefined, - subscribe: (): Unsubscribe => (): void => undefined, - }), - queryRoom: () => ({ - getCurrentValue: (): undefined => undefined, - subscribe: (): Unsubscribe => (): void => undefined, - }), - querySubscriptions: () => ({ - getCurrentValue: (): [] => [], - subscribe: (): Unsubscribe => (): void => undefined, - }), + queryPreference: () => [() => (): void => undefined, (): undefined => undefined], + querySubscription: () => [() => (): void => undefined, (): undefined => undefined], + queryRoom: () => [() => (): void => undefined, (): undefined => undefined], + querySubscriptions: () => [() => (): void => undefined, (): [] => []], }); diff --git a/packages/ui-contexts/src/hooks/useAllPermissions.ts b/packages/ui-contexts/src/hooks/useAllPermissions.ts index 5fe4a826282..06acd07d92b 100644 --- a/packages/ui-contexts/src/hooks/useAllPermissions.ts +++ b/packages/ui-contexts/src/hooks/useAllPermissions.ts @@ -1,11 +1,11 @@ import type { ObjectId } from 'mongodb'; import { useContext, useMemo } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { AuthorizationContext } from '../AuthorizationContext'; export const useAllPermissions = (permissions: (string | ObjectId)[], scope?: string | ObjectId): boolean => { const { queryAllPermissions } = useContext(AuthorizationContext); - const subscription = useMemo(() => queryAllPermissions(permissions, scope), [queryAllPermissions, permissions, scope]); - return useSubscription(subscription); + const [subscribe, getSnapshot] = useMemo(() => queryAllPermissions(permissions, scope), [queryAllPermissions, permissions, scope]); + return useSyncExternalStore(subscribe, getSnapshot); }; diff --git a/packages/ui-contexts/src/hooks/useAtLeastOnePermission.ts b/packages/ui-contexts/src/hooks/useAtLeastOnePermission.ts index 1bcb9d46a5a..4a2fe9d060a 100644 --- a/packages/ui-contexts/src/hooks/useAtLeastOnePermission.ts +++ b/packages/ui-contexts/src/hooks/useAtLeastOnePermission.ts @@ -1,11 +1,14 @@ import type { ObjectId } from 'mongodb'; import { useContext, useMemo } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { AuthorizationContext } from '../AuthorizationContext'; export const useAtLeastOnePermission = (permissions: (string | ObjectId)[], scope?: string | ObjectId): boolean => { const { queryAtLeastOnePermission } = useContext(AuthorizationContext); - const subscription = useMemo(() => queryAtLeastOnePermission(permissions, scope), [queryAtLeastOnePermission, permissions, scope]); - return useSubscription(subscription); + const [subscribe, getSnapshot] = useMemo( + () => queryAtLeastOnePermission(permissions, scope), + [queryAtLeastOnePermission, permissions, scope], + ); + return useSyncExternalStore(subscribe, getSnapshot); }; diff --git a/packages/ui-contexts/src/hooks/useCurrentRoute.ts b/packages/ui-contexts/src/hooks/useCurrentRoute.ts index 4a6ed6dbe63..b7cc0e15f08 100644 --- a/packages/ui-contexts/src/hooks/useCurrentRoute.ts +++ b/packages/ui-contexts/src/hooks/useCurrentRoute.ts @@ -1,10 +1,11 @@ import { useContext, useMemo } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { QueryStringParameters, RouteGroupName, RouteName, RouteParameters, RouterContext } from '../RouterContext'; export const useCurrentRoute = (): [RouteName?, RouteParameters?, QueryStringParameters?, RouteGroupName?] => { const { queryCurrentRoute } = useContext(RouterContext); - return useSubscription(useMemo(() => queryCurrentRoute(), [queryCurrentRoute])); + const [subscribe, getSnapshot] = useMemo(() => queryCurrentRoute(), [queryCurrentRoute]); + return useSyncExternalStore(subscribe, getSnapshot); }; diff --git a/packages/ui-contexts/src/hooks/usePermission.ts b/packages/ui-contexts/src/hooks/usePermission.ts index 950b0832e7b..cce1d6effd4 100644 --- a/packages/ui-contexts/src/hooks/usePermission.ts +++ b/packages/ui-contexts/src/hooks/usePermission.ts @@ -1,11 +1,11 @@ import type { ObjectId } from 'mongodb'; import { useContext, useMemo } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { AuthorizationContext } from '../AuthorizationContext'; export const usePermission = (permission: string | ObjectId, scope?: string | ObjectId): boolean => { const { queryPermission } = useContext(AuthorizationContext); - const subscription = useMemo(() => queryPermission(permission, scope), [queryPermission, permission, scope]); - return useSubscription(subscription); + const [subscribe, getSnapshot] = useMemo(() => queryPermission(permission, scope), [queryPermission, permission, scope]); + return useSyncExternalStore(subscribe, getSnapshot); }; diff --git a/packages/ui-contexts/src/hooks/useQueryStringParameter.ts b/packages/ui-contexts/src/hooks/useQueryStringParameter.ts index d0dbc3d4ab3..cc7a50dfad7 100644 --- a/packages/ui-contexts/src/hooks/useQueryStringParameter.ts +++ b/packages/ui-contexts/src/hooks/useQueryStringParameter.ts @@ -1,10 +1,12 @@ import { useContext, useMemo } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { RouterContext } from '../RouterContext'; export const useQueryStringParameter = (name: string): string | undefined => { const { queryQueryStringParameter } = useContext(RouterContext); - return useSubscription(useMemo(() => queryQueryStringParameter(name), [queryQueryStringParameter, name])); + const [subscribe, getSnapshot] = useMemo(() => queryQueryStringParameter(name), [queryQueryStringParameter, name]); + + return useSyncExternalStore(subscribe, getSnapshot); }; diff --git a/packages/ui-contexts/src/hooks/useRole.ts b/packages/ui-contexts/src/hooks/useRole.ts index b2cd3b23c1e..da35e5b8405 100644 --- a/packages/ui-contexts/src/hooks/useRole.ts +++ b/packages/ui-contexts/src/hooks/useRole.ts @@ -1,11 +1,11 @@ import type { ObjectId } from 'mongodb'; import { useContext, useMemo } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { AuthorizationContext } from '../AuthorizationContext'; export const useRole = (role: string | ObjectId): boolean => { const { queryRole } = useContext(AuthorizationContext); - const subscription = useMemo(() => queryRole(role), [queryRole, role]); - return useSubscription(subscription); + const [subscribe, getSnapshot] = useMemo(() => queryRole(role), [queryRole, role]); + return useSyncExternalStore(subscribe, getSnapshot); }; diff --git a/packages/ui-contexts/src/hooks/useRolesDescription.ts b/packages/ui-contexts/src/hooks/useRolesDescription.ts index 4b523be76ce..4cdc13983c7 100644 --- a/packages/ui-contexts/src/hooks/useRolesDescription.ts +++ b/packages/ui-contexts/src/hooks/useRolesDescription.ts @@ -1,25 +1,25 @@ import { useCallback, useContext, useMemo } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { AuthorizationContext, IRoles } from '../AuthorizationContext'; export const useRolesDescription = (): ((ids: Array<string>) => [string]) => { const { roleStore } = useContext(AuthorizationContext); - const subscription = useMemo( - () => ({ - getCurrentValue: (): IRoles => roleStore.roles, - subscribe: (callback: () => void): (() => void) => { + const [subscribe, getSnapshot] = useMemo( + () => [ + (callback: () => void): (() => void) => { roleStore.on('change', callback); return (): void => { roleStore.off('change', callback); }; }, - }), + (): IRoles => roleStore.roles, + ], [roleStore], ); - const roles = useSubscription<IRoles>(subscription); + const roles = useSyncExternalStore(subscribe, getSnapshot); return useCallback((values) => values.map((role) => roles[role]?.description || roles[role]?.name || role), [roles]) as ( ids: Array<string>, diff --git a/packages/ui-contexts/src/hooks/useRoute.ts b/packages/ui-contexts/src/hooks/useRoute.ts index db1db7c1519..5b48d9225c1 100644 --- a/packages/ui-contexts/src/hooks/useRoute.ts +++ b/packages/ui-contexts/src/hooks/useRoute.ts @@ -14,10 +14,9 @@ export const useRoute = (name: string): Route => { return useMemo<Route>( () => ({ - getPath: (parameters, queryStringParameters): string | undefined => - queryRoutePath(name, parameters, queryStringParameters).getCurrentValue(), + getPath: (parameters, queryStringParameters): string | undefined => queryRoutePath(name, parameters, queryStringParameters)[1](), getUrl: (parameters, queryStringParameters): ReturnType<Route['getUrl']> => - queryRouteUrl(name, parameters, queryStringParameters).getCurrentValue(), + queryRouteUrl(name, parameters, queryStringParameters)[1](), push: (parameters, queryStringParameters): ReturnType<Route['push']> => pushRoute(name, parameters, queryStringParameters), replace: (parameters, queryStringParameters): ReturnType<Route['replace']> => replaceRoute(name, parameters, queryStringParameters), }), diff --git a/packages/ui-contexts/src/hooks/useRouteParameter.ts b/packages/ui-contexts/src/hooks/useRouteParameter.ts index 01806bbc523..eaae7e45fd0 100644 --- a/packages/ui-contexts/src/hooks/useRouteParameter.ts +++ b/packages/ui-contexts/src/hooks/useRouteParameter.ts @@ -1,10 +1,12 @@ import { useContext, useMemo } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { RouterContext } from '../RouterContext'; export const useRouteParameter = (name: string): string | undefined => { const { queryRouteParameter } = useContext(RouterContext); - return useSubscription(useMemo(() => queryRouteParameter(name), [queryRouteParameter, name])); + const [subscribe, getSnapshot] = useMemo(() => queryRouteParameter(name), [queryRouteParameter, name]); + + return useSyncExternalStore(subscribe, getSnapshot); }; diff --git a/packages/ui-contexts/src/hooks/useRoutePath.ts b/packages/ui-contexts/src/hooks/useRoutePath.ts index a30e1ec63f1..f159b1fa8b5 100644 --- a/packages/ui-contexts/src/hooks/useRoutePath.ts +++ b/packages/ui-contexts/src/hooks/useRoutePath.ts @@ -1,5 +1,5 @@ import { useContext, useMemo } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { QueryStringParameters, RouteParameters, RouterContext } from '../RouterContext'; @@ -10,7 +10,10 @@ export const useRoutePath = ( ): string | undefined => { const { queryRoutePath } = useContext(RouterContext); - return useSubscription( - useMemo(() => queryRoutePath(name, parameters, queryStringParameters), [queryRoutePath, name, parameters, queryStringParameters]), + const [subscribe, getSnapshot] = useMemo( + () => queryRoutePath(name, parameters, queryStringParameters), + [queryRoutePath, name, parameters, queryStringParameters], ); + + return useSyncExternalStore(subscribe, getSnapshot); }; diff --git a/packages/ui-contexts/src/hooks/useRouteUrl.ts b/packages/ui-contexts/src/hooks/useRouteUrl.ts index b88adcd4488..9d1f8908710 100644 --- a/packages/ui-contexts/src/hooks/useRouteUrl.ts +++ b/packages/ui-contexts/src/hooks/useRouteUrl.ts @@ -1,5 +1,5 @@ import { useContext, useMemo } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { QueryStringParameters, RouteParameters, RouterContext } from '../RouterContext'; @@ -10,7 +10,10 @@ export const useRouteUrl = ( ): string | undefined => { const { queryRouteUrl } = useContext(RouterContext); - return useSubscription( - useMemo(() => queryRouteUrl(name, parameters, queryStringParameters), [queryRouteUrl, name, parameters, queryStringParameters]), + const [subscribe, getSnapshot] = useMemo( + () => queryRouteUrl(name, parameters, queryStringParameters), + [queryRouteUrl, name, parameters, queryStringParameters], ); + + return useSyncExternalStore(subscribe, getSnapshot); }; diff --git a/packages/ui-contexts/src/hooks/useSession.ts b/packages/ui-contexts/src/hooks/useSession.ts index f0860691727..0dd1826de15 100644 --- a/packages/ui-contexts/src/hooks/useSession.ts +++ b/packages/ui-contexts/src/hooks/useSession.ts @@ -1,10 +1,10 @@ import { useContext, useMemo } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { SessionContext } from '../SessionContext'; export const useSession = (name: string): unknown => { const { query } = useContext(SessionContext); - const subscription = useMemo(() => query(name), [query, name]); - return useSubscription(subscription); + const [subscribe, getSnapshot] = useMemo(() => query(name), [query, name]); + return useSyncExternalStore(subscribe, getSnapshot); }; diff --git a/packages/ui-contexts/src/hooks/useSettingStructure.ts b/packages/ui-contexts/src/hooks/useSettingStructure.ts index 8139e77ce5f..19eeb30815f 100644 --- a/packages/ui-contexts/src/hooks/useSettingStructure.ts +++ b/packages/ui-contexts/src/hooks/useSettingStructure.ts @@ -1,11 +1,11 @@ import type { SettingId, ISetting } from '@rocket.chat/core-typings'; import { useContext, useMemo } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { SettingsContext } from '../SettingsContext'; export const useSettingStructure = (_id: SettingId): ISetting | undefined => { const { querySetting } = useContext(SettingsContext); - const subscription = useMemo(() => querySetting(_id), [querySetting, _id]); - return useSubscription(subscription); + const [subscribe, getSnapshot] = useMemo(() => querySetting(_id), [querySetting, _id]); + return useSyncExternalStore(subscribe, getSnapshot); }; diff --git a/packages/ui-contexts/src/hooks/useSettings.ts b/packages/ui-contexts/src/hooks/useSettings.ts index f561034d99f..e2839a2ae56 100644 --- a/packages/ui-contexts/src/hooks/useSettings.ts +++ b/packages/ui-contexts/src/hooks/useSettings.ts @@ -1,11 +1,11 @@ import type { ISetting } from '@rocket.chat/core-typings'; import { useContext, useMemo } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { SettingsContext, SettingsContextQuery } from '../SettingsContext'; export const useSettings = (query?: SettingsContextQuery): ISetting[] => { const { querySettings } = useContext(SettingsContext); - const subscription = useMemo(() => querySettings(query ?? {}), [querySettings, query]); - return useSubscription(subscription); + const [subscribe, getSnapshot] = useMemo(() => querySettings(query ?? {}), [querySettings, query]); + return useSyncExternalStore(subscribe, getSnapshot); }; diff --git a/packages/ui-contexts/src/hooks/useUserPreference.ts b/packages/ui-contexts/src/hooks/useUserPreference.ts index cb6a715927e..2865cade101 100644 --- a/packages/ui-contexts/src/hooks/useUserPreference.ts +++ b/packages/ui-contexts/src/hooks/useUserPreference.ts @@ -1,10 +1,10 @@ import { useContext, useMemo } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { UserContext } from '../UserContext'; export const useUserPreference = <T>(key: string, defaultValue?: T): T | undefined => { const { queryPreference } = useContext(UserContext); - const subscription = useMemo(() => queryPreference(key, defaultValue), [queryPreference, key, defaultValue]); - return useSubscription(subscription); + const [subscribe, getSnapshot] = useMemo(() => queryPreference(key, defaultValue), [queryPreference, key, defaultValue]); + return useSyncExternalStore(subscribe, getSnapshot); }; diff --git a/packages/ui-contexts/src/hooks/useUserRoom.ts b/packages/ui-contexts/src/hooks/useUserRoom.ts index 412e7165fcf..f67a63eb07d 100644 --- a/packages/ui-contexts/src/hooks/useUserRoom.ts +++ b/packages/ui-contexts/src/hooks/useUserRoom.ts @@ -1,11 +1,11 @@ import type { IRoom } from '@rocket.chat/core-typings'; import { useContext, useMemo } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { Fields, UserContext } from '../UserContext'; export const useUserRoom = (rid: string, fields?: Fields): IRoom | undefined => { const { queryRoom } = useContext(UserContext); - const subscription = useMemo(() => queryRoom({ _id: rid }, fields), [queryRoom, rid, fields]); - return useSubscription(subscription); + const [subscribe, getSnapshot] = useMemo(() => queryRoom({ _id: rid }, fields), [queryRoom, rid, fields]); + return useSyncExternalStore(subscribe, getSnapshot); }; diff --git a/packages/ui-contexts/src/hooks/useUserSubscription.ts b/packages/ui-contexts/src/hooks/useUserSubscription.ts index 112c016253e..c5643c7432c 100644 --- a/packages/ui-contexts/src/hooks/useUserSubscription.ts +++ b/packages/ui-contexts/src/hooks/useUserSubscription.ts @@ -1,11 +1,11 @@ import type { ISubscription } from '@rocket.chat/core-typings'; import { useContext, useMemo } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { Fields, UserContext } from '../UserContext'; export const useUserSubscription = (rid: string, fields?: Fields): ISubscription | undefined => { const { querySubscription } = useContext(UserContext); - const subscription = useMemo(() => querySubscription({ rid }, fields), [querySubscription, rid, fields]); - return useSubscription(subscription); + const [subscribe, getSnapshot] = useMemo(() => querySubscription({ rid }, fields), [querySubscription, rid, fields]); + return useSyncExternalStore(subscribe, getSnapshot); }; diff --git a/packages/ui-contexts/src/hooks/useUserSubscriptionByName.ts b/packages/ui-contexts/src/hooks/useUserSubscriptionByName.ts index 8ec78f325c5..5768113fc2f 100644 --- a/packages/ui-contexts/src/hooks/useUserSubscriptionByName.ts +++ b/packages/ui-contexts/src/hooks/useUserSubscriptionByName.ts @@ -1,11 +1,11 @@ import type { ISubscription } from '@rocket.chat/core-typings'; import { useContext, useMemo } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { Fields, Sort, UserContext } from '../UserContext'; -export const useUserSubscriptionByName = (name: string, fields: Fields, sort?: Sort): ISubscription | undefined => { +export const useUserSubscriptionByName = (name: string, fields?: Fields, sort?: Sort): ISubscription | undefined => { const { querySubscription } = useContext(UserContext); - const subscription = useMemo(() => querySubscription({ name }, fields, sort), [querySubscription, name, fields, sort]); - return useSubscription(subscription); + const [subscribe, getSnapshot] = useMemo(() => querySubscription({ name }, fields, sort), [querySubscription, name, fields, sort]); + return useSyncExternalStore(subscribe, getSnapshot); }; diff --git a/packages/ui-contexts/src/hooks/useUserSubscriptions.ts b/packages/ui-contexts/src/hooks/useUserSubscriptions.ts index f73d42eca35..ffcb0b5cb7f 100644 --- a/packages/ui-contexts/src/hooks/useUserSubscriptions.ts +++ b/packages/ui-contexts/src/hooks/useUserSubscriptions.ts @@ -1,11 +1,11 @@ import { useContext, useMemo } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import type { ISubscription } from '../../../core-typings/dist'; import { FindOptions, SubscriptionQuery, UserContext } from '../UserContext'; export const useUserSubscriptions = (query: SubscriptionQuery, options?: FindOptions): Array<ISubscription> | [] => { const { querySubscriptions } = useContext(UserContext); - const subscription = useMemo(() => querySubscriptions(query, options), [querySubscriptions, query, options]); - return useSubscription(subscription); + const [subscribe, getSnapshot] = useMemo(() => querySubscriptions(query, options), [querySubscriptions, query, options]); + return useSyncExternalStore(subscribe, getSnapshot); }; diff --git a/turbo.json b/turbo.json index 090f296587a..c12232840f7 100644 --- a/turbo.json +++ b/turbo.json @@ -25,6 +25,10 @@ "outputs": [] }, "dev": { + "dependsOn": ["^dev"], + "cache": false + }, + "dsv": { "dependsOn": ["build"], "cache": false } diff --git a/yarn.lock b/yarn.lock index 06f4fc2cfcf..78958caeabd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3443,6 +3443,15 @@ __metadata: languageName: unknown linkType: soft +"@rocket.chat/favicon@workspace:^, @rocket.chat/favicon@workspace:packages/favicon": + version: 0.0.0-use.local + resolution: "@rocket.chat/favicon@workspace:packages/favicon" + dependencies: + eslint: ^8.12.0 + typescript: ~4.3.4 + languageName: unknown + linkType: soft + "@rocket.chat/forked-matrix-appservice-bridge@npm:^4.0.1": version: 4.0.1 resolution: "@rocket.chat/forked-matrix-appservice-bridge@npm:4.0.1" @@ -3503,7 +3512,7 @@ __metadata: languageName: node linkType: hard -"@rocket.chat/fuselage-hooks@npm:^0.31.11, @rocket.chat/fuselage-hooks@npm:^0.31.13, @rocket.chat/fuselage-hooks@npm:~0.31.12": +"@rocket.chat/fuselage-hooks@npm:^0.31.13": version: 0.31.13 resolution: "@rocket.chat/fuselage-hooks@npm:0.31.13" dependencies: @@ -3529,6 +3538,18 @@ __metadata: languageName: node linkType: hard +"@rocket.chat/fuselage-hooks@npm:~0.31.14-dev.9": + version: 0.31.14-dev.9 + resolution: "@rocket.chat/fuselage-hooks@npm:0.31.14-dev.9" + dependencies: + use-sync-external-store: ~1.2.0 + peerDependencies: + "@rocket.chat/fuselage-tokens": "*" + react: ^17.0.2 + checksum: fa79c8c8a5b56d0c5ccd2118735a6eef5aaf99f1392161d90e72da4dc0cd1dc9007387df71ab428545f72a3c5c8c1648c086078724cb4698ec05044a0462e0b6 + languageName: node + linkType: hard + "@rocket.chat/fuselage-polyfills@npm:~0.31.12": version: 0.31.13 resolution: "@rocket.chat/fuselage-polyfills@npm:0.31.13" @@ -3804,10 +3825,11 @@ __metadata: "@rocket.chat/css-in-js": ~0.31.12 "@rocket.chat/emitter": ~0.31.12 "@rocket.chat/eslint-config": "workspace:^" + "@rocket.chat/favicon": "workspace:^" "@rocket.chat/forked-matrix-appservice-bridge": ^4.0.1 "@rocket.chat/forked-matrix-bot-sdk": ^0.6.0-beta.2 "@rocket.chat/fuselage": 0.32.0-dev.49 - "@rocket.chat/fuselage-hooks": ~0.31.12 + "@rocket.chat/fuselage-hooks": ~0.31.14-dev.9 "@rocket.chat/fuselage-polyfills": ~0.31.12 "@rocket.chat/fuselage-toastbar": ^0.32.0-dev.22 "@rocket.chat/fuselage-tokens": ~0.31.12 @@ -3894,7 +3916,7 @@ __metadata: "@types/supertest": ^2.0.11 "@types/ua-parser-js": ^0.7.36 "@types/underscore.string": 0.0.38 - "@types/use-subscription": ^1.0.0 + "@types/use-sync-external-store": ^0.0.3 "@types/uuid": ^8.3.4 "@types/xml-crypto": ^1.4.1 "@types/xmldom": ^0.1.30 @@ -4071,7 +4093,7 @@ __metadata: underscore.string: ^3.3.6 universal-perf-hooks: ^1.0.1 url-polyfill: ^1.1.12 - use-subscription: ~1.6.0 + use-sync-external-store: ^1.2.0 uuid: ^8.3.2 vm2: ^3.9.9 webdav: ^4.9.0 @@ -4227,25 +4249,25 @@ __metadata: dependencies: "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/emitter": ^0.31.11 - "@rocket.chat/fuselage-hooks": ^0.31.11 + "@rocket.chat/fuselage-hooks": ~0.31.14-dev.9 "@rocket.chat/rest-typings": "workspace:^" "@types/jest": ^27.4.1 "@types/mongodb": ~3.6.10 "@types/react": ^17 - "@types/use-subscription": ^1 + "@types/use-sync-external-store": ^0.0.3 eslint: ^8.12.0 jest: ^27.5.1 react: ~17.0.2 ts-jest: ^27.1.4 typescript: ~4.3.5 - use-subscription: ~1.6.0 + use-sync-external-store: ^1.2.0 peerDependencies: "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/emitter": "*" "@rocket.chat/fuselage-hooks": "*" "@rocket.chat/rest-typings": "workspace:^" react: ~17.0.2 - use-subscription: "*" + use-sync-external-store: ^1.2.0 languageName: unknown linkType: soft @@ -5873,6 +5895,15 @@ __metadata: languageName: node linkType: hard +"@types/chart.js@npm:^2.9.37": + version: 2.9.37 + resolution: "@types/chart.js@npm:2.9.37" + dependencies: + moment: ^2.10.2 + checksum: 5626a8cc3b410c9f2668ee7efd5b1b408f7de814608f83f9fdb349f59e3c7f5eb7199e73b41e6634c24b4ccb8d3846b290264df8d4224d184f63b095f82cfb94 + languageName: node + linkType: hard + "@types/clipboard@npm:^2.0.7": version: 2.0.7 resolution: "@types/clipboard@npm:2.0.7" @@ -6832,10 +6863,10 @@ __metadata: languageName: node linkType: hard -"@types/use-subscription@npm:^1, @types/use-subscription@npm:^1.0.0": - version: 1.0.0 - resolution: "@types/use-subscription@npm:1.0.0" - checksum: 47fff868682692ecda7110bd04ba4c5b1324854c0bcccc765606a42d4bd9be475207413c8829a883b98e7edd801100df53876da0ff89ac21a8f964e440636ef2 +"@types/use-sync-external-store@npm:^0.0.3": + version: 0.0.3 + resolution: "@types/use-sync-external-store@npm:0.0.3" + checksum: 161ddb8eec5dbe7279ac971531217e9af6b99f7783213566d2b502e2e2378ea19cf5e5ea4595039d730aa79d3d35c6567d48599f69773a02ffcff1776ec2a44e languageName: node linkType: hard @@ -7676,7 +7707,7 @@ __metadata: human-interval: ~1.0.0 moment-timezone: ~0.5.27 mongodb: ~3.5.0 - checksum: cc8c1bbba7545628d9d039c58e701ff65cf07f241f035b731716eec0d5ef906ce09d60c3b321bbfb9e6c641994d1afd23aaeb92d645b33bf7be9942f13574173 + checksum: acb4ebb7e7356f6e53e810d821eb6aa3d88bbfb9e85183e707517bee6d1eea1f189f38bdf0dd2b91360492ab7643134d510c320d2523d86596498ab98e59735b languageName: node linkType: hard @@ -10061,9 +10092,9 @@ __metadata: linkType: hard "caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001109, caniuse-lite@npm:^1.0.30001349": - version: 1.0.30001352 - resolution: "caniuse-lite@npm:1.0.30001352" - checksum: 575ad031349e56224471859decd100d0f90c804325bf1b543789b212d6126f6e18925766b325b1d96f75e48df0036e68f92af26d1fb175803fd6ad935bc807ac + version: 1.0.30001356 + resolution: "caniuse-lite@npm:1.0.30001356" + checksum: 2b1f377f503de8c3d585b85b648422d1a202528d04321aaff5e37054cf413e927db1cc37a3408b44646c6eab7b9e38cae4b44b3a81fbf2e2f8f676cf559518b5 languageName: node linkType: hard @@ -21864,6 +21895,13 @@ __metadata: languageName: node linkType: hard +"moment@npm:^2.10.2": + version: 2.29.3 + resolution: "moment@npm:2.29.3" + checksum: 2e780e36d9a1823c08a1b6313cbb08bd01ecbb2a9062095820a34f42c878991ccba53abaa6abb103fd5c01e763724f295162a8c50b7e95b4f1c992ef0772d3f0 + languageName: node + linkType: hard + "mongodb-connection-string-url@npm:^2.5.2": version: 2.5.2 resolution: "mongodb-connection-string-url@npm:2.5.2" @@ -27233,6 +27271,7 @@ __metadata: version: 0.0.0-use.local resolution: "rocket.chat@workspace:." dependencies: + "@types/chart.js": ^2.9.37 "@types/js-yaml": ^4.0.5 husky: ^7.0.4 turbo: 1.2.5 @@ -31203,12 +31242,12 @@ __metadata: languageName: node linkType: hard -"use-subscription@npm:~1.6.0": - version: 1.6.0 - resolution: "use-subscription@npm:1.6.0" +"use-sync-external-store@npm:^1.2.0, use-sync-external-store@npm:~1.2.0": + version: 1.2.0 + resolution: "use-sync-external-store@npm:1.2.0" peerDependencies: - react: ^18.0.0 - checksum: 0ce15a3ed1f66f78cb2d7a83ff4980e2ac93054b0bcbdc4e492e77fed599233372aaaf9c9fd9b370484c491c5aee0b980859db1108ad63ee4cdba0f468c4f5d1 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + checksum: 5c639e0f8da3521d605f59ce5be9e094ca772bd44a4ce7322b055a6f58eeed8dda3c94cabd90c7a41fb6fa852210092008afe48f7038792fd47501f33299116a languageName: node linkType: hard -- GitLab