diff --git a/.docker/Dockerfile.rhel b/.docker/Dockerfile.rhel index dbc1bd418c3b..f1ee9d09833d 100644 --- a/.docker/Dockerfile.rhel +++ b/.docker/Dockerfile.rhel @@ -1,6 +1,6 @@ FROM registry.access.redhat.com/ubi8/nodejs-12 -ENV RC_VERSION 3.11.1 +ENV RC_VERSION 3.12.0 MAINTAINER buildmaster@rocket.chat diff --git a/.github/history-manual.json b/.github/history-manual.json index 87583c83bfa9..da4c6418600d 100644 --- a/.github/history-manual.json +++ b/.github/history-manual.json @@ -56,5 +56,12 @@ "contributors": [ "sampaiodiego" ] + }], + "3.12.0-rc.3": [{ + "title": "[FIX] Security Hotfix (https://docs.rocket.chat/guides/security/security-updates)", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego" + ] }] } diff --git a/.github/history.json b/.github/history.json index aa889cac9931..f977956d1a66 100644 --- a/.github/history.json +++ b/.github/history.json @@ -55308,6 +55308,1280 @@ ] } ] + }, + "3.12.0-rc.0": { + "node_version": "12.18.4", + "npm_version": "6.14.8", + "apps_engine_version": "1.23.0-alpha.4655", + "mongo_versions": [ + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [ + { + "pr": "20838", + "title": "[NEW] Cloud Workspace bridge", + "userLogin": "d-gubert", + "description": "Adds the new CloudWorkspace functionality.\r\n\r\nIt allows apps to request the access token for the workspace it's installed on, so it can perform actions with other Rocket.Chat services, such as the Omni Gateway.\r\n\r\nhttps://github.com/RocketChat/Rocket.Chat.Apps-engine/pull/382", + "milestone": "3.12.0", + "contributors": [ + "d-gubert", + "geekgonecrazy" + ] + }, + { + "pr": "20832", + "title": "[NEW] Statistics about language usage", + "userLogin": "g-thome", + "description": "track what languages get picked the most as preferred ui language.", + "contributors": [ + "g-thome", + "pierre-lehnen-rc", + "sampaiodiego" + ] + }, + { + "pr": "20014", + "title": "[FIX] Custom OAuth provider creation from env vars", + "userLogin": "pierreozoux", + "contributors": [ + "pierreozoux", + "web-flow" + ] + }, + { + "pr": "20843", + "title": "Bump Livechat Widget", + "userLogin": "renatobecker", + "description": "Update Livechat version to `1.8.0` .", + "milestone": "3.12.0", + "contributors": [ + "renatobecker" + ] + }, + { + "pr": "20738", + "title": "Improve: Add more API tests", + "userLogin": "r0zbot", + "description": "Add end-to-end tests for untested endpoints.", + "contributors": [ + "r0zbot", + "web-flow", + "pierre-lehnen-rc" + ] + }, + { + "pr": "20834", + "title": "[FIX] Atlassian Crowd login with 2FA enabled", + "userLogin": "pierre-lehnen-rc", + "contributors": [ + "pierre-lehnen-rc" + ] + }, + { + "pr": "20840", + "title": "[FIX] CAS login failing due to TOTP requirement", + "userLogin": "pierre-lehnen-rc", + "contributors": [ + "pierre-lehnen-rc" + ] + }, + { + "pr": "20696", + "title": "[FIX] CORS config not accepting multiple origins", + "userLogin": "g-thome", + "description": "always include only one value in access-control-allow-origin", + "contributors": [ + "g-thome", + "sampaiodiego" + ] + }, + { + "pr": "20833", + "title": "[FIX] height prop on departments agents table", + "userLogin": "dougfabris", + "description": "![image](https://user-images.githubusercontent.com/27704687/108572412-fbf83f80-72f0-11eb-801a-5f659000325d.png)", + "milestone": "3.12.0", + "contributors": [ + "dougfabris" + ] + }, + { + "pr": "20815", + "title": "[FIX] Quoted messages from message links when user has no permission ", + "userLogin": "KevLehman", + "contributors": [ + "KevLehman", + "web-flow", + "sampaiodiego" + ] + }, + { + "pr": "20830", + "title": "[FIX] Omnichannel agents are unable to access the chat queue on the sidebar", + "userLogin": "rafaelblink", + "milestone": "3.12.0", + "contributors": [ + "rafaelblink", + "renatobecker", + "web-flow" + ] + }, + { + "pr": "20216", + "title": "[FIX] Several Slack Importer issues", + "userLogin": "pierre-lehnen-rc", + "description": "- Fix: Slack Importer crashes when importing a large users.json file\r\n- Fix: Slack importer crashes when messages have invalid mentions\r\n- Skip listing all users on the preparation screen when the user count is too large.\r\n- Split avatar download into a separate process.\r\n- Update room's last message when the import is complete.\r\n- Prevent invalid or duplicated channel names\r\n- Improve message error handling.\r\n- Reduce max allowed BSON size to avoid possible issues in some servers.\r\n- Improve handling of very large channel files.", + "milestone": "3.12.0", + "contributors": [ + "pierre-lehnen-rc", + "web-flow" + ] + }, + { + "pr": "20826", + "title": "Regression: Fix loadHistory method being called multiple times", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "20772", + "title": "[FIX] Adding the accidentally deleted tag template, used by other templates", + "userLogin": "yash-rajpal", + "description": "Adding back accidentally deleted tag Template.", + "contributors": [ + "yash-rajpal" + ] + }, + { + "pr": "20829", + "title": "Regression: Fix notification worker not firing", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "20800", + "title": "[FIX] Remove warning problems from console", + "userLogin": "tiagoevanp", + "contributors": [ + "tiagoevanp", + "tassoevan", + "web-flow" + ] + }, + { + "pr": "20739", + "title": "[FIX] Missing height on departments agents table", + "userLogin": "dougfabris", + "description": "![image](https://user-images.githubusercontent.com/27704687/107807002-510ee100-6d46-11eb-86e9-d65da7ab4129.png)", + "milestone": "3.12.0", + "contributors": [ + "dougfabris" + ] + }, + { + "pr": "20744", + "title": "[FIX] Missing setting to control when to send the ReplyTo field in email notifications", + "userLogin": "matheusbsilva137", + "description": "- Add a new setting (\"Add Reply-To header\") in the Email settings' page to control when the Reply-To header is used in e-mail notifications;\r\n- The new setting is turned off (`false` value) by default.", + "contributors": [ + "matheusbsilva137", + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "20827", + "title": "[IMPROVE] Make message field required in Omnichannel Triggers form", + "userLogin": "rafaelblink", + "contributors": [ + "rafaelblink", + "renatobecker", + "web-flow" + ] + }, + { + "pr": "20814", + "title": "[IMPROVE] New chat started system message for Omnichannel conversations", + "userLogin": "rafaelblink", + "contributors": [ + "rafaelblink", + "renatobecker", + "web-flow" + ] + }, + { + "pr": "20549", + "title": "[NEW] Button to unset Slackbridge's importIds", + "userLogin": "pierre-lehnen-rc", + "contributors": [ + "pierre-lehnen-rc", + "web-flow" + ] + }, + { + "pr": "20740", + "title": "[FIX] External systems not being able to change Omnichannel Inquiry priorities ", + "userLogin": "renatobecker", + "description": "Due to a wrong property name, external applications were not able to change the priority of Omnichannel Inquires.", + "milestone": "3.11.2", + "contributors": [ + "renatobecker" + ] + }, + { + "pr": "20727", + "title": "[FIX] Room owner not being able to override global retention policy", + "userLogin": "g-thome", + "description": "use correct permissions to check if room owner can override global retention policy", + "milestone": "3.11.2", + "contributors": [ + "g-thome" + ] + }, + { + "pr": "20725", + "title": "[FIX] Threads Issues", + "userLogin": "ggazzo", + "milestone": "3.11.2", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "20447", + "title": "[IMPROVE] Add symbol to indicate apps' required settings in the UI", + "userLogin": "matheusbsilva137", + "description": "- Apps are able to define **required** settings. These settings should not be left blank by the user and an error will be thrown and shown in the interface if an user attempts to save changes in the app details page leaving any required fields blank;\r\n![prt_screen_required_app_settings_warning](https://user-images.githubusercontent.com/36537004/106032964-e73cd900-60af-11eb-8eab-c11fd651b593.png)\r\n\r\n - A sign (*) is added to the label of app settings' fields that are required so as to highlight the fields which must not be left blank.\r\n![prt_screen_required_app_settings](https://user-images.githubusercontent.com/36537004/106014879-ae473900-609c-11eb-9b9e-95de7bbf20a5.png)", + "contributors": [ + "matheusbsilva137", + "web-flow" + ] + }, + { + "pr": "20704", + "title": "[FIX] E2E issues", + "userLogin": "ggazzo", + "milestone": "3.12.0", + "contributors": [ + "ggazzo", + "tassoevan", + "web-flow" + ] + }, + { + "pr": "20793", + "title": "[IMPROVE] Customize announcement", + "userLogin": "im-adithya", + "description": "Included new variables in customizable ones", + "contributors": [ + "im-adithya", + "web-flow", + "dougfabris" + ] + }, + { + "pr": "20757", + "title": "Language update from LingoHub 🤖 on 2021-02-15Z", + "userLogin": "lingohub[bot]", + "contributors": [ + null, + "sampaiodiego" + ] + }, + { + "pr": "20573", + "title": "[IMPROVE] Selector for default custom oauth key field", + "userLogin": "paulobernardoaf", + "milestone": "3.12.0", + "contributors": [ + "paulobernardoaf", + "web-flow" + ] + }, + { + "pr": "20663", + "title": "[FIX] Event emitter warning", + "userLogin": "sampaiodiego", + "milestone": "3.12.0", + "contributors": [ + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "20666", + "title": "[FIX] Increasing unread counter twice for new threads in DMs or with mentions", + "userLogin": "KevLehman", + "description": "- Unread messages count won't be incremented when the message sent is on a thread (thread count is treated different)", + "contributors": [ + null + ] + }, + { + "pr": "20801", + "title": "[FIX] Message payload from `__my_messages__` stream", + "userLogin": "ggazzo", + "milestone": "3.12.0", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "20799", + "title": "Mixed client and server code on Storybook", + "userLogin": "tassoevan", + "description": "For Storybook to work, we've mocked all modules under `**/server/`, thus making them suitable to hold all code that refers Node.js modules. This implies some duplication, between `client/` and `server/` modules, mediated by modules under `libs/`.", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "20606", + "title": "[FIX] Default Attachments - Show Full Attachment.Text with Markdown", + "userLogin": "aditya-mitra", + "description": "Removed truncating of text in `Attachment.Text`. \r\nAdded `Attachment.Text` to be parsed to markdown by default.\r\n\r\n### Earlier\r\n![earlier](https://user-images.githubusercontent.com/55396651/106910781-92d8cf80-6727-11eb-82ec-818df7544ff0.png)\r\n\r\n### Now\r\n\r\n![now](https://user-images.githubusercontent.com/55396651/106910840-a126eb80-6727-11eb-8bd6-d86383dd9181.png)", + "contributors": [ + "aditya-mitra", + "ggazzo", + "web-flow" + ] + }, + { + "pr": "19954", + "title": "[IMPROVE] Added auto-focus for better user-experience.", + "userLogin": "Darshilp326", + "contributors": [ + "Darshilp326", + "MartinSchoeler", + "web-flow" + ] + }, + { + "pr": "20185", + "title": "[FIX] Remove duplicate getCommonRoomEvents() event binding for starredMessages", + "userLogin": "aKn1ghtOut", + "description": "The getCommonRoomEvents() returned functions were bound to the starredMessages template twice. This was causing some bugs, as detailed in the Issue mentioned below.\r\nI removed the top events call that only bound the getCommonRoomEvents(). Therefore, only one call for the same is left, which is at the end of the file. Having the events bound just once removes the bugs mentioned.", + "contributors": [ + "aKn1ghtOut" + ] + }, + { + "pr": "19645", + "title": "[FIX] star icon was visible after unstarring a message", + "userLogin": "bhavayAnand9", + "contributors": [ + "bhavayAnand9", + "sampaiodiego", + "web-flow", + "MartinSchoeler" + ] + }, + { + "pr": "20785", + "title": "[FIX] Admin cannot clear user details like bio or nickname", + "userLogin": "yash-rajpal", + "description": "When the API users.update is called to update user data, it passes data to saveUser function. Here before saving data like bio or nickname we are checking if they are available or not. If data is available then we are saving it, but we are not doing anything when data isn't available.\r\n\r\nSo unsetting data if data isn't available to save. Will also fix bio and other fields. :)", + "contributors": [ + "yash-rajpal" + ] + }, + { + "pr": "20618", + "title": "[FIX] Default Attachments - Remove Extra Margin in Field Attachments", + "userLogin": "aditya-mitra", + "description": "A large amount of unnecessary margin which existed in the **Field Attachments inside the `DefaultAttachments`** has been fixed.\r\n\r\n### Earlier\r\n\r\n![earlier](https://user-images.githubusercontent.com/55396651/107056792-ba4b9d00-67f8-11eb-9153-05281416cddb.png)\r\n\r\n### Now\r\n\r\n![now](https://user-images.githubusercontent.com/55396651/107057196-3219c780-67f9-11eb-84db-e4a0addfc168.png)", + "contributors": [ + "aditya-mitra" + ] + }, + { + "pr": "20408", + "title": "[FIX] Selected messages don't get unselected", + "userLogin": "im-adithya", + "description": "https://user-images.githubusercontent.com/64399555/105844776-c157fb80-5fff-11eb-90cc-94e9f69649b6.mp4", + "contributors": [ + "im-adithya", + "web-flow", + "gabriellsh" + ] + }, + { + "pr": "20750", + "title": "[IMPROVE] Better Presentation of Blockquotes", + "userLogin": "aditya-mitra", + "description": "Changed the values of `margin-top` and `margin-bottom` for *first* and *last* childs in blockquotes to increase readability.\r\n\r\n### Before\r\n\r\n![before](https://user-images.githubusercontent.com/55396651/107858662-3e3a0080-6e5b-11eb-8274-9bd956807235.png)\r\n\r\n### Now\r\n\r\n![now](https://user-images.githubusercontent.com/55396651/107858471-480f3400-6e5a-11eb-9ccb-3f1be2fed0a4.png)", + "contributors": [ + "aditya-mitra" + ] + }, + { + "pr": "17968", + "title": "[FIX] Incorrect display of \"Reply in Direct Message\" in MessageAction", + "userLogin": "abrom", + "description": "[FIX] Incorrect display of \"Reply in Direct Message\" in MessageAction", + "milestone": "3.10.0", + "contributors": [ + "abrom", + "MartinSchoeler", + "web-flow" + ] + }, + { + "pr": "20737", + "title": "[FIX] Save user password and email from My Account", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "20745", + "title": "[FIX] Don't ask again not rendering", + "userLogin": "gabriellsh", + "milestone": "3.12.0", + "contributors": [ + "gabriellsh", + "web-flow" + ] + }, + { + "pr": "20390", + "title": "[FIX] Retry icon comes out of the div", + "userLogin": "im-adithya", + "description": "Changed the height of the div container.", + "contributors": [ + "im-adithya", + "web-flow", + "tiagoevanp" + ] + }, + { + "pr": "20798", + "title": "[FIX] Regenerate token modal on top of 2FA modal", + "userLogin": "gabriellsh", + "milestone": "3.12.0", + "contributors": [ + "gabriellsh" + ] + }, + { + "pr": "20366", + "title": "[IMPROVE] Check Livechat message length through REST API endpoint", + "userLogin": "yash-rajpal", + "description": "Added checks for message length for livechat message api, it shouldn't exceed specified character limit.", + "milestone": "3.12.0", + "contributors": [ + "yash-rajpal", + "renatobecker", + "web-flow" + ] + }, + { + "pr": "20607", + "title": "Chore: Disable Sessions Aggregates tests locally", + "userLogin": "KevLehman", + "description": "Disable Session aggregates tests in local environments\r\nFor context, refer to: #20161", + "contributors": [ + null, + "KevLehman" + ] + }, + { + "pr": "20735", + "title": "Exclude user's own password from /me endpoint", + "userLogin": "KevLehman", + "contributors": [ + "KevLehman" + ] + }, + { + "pr": "20403", + "title": "[FIX] Added check for view admin permission page", + "userLogin": "yash-rajpal", + "description": "Admin Permission page was visible to all, if you add admin/permissions after the base url. This should not be visible to all user, only people with certain permissions should be able to see this page.\r\nI am also able to see permissions page for open workspace of Rocket chat.\r\n![image](https://user-images.githubusercontent.com/58601732/105829728-bfd00880-5fea-11eb-9121-6c53a752f140.png)", + "contributors": [ + "yash-rajpal", + "gabriellsh", + "web-flow" + ] + }, + { + "pr": "20726", + "title": "[FIX] Mark messages inside a thread as unread", + "userLogin": "im-adithya", + "description": "Added threads to mark unread action button.", + "contributors": [ + "im-adithya", + "web-flow" + ] + }, + { + "pr": "20733", + "title": "[IMPROVE] Update rc-scrollbars", + "userLogin": "tiagoevanp", + "contributors": [ + "tiagoevanp" + ] + }, + { + "pr": "20722", + "title": "[FIX] OAuth Login not working on Firefox", + "userLogin": "gabriellsh", + "milestone": "3.12.0", + "contributors": [ + "gabriellsh", + "ggazzo", + "web-flow" + ] + }, + { + "pr": "20706", + "title": "Chore: Push correct Docker tag of service images", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "20720", + "title": "[FIX] Sending user to home after logging in from resume token query param", + "userLogin": "sampaiodiego", + "description": "Do not redirect to `/home` anymore after logging in with `resumeToken`.", + "milestone": "3.12.0", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "20670", + "title": "[FIX] New Integration page was not being displayed", + "userLogin": "yash-rajpal", + "milestone": "3.12.0", + "contributors": [ + "yash-rajpal", + "MartinSchoeler", + "web-flow" + ] + }, + { + "pr": "20713", + "title": "[FIX] Icon for OTR messages", + "userLogin": "tiagoevanp", + "contributors": [ + "tiagoevanp" + ] + }, + { + "pr": "20709", + "title": "Chore: update RC with the latest fuselage-polyfills", + "userLogin": "dougfabris", + "contributors": [ + "dougfabris" + ] + }, + { + "pr": "20605", + "title": "[FIX] Notification worker stopping on error", + "userLogin": "sampaiodiego", + "milestone": "3.12.0", + "contributors": [ + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "20456", + "title": "[FIX] Add tooltips to Thread header buttons", + "userLogin": "aKn1ghtOut", + "description": "Added tooltips to \"Expand\" and \"Follow Message\"/\"Unfollow Message\" in ThreadView for coherency.", + "milestone": "3.12.0", + "contributors": [ + "aKn1ghtOut" + ] + }, + { + "pr": "20680", + "title": "[FIX] Room's last message's update date format on IE", + "userLogin": "dougfabris", + "description": "The proposed change fixes a bug when updates the cached records on Internet Explorer and it breaks the sidebar as shown on the screenshot below:\r\n\r\n![image](https://user-images.githubusercontent.com/27704687/107578007-f2285b00-6bd1-11eb-9250-1e76ae67f9c9.png)", + "contributors": [ + "dougfabris", + "web-flow" + ] + }, + { + "pr": "20661", + "title": "Added toast message after deleting file.", + "userLogin": "Darshilp326", + "description": "https://user-images.githubusercontent.com/55157259/107410849-d1a9c380-6b33-11eb-8d10-3d225dc7a9db.mp4", + "contributors": [ + "Darshilp326" + ] + }, + { + "pr": "20545", + "title": "Chore: Remove node-sprite-generator dependency", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "20679", + "title": "[FIX] Hide system messages not working on second save", + "userLogin": "MartinSchoeler", + "contributors": [ + "MartinSchoeler" + ] + }, + { + "pr": "20662", + "title": "[FIX] Omnichannel Routing System not assigning chats to Bot agents", + "userLogin": "renatobecker", + "description": "The `Omnichannel Routing System` is no longer assigning chats to `bot` agents when the `bot` agent is the default agent of the inquiry.", + "milestone": "3.11.1", + "contributors": [ + "renatobecker", + "web-flow" + ] + }, + { + "pr": "19778", + "title": "[IMPROVE] Rewrite Call as React component ", + "userLogin": "tiagoevanp", + "milestone": "3.11.0", + "contributors": [ + "tiagoevanp", + "ggazzo", + "tassoevan" + ] + }, + { + "pr": "20665", + "title": "[FIX] Server-side marked parsing", + "userLogin": "MartinSchoeler", + "contributors": [ + "MartinSchoeler", + "web-flow" + ] + }, + { + "pr": "20653", + "title": "[FIX] Livechat bridge permission checkers", + "userLogin": "d-gubert", + "description": "Update to latest patch version of the Apps-Engine with a fix for the Livechat bridge, as seen in https://github.com/RocketChat/Rocket.Chat.Apps-engine/pull/379", + "milestone": "3.11.1", + "contributors": [ + "d-gubert", + "lolimay", + "web-flow" + ] + }, + { + "pr": "20481", + "title": "[FIX] Users autocomplete showing duplicated results", + "userLogin": "Darshilp326", + "description": "Added new query for outside room users so that room members are not shown twice.\r\n\r\nhttps://user-images.githubusercontent.com/55157259/106174582-33c10b00-61bb-11eb-9716-377ef7bba34e.mp4", + "contributors": [ + "Darshilp326" + ] + }, + { + "pr": "20585", + "title": "[FIX] Attachment download from title fixed", + "userLogin": "yash-rajpal", + "description": "Added target = '_self' to attachment link, this seems to fix the problem, without this attribute, error page is displayed.", + "milestone": "3.11.1", + "contributors": [ + "yash-rajpal", + "tiagoevanp", + "web-flow" + ] + }, + { + "pr": "19934", + "title": "[IMPROVE] Adds tooltip for sidebar header icons", + "userLogin": "RonLek", + "description": "Previously the header icons in the sidebar didn't show a tooltip when hovered over. This PR fixes that.\r\n\r\n![Screenshot from 2020-12-22 15-17-41](https://user-images.githubusercontent.com/28918901/102874804-f2756700-4468-11eb-8324-b7f3194e62fe.png)", + "milestone": "3.11.0", + "contributors": [ + "RonLek" + ] + }, + { + "pr": "20617", + "title": "Rewrite: CreateChannel modal component", + "userLogin": "tiagoevanp", + "description": "![image](https://user-images.githubusercontent.com/17487063/107058434-5f438700-67b3-11eb-8cf2-1ad3d5008aa8.png)", + "contributors": [ + "tiagoevanp", + "MartinSchoeler", + "web-flow" + ] + }, + { + "pr": "20654", + "title": "[FIX] Gif images aspect ratio on preview", + "userLogin": "tiagoevanp", + "milestone": "3.11.1", + "contributors": [ + "tiagoevanp" + ] + }, + { + "pr": "20237", + "title": "[FIX] - Cancel button on Room Notification don't close contextualBar", + "userLogin": "dougfabris", + "milestone": "3.12.0", + "contributors": [ + "dougfabris", + "ggazzo", + "web-flow" + ] + }, + { + "pr": "20651", + "title": "[FIX] Links not opening in new tabs", + "userLogin": "MartinSchoeler", + "contributors": [ + "MartinSchoeler" + ] + }, + { + "pr": "20649", + "title": "[FIX] Room Scroll to Bottom", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "20652", + "title": "Regression: Discussions inside direct messages not rendering", + "userLogin": "gabriellsh", + "contributors": [ + "gabriellsh" + ] + }, + { + "pr": "20381", + "title": "[FIX] Announcement with multiple lines fixed.", + "userLogin": "yash-rajpal", + "description": "Announcements with multiple lines used to break UI for announcements bar. Fixed it by replacing all break lines in announcement with empty space (\" \") . The announcement modal would work as usual and show all break lines.", + "contributors": [ + "yash-rajpal" + ] + }, + { + "pr": "20329", + "title": "[FIX] Fix Empty highlighted words field", + "userLogin": "yash-rajpal", + "description": "Able to Empty the highlighted text field in preferences", + "contributors": [ + "yash-rajpal" + ] + }, + { + "pr": "20592", + "title": "[FIX] OTR issue", + "userLogin": "ggazzo", + "description": "Since the users are not being stored at the user collection anymore (thats a good thing actually), there is no such record to to fetch and show the username.", + "milestone": "3.10.6", + "contributors": [ + "ggazzo", + "web-flow" + ] + }, + { + "pr": "20611", + "title": "[FIX] Update NPS banner when changing score", + "userLogin": "sampaiodiego", + "milestone": "3.11.1", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "20625", + "title": "Remove `uiKitText` reference", + "userLogin": "tassoevan", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "20624", + "title": "[FIX] List of Omnichannel triggers is not listing data", + "userLogin": "rafaelblink", + "description": "### Before\r\n![image](https://user-images.githubusercontent.com/2493803/107095379-7308e080-67e7-11eb-8251-7e7ff891087a.png)\r\n\r\n\r\n### After\r\n![image](https://user-images.githubusercontent.com/2493803/107095261-3b019d80-67e7-11eb-8425-8612b03ac50a.png)", + "milestone": "3.12.0", + "contributors": [ + "rafaelblink" + ] + }, + { + "pr": "20616", + "title": "Regression: Header Styles", + "userLogin": "gabriellsh", + "contributors": [ + "gabriellsh", + "web-flow" + ] + }, + { + "pr": "20613", + "title": "[FIX] Regular status mutating custom status", + "userLogin": "gabriellsh", + "contributors": [ + "gabriellsh" + ] + }, + { + "pr": "20484", + "title": "[FIX] Channel mentions showing user subscribed channels twice", + "userLogin": "Darshilp326", + "description": "Channel mention shows user subscribed channels twice.\r\n\r\nhttps://user-images.githubusercontent.com/55157259/106183033-b353d780-61c5-11eb-8aab-1dbb62b02ff8.mp4", + "contributors": [ + "Darshilp326" + ] + }, + { + "pr": "20612", + "title": "[IMPROVE] Change header based on room type", + "userLogin": "dougfabris", + "description": "It brings more flexibility, allowing us to use different hooks and different components for each header", + "milestone": "3.12.0", + "contributors": [ + "dougfabris", + "gabriellsh", + "web-flow", + "ggazzo" + ] + }, + { + "pr": "20609", + "title": "[NEW] Header with Breadcrumbs", + "userLogin": "dougfabris", + "description": "![image](https://user-images.githubusercontent.com/27704687/106945019-1386d400-6706-11eb-90db-c12b50f260d5.png)", + "milestone": "3.12.0", + "contributors": [ + "dougfabris", + "gabriellsh", + "web-flow", + "ggazzo" + ] + }, + { + "pr": "20250", + "title": "Chore: Change error message when marking empty chat as unread", + "userLogin": "lucassartor", + "contributors": [ + "lucassartor" + ] + }, + { + "pr": "20519", + "title": "Chore: Improve performance of messages’ watcher", + "userLogin": "rodrigok", + "milestone": "3.12.0", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "20550", + "title": "RoomFiles hook", + "userLogin": "tiagoevanp", + "contributors": [ + "tiagoevanp", + "ggazzo", + "web-flow" + ] + }, + { + "pr": "20586", + "title": "[FIX] ESLint Warning - react-hooks/exhaustive-deps", + "userLogin": "aditya-mitra", + "description": "Added the required dep (`label`) in `useMemo` to fix eslint warning `react-hooks/exhaustive-deps`.", + "contributors": [ + "aditya-mitra" + ] + }, + { + "pr": "20584", + "title": "[NEW] useUserData Hook", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "20320", + "title": "[FIX] Filters are not being applied correctly in Omnichannel Current Chats list", + "userLogin": "rafaelblink", + "description": "### Before\r\n![image](https://user-images.githubusercontent.com/2493803/105537672-082cb500-5cd1-11eb-8f1b-1726ba60420a.png)\r\n\r\n### After\r\n![image](https://user-images.githubusercontent.com/2493803/105537773-2d212800-5cd1-11eb-8746-048deb9502d9.png)\r\n\r\n![image](https://user-images.githubusercontent.com/2493803/106494728-88090b00-6499-11eb-922e-5386107e2389.png)\r\n\r\n![image](https://user-images.githubusercontent.com/2493803/106494751-90f9dc80-6499-11eb-901b-5e4dbdc678ba.png)", + "milestone": "3.12.0", + "contributors": [ + "rafaelblink", + "web-flow", + "renatobecker" + ] + }, + { + "pr": "20297", + "title": "[FIX] Add debouncing to add users search field.", + "userLogin": "Darshilp326", + "description": "BEFORE\r\n\r\nhttps://user-images.githubusercontent.com/55157259/105350722-98a3c080-5c11-11eb-82f3-d9a62a4fa50b.mp4\r\n\r\n\r\nAFTER\r\n\r\nhttps://user-images.githubusercontent.com/55157259/105350757-a2c5bf00-5c11-11eb-91db-25c0b9e01a28.mp4", + "contributors": [ + "Darshilp326", + "dougfabris", + "web-flow" + ] + }, + { + "pr": "20356", + "title": "[FIX] Changed password input field for password access in edit room info.", + "userLogin": "Darshilp326", + "description": "Password field would be secured with asterisks in edit room info\r\n\r\nhttps://user-images.githubusercontent.com/55157259/105641758-cad04f00-5eab-11eb-90de-0c91263edd55.mp4\r\n\r\n.", + "contributors": [ + "Darshilp326" + ] + }, + { + "pr": "20179", + "title": "[FIX] Remove duplicate getCommonRoomEvents() event binding for pinnedMessages", + "userLogin": "aKn1ghtOut", + "description": "The getCommonRoomEvents() returned functions were bound to the pinnedMessages template twice. This was causing some bugs, as detailed in the Issue mentioned below.", + "contributors": [ + "aKn1ghtOut", + "web-flow" + ] + }, + { + "pr": "20341", + "title": "[FIX] User statuses in admin user info panel", + "userLogin": "RonLek", + "description": "Modifies user statuses in admin info panel based on their actual status instead of their `statusConnection`. This enables correct and consistent change in user statuses. \r\nAlso, bot users having status as online were classified as offline, with this change they are now correctly classified based on their corresponding statuses.\r\n\r\nhttps://user-images.githubusercontent.com/28918901/105624438-b8bcc500-5e47-11eb-8d1e-3a4180da1304.mp4", + "contributors": [ + "RonLek" + ] + }, + { + "pr": "20193", + "title": "[FIX] Blank Personal Access Token Bug", + "userLogin": "RonLek", + "description": "Adds error when personal access token is blank thereby disallowing the creation of one.\r\n\r\nhttps://user-images.githubusercontent.com/28918901/104483631-5adde100-55ee-11eb-9938-64146bce127e.mp4", + "contributors": [ + "RonLek", + "web-flow" + ] + }, + { + "pr": "20339", + "title": "[FIX] Feedback on bulk invite", + "userLogin": "aKn1ghtOut", + "description": "Resolved structure where no response was being received. Changed from callback to async/await.\r\nAdded error in case of empty submission, or if no valid emails were found.\r\n\r\nhttps://user-images.githubusercontent.com/38764067/105613964-dfe5a900-5deb-11eb-80f2-21fc8dee57c0.mp4", + "contributors": [ + "aKn1ghtOut" + ] + }, + { + "pr": "20337", + "title": "[IMPROVE] Added disable button check for send invite button", + "userLogin": "yash-rajpal", + "description": "Added Disable check for send invite button. If the text field is empty button would be disabled, and after any valid email is filled, button would get enabled", + "contributors": [ + "yash-rajpal" + ] + }, + { + "pr": "20358", + "title": "[FIX]Selected hide system messages would now be viewed in vertical bar.", + "userLogin": "Darshilp326", + "description": "All selected hide system messages are now in vertical Bar.\r\n\r\nhttps://user-images.githubusercontent.com/55157259/105642624-d5411780-5eb0-11eb-8848-93e4b02629cb.mp4", + "contributors": [ + "Darshilp326" + ] + }, + { + "pr": "20426", + "title": "[FIX] Typo in Message Character Limit", + "userLogin": "aditya-mitra", + "description": "Changed the spelling of *Characther* to *Character*", + "contributors": [ + "aditya-mitra" + ] + }, + { + "pr": "20444", + "title": "[FIX] Unset tshow on deleted messages", + "userLogin": "aKn1ghtOut", + "description": "When setting 'Message_ShowDeletedStatus' is set to true, deleting a message with `tshow: true` causes a bug on the frontend. This issue should, however, never be logically possible as a 'removed' message should not have tshow anyway. Hence, this PR unsets that when the message is set to \"Message Removed\".", + "contributors": [ + "aKn1ghtOut" + ] + }, + { + "pr": "20305", + "title": "[FIX] Added Bio Structure for UserCard, rendering Skeleton View on loading Instead of [Object][Object] ", + "userLogin": "yash-rajpal", + "description": "Added Bio Structure for rendering Skeleton View on loading UserCard.", + "milestone": "3.11.0", + "contributors": [ + "yash-rajpal" + ] + }, + { + "pr": "20392", + "title": "[IMPROVE] Replace react-window for react-virtuoso package", + "userLogin": "tiagoevanp", + "description": "Remove:\r\n- react-window\r\n- react-window-infinite-loader\r\n- simplebar-react\r\n\r\nInclude:\r\n- react-virtuoso\r\n- rc-scrollbars", + "milestone": "3.12.0", + "contributors": [ + "tiagoevanp", + "ggazzo", + "tassoevan", + "web-flow" + ] + }, + { + "pr": "20470", + "title": "[IMPROVE] Added Markdown links to custom status.", + "userLogin": "yash-rajpal", + "description": "Added markdown links to user's custom status.", + "contributors": [ + "yash-rajpal" + ] + }, + { + "pr": "20473", + "title": "[IMPROVE] Added key prop, removing unwanted warnings", + "userLogin": "yash-rajpal", + "description": "Removes warnings listed on the issue", + "contributors": [ + "yash-rajpal" + ] + }, + { + "pr": "20498", + "title": "[FIX] Removed tooltip in kebab menu options.", + "userLogin": "Darshilp326", + "description": "Removed tooltip as it was not needed.\r\n\r\nhttps://user-images.githubusercontent.com/55157259/106246146-a53ca000-6233-11eb-9874-cbd1b4331bc0.mp4", + "contributors": [ + "Darshilp326" + ] + }, + { + "pr": "20308", + "title": "[IMPROVE] Add visual validation on users admin forms", + "userLogin": "dougfabris", + "contributors": [ + "dougfabris", + "gabriellsh" + ] + }, + { + "pr": "20508", + "title": "Wrong method used while starring", + "userLogin": "im-adithya", + "description": "Changed the method from pinMessage to starMessage", + "contributors": [ + "im-adithya", + "web-flow" + ] + }, + { + "pr": "20046", + "title": "Chore: Try building micro services early on CI", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego", + "renatobecker", + "web-flow", + "rodrigok" + ] + }, + { + "pr": "20533", + "title": "Merge master into develop & Set version to 3.12.0-develop", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego", + "web-flow" + ] + } + ] + }, + "3.12.0-rc.1": { + "node_version": "12.18.4", + "npm_version": "6.14.8", + "apps_engine_version": "1.23.0-alpha.4655", + "mongo_versions": [ + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [ + { + "pr": "20871", + "title": "Regression: Fix scopes not being provided to getWorkspaceAccessToken", + "userLogin": "geekgonecrazy", + "milestone": "3.12.0", + "contributors": [ + "geekgonecrazy" + ] + }, + { + "pr": "20869", + "title": "Regression: Keep user custom status after change presence", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "20767", + "title": "[FIX] Markdown prop variants", + "userLogin": "dougfabris", + "description": "A new prop variants on Markdown component: **inline** and **inlineWithoutBreaks**", + "contributors": [ + "dougfabris", + "web-flow", + "ggazzo" + ] + }, + { + "pr": "20868", + "title": "[FIX] Open Visitor Info when omnichannel chat was open", + "userLogin": "tiagoevanp", + "contributors": [ + "tiagoevanp" + ] + }, + { + "pr": "20860", + "title": "Regression: Prevent Message Attachment rendering", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "20820", + "title": "[FIX] Download buttons on desktop app and CDN being ignored", + "userLogin": "ggazzo", + "milestone": "3.12.0", + "contributors": [ + "ggazzo" + ] + } + ] + }, + "3.12.0-rc.2": { + "node_version": "12.18.4", + "npm_version": "6.14.8", + "apps_engine_version": "1.23.0", + "mongo_versions": [ + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [ + { + "pr": "20912", + "title": "[FIX] Admin Panel pages not visible in Safari", + "userLogin": "tiagoevanp", + "milestone": "3.12.0", + "contributors": [ + "tiagoevanp" + ] + }, + { + "pr": "20921", + "title": "Update Apps-Engine version", + "userLogin": "d-gubert", + "description": "Update the Apps-Engine to latest version for the release.", + "milestone": "3.12.0", + "contributors": [ + "d-gubert" + ] + }, + { + "pr": "20922", + "title": "Regression: Messages not being encrypted E2E", + "userLogin": "ggazzo", + "milestone": "3.11.2", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "20852", + "title": "Fix: Add network observe plug to snap", + "userLogin": "geekgonecrazy", + "contributors": [ + "geekgonecrazy", + "web-flow", + "sampaiodiego" + ] + }, + { + "pr": "20853", + "title": "Language update from LingoHub 🤖 on 2021-02-22Z", + "userLogin": "lingohub[bot]", + "contributors": [ + null + ] + }, + { + "pr": "20819", + "title": "Added types to Emitters", + "userLogin": "ggazzo", + "milestone": "3.12.0", + "contributors": [ + "ggazzo" + ] + } + ] + }, + "3.12.0-rc.3": { + "node_version": "12.18.4", + "npm_version": "6.14.8", + "apps_engine_version": "1.23.0", + "mongo_versions": [ + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [] + }, + "3.12.0-rc.4": { + "node_version": "12.18.4", + "npm_version": "6.14.8", + "apps_engine_version": "1.23.0", + "mongo_versions": [ + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [] + }, + "3.12.0": { + "node_version": "12.18.4", + "npm_version": "6.14.8", + "apps_engine_version": "1.23.0", + "mongo_versions": [ + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [] } } } \ No newline at end of file diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 27c376f35f62..e946ee7ee7fd 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -122,6 +122,12 @@ jobs: - run: meteor npm run typecheck + - name: Build Storybook to sanity check components + run: npm run build-storybook ; rm -rf ./storybook-static + env: + NODE_OPTIONS: --max_old_space_size=8192 + + # To reduce memory need during actual build, build the packages solely first # - name: Build a Meteor cache # run: | @@ -137,6 +143,13 @@ jobs: run: | meteor reset + - name: Try building micro services + run: | + cd ./ee/server/services + npm i + npm run build + rm -rf dist/ + - name: Build Rocket.Chat From Pull Request if: startsWith(github.ref, 'refs/pull/') == true env: @@ -560,4 +573,4 @@ jobs: docker build --build-arg SERVICE=${{ matrix.service }} -t rocketchat/${{ matrix.service }}-service:${IMAGE_TAG} . - docker push rocketchat/${{ matrix.service }}-service + docker push rocketchat/${{ matrix.service }}-service:${IMAGE_TAG} diff --git a/.gitignore b/.gitignore index f68d665bf8e8..8d5cc726ac80 100644 --- a/.gitignore +++ b/.gitignore @@ -79,4 +79,6 @@ tests/end-to-end/temporary_staged_test .screenshots /private/livechat /storybook-static -/tests/cypress/screenshots \ No newline at end of file +/tests/cypress/screenshots +coverage +.nyc_output diff --git a/.meteorignore b/.meteorignore index 35dd55fee165..6453c2f01e3d 100644 --- a/.meteorignore +++ b/.meteorignore @@ -1 +1,2 @@ ee/server/services +coverage diff --git a/.snapcraft/resources/prepareRocketChat b/.snapcraft/resources/prepareRocketChat index 8388f031d5c5..3f301ad8afae 100755 --- a/.snapcraft/resources/prepareRocketChat +++ b/.snapcraft/resources/prepareRocketChat @@ -1,6 +1,6 @@ #!/bin/bash -curl -SLf "https://releases.rocket.chat/3.11.1/download/" -o rocket.chat.tgz +curl -SLf "https://releases.rocket.chat/3.12.0/download/" -o rocket.chat.tgz tar xf rocket.chat.tgz --strip 1 diff --git a/.snapcraft/snap/snapcraft.yaml b/.snapcraft/snap/snapcraft.yaml index 19e9d98c5f81..9e964854b543 100644 --- a/.snapcraft/snap/snapcraft.yaml +++ b/.snapcraft/snap/snapcraft.yaml @@ -7,7 +7,7 @@ # 5. `snapcraft snap` name: rocketchat-server -version: 3.11.1 +version: 3.12.0 summary: Rocket.Chat server description: Have your own Slack like online chat, built with Meteor. https://rocket.chat/ confinement: strict @@ -20,7 +20,7 @@ apps: rocketchat-mongo: command: startmongo daemon: simple - plugs: [network, network-bind] + plugs: [network, network-bind, network-observe] rocketchat-caddy: command: env LC_ALL=C caddy -conf=$SNAP_DATA/Caddyfile daemon: simple diff --git a/HISTORY.md b/HISTORY.md index ae6f7fccf532..6e481b3268ea 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,4 +1,543 @@ +# 3.12.0 +`2021-02-28 · 5 🎉 · 17 🚀 · 74 🐛 · 30 🔍 · 29 👩‍💻👨‍💻` + +### Engine versions +- Node: `12.18.4` +- NPM: `6.14.8` +- MongoDB: `3.4, 3.6, 4.0` +- Apps-Engine: `1.23.0` + +### 🎉 New features + + +- Button to unset Slackbridge's importIds ([#20549](https://github.com/RocketChat/Rocket.Chat/pull/20549)) + +- Cloud Workspace bridge ([#20838](https://github.com/RocketChat/Rocket.Chat/pull/20838)) + + Adds the new CloudWorkspace functionality. + + It allows apps to request the access token for the workspace it's installed on, so it can perform actions with other Rocket.Chat services, such as the Omni Gateway. + + https://github.com/RocketChat/Rocket.Chat.Apps-engine/pull/382 + +- Header with Breadcrumbs ([#20609](https://github.com/RocketChat/Rocket.Chat/pull/20609)) + + ![image](https://user-images.githubusercontent.com/27704687/106945019-1386d400-6706-11eb-90db-c12b50f260d5.png) + +- Statistics about language usage ([#20832](https://github.com/RocketChat/Rocket.Chat/pull/20832)) + + track what languages get picked the most as preferred ui language. + +- useUserData Hook ([#20584](https://github.com/RocketChat/Rocket.Chat/pull/20584)) + +### 🚀 Improvements + + +- Add symbol to indicate apps' required settings in the UI ([#20447](https://github.com/RocketChat/Rocket.Chat/pull/20447)) + + - Apps are able to define **required** settings. These settings should not be left blank by the user and an error will be thrown and shown in the interface if an user attempts to save changes in the app details page leaving any required fields blank; + ![prt_screen_required_app_settings_warning](https://user-images.githubusercontent.com/36537004/106032964-e73cd900-60af-11eb-8eab-c11fd651b593.png) + + - A sign (*) is added to the label of app settings' fields that are required so as to highlight the fields which must not be left blank. + ![prt_screen_required_app_settings](https://user-images.githubusercontent.com/36537004/106014879-ae473900-609c-11eb-9b9e-95de7bbf20a5.png) + +- Add visual validation on users admin forms ([#20308](https://github.com/RocketChat/Rocket.Chat/pull/20308)) + +- Added auto-focus for better user-experience. ([#19954](https://github.com/RocketChat/Rocket.Chat/pull/19954) by [@Darshilp326](https://github.com/Darshilp326)) + +- Added disable button check for send invite button ([#20337](https://github.com/RocketChat/Rocket.Chat/pull/20337) by [@yash-rajpal](https://github.com/yash-rajpal)) + + Added Disable check for send invite button. If the text field is empty button would be disabled, and after any valid email is filled, button would get enabled + +- Added key prop, removing unwanted warnings ([#20473](https://github.com/RocketChat/Rocket.Chat/pull/20473) by [@yash-rajpal](https://github.com/yash-rajpal)) + + Removes warnings listed on the issue + +- Added Markdown links to custom status. ([#20470](https://github.com/RocketChat/Rocket.Chat/pull/20470) by [@yash-rajpal](https://github.com/yash-rajpal)) + + Added markdown links to user's custom status. + +- Adds tooltip for sidebar header icons ([#19934](https://github.com/RocketChat/Rocket.Chat/pull/19934) by [@RonLek](https://github.com/RonLek)) + + Previously the header icons in the sidebar didn't show a tooltip when hovered over. This PR fixes that. + + ![Screenshot from 2020-12-22 15-17-41](https://user-images.githubusercontent.com/28918901/102874804-f2756700-4468-11eb-8324-b7f3194e62fe.png) + +- Better Presentation of Blockquotes ([#20750](https://github.com/RocketChat/Rocket.Chat/pull/20750) by [@aditya-mitra](https://github.com/aditya-mitra)) + + Changed the values of `margin-top` and `margin-bottom` for *first* and *last* childs in blockquotes to increase readability. + + ### Before + + ![before](https://user-images.githubusercontent.com/55396651/107858662-3e3a0080-6e5b-11eb-8274-9bd956807235.png) + + ### Now + + ![now](https://user-images.githubusercontent.com/55396651/107858471-480f3400-6e5a-11eb-9ccb-3f1be2fed0a4.png) + +- Change header based on room type ([#20612](https://github.com/RocketChat/Rocket.Chat/pull/20612)) + + It brings more flexibility, allowing us to use different hooks and different components for each header + +- Check Livechat message length through REST API endpoint ([#20366](https://github.com/RocketChat/Rocket.Chat/pull/20366) by [@yash-rajpal](https://github.com/yash-rajpal)) + + Added checks for message length for livechat message api, it shouldn't exceed specified character limit. + +- Customize announcement ([#20793](https://github.com/RocketChat/Rocket.Chat/pull/20793) by [@im-adithya](https://github.com/im-adithya)) + + Included new variables in customizable ones + +- Make message field required in Omnichannel Triggers form ([#20827](https://github.com/RocketChat/Rocket.Chat/pull/20827)) + +- New chat started system message for Omnichannel conversations ([#20814](https://github.com/RocketChat/Rocket.Chat/pull/20814)) + +- Replace react-window for react-virtuoso package ([#20392](https://github.com/RocketChat/Rocket.Chat/pull/20392)) + + Remove: + - react-window + - react-window-infinite-loader + - simplebar-react + + Include: + - react-virtuoso + - rc-scrollbars + +- Rewrite Call as React component ([#19778](https://github.com/RocketChat/Rocket.Chat/pull/19778)) + +- Selector for default custom oauth key field ([#20573](https://github.com/RocketChat/Rocket.Chat/pull/20573) by [@paulobernardoaf](https://github.com/paulobernardoaf)) + +- Update rc-scrollbars ([#20733](https://github.com/RocketChat/Rocket.Chat/pull/20733)) + +### 🐛 Bug fixes + + +- - Cancel button on Room Notification don't close contextualBar ([#20237](https://github.com/RocketChat/Rocket.Chat/pull/20237)) + +- Add debouncing to add users search field. ([#20297](https://github.com/RocketChat/Rocket.Chat/pull/20297) by [@Darshilp326](https://github.com/Darshilp326)) + + BEFORE + + https://user-images.githubusercontent.com/55157259/105350722-98a3c080-5c11-11eb-82f3-d9a62a4fa50b.mp4 + + + AFTER + + https://user-images.githubusercontent.com/55157259/105350757-a2c5bf00-5c11-11eb-91db-25c0b9e01a28.mp4 + +- Add tooltips to Thread header buttons ([#20456](https://github.com/RocketChat/Rocket.Chat/pull/20456) by [@aKn1ghtOut](https://github.com/aKn1ghtOut)) + + Added tooltips to "Expand" and "Follow Message"/"Unfollow Message" in ThreadView for coherency. + +- Added Bio Structure for UserCard, rendering Skeleton View on loading Instead of [Object][Object] ([#20305](https://github.com/RocketChat/Rocket.Chat/pull/20305) by [@yash-rajpal](https://github.com/yash-rajpal)) + + Added Bio Structure for rendering Skeleton View on loading UserCard. + +- Added check for view admin permission page ([#20403](https://github.com/RocketChat/Rocket.Chat/pull/20403) by [@yash-rajpal](https://github.com/yash-rajpal)) + + Admin Permission page was visible to all, if you add admin/permissions after the base url. This should not be visible to all user, only people with certain permissions should be able to see this page. + I am also able to see permissions page for open workspace of Rocket chat. + ![image](https://user-images.githubusercontent.com/58601732/105829728-bfd00880-5fea-11eb-9121-6c53a752f140.png) + +- Adding the accidentally deleted tag template, used by other templates ([#20772](https://github.com/RocketChat/Rocket.Chat/pull/20772) by [@yash-rajpal](https://github.com/yash-rajpal)) + + Adding back accidentally deleted tag Template. + +- Admin cannot clear user details like bio or nickname ([#20785](https://github.com/RocketChat/Rocket.Chat/pull/20785) by [@yash-rajpal](https://github.com/yash-rajpal)) + + When the API users.update is called to update user data, it passes data to saveUser function. Here before saving data like bio or nickname we are checking if they are available or not. If data is available then we are saving it, but we are not doing anything when data isn't available. + + So unsetting data if data isn't available to save. Will also fix bio and other fields. :) + +- Admin Panel pages not visible in Safari ([#20912](https://github.com/RocketChat/Rocket.Chat/pull/20912)) + +- Announcement with multiple lines fixed. ([#20381](https://github.com/RocketChat/Rocket.Chat/pull/20381) by [@yash-rajpal](https://github.com/yash-rajpal)) + + Announcements with multiple lines used to break UI for announcements bar. Fixed it by replacing all break lines in announcement with empty space (" ") . The announcement modal would work as usual and show all break lines. + +- Atlassian Crowd login with 2FA enabled ([#20834](https://github.com/RocketChat/Rocket.Chat/pull/20834)) + +- Attachment download from title fixed ([#20585](https://github.com/RocketChat/Rocket.Chat/pull/20585) by [@yash-rajpal](https://github.com/yash-rajpal)) + + Added target = '_self' to attachment link, this seems to fix the problem, without this attribute, error page is displayed. + +- Blank Personal Access Token Bug ([#20193](https://github.com/RocketChat/Rocket.Chat/pull/20193) by [@RonLek](https://github.com/RonLek)) + + Adds error when personal access token is blank thereby disallowing the creation of one. + + https://user-images.githubusercontent.com/28918901/104483631-5adde100-55ee-11eb-9938-64146bce127e.mp4 + +- CAS login failing due to TOTP requirement ([#20840](https://github.com/RocketChat/Rocket.Chat/pull/20840)) + +- Changed password input field for password access in edit room info. ([#20356](https://github.com/RocketChat/Rocket.Chat/pull/20356) by [@Darshilp326](https://github.com/Darshilp326)) + + Password field would be secured with asterisks in edit room info + + https://user-images.githubusercontent.com/55157259/105641758-cad04f00-5eab-11eb-90de-0c91263edd55.mp4 + + . + +- Channel mentions showing user subscribed channels twice ([#20484](https://github.com/RocketChat/Rocket.Chat/pull/20484) by [@Darshilp326](https://github.com/Darshilp326)) + + Channel mention shows user subscribed channels twice. + + https://user-images.githubusercontent.com/55157259/106183033-b353d780-61c5-11eb-8aab-1dbb62b02ff8.mp4 + +- CORS config not accepting multiple origins ([#20696](https://github.com/RocketChat/Rocket.Chat/pull/20696)) + + always include only one value in access-control-allow-origin + +- Custom OAuth provider creation from env vars ([#20014](https://github.com/RocketChat/Rocket.Chat/pull/20014) by [@pierreozoux](https://github.com/pierreozoux)) + +- Default Attachments - Remove Extra Margin in Field Attachments ([#20618](https://github.com/RocketChat/Rocket.Chat/pull/20618) by [@aditya-mitra](https://github.com/aditya-mitra)) + + A large amount of unnecessary margin which existed in the **Field Attachments inside the `DefaultAttachments`** has been fixed. + + ### Earlier + + ![earlier](https://user-images.githubusercontent.com/55396651/107056792-ba4b9d00-67f8-11eb-9153-05281416cddb.png) + + ### Now + + ![now](https://user-images.githubusercontent.com/55396651/107057196-3219c780-67f9-11eb-84db-e4a0addfc168.png) + +- Default Attachments - Show Full Attachment.Text with Markdown ([#20606](https://github.com/RocketChat/Rocket.Chat/pull/20606) by [@aditya-mitra](https://github.com/aditya-mitra)) + + Removed truncating of text in `Attachment.Text`. + Added `Attachment.Text` to be parsed to markdown by default. + + ### Earlier + ![earlier](https://user-images.githubusercontent.com/55396651/106910781-92d8cf80-6727-11eb-82ec-818df7544ff0.png) + + ### Now + + ![now](https://user-images.githubusercontent.com/55396651/106910840-a126eb80-6727-11eb-8bd6-d86383dd9181.png) + +- Don't ask again not rendering ([#20745](https://github.com/RocketChat/Rocket.Chat/pull/20745)) + +- Download buttons on desktop app and CDN being ignored ([#20820](https://github.com/RocketChat/Rocket.Chat/pull/20820)) + +- E2E issues ([#20704](https://github.com/RocketChat/Rocket.Chat/pull/20704)) + +- ESLint Warning - react-hooks/exhaustive-deps ([#20586](https://github.com/RocketChat/Rocket.Chat/pull/20586) by [@aditya-mitra](https://github.com/aditya-mitra)) + + Added the required dep (`label`) in `useMemo` to fix eslint warning `react-hooks/exhaustive-deps`. + +- Event emitter warning ([#20663](https://github.com/RocketChat/Rocket.Chat/pull/20663)) + +- External systems not being able to change Omnichannel Inquiry priorities ([#20740](https://github.com/RocketChat/Rocket.Chat/pull/20740)) + + Due to a wrong property name, external applications were not able to change the priority of Omnichannel Inquires. + +- Feedback on bulk invite ([#20339](https://github.com/RocketChat/Rocket.Chat/pull/20339) by [@aKn1ghtOut](https://github.com/aKn1ghtOut)) + + Resolved structure where no response was being received. Changed from callback to async/await. + Added error in case of empty submission, or if no valid emails were found. + + https://user-images.githubusercontent.com/38764067/105613964-dfe5a900-5deb-11eb-80f2-21fc8dee57c0.mp4 + +- Filters are not being applied correctly in Omnichannel Current Chats list ([#20320](https://github.com/RocketChat/Rocket.Chat/pull/20320)) + + ### Before + ![image](https://user-images.githubusercontent.com/2493803/105537672-082cb500-5cd1-11eb-8f1b-1726ba60420a.png) + + ### After + ![image](https://user-images.githubusercontent.com/2493803/105537773-2d212800-5cd1-11eb-8746-048deb9502d9.png) + + ![image](https://user-images.githubusercontent.com/2493803/106494728-88090b00-6499-11eb-922e-5386107e2389.png) + + ![image](https://user-images.githubusercontent.com/2493803/106494751-90f9dc80-6499-11eb-901b-5e4dbdc678ba.png) + +- Fix Empty highlighted words field ([#20329](https://github.com/RocketChat/Rocket.Chat/pull/20329) by [@yash-rajpal](https://github.com/yash-rajpal)) + + Able to Empty the highlighted text field in preferences + +- Gif images aspect ratio on preview ([#20654](https://github.com/RocketChat/Rocket.Chat/pull/20654)) + +- height prop on departments agents table ([#20833](https://github.com/RocketChat/Rocket.Chat/pull/20833)) + + ![image](https://user-images.githubusercontent.com/27704687/108572412-fbf83f80-72f0-11eb-801a-5f659000325d.png) + +- Hide system messages not working on second save ([#20679](https://github.com/RocketChat/Rocket.Chat/pull/20679)) + +- Icon for OTR messages ([#20713](https://github.com/RocketChat/Rocket.Chat/pull/20713)) + +- Incorrect display of "Reply in Direct Message" in MessageAction ([#17968](https://github.com/RocketChat/Rocket.Chat/pull/17968) by [@abrom](https://github.com/abrom)) + + [FIX] Incorrect display of "Reply in Direct Message" in MessageAction + +- Increasing unread counter twice for new threads in DMs or with mentions ([#20666](https://github.com/RocketChat/Rocket.Chat/pull/20666)) + + - Unread messages count won't be incremented when the message sent is on a thread (thread count is treated different) + +- Links not opening in new tabs ([#20651](https://github.com/RocketChat/Rocket.Chat/pull/20651)) + +- List of Omnichannel triggers is not listing data ([#20624](https://github.com/RocketChat/Rocket.Chat/pull/20624)) + + ### Before + ![image](https://user-images.githubusercontent.com/2493803/107095379-7308e080-67e7-11eb-8251-7e7ff891087a.png) + + + ### After + ![image](https://user-images.githubusercontent.com/2493803/107095261-3b019d80-67e7-11eb-8425-8612b03ac50a.png) + +- Livechat bridge permission checkers ([#20653](https://github.com/RocketChat/Rocket.Chat/pull/20653)) + + Update to latest patch version of the Apps-Engine with a fix for the Livechat bridge, as seen in https://github.com/RocketChat/Rocket.Chat.Apps-engine/pull/379 + +- Mark messages inside a thread as unread ([#20726](https://github.com/RocketChat/Rocket.Chat/pull/20726) by [@im-adithya](https://github.com/im-adithya)) + + Added threads to mark unread action button. + +- Markdown prop variants ([#20767](https://github.com/RocketChat/Rocket.Chat/pull/20767)) + + A new prop variants on Markdown component: **inline** and **inlineWithoutBreaks** + +- Message payload from `__my_messages__` stream ([#20801](https://github.com/RocketChat/Rocket.Chat/pull/20801)) + +- Missing height on departments agents table ([#20739](https://github.com/RocketChat/Rocket.Chat/pull/20739)) + + ![image](https://user-images.githubusercontent.com/27704687/107807002-510ee100-6d46-11eb-86e9-d65da7ab4129.png) + +- Missing setting to control when to send the ReplyTo field in email notifications ([#20744](https://github.com/RocketChat/Rocket.Chat/pull/20744)) + + - Add a new setting ("Add Reply-To header") in the Email settings' page to control when the Reply-To header is used in e-mail notifications; + - The new setting is turned off (`false` value) by default. + +- New Integration page was not being displayed ([#20670](https://github.com/RocketChat/Rocket.Chat/pull/20670) by [@yash-rajpal](https://github.com/yash-rajpal)) + +- Notification worker stopping on error ([#20605](https://github.com/RocketChat/Rocket.Chat/pull/20605)) + +- OAuth Login not working on Firefox ([#20722](https://github.com/RocketChat/Rocket.Chat/pull/20722)) + +- Omnichannel agents are unable to access the chat queue on the sidebar ([#20830](https://github.com/RocketChat/Rocket.Chat/pull/20830)) + +- Omnichannel Routing System not assigning chats to Bot agents ([#20662](https://github.com/RocketChat/Rocket.Chat/pull/20662)) + + The `Omnichannel Routing System` is no longer assigning chats to `bot` agents when the `bot` agent is the default agent of the inquiry. + +- Open Visitor Info when omnichannel chat was open ([#20868](https://github.com/RocketChat/Rocket.Chat/pull/20868)) + +- OTR issue ([#20592](https://github.com/RocketChat/Rocket.Chat/pull/20592)) + + Since the users are not being stored at the user collection anymore (thats a good thing actually), there is no such record to to fetch and show the username. + +- Quoted messages from message links when user has no permission ([#20815](https://github.com/RocketChat/Rocket.Chat/pull/20815)) + +- Regenerate token modal on top of 2FA modal ([#20798](https://github.com/RocketChat/Rocket.Chat/pull/20798)) + +- Regular status mutating custom status ([#20613](https://github.com/RocketChat/Rocket.Chat/pull/20613)) + +- Remove duplicate getCommonRoomEvents() event binding for pinnedMessages ([#20179](https://github.com/RocketChat/Rocket.Chat/pull/20179) by [@aKn1ghtOut](https://github.com/aKn1ghtOut)) + + The getCommonRoomEvents() returned functions were bound to the pinnedMessages template twice. This was causing some bugs, as detailed in the Issue mentioned below. + +- Remove duplicate getCommonRoomEvents() event binding for starredMessages ([#20185](https://github.com/RocketChat/Rocket.Chat/pull/20185) by [@aKn1ghtOut](https://github.com/aKn1ghtOut)) + + The getCommonRoomEvents() returned functions were bound to the starredMessages template twice. This was causing some bugs, as detailed in the Issue mentioned below. + I removed the top events call that only bound the getCommonRoomEvents(). Therefore, only one call for the same is left, which is at the end of the file. Having the events bound just once removes the bugs mentioned. + +- Remove warning problems from console ([#20800](https://github.com/RocketChat/Rocket.Chat/pull/20800)) + +- Removed tooltip in kebab menu options. ([#20498](https://github.com/RocketChat/Rocket.Chat/pull/20498) by [@Darshilp326](https://github.com/Darshilp326)) + + Removed tooltip as it was not needed. + + https://user-images.githubusercontent.com/55157259/106246146-a53ca000-6233-11eb-9874-cbd1b4331bc0.mp4 + +- Retry icon comes out of the div ([#20390](https://github.com/RocketChat/Rocket.Chat/pull/20390) by [@im-adithya](https://github.com/im-adithya)) + + Changed the height of the div container. + +- Room owner not being able to override global retention policy ([#20727](https://github.com/RocketChat/Rocket.Chat/pull/20727)) + + use correct permissions to check if room owner can override global retention policy + +- Room Scroll to Bottom ([#20649](https://github.com/RocketChat/Rocket.Chat/pull/20649)) + +- Room's last message's update date format on IE ([#20680](https://github.com/RocketChat/Rocket.Chat/pull/20680)) + + The proposed change fixes a bug when updates the cached records on Internet Explorer and it breaks the sidebar as shown on the screenshot below: + + ![image](https://user-images.githubusercontent.com/27704687/107578007-f2285b00-6bd1-11eb-9250-1e76ae67f9c9.png) + +- Save user password and email from My Account ([#20737](https://github.com/RocketChat/Rocket.Chat/pull/20737)) + +- Security Hotfix (https://docs.rocket.chat/guides/security/security-updates) + +- Selected hide system messages would now be viewed in vertical bar. ([#20358](https://github.com/RocketChat/Rocket.Chat/pull/20358) by [@Darshilp326](https://github.com/Darshilp326)) + + All selected hide system messages are now in vertical Bar. + + https://user-images.githubusercontent.com/55157259/105642624-d5411780-5eb0-11eb-8848-93e4b02629cb.mp4 + +- Selected messages don't get unselected ([#20408](https://github.com/RocketChat/Rocket.Chat/pull/20408) by [@im-adithya](https://github.com/im-adithya)) + + https://user-images.githubusercontent.com/64399555/105844776-c157fb80-5fff-11eb-90cc-94e9f69649b6.mp4 + +- Sending user to home after logging in from resume token query param ([#20720](https://github.com/RocketChat/Rocket.Chat/pull/20720)) + + Do not redirect to `/home` anymore after logging in with `resumeToken`. + +- Server-side marked parsing ([#20665](https://github.com/RocketChat/Rocket.Chat/pull/20665)) + +- Several Slack Importer issues ([#20216](https://github.com/RocketChat/Rocket.Chat/pull/20216)) + + - Fix: Slack Importer crashes when importing a large users.json file + - Fix: Slack importer crashes when messages have invalid mentions + - Skip listing all users on the preparation screen when the user count is too large. + - Split avatar download into a separate process. + - Update room's last message when the import is complete. + - Prevent invalid or duplicated channel names + - Improve message error handling. + - Reduce max allowed BSON size to avoid possible issues in some servers. + - Improve handling of very large channel files. + +- star icon was visible after unstarring a message ([#19645](https://github.com/RocketChat/Rocket.Chat/pull/19645) by [@bhavayAnand9](https://github.com/bhavayAnand9)) + +- Threads Issues ([#20725](https://github.com/RocketChat/Rocket.Chat/pull/20725)) + +- Typo in Message Character Limit ([#20426](https://github.com/RocketChat/Rocket.Chat/pull/20426) by [@aditya-mitra](https://github.com/aditya-mitra)) + + Changed the spelling of *Characther* to *Character* + +- Unset tshow on deleted messages ([#20444](https://github.com/RocketChat/Rocket.Chat/pull/20444) by [@aKn1ghtOut](https://github.com/aKn1ghtOut)) + + When setting 'Message_ShowDeletedStatus' is set to true, deleting a message with `tshow: true` causes a bug on the frontend. This issue should, however, never be logically possible as a 'removed' message should not have tshow anyway. Hence, this PR unsets that when the message is set to "Message Removed". + +- Update NPS banner when changing score ([#20611](https://github.com/RocketChat/Rocket.Chat/pull/20611)) + +- User statuses in admin user info panel ([#20341](https://github.com/RocketChat/Rocket.Chat/pull/20341) by [@RonLek](https://github.com/RonLek)) + + Modifies user statuses in admin info panel based on their actual status instead of their `statusConnection`. This enables correct and consistent change in user statuses. + Also, bot users having status as online were classified as offline, with this change they are now correctly classified based on their corresponding statuses. + + https://user-images.githubusercontent.com/28918901/105624438-b8bcc500-5e47-11eb-8d1e-3a4180da1304.mp4 + +- Users autocomplete showing duplicated results ([#20481](https://github.com/RocketChat/Rocket.Chat/pull/20481) by [@Darshilp326](https://github.com/Darshilp326)) + + Added new query for outside room users so that room members are not shown twice. + + https://user-images.githubusercontent.com/55157259/106174582-33c10b00-61bb-11eb-9716-377ef7bba34e.mp4 + +
+🔍 Minor changes + + +- Added toast message after deleting file. ([#20661](https://github.com/RocketChat/Rocket.Chat/pull/20661) by [@Darshilp326](https://github.com/Darshilp326)) + + https://user-images.githubusercontent.com/55157259/107410849-d1a9c380-6b33-11eb-8d10-3d225dc7a9db.mp4 + +- Added types to Emitters ([#20819](https://github.com/RocketChat/Rocket.Chat/pull/20819)) + +- Bump Livechat Widget ([#20843](https://github.com/RocketChat/Rocket.Chat/pull/20843)) + + Update Livechat version to `1.8.0` . + +- Chore: Change error message when marking empty chat as unread ([#20250](https://github.com/RocketChat/Rocket.Chat/pull/20250)) + +- Chore: Disable Sessions Aggregates tests locally ([#20607](https://github.com/RocketChat/Rocket.Chat/pull/20607)) + + Disable Session aggregates tests in local environments + For context, refer to: #20161 + +- Chore: Improve performance of messages’ watcher ([#20519](https://github.com/RocketChat/Rocket.Chat/pull/20519)) + +- Chore: Push correct Docker tag of service images ([#20706](https://github.com/RocketChat/Rocket.Chat/pull/20706)) + +- Chore: Remove node-sprite-generator dependency ([#20545](https://github.com/RocketChat/Rocket.Chat/pull/20545)) + +- Chore: Try building micro services early on CI ([#20046](https://github.com/RocketChat/Rocket.Chat/pull/20046)) + +- Chore: update RC with the latest fuselage-polyfills ([#20709](https://github.com/RocketChat/Rocket.Chat/pull/20709)) + +- Exclude user's own password from /me endpoint ([#20735](https://github.com/RocketChat/Rocket.Chat/pull/20735)) + +- Fix: Add network observe plug to snap ([#20852](https://github.com/RocketChat/Rocket.Chat/pull/20852)) + +- Improve: Add more API tests ([#20738](https://github.com/RocketChat/Rocket.Chat/pull/20738)) + + Add end-to-end tests for untested endpoints. + +- Language update from LingoHub 🤖 on 2021-02-15Z ([#20757](https://github.com/RocketChat/Rocket.Chat/pull/20757)) + +- Language update from LingoHub 🤖 on 2021-02-22Z ([#20853](https://github.com/RocketChat/Rocket.Chat/pull/20853)) + +- Merge master into develop & Set version to 3.12.0-develop ([#20533](https://github.com/RocketChat/Rocket.Chat/pull/20533)) + +- Mixed client and server code on Storybook ([#20799](https://github.com/RocketChat/Rocket.Chat/pull/20799)) + + For Storybook to work, we've mocked all modules under `**/server/`, thus making them suitable to hold all code that refers Node.js modules. This implies some duplication, between `client/` and `server/` modules, mediated by modules under `libs/`. + +- Regression: Discussions inside direct messages not rendering ([#20652](https://github.com/RocketChat/Rocket.Chat/pull/20652)) + +- Regression: Fix loadHistory method being called multiple times ([#20826](https://github.com/RocketChat/Rocket.Chat/pull/20826)) + +- Regression: Fix notification worker not firing ([#20829](https://github.com/RocketChat/Rocket.Chat/pull/20829)) + +- Regression: Fix scopes not being provided to getWorkspaceAccessToken ([#20871](https://github.com/RocketChat/Rocket.Chat/pull/20871)) + +- Regression: Header Styles ([#20616](https://github.com/RocketChat/Rocket.Chat/pull/20616)) + +- Regression: Keep user custom status after change presence ([#20869](https://github.com/RocketChat/Rocket.Chat/pull/20869)) + +- Regression: Messages not being encrypted E2E ([#20922](https://github.com/RocketChat/Rocket.Chat/pull/20922)) + +- Regression: Prevent Message Attachment rendering ([#20860](https://github.com/RocketChat/Rocket.Chat/pull/20860)) + +- Remove `uiKitText` reference ([#20625](https://github.com/RocketChat/Rocket.Chat/pull/20625)) + +- Rewrite: CreateChannel modal component ([#20617](https://github.com/RocketChat/Rocket.Chat/pull/20617)) + + ![image](https://user-images.githubusercontent.com/17487063/107058434-5f438700-67b3-11eb-8cf2-1ad3d5008aa8.png) + +- RoomFiles hook ([#20550](https://github.com/RocketChat/Rocket.Chat/pull/20550)) + +- Update Apps-Engine version ([#20921](https://github.com/RocketChat/Rocket.Chat/pull/20921)) + + Update the Apps-Engine to latest version for the release. + +- Wrong method used while starring ([#20508](https://github.com/RocketChat/Rocket.Chat/pull/20508) by [@im-adithya](https://github.com/im-adithya)) + + Changed the method from pinMessage to starMessage + +
+ +### 👩‍💻👨‍💻 Contributors 😍 + +- [@Darshilp326](https://github.com/Darshilp326) +- [@RonLek](https://github.com/RonLek) +- [@aKn1ghtOut](https://github.com/aKn1ghtOut) +- [@abrom](https://github.com/abrom) +- [@aditya-mitra](https://github.com/aditya-mitra) +- [@bhavayAnand9](https://github.com/bhavayAnand9) +- [@im-adithya](https://github.com/im-adithya) +- [@paulobernardoaf](https://github.com/paulobernardoaf) +- [@pierreozoux](https://github.com/pierreozoux) +- [@yash-rajpal](https://github.com/yash-rajpal) + +### 👩‍💻👨‍💻 Core Team 🤓 + +- [@KevLehman](https://github.com/KevLehman) +- [@MartinSchoeler](https://github.com/MartinSchoeler) +- [@d-gubert](https://github.com/d-gubert) +- [@dougfabris](https://github.com/dougfabris) +- [@g-thome](https://github.com/g-thome) +- [@gabriellsh](https://github.com/gabriellsh) +- [@geekgonecrazy](https://github.com/geekgonecrazy) +- [@ggazzo](https://github.com/ggazzo) +- [@lolimay](https://github.com/lolimay) +- [@lucassartor](https://github.com/lucassartor) +- [@matheusbsilva137](https://github.com/matheusbsilva137) +- [@pierre-lehnen-rc](https://github.com/pierre-lehnen-rc) +- [@r0zbot](https://github.com/r0zbot) +- [@rafaelblink](https://github.com/rafaelblink) +- [@renatobecker](https://github.com/renatobecker) +- [@rodrigok](https://github.com/rodrigok) +- [@sampaiodiego](https://github.com/sampaiodiego) +- [@tassoevan](https://github.com/tassoevan) +- [@tiagoevanp](https://github.com/tiagoevanp) + # 3.11.1 `2021-02-10 · 5 🐛 · 6 👩‍💻👨‍💻` diff --git a/app/2fa/client/TOTPCrowd.js b/app/2fa/client/TOTPCrowd.js new file mode 100644 index 000000000000..44a08fe8b69f --- /dev/null +++ b/app/2fa/client/TOTPCrowd.js @@ -0,0 +1,35 @@ +import { Meteor } from 'meteor/meteor'; +import { Accounts } from 'meteor/accounts-base'; + +import { Utils2fa } from './lib/2fa'; +import '../../crowd/client/index'; + +Meteor.loginWithCrowdAndTOTP = function(username, password, code, callback) { + const loginRequest = { + crowd: true, + username, + crowdPassword: password, + }; + + Accounts.callLoginMethod({ + methodArguments: [{ + totp: { + login: loginRequest, + code, + }, + }], + userCallback(error) { + if (error) { + Utils2fa.reportError(error, callback); + } else { + callback && callback(); + } + }, + }); +}; + +const { loginWithCrowd } = Meteor; + +Meteor.loginWithCrowd = function(username, password, callback) { + Utils2fa.overrideLoginMethod(loginWithCrowd, [username, password], callback, Meteor.loginWithCrowdAndTOTP); +}; diff --git a/app/2fa/client/index.js b/app/2fa/client/index.js index 933bd287703e..24fd7cc72946 100644 --- a/app/2fa/client/index.js +++ b/app/2fa/client/index.js @@ -4,3 +4,4 @@ import './TOTPOAuth'; import './TOTPGoogle'; import './TOTPSaml'; import './TOTPLDAP'; +import './TOTPCrowd'; diff --git a/app/2fa/server/code/ICodeCheck.ts b/app/2fa/server/code/ICodeCheck.ts index 3c8f6a896fc3..3cdd9fb6f4e7 100644 --- a/app/2fa/server/code/ICodeCheck.ts +++ b/app/2fa/server/code/ICodeCheck.ts @@ -9,9 +9,9 @@ export interface IProcessInvalidCodeResult { export interface ICodeCheck { readonly name: string; - isEnabled(user: IUser): boolean; + isEnabled(user: IUser, force?: boolean): boolean; - verify(user: IUser, code: string): boolean; + verify(user: IUser, code: string, force?: boolean): boolean; processInvalidCode(user: IUser): IProcessInvalidCodeResult; } diff --git a/app/2fa/server/code/PasswordCheckFallback.ts b/app/2fa/server/code/PasswordCheckFallback.ts index ad11f0271167..ed6a3898d9a8 100644 --- a/app/2fa/server/code/PasswordCheckFallback.ts +++ b/app/2fa/server/code/PasswordCheckFallback.ts @@ -7,7 +7,10 @@ import { IUser } from '../../../../definition/IUser'; export class PasswordCheckFallback implements ICodeCheck { public readonly name = 'password'; - public isEnabled(user: IUser): boolean { + public isEnabled(user: IUser, force: boolean): boolean { + if (force) { + return true; + } // TODO: Remove this setting for version 4.0 forcing the // password fallback for who has password set. if (settings.get('Accounts_TwoFactorAuthentication_Enforce_Password_Fallback')) { @@ -16,8 +19,8 @@ export class PasswordCheckFallback implements ICodeCheck { return false; } - public verify(user: IUser, code: string): boolean { - if (!this.isEnabled(user)) { + public verify(user: IUser, code: string, force: boolean): boolean { + if (!this.isEnabled(user, force)) { return false; } diff --git a/app/2fa/server/code/index.ts b/app/2fa/server/code/index.ts index 6bf56df20d66..c708400c80d6 100644 --- a/app/2fa/server/code/index.ts +++ b/app/2fa/server/code/index.ts @@ -15,6 +15,7 @@ import { IMethodConnection } from '../../../../definition/IMethodThisType'; export interface ITwoFactorOptions { disablePasswordFallback?: boolean; disableRememberMe?: boolean; + requireSecondFactor?: boolean; // whether any two factor should be required } export const totpCheck = new TOTPCheck(); @@ -83,6 +84,11 @@ export function isAuthorizedForToken(connection: IMethodConnection, user: IUser, return false; } + // if any two factor is required, early abort + if (options.requireSecondFactor) { + return false; + } + if (tokenObject.bypassTwoFactor === true) { return true; } @@ -131,7 +137,29 @@ interface ICheckCodeForUser { connection?: IMethodConnection; } -function _checkCodeForUser({ user, code, method, options = {}, connection }: ICheckCodeForUser): boolean { +const getSecondFactorMethod = (user: IUser, method: string | undefined, options: ITwoFactorOptions): ICodeCheck | undefined => { + // try first getting one of the available methods or the one that was already provided + const selectedMethod = getMethodByNameOrFirstActiveForUser(user, method); + if (selectedMethod) { + return selectedMethod; + } + + // if none found but a second factor is required, chose the password check + if (options.requireSecondFactor) { + return passwordCheckFallback; + } + + // check if password fallback is enabled + if (!options.disablePasswordFallback && passwordCheckFallback.isEnabled(user, !!options.requireSecondFactor)) { + return passwordCheckFallback; + } +}; + +export function checkCodeForUser({ user, code, method, options = {}, connection }: ICheckCodeForUser): boolean { + if (process.env.TEST_MODE && !options.requireSecondFactor) { + return true; + } + if (typeof user === 'string') { user = getUserForCheck(user); } @@ -145,13 +173,10 @@ function _checkCodeForUser({ user, code, method, options = {}, connection }: ICh return true; } - let selectedMethod = getMethodByNameOrFirstActiveForUser(user, method); - + // select a second factor method or return if none is found/available + const selectedMethod = getSecondFactorMethod(user, method, options); if (!selectedMethod) { - if (options.disablePasswordFallback || !passwordCheckFallback.isEnabled(user)) { - return true; - } - selectedMethod = passwordCheckFallback; + return true; } if (!code) { @@ -161,7 +186,7 @@ function _checkCodeForUser({ user, code, method, options = {}, connection }: ICh throw new Meteor.Error('totp-required', 'TOTP Required', { method: selectedMethod.name, ...data, availableMethods }); } - const valid = selectedMethod.verify(user, code); + const valid = selectedMethod.verify(user, code, options.requireSecondFactor); if (!valid) { throw new Meteor.Error('totp-invalid', 'TOTP Invalid', { method: selectedMethod.name }); @@ -173,5 +198,3 @@ function _checkCodeForUser({ user, code, method, options = {}, connection }: ICh return true; } - -export const checkCodeForUser = process.env.TEST_MODE ? (): boolean => true : _checkCodeForUser; diff --git a/app/2fa/server/loginHandler.js b/app/2fa/server/loginHandler.js index 531c87b1a090..ae85326bb196 100644 --- a/app/2fa/server/loginHandler.js +++ b/app/2fa/server/loginHandler.js @@ -19,7 +19,13 @@ callbacks.add('onValidateLogin', (login) => { return login; } - const { totp } = login.methodArguments[0]; + const [loginArgs] = login.methodArguments; + // CAS login doesn't yet support 2FA. + if (loginArgs.cas) { + return login; + } + + const { totp } = loginArgs; checkCodeForUser({ user: login.user, code: totp && totp.code, options: { disablePasswordFallback: true } }); diff --git a/app/api/server/api.js b/app/api/server/api.js index 4fee7cd8f5e9..905049f62367 100644 --- a/app/api/server/api.js +++ b/app/api/server/api.js @@ -653,20 +653,51 @@ API = { }; const defaultOptionsEndpoint = function _defaultOptionsEndpoint() { - if (this.request.method === 'OPTIONS' && this.request.headers['access-control-request-method']) { - if (settings.get('API_Enable_CORS') === true) { - this.response.writeHead(200, { - 'Access-Control-Allow-Origin': settings.get('API_CORS_Origin'), - 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, HEAD, PATCH', - 'Access-Control-Allow-Headers': 'Origin, X-Requested-With, Content-Type, Accept, X-User-Id, X-Auth-Token, x-visitor-token, Authorization', - }); - } else { - this.response.writeHead(405); - this.response.write('CORS not enabled. Go to "Admin > General > REST Api" to enable it.'); - } - } else { - this.response.writeHead(404); + // check if a pre-flight request + if (!this.request.headers['access-control-request-method'] && !this.request.headers.origin) { + this.done(); + return; + } + + if (!settings.get('API_Enable_CORS')) { + this.response.writeHead(405); + this.response.write('CORS not enabled. Go to "Admin > General > REST Api" to enable it.'); + this.done(); + return; + } + + const CORSOriginSetting = String(settings.get('API_CORS_Origin')); + + const defaultHeaders = { + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, HEAD, PATCH', + 'Access-Control-Allow-Headers': 'Origin, X-Requested-With, Content-Type, Accept, X-User-Id, X-Auth-Token, x-visitor-token, Authorization', + }; + + if (CORSOriginSetting === '*') { + this.response.writeHead(200, { + 'Access-Control-Allow-Origin': '*', + ...defaultHeaders, + }); + this.done(); + return; + } + + const origins = CORSOriginSetting + .trim() + .split(',') + .map((origin) => String(origin).trim().toLocaleLowerCase()); + + // if invalid origin reply without required CORS headers + if (!origins.includes(this.request.headers.origin)) { + this.done(); + return; } + + this.response.writeHead(200, { + 'Access-Control-Allow-Origin': this.request.headers.origin, + Vary: 'Origin', + ...defaultHeaders, + }); this.done(); }; @@ -679,24 +710,6 @@ const createApi = function _createApi(_api, options = {}) { auth: getUserAuth(), }, options)); - delete _api._config.defaultHeaders['Access-Control-Allow-Origin']; - delete _api._config.defaultHeaders['Access-Control-Allow-Headers']; - delete _api._config.defaultHeaders.Vary; - - if (settings.get('API_Enable_CORS')) { - const origin = settings.get('API_CORS_Origin'); - - if (origin) { - _api._config.defaultHeaders['Access-Control-Allow-Origin'] = origin; - - if (origin !== '*') { - _api._config.defaultHeaders.Vary = 'Origin'; - } - } - - _api._config.defaultHeaders['Access-Control-Allow-Headers'] = 'Origin, X-Requested-With, Content-Type, Accept, X-User-Id, X-Auth-Token'; - } - return _api; }; diff --git a/app/api/server/v1/channels.js b/app/api/server/v1/channels.js index 76af348d9a6c..baf85dac4781 100644 --- a/app/api/server/v1/channels.js +++ b/app/api/server/v1/channels.js @@ -188,7 +188,7 @@ function createChannelValidator(params) { function createChannel(userId, params) { const readOnly = typeof params.readOnly !== 'undefined' ? params.readOnly : false; - const id = Meteor.runAsUser(userId, () => Meteor.call('createChannel', params.name, params.members ? params.members : [], readOnly, params.customFields)); + const id = Meteor.runAsUser(userId, () => Meteor.call('createChannel', params.name, params.members ? params.members : [], readOnly, params.customFields, params.extraData)); return { channel: findChannelByIdOrName({ params: { roomId: id.rid }, userId: this.userId }), diff --git a/app/api/server/v1/groups.js b/app/api/server/v1/groups.js index ba5002f8bbb4..c3f41db768f7 100644 --- a/app/api/server/v1/groups.js +++ b/app/api/server/v1/groups.js @@ -223,12 +223,16 @@ API.v1.addRoute('groups.create', { authRequired: true }, { if (this.bodyParams.customFields && !(typeof this.bodyParams.customFields === 'object')) { return API.v1.failure('Body param "customFields" must be an object if provided'); } + if (this.bodyParams.extraData && !(typeof this.bodyParams.extraData === 'object')) { + return API.v1.failure('Body param "extraData" must be an object if provided'); + } const readOnly = typeof this.bodyParams.readOnly !== 'undefined' ? this.bodyParams.readOnly : false; let id; + Meteor.runAsUser(this.userId, () => { - id = Meteor.call('createPrivateGroup', this.bodyParams.name, this.bodyParams.members ? this.bodyParams.members : [], readOnly, this.bodyParams.customFields); + id = Meteor.call('createPrivateGroup', this.bodyParams.name, this.bodyParams.members ? this.bodyParams.members : [], readOnly, this.bodyParams.customFields, this.bodyParams.extraData); }); return API.v1.success({ diff --git a/app/api/server/v1/import.js b/app/api/server/v1/import.js index 81f18e38eab7..614c174201d2 100644 --- a/app/api/server/v1/import.js +++ b/app/api/server/v1/import.js @@ -95,6 +95,31 @@ API.v1.addRoute('downloadPendingFiles', { authRequired: true }, { }, }); +API.v1.addRoute('downloadPendingAvatars', { authRequired: true }, { + post() { + if (!this.userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'downloadPendingAvatars' }); + } + + if (!hasPermission(this.userId, 'run-import')) { + throw new Meteor.Error('not_authorized'); + } + + const importer = Importers.get('pending-avatars'); + if (!importer) { + throw new Meteor.Error('error-importer-not-defined', 'The Pending File Importer was not found.', { method: 'downloadPendingAvatars' }); + } + + importer.instance = new importer.importer(importer); // eslint-disable-line new-cap + const count = importer.instance.prepareFileCount(); + + return API.v1.success({ + success: true, + count, + }); + }, +}); + API.v1.addRoute('getCurrentImportOperation', { authRequired: true }, { get() { if (!this.userId) { diff --git a/app/api/server/v1/misc.js b/app/api/server/v1/misc.js index b35fc1115363..5d06b01e495e 100644 --- a/app/api/server/v1/misc.js +++ b/app/api/server/v1/misc.js @@ -49,7 +49,8 @@ API.v1.addRoute('info', { authRequired: false }, { API.v1.addRoute('me', { authRequired: true }, { get() { - return API.v1.success(this.getUserInfo(Users.findOneById(this.userId, { fields: getDefaultUserFields() }))); + const { 'services.password.bcrypt': password, ...fields } = getDefaultUserFields(); + return API.v1.success(this.getUserInfo(Users.findOneById(this.userId, { fields }))); }, }); diff --git a/app/api/server/v1/users.js b/app/api/server/v1/users.js index 2e17b8ff9df5..a7d99d933c72 100644 --- a/app/api/server/v1/users.js +++ b/app/api/server/v1/users.js @@ -507,7 +507,15 @@ API.v1.addRoute('users.updateOwnBasicInfo', { authRequired: true }, { typedPassword: this.bodyParams.data.currentPassword, }; - Meteor.runAsUser(this.userId, () => Meteor.call('saveUserProfile', userData, this.bodyParams.customFields)); + // 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.runAsUser(this.userId, () => Meteor.call('saveUserProfile', userData, this.bodyParams.customFields, twoFactorOptions)); return API.v1.success({ user: Users.findOneById(this.userId, { fields: API.v1.defaultFieldsToExclude }) }); }, diff --git a/app/apps/server/bridges/bridges.js b/app/apps/server/bridges/bridges.js index ed62b2c6479e..d03160e02d0f 100644 --- a/app/apps/server/bridges/bridges.js +++ b/app/apps/server/bridges/bridges.js @@ -2,6 +2,7 @@ import { AppBridges } from '@rocket.chat/apps-engine/server/bridges'; import { AppActivationBridge } from './activation'; import { AppDetailChangesBridge } from './details'; +import { AppCloudBridge } from './cloud'; import { AppCommandsBridge } from './commands'; import { AppApisBridge } from './api'; import { AppEnvironmentalVariableBridge } from './environmental'; @@ -39,6 +40,7 @@ export class RealAppBridges extends AppBridges { this._uploadBridge = new AppUploadBridge(orch); this._uiInteractionBridge = new UiInteractionBridge(orch); this._schedulerBridge = new AppSchedulerBridge(orch); + this._cloudWorkspaceBridge = new AppCloudBridge(orch); } getCommandBridge() { @@ -108,4 +110,8 @@ export class RealAppBridges extends AppBridges { getSchedulerBridge() { return this._schedulerBridge; } + + getCloudWorkspaceBridge() { + return this._cloudWorkspaceBridge; + } } diff --git a/app/apps/server/bridges/cloud.ts b/app/apps/server/bridges/cloud.ts new file mode 100644 index 000000000000..f8894a0fbb0c --- /dev/null +++ b/app/apps/server/bridges/cloud.ts @@ -0,0 +1,21 @@ +import { Meteor } from 'meteor/meteor'; +import { ICloudWorkspaceBridge } from '@rocket.chat/apps-engine/server/bridges'; +import { IWorkspaceToken } from '@rocket.chat/apps-engine/definition/cloud/IWorkspaceToken'; + +import { getWorkspaceAccessTokenWithScope } from '../../../cloud/server'; +import { AppServerOrchestrator } from '../orchestrator'; + +const boundGetWorkspaceAccessToken = Meteor.bindEnvironment(getWorkspaceAccessTokenWithScope); + +export class AppCloudBridge implements ICloudWorkspaceBridge { + // eslint-disable-next-line no-empty-function + constructor(private readonly orch: AppServerOrchestrator) {} + + public async getWorkspaceToken(scope: string, appId: string): Promise { + this.orch.debugLog(`App ${ appId } is getting the workspace's token`); + + const token = boundGetWorkspaceAccessToken(scope); + + return token; + } +} diff --git a/app/apps/server/orchestrator.js b/app/apps/server/orchestrator.js index 2034b02ad13e..612fe2bdf8a6 100644 --- a/app/apps/server/orchestrator.js +++ b/app/apps/server/orchestrator.js @@ -18,7 +18,7 @@ function isTesting() { return process.env.TEST_MODE === 'true'; } -class AppServerOrchestrator { +export class AppServerOrchestrator { constructor() { this._isInitialized = false; } diff --git a/app/authorization/server/functions/hasRole.js b/app/authorization/server/functions/hasRole.js index e5d8927cbb74..545adc3f737b 100644 --- a/app/authorization/server/functions/hasRole.js +++ b/app/authorization/server/functions/hasRole.js @@ -1,7 +1,10 @@ import { Roles } from '../../../models/server/raw'; export const hasRoleAsync = async (userId, roleNames, scope) => { - roleNames = [].concat(roleNames); + if (!userId || userId === '') { + return false; + } + return Roles.isUserInRoles(userId, roleNames, scope); }; diff --git a/app/cloud/server/functions/buildRegistrationData.js b/app/cloud/server/functions/buildRegistrationData.js index 348702ae93f3..786b3981a9db 100644 --- a/app/cloud/server/functions/buildRegistrationData.js +++ b/app/cloud/server/functions/buildRegistrationData.js @@ -1,10 +1,10 @@ -import { settings } from '../../../settings'; -import { Users } from '../../../models'; +import { settings } from '../../../settings/server'; +import { Users, Statistics } from '../../../models/server'; import { statistics } from '../../../statistics'; import { LICENSE_VERSION } from '../license'; export function buildWorkspaceRegistrationData() { - const stats = statistics.get(); + const stats = Statistics.findLast() || statistics.get(); const address = settings.get('Site_Url'); const siteName = settings.get('Site_Name'); diff --git a/app/cloud/server/functions/getWorkspaceAccessToken.js b/app/cloud/server/functions/getWorkspaceAccessToken.js index 5ae3552e0001..b9e4a35694f3 100644 --- a/app/cloud/server/functions/getWorkspaceAccessToken.js +++ b/app/cloud/server/functions/getWorkspaceAccessToken.js @@ -1,12 +1,7 @@ -import { HTTP } from 'meteor/http'; - - -import { getRedirectUri } from './getRedirectUri'; import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; -import { unregisterWorkspace } from './unregisterWorkspace'; +import { getWorkspaceAccessTokenWithScope } from './getWorkspaceAccessTokenWithScope'; import { Settings } from '../../../models'; import { settings } from '../../../settings'; -import { workspaceScopes } from '../oauthScopes'; export function getWorkspaceAccessToken(forceNew = false, scope = '', save = true) { const { connectToCloud, workspaceRegistered } = retrieveRegistrationStatus(); @@ -15,11 +10,6 @@ export function getWorkspaceAccessToken(forceNew = false, scope = '', save = tru return ''; } - const client_id = settings.get('Cloud_Workspace_Client_Id'); - if (!client_id) { - return ''; - } - const expires = Settings.findOneById('Cloud_Workspace_Access_Token_Expires_At'); const now = new Date(); @@ -27,48 +17,12 @@ export function getWorkspaceAccessToken(forceNew = false, scope = '', save = tru return settings.get('Cloud_Workspace_Access_Token'); } - const cloudUrl = settings.get('Cloud_Url'); - const client_secret = settings.get('Cloud_Workspace_Client_Secret'); - const redirectUri = getRedirectUri(); - - if (scope === '') { - scope = workspaceScopes.join(' '); - } - - let authTokenResult; - try { - authTokenResult = HTTP.post(`${ cloudUrl }/api/oauth/token`, { - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - params: { - client_id, - client_secret, - scope, - grant_type: 'client_credentials', - redirect_uri: redirectUri, - }, - }); - } catch (e) { - if (e.response && e.response.data && e.response.data.error) { - console.error(`Failed to get AccessToken from Rocket.Chat Cloud. Error: ${ e.response.data.error }`); - - if (e.response.data.error === 'oauth_invalid_client_credentials') { - console.error('Server has been unregistered from cloud'); - unregisterWorkspace(); - } - } else { - console.error(e); - } - - return ''; - } + const accessToken = getWorkspaceAccessTokenWithScope(scope); if (save) { - const expiresAt = new Date(); - expiresAt.setSeconds(expiresAt.getSeconds() + authTokenResult.data.expires_in); - - Settings.updateValueById('Cloud_Workspace_Access_Token', authTokenResult.data.access_token); - Settings.updateValueById('Cloud_Workspace_Access_Token_Expires_At', expiresAt); + Settings.updateValueById('Cloud_Workspace_Access_Token', accessToken.token); + Settings.updateValueById('Cloud_Workspace_Access_Token_Expires_At', accessToken.expiresAt); } - return authTokenResult.data.access_token; + return accessToken.token; } diff --git a/app/cloud/server/functions/getWorkspaceAccessTokenWithScope.js b/app/cloud/server/functions/getWorkspaceAccessTokenWithScope.js new file mode 100644 index 000000000000..f7ad77aa38d1 --- /dev/null +++ b/app/cloud/server/functions/getWorkspaceAccessTokenWithScope.js @@ -0,0 +1,66 @@ +import { HTTP } from 'meteor/http'; + + +import { getRedirectUri } from './getRedirectUri'; +import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; +import { unregisterWorkspace } from './unregisterWorkspace'; +import { settings } from '../../../settings'; +import { workspaceScopes } from '../oauthScopes'; + +export function getWorkspaceAccessTokenWithScope(scope = '') { + const { connectToCloud, workspaceRegistered } = retrieveRegistrationStatus(); + + const tokenResponse = { token: '', expiresAt: new Date() }; + + if (!connectToCloud || !workspaceRegistered) { + return tokenResponse; + } + + const client_id = settings.get('Cloud_Workspace_Client_Id'); + if (!client_id) { + return tokenResponse; + } + + if (scope === '') { + scope = workspaceScopes.join(' '); + } + + const cloudUrl = settings.get('Cloud_Url'); + const client_secret = settings.get('Cloud_Workspace_Client_Secret'); + const redirectUri = getRedirectUri(); + + let authTokenResult; + try { + authTokenResult = HTTP.post(`${ cloudUrl }/api/oauth/token`, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + params: { + client_id, + client_secret, + scope, + grant_type: 'client_credentials', + redirect_uri: redirectUri, + }, + }); + } catch (e) { + if (e.response && e.response.data && e.response.data.error) { + console.error(`Failed to get AccessToken from Rocket.Chat Cloud. Error: ${ e.response.data.error }`); + + if (e.response.data.error === 'oauth_invalid_client_credentials') { + console.error('Server has been unregistered from cloud'); + unregisterWorkspace(); + } + } else { + console.error(e); + } + + return tokenResponse; + } + + const expiresAt = new Date(); + expiresAt.setSeconds(expiresAt.getSeconds() + authTokenResult.data.expires_in); + + tokenResponse.expiresAt = expiresAt; + tokenResponse.token = authTokenResult.data.access_token; + + return tokenResponse; +} diff --git a/app/cloud/server/index.js b/app/cloud/server/index.js index 43d3816782d7..b574f4fe4776 100644 --- a/app/cloud/server/index.js +++ b/app/cloud/server/index.js @@ -3,6 +3,7 @@ import { SyncedCron } from 'meteor/littledata:synced-cron'; import './methods'; import { getWorkspaceAccessToken } from './functions/getWorkspaceAccessToken'; +import { getWorkspaceAccessTokenWithScope } from './functions/getWorkspaceAccessTokenWithScope'; import { getWorkspaceLicense } from './functions/getWorkspaceLicense'; import { getUserCloudAccessToken } from './functions/getUserCloudAccessToken'; import { getWorkspaceKey } from './functions/getWorkspaceKey'; @@ -40,4 +41,4 @@ Meteor.startup(function() { }); }); -export { getWorkspaceAccessToken, getWorkspaceLicense, getWorkspaceKey, getUserCloudAccessToken }; +export { getWorkspaceAccessToken, getWorkspaceAccessTokenWithScope, getWorkspaceLicense, getWorkspaceKey, getUserCloudAccessToken }; diff --git a/app/custom-oauth/client/custom_oauth_client.js b/app/custom-oauth/client/custom_oauth_client.js index 40e802be8f6e..72b8d9b4c62f 100644 --- a/app/custom-oauth/client/custom_oauth_client.js +++ b/app/custom-oauth/client/custom_oauth_client.js @@ -5,6 +5,7 @@ import { Random } from 'meteor/random'; import { ServiceConfiguration } from 'meteor/service-configuration'; import { OAuth } from 'meteor/oauth'; import s from 'underscore.string'; +import './swapSessionStorage'; import { isURL } from '../../utils/lib/isURL'; diff --git a/app/custom-oauth/client/swapSessionStorage.js b/app/custom-oauth/client/swapSessionStorage.js new file mode 100644 index 000000000000..58e763985ba7 --- /dev/null +++ b/app/custom-oauth/client/swapSessionStorage.js @@ -0,0 +1,42 @@ +import { Meteor } from 'meteor/meteor'; +import { OAuth } from 'meteor/oauth'; +import { Reload } from 'meteor/reload'; + +// TODO: This is a nasty workaround and should be removed as soon as possible +// Firefox is losing the sessionStorage data (v >= 79.0) after the redirect + +if (navigator.userAgent.indexOf('Firefox') !== -1) { + const KEY_NAME = 'Swapped_Storage_Workaround'; + + OAuth.saveDataForRedirect = (loginService, credentialToken) => { + Meteor._localStorage.setItem(KEY_NAME, JSON.stringify({ loginService, credentialToken })); + Reload._migrate(null, { immediateMigration: true }); + }; + + OAuth.getDataAfterRedirect = () => { + let migrationData = Meteor._localStorage.getItem(KEY_NAME); + Meteor._localStorage.removeItem(KEY_NAME); + try { + migrationData = JSON.parse(migrationData); + } catch (error) { + migrationData = null; + } + + if (! (migrationData && migrationData.credentialToken)) { return null; } + + const { credentialToken } = migrationData; + const key = OAuth._storageTokenPrefix + credentialToken; + let credentialSecret; + try { + credentialSecret = sessionStorage.getItem(key); + sessionStorage.removeItem(key); + } catch (e) { + Meteor._debug('error retrieving credentialSecret', e); + } + return { + loginService: migrationData.loginService, + credentialToken, + credentialSecret, + }; + }; +} diff --git a/app/custom-oauth/server/custom_oauth_server.js b/app/custom-oauth/server/custom_oauth_server.js index ad0f6b92c1be..bd698fd90c2b 100644 --- a/app/custom-oauth/server/custom_oauth_server.js +++ b/app/custom-oauth/server/custom_oauth_server.js @@ -73,6 +73,7 @@ export class CustomOAuth { this.identityPath = options.identityPath; this.tokenSentVia = options.tokenSentVia; this.identityTokenSentVia = options.identityTokenSentVia; + this.keyField = options.keyField; this.usernameField = (options.usernameField || '').trim(); this.emailField = (options.emailField || '').trim(); this.nameField = (options.nameField || '').trim(); @@ -334,7 +335,14 @@ export class CustomOAuth { } if (serviceData.username) { - const user = Users.findOneByUsernameAndServiceNameIgnoringCase(serviceData.username, serviceData._id, serviceName); + let user = undefined; + + if (this.keyField === 'username') { + user = Users.findOneByUsernameAndServiceNameIgnoringCase(serviceData.username, serviceData._id, serviceName); + } else if (this.keyField === 'email') { + user = Users.findOneByEmailAddressAndServiceNameIgnoringCase(serviceData.email, serviceData._id, serviceName); + } + if (!user) { return; } diff --git a/app/discussion/client/views/creationDialog/CreateDiscussion.html b/app/discussion/client/views/creationDialog/CreateDiscussion.html index 9eff9c8e57ad..94805e968325 100644 --- a/app/discussion/client/views/creationDialog/CreateDiscussion.html +++ b/app/discussion/client/views/creationDialog/CreateDiscussion.html @@ -118,3 +118,17 @@
{{ description }}
+ + \ No newline at end of file diff --git a/app/e2e/client/rocketchat.e2e.js b/app/e2e/client/rocketchat.e2e.js index e2867bfba7ba..3be8728ade01 100644 --- a/app/e2e/client/rocketchat.e2e.js +++ b/app/e2e/client/rocketchat.e2e.js @@ -5,10 +5,10 @@ import { Tracker } from 'meteor/tracker'; import { EJSON } from 'meteor/ejson'; import { FlowRouter } from 'meteor/kadira:flow-router'; import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; +import { Emitter } from '@rocket.chat/emitter'; import { E2ERoom } from './rocketchat.e2e.room'; import { - Deferred, toString, toArrayBuffer, joinVectorAndEcryptedData, @@ -21,18 +21,19 @@ import { importRawKey, deriveKey, } from './helper'; +import * as banners from '../../../client/lib/banners'; import { Rooms, Subscriptions, Messages } from '../../models'; import { promises } from '../../promises/client'; import { settings } from '../../settings'; import { Notifications } from '../../notifications/client'; import { Layout, call, modal } from '../../ui-utils'; -import * as banners from '../../../client/lib/banners'; - import './events.js'; import './tabbar'; +import { getConfig } from '../../ui-utils/client/config'; + +const debug = [getConfig('debug'), getConfig('debug-e2e')].includes('true'); let failedToDecodeKey = false; -let showingE2EAlert = false; const waitUntilFind = (fn) => new Promise((resolve) => { Tracker.autorun((c) => { @@ -40,25 +41,30 @@ const waitUntilFind = (fn) => new Promise((resolve) => { return result && resolve(result) && c.stop(); }); }); - -class E2E { +class E2E extends Emitter { constructor() { + super(); this.started = false; this.enabled = new ReactiveVar(false); this._ready = new ReactiveVar(false); this.instancesByRoomId = {}; - this.readyPromise = new Deferred(); - this.readyPromise.then(() => { + + this.on('ready', () => { this._ready.set(true); + this.log('startClient -> Done'); + this.log('decryptSubscriptions'); + + this.decryptSubscriptions(); + this.log('decryptSubscriptions -> Done'); }); } log(...msg) { - console.log('[E2E]', ...msg); + debug && console.log('[E2E]', ...msg); } error(...msg) { - console.error('[E2E]', ...msg); + debug && console.error('[E2E]', ...msg); } @@ -70,10 +76,6 @@ class E2E { return this.enabled.get() && this._ready.get(); } - async ready() { - return this.readyPromise; - } - getE2ERoom(rid) { return this.instancesByRoomId[rid]; } @@ -83,8 +85,6 @@ class E2E { } async getInstanceByRoomId(roomId) { - await this.ready(); - const room = await waitUntilFind(() => Rooms.findOne({ _id: roomId, })); @@ -186,21 +186,12 @@ class E2E { }, }); } - - this.readyPromise.resolve(); - this.log('startClient -> Done'); - this.log('decryptPendingSubscriptions'); - - this.decryptPendingSubscriptions(); - this.log('decryptPendingSubscriptions -> Done'); + this.emit('ready'); } async stopClient() { this.log('-> Stop Client'); - // This flag is used to avoid closing unrelated alerts. - if (showingE2EAlert) { - banners.close(); - } + this.closeAlert(); Meteor._localStorage.removeItem('public_key'); Meteor._localStorage.removeItem('private_key'); @@ -209,11 +200,6 @@ class E2E { this.enabled.set(false); this._ready.set(false); this.started = false; - - this.readyPromise = new Deferred(); - this.readyPromise.then(() => { - this._ready.set(true); - }); } async changePassword(newPassword) { @@ -417,26 +403,22 @@ class E2E { async decryptSubscription(rid) { const e2eRoom = await this.getInstanceByRoomId(rid); - this.log('decryptPendingSubscriptions ->', rid); - e2eRoom?.decryptPendingSubscription(); + this.log('decryptSubscription ->', rid); + e2eRoom?.decryptSubscription(); } - async decryptPendingSubscriptions() { + async decryptSubscriptions() { Subscriptions.find({ encrypted: true, }).forEach((room) => this.decryptSubscription(room._id)); } openAlert(config) { - showingE2EAlert = true; - banners.open(config); + banners.open({ id: 'e2e', ...config }); } closeAlert() { - if (showingE2EAlert) { - banners.close(); - } - showingE2EAlert = false; + banners.closeById('e2e'); } } @@ -491,7 +473,7 @@ Meteor.startup(function() { } - doc.encrypted ? e2eRoom.enable() : e2eRoom.pause(); + doc.encrypted ? e2eRoom.unPause() : e2eRoom.pause(); // Cover private groups and direct messages if (!e2eRoom.isSupportedRoomType(doc.t)) { @@ -505,7 +487,7 @@ Meteor.startup(function() { if (!e2eRoom.isReady()) { return; } - e2eRoom.decryptPendingSubscription(); + e2eRoom.decryptSubscription(); }, added: async (doc) => { if (!doc.encrypted && !doc.E2EKey) { diff --git a/app/e2e/client/rocketchat.e2e.room.js b/app/e2e/client/rocketchat.e2e.room.js index 83ac0bf3aa2f..6178b6ce5055 100644 --- a/app/e2e/client/rocketchat.e2e.room.js +++ b/app/e2e/client/rocketchat.e2e.room.js @@ -26,12 +26,12 @@ import { Notifications } from '../../notifications/client'; import { Rooms, Subscriptions, Messages } from '../../models'; import { call } from '../../ui-utils'; import { roomTypes, RoomSettingsEnum } from '../../utils'; +import { getConfig } from '../../ui-utils/client/config'; export const E2E_ROOM_STATES = { NO_PASSWORD_SET: 'NO_PASSWORD_SET', NOT_STARTED: 'NOT_STARTED', DISABLED: 'DISABLED', - PAUSED: 'PAUSED', HANDSHAKE: 'HANDSHAKE', ESTABLISHING: 'ESTABLISHING', CREATING_KEYS: 'CREATING_KEYS', @@ -42,55 +42,39 @@ export const E2E_ROOM_STATES = { }; const KEY_ID = Symbol('keyID'); +const PAUSED = Symbol('PAUSED'); const reduce = (prev, next) => { if (prev === next) { return next === E2E_ROOM_STATES.ERROR; } - - switch (next) { - case E2E_ROOM_STATES.READY: - if (prev === E2E_ROOM_STATES.PAUSED) { - return E2E_ROOM_STATES.READY; - } - return E2E_ROOM_STATES.DISABLED; - case E2E_ROOM_STATES.PAUSED: - if (prev === E2E_ROOM_STATES.READY) { - return E2E_ROOM_STATES.PAUSED; - } - return E2E_ROOM_STATES.DISABLED; - } switch (prev) { - case E2E_ROOM_STATES.PAUSED: - if (next === E2E_ROOM_STATES.READY) { - return E2E_ROOM_STATES.READY; - } - return false; case E2E_ROOM_STATES.NOT_STARTED: - return [E2E_ROOM_STATES.ESTABLISHING, E2E_ROOM_STATES.PAUSED, E2E_ROOM_STATES.DISABLED, E2E_ROOM_STATES.KEYS_RECEIVED].includes(next) && next; + return [E2E_ROOM_STATES.ESTABLISHING, E2E_ROOM_STATES.DISABLED, E2E_ROOM_STATES.KEYS_RECEIVED].includes(next) && next; case E2E_ROOM_STATES.READY: return [E2E_ROOM_STATES.PAUSED, E2E_ROOM_STATES.DISABLED].includes(next) && next; case E2E_ROOM_STATES.ERROR: return [E2E_ROOM_STATES.KEYS_RECEIVED, E2E_ROOM_STATES.NOT_STARTED].includes(next) && next; case E2E_ROOM_STATES.WAITING_KEYS: - return [E2E_ROOM_STATES.KEYS_RECEIVED, E2E_ROOM_STATES.ERROR, E2E_ROOM_STATES.PAUSED, E2E_ROOM_STATES.DISABLED].includes(next) && next; + return [E2E_ROOM_STATES.KEYS_RECEIVED, E2E_ROOM_STATES.ERROR, E2E_ROOM_STATES.DISABLED].includes(next) && next; case E2E_ROOM_STATES.ESTABLISHING: - return [E2E_ROOM_STATES.READY, E2E_ROOM_STATES.KEYS_RECEIVED, E2E_ROOM_STATES.ERROR, E2E_ROOM_STATES.PAUSED, E2E_ROOM_STATES.DISABLED, E2E_ROOM_STATES.WAITING_KEYS].includes(next) && next; + return [E2E_ROOM_STATES.READY, E2E_ROOM_STATES.KEYS_RECEIVED, E2E_ROOM_STATES.ERROR, E2E_ROOM_STATES.DISABLED, E2E_ROOM_STATES.WAITING_KEYS].includes(next) && next; default: return next; } }; +const debug = [getConfig('debug'), getConfig('debug-e2e')].includes('true'); export class E2ERoom extends Emitter { log(...msg) { - if (this.roomId === Session.get('openedRoom')) { + if (debug) { console.log('[E2E ROOM]', `[STATE: ${ this.state }]`, `[RID: ${ this.roomId }]`, ...msg); } } error(...msg) { - if (this.roomId === Session.get('openedRoom')) { + if (debug) { console.error('[E2E ROOM]', `[STATE: ${ this.state }]`, `[RID: ${ this.roomId }]`, ...msg); } } @@ -119,7 +103,7 @@ export class E2ERoom extends Emitter { this.typeOfRoom = t; this.once(E2E_ROOM_STATES.READY, () => this.decryptPendingMessages()); - this.once(E2E_ROOM_STATES.READY, () => this.decryptPendingSubscription()); + this.once(E2E_ROOM_STATES.READY, () => this.decryptSubscription()); this.on('STATE_CHANGED', (prev) => { if (this.roomId === Session.get('openedRoom')) { this.log(`[PREV: ${ prev }]`, 'State CHANGED'); @@ -139,7 +123,15 @@ export class E2ERoom extends Emitter { } pause() { - ![E2E_ROOM_STATES.PAUSED, E2E_ROOM_STATES.DISABLED].includes(this.state) && this.setState(this.state === E2E_ROOM_STATES.READY ? E2E_ROOM_STATES.PAUSED : E2E_ROOM_STATES.DISABLED); + this[PAUSED] = true; + } + + unPause() { + this[PAUSED] = false; + } + + isPaused() { + return this[PAUSED]; } enable() { @@ -158,10 +150,6 @@ export class E2ERoom extends Emitter { return [E2E_ROOM_STATES.DISABLED].includes(this.state); } - isPaused() { - return [E2E_ROOM_STATES.PAUSED].includes(this.state); - } - wait(state) { return new Promise((resolve) => (state === this.state ? resolve(this) : this.once(state, () => resolve(this)))).then((el) => { this.log(this.state, el); @@ -185,14 +173,14 @@ export class E2ERoom extends Emitter { this[KEY_ID] = keyID; } - async decryptPendingSubscription() { + async decryptSubscription() { const subscription = Subscriptions.findOne({ rid: this.roomId, }); const data = await (subscription.lastMessage?.msg && this.decrypt(subscription.lastMessage.msg)); if (!data?.text) { - this.log('decryptPendingSubscriptions nothing to do'); + this.log('decryptSubscriptions nothing to do'); return; } @@ -204,7 +192,7 @@ export class E2ERoom extends Emitter { 'lastMessage.e2e': 'done', }, }); - this.log('decryptPendingSubscriptions Done'); + this.log('decryptSubscriptions Done'); } async decryptPendingMessages() { diff --git a/app/emoji-emojione/lib/generateEmojiIndex.mjs b/app/emoji-emojione/lib/generateEmojiIndex.mjs index f7f82101af9d..d300fd64c6c7 100644 --- a/app/emoji-emojione/lib/generateEmojiIndex.mjs +++ b/app/emoji-emojione/lib/generateEmojiIndex.mjs @@ -1,5 +1,7 @@ /* eslint-disable */ +// before using this script make sure to run: npm i --no-save node-sprite-generator + // node --experimental-modules generateEmojiIndex.mjs import fs from 'fs'; import nsg from 'node-sprite-generator'; diff --git a/app/importer-pending-avatars/server/importer.js b/app/importer-pending-avatars/server/importer.js new file mode 100644 index 000000000000..5d034a8c7d37 --- /dev/null +++ b/app/importer-pending-avatars/server/importer.js @@ -0,0 +1,85 @@ +import { Meteor } from 'meteor/meteor'; + +import { + Base, + ProgressStep, + Selection, +} from '../../importer/server'; +import { Users } from '../../models'; + +export class PendingAvatarImporter extends Base { + constructor(info, importRecord) { + super(info, importRecord); + this.userTags = []; + this.bots = {}; + } + + prepareFileCount() { + this.logger.debug('start preparing import operation'); + super.updateProgress(ProgressStep.PREPARING_STARTED); + + const users = Users.findAllUsersWithPendingAvatar(); + const fileCount = users.count(); + + if (fileCount === 0) { + super.updateProgress(ProgressStep.DONE); + return 0; + } + + this.updateRecord({ 'count.messages': fileCount, messagesstatus: null }); + this.addCountToTotal(fileCount); + + const fileData = new Selection(this.name, [], [], fileCount); + this.updateRecord({ fileData }); + + super.updateProgress(ProgressStep.IMPORTING_FILES); + Meteor.defer(() => { + this.startImport(fileData); + }); + + return fileCount; + } + + startImport() { + const pendingFileUserList = Users.findAllUsersWithPendingAvatar(); + try { + pendingFileUserList.forEach((user) => { + try { + const { _pendingAvatarUrl: url, name, _id } = user; + + try { + if (!url || !url.startsWith('http')) { + return; + } + + Meteor.runAsUser(_id, () => { + try { + Meteor.call('setAvatarFromService', url, undefined, 'url'); + Users.update({ _id }, { $unset: { _pendingAvatarUrl: '' } }); + } catch (error) { + this.logger.warn(`Failed to set ${ name }'s avatar from url ${ url }`); + console.log(`Failed to set ${ name }'s avatar from url ${ url }`); + } + }); + } finally { + this.addCountCompleted(1); + } + } catch (error) { + this.logger.error(error); + } + }); + } catch (error) { + // If the cursor expired, restart the method + if (error && error.codeName === 'CursorNotFound') { + console.log('CursorNotFound'); + return this.startImport(); + } + + super.updateProgress(ProgressStep.ERROR); + throw error; + } + + super.updateProgress(ProgressStep.DONE); + return this.getProgress(); + } +} diff --git a/app/importer-pending-avatars/server/index.js b/app/importer-pending-avatars/server/index.js new file mode 100644 index 000000000000..904fa05e6bce --- /dev/null +++ b/app/importer-pending-avatars/server/index.js @@ -0,0 +1,5 @@ +import { PendingAvatarImporter } from './importer'; +import { Importers } from '../../importer/server'; +import { PendingAvatarImporterInfo } from './info'; + +Importers.add(new PendingAvatarImporterInfo(), PendingAvatarImporter); diff --git a/app/importer-pending-avatars/server/info.js b/app/importer-pending-avatars/server/info.js new file mode 100644 index 000000000000..32e8886cb538 --- /dev/null +++ b/app/importer-pending-avatars/server/info.js @@ -0,0 +1,7 @@ +import { ImporterInfo } from '../../importer/lib/ImporterInfo'; + +export class PendingAvatarImporterInfo extends ImporterInfo { + constructor() { + super('pending-avatars', 'Pending Avatars', ''); + } +} diff --git a/app/importer-slack/server/importer.js b/app/importer-slack/server/importer.js index a82d2e79dd24..a996985ff06f 100644 --- a/app/importer-slack/server/importer.js +++ b/app/importer-slack/server/importer.js @@ -65,7 +65,15 @@ export class SlackImporter extends Base { this.logger.debug(`loaded ${ data.length } ${ typeName }.`); // Insert the channels records. - this.collection.insert({ import: this.importRecord._id, importer: this.name, type: typeName, channels: data }); + if (Base.getBSONSize(data) > Base.getMaxBSONSize()) { + const tmp = Base.getBSONSafeArraysFromAnArray(data); + Object.keys(tmp).forEach((i) => { + const splitChannels = tmp[i]; + this.collection.insert({ import: this.importRecord._id, importer: this.name, type: typeName, name: `${ typeName }/${ i }`, channels: splitChannels, i }); + }); + } else { + this.collection.insert({ import: this.importRecord._id, importer: this.name, type: typeName, channels: data }); + } this.updateRecord({ 'count.channels': tempGroups.length + tempChannels.length + tempDMs.length + tempMpims.length + data.length }); this.addCountToTotal(data.length); return data; @@ -111,7 +119,16 @@ export class SlackImporter extends Base { this.logger.debug(`loaded ${ tempUsers.length } users.`); // Insert the users record - this.collection.insert({ import: this.importRecord._id, importer: this.name, type: 'users', users: tempUsers }); + if (Base.getBSONSize(tempUsers) > Base.getMaxBSONSize()) { + const tmp = Base.getBSONSafeArraysFromAnArray(tempUsers); + Object.keys(tmp).forEach((i) => { + const splitUsers = tmp[i]; + this.collection.insert({ import: this.importRecord._id, importer: this.name, type: 'users', name: `users/${ i }`, users: splitUsers, i }); + }); + } else { + this.collection.insert({ import: this.importRecord._id, importer: this.name, type: 'users', name: 'users', users: tempUsers }); + } + this.updateRecord({ 'count.users': tempUsers.length }); this.addCountToTotal(tempUsers.length); @@ -171,22 +188,57 @@ export class SlackImporter extends Base { ImporterWebsocket.progressUpdated({ rate: 100 }); this.updateRecord({ 'count.messages': messagesCount, messagesstatus: null }); + const roomCount = tempChannels.length + tempGroups.length + tempDMs.length + tempMpims.length; - if ([tempUsers.length, tempChannels.length + tempGroups.length + tempDMs.length + tempMpims.length, messagesCount].some((e) => e === 0)) { + if ([tempUsers.length, roomCount, messagesCount].some((e) => e === 0)) { this.logger.warn(`Loaded ${ tempUsers.length } users, ${ tempChannels.length } channels, ${ tempGroups.length } groups, ${ tempDMs.length } DMs, ${ tempMpims.length } multi party IMs and ${ messagesCount } messages`); super.updateProgress(ProgressStep.ERROR); return this.getProgress(); } - const selectionUsers = tempUsers.map((user) => new SelectionUser(user.id, user.name, user.profile.email, user.deleted, user.is_bot, !user.is_bot)); - const selectionChannels = tempChannels.map((channel) => new SelectionChannel(channel.id, channel.name, channel.is_archived, true, false)); - const selectionGroups = tempGroups.map((channel) => new SelectionChannel(channel.id, channel.name, channel.is_archived, true, true)); - const selectionMpims = tempMpims.map((channel) => new SelectionChannel(channel.id, channel.name, channel.is_archived, true, true)); + const selectionUsers = (() => { + if (tempUsers.length <= 500) { + return tempUsers.map((user) => new SelectionUser(user.id, user.name, user.profile.email, user.deleted, user.is_bot, !user.is_bot)); + } + + return [ + new SelectionUser('users', 'Regular Users', '', false, false, true), + new SelectionUser('bot_users', 'Bot Users', '', false, true, false), + new SelectionUser('deleted_users', 'Deleted Users', '', true, false, true), + ]; + })(); + + const selectionChannels = (() => { + if (roomCount <= 500) { + return tempChannels.map((channel) => new SelectionChannel(channel.id, channel.name, channel.is_archived, true, false)); + } + + return [ + new SelectionChannel('channels', 'Regular Channels', false, true, false), + new SelectionChannel('archived_channels', 'Archived Channels', true, true, false), + ]; + })(); + + const selectionGroups = (() => { + if (roomCount <= 500) { + return tempGroups.map((channel) => new SelectionChannel(channel.id, channel.name, channel.is_archived, true, true)); + } + + return [ + new SelectionChannel('groups', 'Regular Groups', false, true, true), + new SelectionChannel('archived_groups', 'Archived Groups', true, true, true), + ]; + })(); + + const selectionMpims = [ + new SelectionChannel('mpims', 'Multi Party DMs', false, true, true), + new SelectionChannel('archived_mimps', 'Archived Multi Party DMs', true, true, true), + ]; const selectionMessages = this.importRecord.count.messages; super.updateProgress(ProgressStep.USER_SELECTION); - return new Selection(this.name, selectionUsers, selectionChannels.concat(selectionGroups).concat(selectionMpims), selectionMessages); + return new Selection(this.name, selectionUsers, selectionMpims.concat(selectionChannels).concat(selectionGroups), selectionMessages); } performUserImport(user, startedByUserId) { @@ -211,11 +263,13 @@ export class SlackImporter extends Base { Meteor.call('setUsername', user.name, { joinDefaultChannelsSilenced: true }); const url = user.profile.image_original || user.profile.image_512; - try { - Meteor.call('setAvatarFromService', url, undefined, 'url'); - } catch (error) { - this.logger.warn(`Failed to set ${ user.name }'s avatar from url ${ url }`); - console.log(`Failed to set ${ user.name }'s avatar from url ${ url }`); + if (url) { + try { + Users.update({ _id: userId }, { $set: { _pendingAvatarUrl: url } }); + } catch (error) { + this.logger.warn(`Failed to set ${ user.name }'s avatar from url ${ url }`); + console.log(`Failed to set ${ user.name }'s avatar from url ${ url }`); + } } // Slack's is -18000 which translates to Rocket.Chat's after dividing by 3600 @@ -266,7 +320,9 @@ export class SlackImporter extends Base { this.logger.warn(`Failed to import user mention with name: ${ user }`); } }); - message.mentions.push(...users); + + const filteredUsers = users.filter((u) => u); + message.mentions.push(...filteredUsers); if (!message.channels) { message.channels = []; @@ -282,7 +338,9 @@ export class SlackImporter extends Base { this.logger.warn(`Failed to import channel mention with name: ${ chan }`); } }); - message.channels.push(...channels); + + const filteredChannels = channels.filter((c) => c); + message.channels.push(...filteredChannels); } processMessageSubType(message, room, msgDataDefaults, missedTypes) { @@ -448,7 +506,12 @@ export class SlackImporter extends Base { if (message.thread_ts && (message.thread_ts !== message.ts)) { msgObj.tmid = `slack-${ slackChannel.id }-${ message.thread_ts.replace(/\./g, '-') }`; } - insertMessage(fileUser, msgObj, room, this._anyExistingSlackMessage); + try { + insertMessage(fileUser, msgObj, room, this._anyExistingSlackMessage); + } catch (e) { + this.logger.warn(`Failed to import the message file: ${ msgDataDefaults._id }-${ fileIndex }`); + this.logger.error(e); + } }); } @@ -507,6 +570,7 @@ export class SlackImporter extends Base { insertMessage(this.getRocketUserFromUserId(message.user), msgObj, room, this._anyExistingSlackMessage); } catch (e) { this.logger.warn(`Failed to import the message: ${ msgDataDefaults._id }`); + this.logger.error(e); } } } @@ -537,51 +601,56 @@ export class SlackImporter extends Base { this._userIdReference = {}; super.updateProgress(ProgressStep.IMPORTING_USERS); - this.users.users.forEach((user) => this.performUserImport(user, startedByUserId)); - this.collection.update({ _id: this.users._id }, { $set: { users: this.users.users } }); + for (const list of this.userLists) { + list.users.forEach((user) => this.performUserImport(user, startedByUserId)); + this.collection.update({ _id: list._id }, { $set: { users: list.users } }); + } } _importChannels(startedByUserId, channelNames) { - if (!this.channels || !this.channels.channels) { - return; - } - super.updateProgress(ProgressStep.IMPORTING_CHANNELS); - this.channels.channels.forEach((channel) => { - if (!channel.do_import) { - this.addCountCompleted(1); - return; - } - channelNames.push(channel.name); + for (const list of this.channelsLists) { + list.channels.forEach((channel) => { + if (!channel.do_import) { + this.addCountCompleted(1); + return; + } + + if (channelNames.includes(channel.name)) { + this.logger.warn(`Duplicated channel name will be skipped: ${ channel.name }`); + return; + } + channelNames.push(channel.name); - Meteor.runAsUser(startedByUserId, () => { - const existingRoom = this._findExistingRoom(channel.name); + Meteor.runAsUser(startedByUserId, () => { + const existingRoom = this._findExistingRoom(channel.name); - if (existingRoom || channel.is_general) { - if (channel.is_general && existingRoom && channel.name !== existingRoom.name) { - Meteor.call('saveRoomSettings', 'GENERAL', 'roomName', channel.name); - } + if (existingRoom || channel.is_general) { + if (channel.is_general && existingRoom && channel.name !== existingRoom.name) { + Meteor.call('saveRoomSettings', 'GENERAL', 'roomName', channel.name); + } - channel.rocketId = channel.is_general ? 'GENERAL' : existingRoom._id; - Rooms.update({ _id: channel.rocketId }, { $addToSet: { importIds: channel.id } }); - } else { - const users = this._getChannelUserList(channel); - const userId = this.getImportedRocketUserIdFromSlackUserId(channel.creator) || startedByUserId; + channel.rocketId = channel.is_general ? 'GENERAL' : existingRoom._id; + Rooms.update({ _id: channel.rocketId }, { $addToSet: { importIds: channel.id } }); + } else { + const users = this._getChannelUserList(channel); + const userId = this.getImportedRocketUserIdFromSlackUserId(channel.creator) || startedByUserId; - Meteor.runAsUser(userId, () => { - const returned = Meteor.call('createChannel', channel.name, users); - channel.rocketId = returned.rid; - }); + Meteor.runAsUser(userId, () => { + const returned = Meteor.call('createChannel', channel.name, users); + channel.rocketId = returned.rid; + }); - this._updateImportedChannelTopicAndDescription(channel); - } + this._updateImportedChannelTopicAndDescription(channel); + } - this.addCountCompleted(1); + this.addCountCompleted(1); + }); }); - }); - this.collection.update({ _id: this.channels._id }, { $set: { channels: this.channels.channels } }); + this.collection.update({ _id: list._id }, { $set: { channels: list.channels } }); + } } _findExistingRoom(name) { @@ -616,46 +685,49 @@ export class SlackImporter extends Base { }, []); } - _importPrivateGroupList(startedByUserId, list, channelNames) { - if (!list || !list.channels) { - return; - } + _importPrivateGroupList(startedByUserId, listList, channelNames) { + for (const list of listList) { + list.channels.forEach((channel) => { + if (!channel.do_import) { + this.addCountCompleted(1); + return; + } - list.channels.forEach((channel) => { - if (!channel.do_import) { - this.addCountCompleted(1); - return; - } + if (channelNames.includes(channel.name)) { + this.logger.warn(`Duplicated group name will be skipped: ${ channel.name }`); + return; + } - channelNames.push(channel.name); + channelNames.push(channel.name); - Meteor.runAsUser(startedByUserId, () => { - const existingRoom = this._findExistingRoom(channel.name); + Meteor.runAsUser(startedByUserId, () => { + const existingRoom = this._findExistingRoom(channel.name); - if (existingRoom) { - channel.rocketId = existingRoom._id; - Rooms.update({ _id: channel.rocketId }, { $addToSet: { importIds: channel.id } }); - } else { - const users = this._getChannelUserList(channel); + if (existingRoom) { + channel.rocketId = existingRoom._id; + Rooms.update({ _id: channel.rocketId }, { $addToSet: { importIds: channel.id } }); + } else { + const users = this._getChannelUserList(channel); - const userId = this.getImportedRocketUserIdFromSlackUserId(channel.creator) || startedByUserId; - Meteor.runAsUser(userId, () => { - const returned = Meteor.call('createPrivateGroup', channel.name, users); - channel.rocketId = returned.rid; - }); + const userId = this.getImportedRocketUserIdFromSlackUserId(channel.creator) || startedByUserId; + Meteor.runAsUser(userId, () => { + const returned = Meteor.call('createPrivateGroup', channel.name, users); + channel.rocketId = returned.rid; + }); - this._updateImportedChannelTopicAndDescription(channel); - } + this._updateImportedChannelTopicAndDescription(channel); + } - this.addCountCompleted(1); + this.addCountCompleted(1); + }); }); - }); - this.collection.update({ _id: list._id }, { $set: { channels: list.channels } }); + this.collection.update({ _id: list._id }, { $set: { channels: list.channels } }); + } } _importGroups(startedByUserId, channelNames) { - this._importPrivateGroupList(startedByUserId, this.groups, channelNames); + this._importPrivateGroupList(startedByUserId, this.groupsLists, channelNames); } _updateImportedChannelTopicAndDescription(slackChannel) { @@ -676,101 +748,111 @@ export class SlackImporter extends Base { } _importMpims(startedByUserId, channelNames) { - if (!this.mpims || !this.mpims.channels) { - return; - } - const maxUsers = settings.get('DirectMesssage_maxUsers') || 1; - this.mpims.channels.forEach((channel) => { - if (!channel.do_import) { - this.addCountCompleted(1); - return; - } - channelNames.push(channel.name); + for (const list of this.mpimsLists) { + list.channels.forEach((channel) => { + if (!channel.do_import) { + this.addCountCompleted(1); + return; + } - Meteor.runAsUser(startedByUserId, () => { - const users = this._getChannelUserList(channel, true, true); - const existingRoom = Rooms.findOneDirectRoomContainingAllUserIDs(users, { fields: { _id: 1 } }); + if (channelNames.includes(channel.name)) { + this.logger.warn(`Duplicated multi party IM name will be skipped: ${ channel.name }`); + return; + } - if (existingRoom) { - channel.rocketId = existingRoom._id; - Rooms.update({ _id: channel.rocketId }, { $addToSet: { importIds: channel.id } }); - } else { - const userId = this.getImportedRocketUserIdFromSlackUserId(channel.creator) || startedByUserId; - Meteor.runAsUser(userId, () => { - // If there are too many users for a direct room, then create a private group instead - if (users.length > maxUsers) { - const usernames = users.map((user) => user.username); - const group = Meteor.call('createPrivateGroup', channel.name, usernames); - channel.rocketId = group.rid; - return; - } + channelNames.push(channel.name); - const newRoom = createDirectRoom(users); - channel.rocketId = newRoom._id; - }); + Meteor.runAsUser(startedByUserId, () => { + const users = this._getChannelUserList(channel, true, true); + const existingRoom = Rooms.findOneDirectRoomContainingAllUserIDs(users, { fields: { _id: 1 } }); - this._updateImportedChannelTopicAndDescription(channel); - } + if (existingRoom) { + channel.rocketId = existingRoom._id; + Rooms.update({ _id: channel.rocketId }, { $addToSet: { importIds: channel.id } }); + } else { + const userId = this.getImportedRocketUserIdFromSlackUserId(channel.creator) || startedByUserId; + Meteor.runAsUser(userId, () => { + // If there are too many users for a direct room, then create a private group instead + if (users.length > maxUsers) { + const usernames = users.map((user) => user.username); + const group = Meteor.call('createPrivateGroup', channel.name, usernames); + channel.rocketId = group.rid; + return; + } + + const newRoom = createDirectRoom(users); + channel.rocketId = newRoom._id; + }); - this.addCountCompleted(1); + this._updateImportedChannelTopicAndDescription(channel); + } + + this.addCountCompleted(1); + }); }); - }); - this.collection.update({ _id: this.mpims._id }, { $set: { channels: this.mpims.channels } }); + this.collection.update({ _id: list._id }, { $set: { channels: list.channels } }); + } } _importDMs(startedByUserId, channelNames) { - if (!this.dms || !this.dms.channels) { - return; - } + for (const list of this.dmsLists) { + list.channels.forEach((channel) => { + if (channelNames.includes(channel.id)) { + this.logger.warn(`Duplicated DM id will be skipped (DMs): ${ channel.id }`); + return; + } + channelNames.push(channel.id); - this.dms.channels.forEach((channel) => { - channelNames.push(channel.id); + if (!channel.members || channel.members.length !== 2) { + this.addCountCompleted(1); + return; + } - if (!channel.members || channel.members.length !== 2) { - this.addCountCompleted(1); - return; - } + Meteor.runAsUser(startedByUserId, () => { + const user1 = this.getRocketUserFromUserId(channel.members[0]); + const user2 = this.getRocketUserFromUserId(channel.members[1]); - Meteor.runAsUser(startedByUserId, () => { - const user1 = this.getRocketUserFromUserId(channel.members[0]); - const user2 = this.getRocketUserFromUserId(channel.members[1]); + const existingRoom = Rooms.findOneDirectRoomContainingAllUserIDs([user1, user2], { fields: { _id: 1 } }); - const existingRoom = Rooms.findOneDirectRoomContainingAllUserIDs([user1, user2], { fields: { _id: 1 } }); + if (existingRoom) { + channel.rocketId = existingRoom._id; + Rooms.update({ _id: channel.rocketId }, { $addToSet: { importIds: channel.id } }); + } else { + if (!user1) { + this.logger.error(`DM creation: User not found for id ${ channel.members[0] } and channel id ${ channel.id }`); + return; + } - if (existingRoom) { - channel.rocketId = existingRoom._id; - Rooms.update({ _id: channel.rocketId }, { $addToSet: { importIds: channel.id } }); - } else { - if (!user1) { - this.logger.error(`DM creation: User not found for id ${ channel.members[0] } and channel id ${ channel.id }`); - return; - } + if (!user2) { + this.logger.error(`DM creation: User not found for id ${ channel.members[1] } and channel id ${ channel.id }`); + return; + } - if (!user2) { - this.logger.error(`DM creation: User not found for id ${ channel.members[1] } and channel id ${ channel.id }`); - return; + const roomInfo = Meteor.runAsUser(user1._id, () => Meteor.call('createDirectMessage', user2.username)); + channel.rocketId = roomInfo.rid; + Rooms.update({ _id: channel.rocketId }, { $addToSet: { importIds: channel.id } }); } - const roomInfo = Meteor.runAsUser(user1._id, () => Meteor.call('createDirectMessage', user2.username)); - channel.rocketId = roomInfo.rid; - Rooms.update({ _id: channel.rocketId }, { $addToSet: { importIds: channel.id } }); - } - - this.addCountCompleted(1); + this.addCountCompleted(1); + }); }); - }); - this.collection.update({ _id: this.dms._id }, { $set: { channels: this.dms.channels } }); + this.collection.update({ _id: list._id }, { $set: { channels: list.channels } }); + } } _importMessages(startedByUserId, channelNames) { const missedTypes = {}; super.updateProgress(ProgressStep.IMPORTING_MESSAGES); for (const channel of channelNames) { + if (!channel) { + continue; + } + const slackChannel = this.getSlackChannelFromName(channel); const room = Rooms.findOneById(slackChannel.rocketId, { fields: { usernames: 1, t: 1, name: 1 } }); @@ -785,7 +867,14 @@ export class SlackImporter extends Base { const packId = pack.i ? `${ pack.date }.${ pack.i }` : pack.date; this.updateRecord({ messagesstatus: `${ channel }/${ packId } (${ pack.messages.length })` }); - pack.messages.forEach((message) => this.performMessageImport(message, room, missedTypes, slackChannel)); + pack.messages.forEach((message) => { + try { + return this.performMessageImport(message, room, missedTypes, slackChannel); + } catch (e) { + this.logger.warn(`Failed to import message with timestamp ${ String(message.ts) } to room ${ room._id }`); + this.logger.debug(e); + } + }); }); }); } @@ -796,55 +885,98 @@ export class SlackImporter extends Base { } _applyUserSelection(importSelection) { + if (importSelection.users.length === 3 && importSelection.users[0].user_id === 'users') { + const regularUsers = importSelection.users[0].do_import; + const botUsers = importSelection.users[1].do_import; + const deletedUsers = importSelection.users[2].do_import; + + for (const list of this.userLists) { + Object.keys(list.users).forEach((k) => { + const u = list.users[k]; + + if (u.is_bot) { + u.do_import = botUsers; + } else if (u.deleted) { + u.do_import = deletedUsers; + } else { + u.do_import = regularUsers; + } + }); + + this.collection.update({ _id: list._id }, { $set: { users: list.users } }); + } + } + Object.keys(importSelection.users).forEach((key) => { const user = importSelection.users[key]; - Object.keys(this.users.users).forEach((k) => { - const u = this.users.users[k]; - if (u.id === user.user_id) { - u.do_import = user.do_import; - } - }); + + for (const list of this.userLists) { + Object.keys(list.users).forEach((k) => { + const u = list.users[k]; + if (u.id === user.user_id) { + u.do_import = user.do_import; + } + }); + + this.collection.update({ _id: list._id }, { $set: { users: list.users } }); + } }); - this.collection.update({ _id: this.users._id }, { $set: { users: this.users.users } }); } _applyChannelSelection(importSelection) { - const iterateChannelList = (list, channel_id, do_import) => { - Object.keys(list).forEach((k) => { - const c = list[k]; - if (c.id === channel_id) { - c.do_import = do_import; + const iterateChannelList = (listList, channel_id, do_import) => { + for (const list of listList) { + for (const c of list.channels) { + if (!c) { + continue; + } + + if (channel_id === '*') { + if (!c.archived) { + c.do_import = do_import; + } + } else if (channel_id === '*/archived') { + if (c.archived) { + c.do_import = do_import; + } + } else if (c.id === channel_id) { + c.do_import = do_import; + } } - }); + + this.collection.update({ _id: list._id }, { $set: { channels: list.channels } }); + } }; Object.keys(importSelection.channels).forEach((key) => { const channel = importSelection.channels[key]; - if (this.channels && this.channels.channels) { - iterateChannelList(this.channels.channels, channel.channel_id, channel.do_import); - } - - if (this.groups && this.groups.channels) { - iterateChannelList(this.groups.channels, channel.channel_id, channel.do_import); - } - - if (this.mpims && this.mpims.channels) { - iterateChannelList(this.mpims.channels, channel.channel_id, channel.do_import); + switch (channel.channel_id) { + case 'channels': + iterateChannelList(this.channelsLists, '*', channel.do_import); + break; + case 'archived_channels': + iterateChannelList(this.channelsLists, '*/archived', channel.do_import); + break; + case 'groups': + iterateChannelList(this.groupsLists, '*', channel.do_import); + break; + case 'archived_groups': + iterateChannelList(this.groupsLists, '*/archived', channel.do_import); + break; + case 'mpims': + iterateChannelList(this.mpimsLists, '*', channel.do_import); + break; + case 'archived_mpims': + iterateChannelList(this.mpimsLists, '*/archived', channel.do_import); + break; + default: + iterateChannelList(this.channelsLists, channel.channel_id, channel.do_import); + iterateChannelList(this.groupsLists, channel.channel_id, channel.do_import); + iterateChannelList(this.mpimsLists, channel.channel_id, channel.do_import); + break; } }); - - if (this.channels && this.channels.channels) { - this.collection.update({ _id: this.channels._id }, { $set: { channels: this.channels.channels } }); - } - - if (this.groups && this.groups.channels) { - this.collection.update({ _id: this.groups._id }, { $set: { channels: this.groups.channels } }); - } - - if (this.mpims && this.mpims.channels) { - this.collection.update({ _id: this.mpims._id }, { $set: { channels: this.mpims.channels } }); - } } startImport(importSelection) { @@ -855,11 +987,11 @@ export class SlackImporter extends Base { this.bots = {}; } - this.users = RawImports.findOne({ import: this.importRecord._id, type: 'users' }); - this.channels = RawImports.findOne({ import: this.importRecord._id, type: 'channels' }); - this.groups = RawImports.findOne({ import: this.importRecord._id, type: 'groups' }); - this.dms = RawImports.findOne({ import: this.importRecord._id, type: 'DMs' }); - this.mpims = RawImports.findOne({ import: this.importRecord._id, type: 'mpims' }); + this.userLists = RawImports.find({ import: this.importRecord._id, type: 'users' }).fetch(); + this.channelsLists = RawImports.find({ import: this.importRecord._id, type: 'channels' }).fetch(); + this.groupsLists = RawImports.find({ import: this.importRecord._id, type: 'groups' }).fetch(); + this.dmsLists = RawImports.find({ import: this.importRecord._id, type: 'DMs' }).fetch(); + this.mpimsLists = RawImports.find({ import: this.importRecord._id, type: 'mpims' }).fetch(); this._userDataCache = {}; this._anyExistingSlackMessage = Boolean(Messages.findOne({ _id: /slack\-.*/ })); @@ -890,15 +1022,14 @@ export class SlackImporter extends Base { super.updateProgress(ProgressStep.FINISHING); try { - if (this.channels) { - this._archiveChannelsAsNeeded(startedByUserId, this.channels); - } - if (this.groups) { - this._archiveChannelsAsNeeded(startedByUserId, this.groups); - } - if (this.mpims) { - this._archiveChannelsAsNeeded(startedByUserId, this.mpims); - } + this._archiveChannelsAsNeeded(startedByUserId, this.channelsLists); + this._archiveChannelsAsNeeded(startedByUserId, this.groupsLists); + this._archiveChannelsAsNeeded(startedByUserId, this.mpimsLists); + + this._updateRoomsLastMessage(this.channelsLists); + this._updateRoomsLastMessage(this.groupsLists); + this._updateRoomsLastMessage(this.mpimsLists); + this._updateRoomsLastMessage(this.dmsLists); } catch (e) { // If it failed to archive some channel, it's no reason to flag the import as incomplete // Just report the error but keep the import as successful. @@ -918,40 +1049,52 @@ export class SlackImporter extends Base { return this.getProgress(); } - _archiveChannelsAsNeeded(startedByUserId, list) { - list.channels.forEach((channel) => { - if (channel.do_import && channel.is_archived && channel.rocketId) { - Meteor.runAsUser(startedByUserId, function() { - Meteor.call('archiveRoom', channel.rocketId); - }); - } - }); + _archiveChannelsAsNeeded(startedByUserId, listList) { + for (const list of listList) { + list.channels.forEach((channel) => { + if (channel.do_import && channel.is_archived && channel.rocketId) { + Meteor.runAsUser(startedByUserId, function() { + Meteor.call('archiveRoom', channel.rocketId); + }); + } + }); + } + } + + _updateRoomsLastMessage(listList) { + for (const list of listList) { + list.channels.forEach((channel) => { + if (channel.do_import && channel.rocketId) { + Rooms.resetLastMessageById(channel.rocketId); + } + }); + } } getSlackChannelFromName(channelName) { - if (this.channels && this.channels.channels) { - const channel = this.channels.channels.find((channel) => channel.name === channelName); + for (const list of this.channelsLists) { + const channel = list.channels.find((channel) => channel.name === channelName); if (channel) { return channel; } } - if (this.groups && this.groups.channels) { - const group = this.groups.channels.find((channel) => channel.name === channelName); + for (const list of this.groupsLists) { + const group = list.channels.find((channel) => channel.name === channelName); if (group) { return group; } } - if (this.mpims && this.mpims.channels) { - const group = this.mpims.channels.find((channel) => channel.name === channelName); + for (const list of this.mpimsLists) { + const group = list.channels.find((channel) => channel.name === channelName); if (group) { return group; } } - if (this.dms && this.dms.channels) { - const dm = this.dms.channels.find((channel) => channel.id === channelName); + for (const list of this.dmsLists) { + const dm = list.channels.find((channel) => channel.id === channelName); if (dm) { return dm; } @@ -987,17 +1130,19 @@ export class SlackImporter extends Base { return 'rocket.cat'; } - for (const user of this.users.users) { - if (user.id !== slackUserId) { - continue; - } + for (const list of this.userLists) { + for (const user of list.users) { + if (user.id !== slackUserId) { + continue; + } - if (user.do_import) { - return user.rocketId; - } + if (user.do_import) { + return user.rocketId; + } - if (user.is_bot) { - return 'rocket.cat'; + if (user.is_bot) { + return 'rocket.cat'; + } } } } diff --git a/app/importer/server/classes/ImporterBase.js b/app/importer/server/classes/ImporterBase.js index 17e36687786a..a8c5c3cea55d 100644 --- a/app/importer/server/classes/ImporterBase.js +++ b/app/importer/server/classes/ImporterBase.js @@ -41,12 +41,12 @@ export class Base { * The max BSON object size we can store in MongoDB is 16777216 bytes * but for some reason the mongo instanace which comes with Meteor * errors out for anything close to that size. So, we are rounding it - * down to 8000000 bytes. + * down to 6000000 bytes. * * @returns {number} 8000000 bytes. */ static getMaxBSONSize() { - return 8000000; + return 6000000; } /** diff --git a/app/lib/server/functions/addOAuthService.js b/app/lib/server/functions/addOAuthService.js index 065152841499..f1117cc11d63 100644 --- a/app/lib/server/functions/addOAuthService.js +++ b/app/lib/server/functions/addOAuthService.js @@ -8,9 +8,9 @@ export function addOAuthService(name, values = {}) { name = name.toLowerCase().replace(/[^a-z0-9_]/g, ''); name = s.capitalize(name); settings.add(`Accounts_OAuth_Custom-${ name }` , values.enabled || false , { type: 'boolean', group: 'OAuth', section: `Custom OAuth: ${ name }`, i18nLabel: 'Accounts_OAuth_Custom_Enable', persistent: true }); - settings.add(`Accounts_OAuth_Custom-${ name }-url` , values.serverURL || '' , { type: 'string' , group: 'OAuth', section: `Custom OAuth: ${ name }`, i18nLabel: 'URL', persistent: true }); + settings.add(`Accounts_OAuth_Custom-${ name }-url` , values.serverURL || '' , { type: 'string' , group: 'OAuth', section: `Custom OAuth: ${ name }`, i18nLabel: 'URL', persistent: true }); settings.add(`Accounts_OAuth_Custom-${ name }-token_path` , values.tokenPath || '/oauth/token' , { type: 'string' , group: 'OAuth', section: `Custom OAuth: ${ name }`, i18nLabel: 'Accounts_OAuth_Custom_Token_Path', persistent: true }); - settings.add(`Accounts_OAuth_Custom-${ name }-token_sent_via` , values.tokenSentVia || 'payload' , { type: 'select' , group: 'OAuth', section: `Custom OAuth: ${ name }`, i18nLabel: 'Accounts_OAuth_Custom_Token_Sent_Via', persistent: true, values: [{ key: 'header', i18nLabel: 'Header' }, { key: 'payload', i18nLabel: 'Payload' }] }); + settings.add(`Accounts_OAuth_Custom-${ name }-token_sent_via` , values.tokenSentVia || 'payload' , { type: 'select' , group: 'OAuth', section: `Custom OAuth: ${ name }`, i18nLabel: 'Accounts_OAuth_Custom_Token_Sent_Via', persistent: true, values: [{ key: 'header', i18nLabel: 'Header' }, { key: 'payload', i18nLabel: 'Payload' }] }); settings.add(`Accounts_OAuth_Custom-${ name }-identity_token_sent_via`, values.identityTokenSentVia || 'default' , { type: 'select' , group: 'OAuth', section: `Custom OAuth: ${ name }`, i18nLabel: 'Accounts_OAuth_Custom_Identity_Token_Sent_Via', persistent: true, values: [{ key: 'default', i18nLabel: 'Same_As_Token_Sent_Via' }, { key: 'header', i18nLabel: 'Header' }, { key: 'payload', i18nLabel: 'Payload' }] }); settings.add(`Accounts_OAuth_Custom-${ name }-identity_path` , values.identityPath || '/me' , { type: 'string' , group: 'OAuth', section: `Custom OAuth: ${ name }`, i18nLabel: 'Accounts_OAuth_Custom_Identity_Path', persistent: true }); settings.add(`Accounts_OAuth_Custom-${ name }-authorize_path` , values.authorizePath || '/oauth/authorize' , { type: 'string' , group: 'OAuth', section: `Custom OAuth: ${ name }`, i18nLabel: 'Accounts_OAuth_Custom_Authorize_Path', persistent: true }); @@ -22,6 +22,7 @@ export function addOAuthService(name, values = {}) { settings.add(`Accounts_OAuth_Custom-${ name }-button_label_text` , values.buttonLabelText || '' , { type: 'string' , group: 'OAuth', section: `Custom OAuth: ${ name }`, i18nLabel: 'Accounts_OAuth_Custom_Button_Label_Text', persistent: true }); settings.add(`Accounts_OAuth_Custom-${ name }-button_label_color` , values.buttonLabelColor || '#FFFFFF' , { type: 'string' , group: 'OAuth', section: `Custom OAuth: ${ name }`, i18nLabel: 'Accounts_OAuth_Custom_Button_Label_Color', persistent: true }); settings.add(`Accounts_OAuth_Custom-${ name }-button_color` , values.buttonColor || '#1d74f5' , { type: 'string' , group: 'OAuth', section: `Custom OAuth: ${ name }`, i18nLabel: 'Accounts_OAuth_Custom_Button_Color', persistent: true }); + settings.add(`Accounts_OAuth_Custom-${ name }-key_field` , values.keyField || 'username' , { type: 'select' , group: 'OAuth', section: `Custom OAuth: ${ name }`, i18nLabel: 'Accounts_OAuth_Custom_Key_Field', persistent: true, values: [{ key: 'username', i18nLabel: 'Username' }, { key: 'email', i18nLabel: 'Email' }] }); settings.add(`Accounts_OAuth_Custom-${ name }-username_field` , values.usernameField || '' , { type: 'string' , group: 'OAuth', section: `Custom OAuth: ${ name }`, i18nLabel: 'Accounts_OAuth_Custom_Username_Field', persistent: true }); settings.add(`Accounts_OAuth_Custom-${ name }-email_field` , values.emailField || '' , { type: 'string' , group: 'OAuth', section: `Custom OAuth: ${ name }`, i18nLabel: 'Accounts_OAuth_Custom_Email_Field', persistent: true }); settings.add(`Accounts_OAuth_Custom-${ name }-name_field` , values.nameField || '' , { type: 'string' , group: 'OAuth', section: `Custom OAuth: ${ name }`, i18nLabel: 'Accounts_OAuth_Custom_Name_Field', persistent: true }); diff --git a/app/lib/server/functions/createRoom.js b/app/lib/server/functions/createRoom.js index 3d5d4ca886ee..8bc4c170b67f 100644 --- a/app/lib/server/functions/createRoom.js +++ b/app/lib/server/functions/createRoom.js @@ -91,7 +91,6 @@ export const createRoom = function(type, name, owner, members = [], readOnly, ex if (type === 'c') { callbacks.run('beforeCreateChannel', owner, room); } - room = Rooms.createWithFullRoomData(room); for (const username of members) { diff --git a/app/lib/server/functions/notifications/email.js b/app/lib/server/functions/notifications/email.js index 2cf06ed1277c..d49bf1c44a1f 100644 --- a/app/lib/server/functions/notifications/email.js +++ b/app/lib/server/functions/notifications/email.js @@ -160,7 +160,7 @@ export function getEmailData({ headers: {}, }; - if (sender.emails?.length > 0) { + if (sender.emails?.length > 0 && settings.get('Add_Sender_To_ReplyTo')) { const [senderEmail] = sender.emails; email.headers['Reply-To'] = generateNameEmail(username, senderEmail.address); } diff --git a/app/lib/server/functions/saveUser.js b/app/lib/server/functions/saveUser.js index 198a730f50cf..bc986a54bd32 100644 --- a/app/lib/server/functions/saveUser.js +++ b/app/lib/server/functions/saveUser.js @@ -199,36 +199,32 @@ function validateUserEditing(userId, userData) { } const handleBio = (updateUser, bio) => { - if (bio) { - if (bio.trim()) { - if (typeof bio !== 'string' || bio.length > 260) { - throw new Meteor.Error('error-invalid-field', 'bio', { - method: 'saveUserProfile', - }); - } - updateUser.$set = updateUser.$set || {}; - updateUser.$set.bio = bio; - } else { - updateUser.$unset = updateUser.$unset || {}; - updateUser.$unset.bio = 1; + if (bio && bio.trim()) { + if (typeof bio !== 'string' || bio.length > 260) { + throw new Meteor.Error('error-invalid-field', 'bio', { + method: 'saveUserProfile', + }); } + updateUser.$set = updateUser.$set || {}; + updateUser.$set.bio = bio; + } else { + updateUser.$unset = updateUser.$unset || {}; + updateUser.$unset.bio = 1; } }; const handleNickname = (updateUser, nickname) => { - if (nickname) { - if (nickname.trim()) { - if (typeof nickname !== 'string' || nickname.length > 120) { - throw new Meteor.Error('error-invalid-field', 'nickname', { - method: 'saveUserProfile', - }); - } - updateUser.$set = updateUser.$set || {}; - updateUser.$set.nickname = nickname; - } else { - updateUser.$unset = updateUser.$unset || {}; - updateUser.$unset.nickname = 1; + if (nickname && nickname.trim()) { + if (typeof nickname !== 'string' || nickname.length > 120) { + throw new Meteor.Error('error-invalid-field', 'nickname', { + method: 'saveUserProfile', + }); } + updateUser.$set = updateUser.$set || {}; + updateUser.$set.nickname = nickname; + } else { + updateUser.$unset = updateUser.$unset || {}; + updateUser.$unset.nickname = 1; } }; diff --git a/app/lib/server/functions/setUserAvatar.js b/app/lib/server/functions/setUserAvatar.js index 7229d5a294e4..bae4c45809af 100644 --- a/app/lib/server/functions/setUserAvatar.js +++ b/app/lib/server/functions/setUserAvatar.js @@ -18,13 +18,13 @@ export const setUserAvatar = function(user, dataURI, contentType, service) { try { result = HTTP.get(dataURI, { npmRequestOptions: { encoding: 'binary', rejectUnauthorized: false } }); if (!result) { - console.log(`Not a valid response, from the avatar url: ${ dataURI }`); - throw new Meteor.Error('error-avatar-invalid-url', `Invalid avatar URL: ${ dataURI }`, { function: 'setUserAvatar', url: dataURI }); + console.log(`Not a valid response, from the avatar url: ${ encodeURI(dataURI) }`); + throw new Meteor.Error('error-avatar-invalid-url', `Invalid avatar URL: ${ encodeURI(dataURI) }`, { function: 'setUserAvatar', url: dataURI }); } } catch (error) { if (!error.response || error.response.statusCode !== 404) { - console.log(`Error while handling the setting of the avatar from a url (${ dataURI }) for ${ user.username }:`, error); - throw new Meteor.Error('error-avatar-url-handling', `Error while handling avatar setting from a URL (${ dataURI }) for ${ user.username }`, { function: 'RocketChat.setUserAvatar', url: dataURI, username: user.username }); + console.log(`Error while handling the setting of the avatar from a url (${ encodeURI(dataURI) }) for ${ user.username }:`, error); + throw new Meteor.Error('error-avatar-url-handling', `Error while handling avatar setting from a URL (${ encodeURI(dataURI) }) for ${ user.username }`, { function: 'RocketChat.setUserAvatar', url: dataURI, username: user.username }); } } diff --git a/app/lib/server/lib/notifyUsersOnMessage.js b/app/lib/server/lib/notifyUsersOnMessage.js index 1218e486f8fe..8216e18dbfcd 100644 --- a/app/lib/server/lib/notifyUsersOnMessage.js +++ b/app/lib/server/lib/notifyUsersOnMessage.js @@ -69,7 +69,8 @@ const getUserIdsFromHighlights = (rid, message) => { }; export function updateUsersSubscriptions(message, room) { - if (room != null) { + // Don't increase unread counter on thread messages + if (room != null && !message.tmid) { const { toAll, toHere, diff --git a/app/lib/server/methods/removeOAuthService.js b/app/lib/server/methods/removeOAuthService.js index 4f83a1008bdb..a60e791f1c1f 100644 --- a/app/lib/server/methods/removeOAuthService.js +++ b/app/lib/server/methods/removeOAuthService.js @@ -34,6 +34,7 @@ Meteor.methods({ settings.removeById(`Accounts_OAuth_Custom-${ name }-button_label_color`); settings.removeById(`Accounts_OAuth_Custom-${ name }-button_color`); settings.removeById(`Accounts_OAuth_Custom-${ name }-login_style`); + settings.removeById(`Accounts_OAuth_Custom-${ name }-key_field`); settings.removeById(`Accounts_OAuth_Custom-${ name }-username_field`); settings.removeById(`Accounts_OAuth_Custom-${ name }-email_field`); settings.removeById(`Accounts_OAuth_Custom-${ name }-name_field`); diff --git a/app/lib/server/methods/sendInvitationEmail.js b/app/lib/server/methods/sendInvitationEmail.js index a8f8e21c827c..84a3a40db8ed 100644 --- a/app/lib/server/methods/sendInvitationEmail.js +++ b/app/lib/server/methods/sendInvitationEmail.js @@ -27,6 +27,12 @@ Meteor.methods({ } const validEmails = emails.filter(Mailer.checkAddressFormat); + if (!validEmails || validEmails.length === 0) { + throw new Meteor.Error('error-email-send-failed', 'No valid email addresses', { + method: 'sendInvitationEmail', + }); + } + const subject = settings.get('Invitation_Subject'); return validEmails.filter((email) => { diff --git a/app/lib/server/startup/email.js b/app/lib/server/startup/email.js index 86139254417f..40cac50e9c8b 100644 --- a/app/lib/server/startup/email.js +++ b/app/lib/server/startup/email.js @@ -476,5 +476,8 @@ settings.addGroup('Email', function() { type: 'boolean', public: true, }); + this.add('Add_Sender_To_ReplyTo', false, { + type: 'boolean', + }); }); }); diff --git a/app/lib/server/startup/oAuthServicesUpdate.js b/app/lib/server/startup/oAuthServicesUpdate.js index e09af1551e56..2e35236f05c5 100644 --- a/app/lib/server/startup/oAuthServicesUpdate.js +++ b/app/lib/server/startup/oAuthServicesUpdate.js @@ -49,6 +49,7 @@ function _OAuthServicesUpdate() { data.buttonColor = settings.get(`${ service.key }-button_color`); data.tokenSentVia = settings.get(`${ service.key }-token_sent_via`); data.identityTokenSentVia = settings.get(`${ service.key }-identity_token_sent_via`); + data.keyField = settings.get(`${ service.key }-key_field`); data.usernameField = settings.get(`${ service.key }-username_field`); data.emailField = settings.get(`${ service.key }-email_field`); data.nameField = settings.get(`${ service.key }-name_field`); @@ -71,6 +72,7 @@ function _OAuthServicesUpdate() { loginStyle: data.loginStyle, tokenSentVia: data.tokenSentVia, identityTokenSentVia: data.identityTokenSentVia, + keyField: data.keyField, usernameField: data.usernameField, emailField: data.emailField, nameField: data.nameField, @@ -177,6 +179,7 @@ function customOAuthServicesInit() { buttonColor: process.env[`${ serviceKey }_button_color`], tokenSentVia: process.env[`${ serviceKey }_token_sent_via`], identityTokenSentVia: process.env[`${ serviceKey }_identity_token_sent_via`], + keyField: process.env[`${ serviceKey }_key_field`], usernameField: process.env[`${ serviceKey }_username_field`], nameField: process.env[`${ serviceKey }_name_field`], emailField: process.env[`${ serviceKey }_email_field`], @@ -184,10 +187,10 @@ function customOAuthServicesInit() { groupsClaim: process.env[`${ serviceKey }_groups_claim`], channelsMap: process.env[`${ serviceKey }_groups_channel_map`], channelsAdmin: process.env[`${ serviceKey }_channels_admin`], - mergeUsers: process.env[`${ serviceKey }_merge_users`], + mergeUsers: process.env[`${ serviceKey }_merge_users`] === 'true', mapChannels: process.env[`${ serviceKey }_map_channels`], - mergeRoles: process.env[`${ serviceKey }_merge_roles`], - showButton: process.env[`${ serviceKey }_show_button`], + mergeRoles: process.env[`${ serviceKey }_merge_roles`] === 'true', + showButton: process.env[`${ serviceKey }_show_button`] === 'true', avatarField: process.env[`${ serviceKey }_avatar_field`], }; diff --git a/app/livechat/client/ui.js b/app/livechat/client/ui.js index 069254af7023..6b7eade52a14 100644 --- a/app/livechat/client/ui.js +++ b/app/livechat/client/ui.js @@ -31,3 +31,9 @@ MessageTypes.registerType({ }; }, }); + +MessageTypes.registerType({ + id: 'livechat-started', + system: true, + message: 'Chat_started', +}); diff --git a/app/livechat/lib/LivechatRoomType.js b/app/livechat/lib/LivechatRoomType.js index 4c05ce721452..ff8e44ba983d 100644 --- a/app/livechat/lib/LivechatRoomType.js +++ b/app/livechat/lib/LivechatRoomType.js @@ -122,17 +122,6 @@ export default class LivechatRoomType extends RoomTypeConfig { if (!room || !room.v || room.v.username !== username) { return false; } - // const button = instance.tabBar.getButtons({ room }).find((button) => button.id === 'visitor-info'); - // if (!button) { - // return false; - // } - - // const { template, i18nTitle: label, icon } = button; - // instance.tabBar.setTemplate(template); - // instance.tabBar.setData({ - // label, - // icon, - // }); instance.tabBar.openUserInfo(); return true; diff --git a/app/livechat/server/api/v1/message.js b/app/livechat/server/api/v1/message.js index 68a83706fc18..df8ac26a07f7 100644 --- a/app/livechat/server/api/v1/message.js +++ b/app/livechat/server/api/v1/message.js @@ -9,6 +9,7 @@ import { loadMessageHistory } from '../../../../lib'; import { findGuest, findRoom, normalizeHttpHeaderData } from '../lib/livechat'; import { Livechat } from '../../lib/Livechat'; import { normalizeMessageFileUpload } from '../../../../utils/server/functions/normalizeMessageFileUpload'; +import { settings } from '../../../../settings/server'; API.v1.addRoute('livechat/message', { post() { @@ -40,6 +41,10 @@ API.v1.addRoute('livechat/message', { throw new Meteor.Error('room-closed'); } + if (settings.get('Livechat_enable_message_character_limit') && msg.length > parseInt(settings.get('Livechat_message_character_limit'))) { + throw new Meteor.Error('message-length-exceeds-character-limit'); + } + const _id = this.bodyParams._id || Random.id(); const sendMessage = { diff --git a/app/livechat/server/lib/Helper.js b/app/livechat/server/lib/Helper.js index e762f51f0db5..f7a321a25e95 100644 --- a/app/livechat/server/lib/Helper.js +++ b/app/livechat/server/lib/Helper.js @@ -12,6 +12,7 @@ import { settings } from '../../../settings'; import { Apps, AppEvents } from '../../../apps/server'; import notifications from '../../../notifications/server/lib/Notifications'; import { sendNotification } from '../../../lib/server'; +import { sendMessage } from '../../../lib/server/functions/sendMessage'; export const allowAgentSkipQueue = (agent) => { check(agent, Match.ObjectIncluding({ @@ -61,6 +62,8 @@ export const createLivechatRoom = (rid, name, guest, roomInfo = {}, extraData = callbacks.run('livechat.newRoom', room); }); + sendMessage(guest, { t: 'livechat-started', msg: '', groupable: false }, room); + return roomId; }; diff --git a/app/livechat/server/lib/Livechat.js b/app/livechat/server/lib/Livechat.js index 9e407614bd18..31a7edcb361d 100644 --- a/app/livechat/server/lib/Livechat.js +++ b/app/livechat/server/lib/Livechat.js @@ -961,7 +961,7 @@ export const Livechat = { } const showAgentInfo = settings.get('Livechat_show_agent_info'); - const ignoredMessageTypes = ['livechat_navigation_history', 'livechat_transcript_history', 'command', 'livechat-close', 'livechat_video_call']; + const ignoredMessageTypes = ['livechat_navigation_history', 'livechat_transcript_history', 'command', 'livechat-close', 'livechat-started', 'livechat_video_call']; const messages = Messages.findVisibleByRoomIdNotContainingTypes(rid, ignoredMessageTypes, { sort: { ts: 1 } }); let html = '

'; diff --git a/app/markdown/client/getGlobalWindow.ts b/app/markdown/client/getGlobalWindow.ts new file mode 100644 index 000000000000..062959a9fe23 --- /dev/null +++ b/app/markdown/client/getGlobalWindow.ts @@ -0,0 +1 @@ +export const getGlobalWindow = (): Omit => window; diff --git a/app/markdown/lib/getGlobalWindow.ts b/app/markdown/lib/getGlobalWindow.ts new file mode 100644 index 000000000000..e1e12b72a10f --- /dev/null +++ b/app/markdown/lib/getGlobalWindow.ts @@ -0,0 +1,5 @@ +import { Meteor } from 'meteor/meteor'; + +export const { getGlobalWindow } = Meteor.isServer + ? require('../server/getGlobalWindow') + : require('../client/getGlobalWindow'); diff --git a/app/markdown/lib/parser/marked/marked.js b/app/markdown/lib/parser/marked/marked.js index f930a3369bb9..91a0db96c34d 100644 --- a/app/markdown/lib/parser/marked/marked.js +++ b/app/markdown/lib/parser/marked/marked.js @@ -1,11 +1,12 @@ import { Random } from 'meteor/random'; import _ from 'underscore'; import _marked from 'marked'; -import dompurify from 'dompurify'; +import createDOMPurify from 'dompurify'; import hljs from '../../hljs'; import { escapeHTML } from '../../../../../lib/escapeHTML'; import { unescapeHTML } from '../../../../../lib/unescapeHTML'; +import { getGlobalWindow } from '../../getGlobalWindow'; const renderer = new _marked.Renderer(); @@ -106,7 +107,9 @@ export const marked = (message, { highlight, }); - message.html = dompurify.sanitize(message.html); + const window = getGlobalWindow(); + const DomPurify = createDOMPurify(window); + message.html = DomPurify.sanitize(message.html, { ADD_ATTR: ['target'] }); return message; }; diff --git a/app/markdown/server/getGlobalWindow.ts b/app/markdown/server/getGlobalWindow.ts new file mode 100644 index 000000000000..0570f6f63f01 --- /dev/null +++ b/app/markdown/server/getGlobalWindow.ts @@ -0,0 +1,6 @@ +import { JSDOM } from 'jsdom'; + +export const getGlobalWindow = (): Omit => { + const { window } = new JSDOM(''); + return window; +}; diff --git a/app/message-mark-as-unread/client/actionButton.js b/app/message-mark-as-unread/client/actionButton.js index 6725215753bd..e2d054acc58e 100644 --- a/app/message-mark-as-unread/client/actionButton.js +++ b/app/message-mark-as-unread/client/actionButton.js @@ -11,7 +11,7 @@ Meteor.startup(() => { id: 'mark-message-as-unread', icon: 'flag', label: 'Mark_unread', - context: ['message', 'message-mobile'], + context: ['message', 'message-mobile', 'threads'], action() { const { msg: message } = messageArgs(this); return Meteor.call('unreadMessages', message, function(error) { diff --git a/app/message-mark-as-unread/server/unreadMessages.js b/app/message-mark-as-unread/server/unreadMessages.js index aec92fcf94b6..da31e72a35af 100644 --- a/app/message-mark-as-unread/server/unreadMessages.js +++ b/app/message-mark-as-unread/server/unreadMessages.js @@ -16,7 +16,7 @@ Meteor.methods({ const lastMessage = Messages.findVisibleByRoomId(room, { limit: 1, sort: { ts: -1 } }).fetch()[0]; if (lastMessage == null) { - throw new Meteor.Error('error-action-not-allowed', 'Not allowed', { + throw new Meteor.Error('error-no-message-for-unread', 'There are no messages to mark unread', { method: 'unreadMessages', action: 'Unread_messages', }); diff --git a/app/message-pin/client/views/pinnedMessages.js b/app/message-pin/client/views/pinnedMessages.js index 5c6305fe4309..1058a23fafda 100644 --- a/app/message-pin/client/views/pinnedMessages.js +++ b/app/message-pin/client/views/pinnedMessages.js @@ -11,8 +11,6 @@ import { getCommonRoomEvents } from '../../../ui/client/views/app/lib/getCommonR const LIMIT_DEFAULT = 50; -Template.pinnedMessages.events(getCommonRoomEvents()); - Template.pinnedMessages.helpers({ hasMessages() { return Template.instance().messages.find().count(); diff --git a/app/message-star/client/views/starredMessages.js b/app/message-star/client/views/starredMessages.js index e4c862e22aea..f43166661b5d 100644 --- a/app/message-star/client/views/starredMessages.js +++ b/app/message-star/client/views/starredMessages.js @@ -12,7 +12,6 @@ import { getCommonRoomEvents } from '../../../ui/client/views/app/lib/getCommonR const LIMIT_DEFAULT = 50; -Template.starredMessages.events(getCommonRoomEvents()); Template.starredMessages.helpers({ hasMessages() { return Template.instance().messages.find().count(); diff --git a/app/message-star/server/starMessage.js b/app/message-star/server/starMessage.js index 9ec8893d3280..4f9d58400f5e 100644 --- a/app/message-star/server/starMessage.js +++ b/app/message-star/server/starMessage.js @@ -14,7 +14,7 @@ Meteor.methods({ if (!settings.get('Message_AllowStarring')) { throw new Meteor.Error('error-action-not-allowed', 'Message starring not allowed', { - method: 'pinMessage', + method: 'starMessage', action: 'Message_starring', }); } diff --git a/app/meteor-accounts-saml/server/lib/SAML.ts b/app/meteor-accounts-saml/server/lib/SAML.ts index 277084414a55..2514cce35ccb 100644 --- a/app/meteor-accounts-saml/server/lib/SAML.ts +++ b/app/meteor-accounts-saml/server/lib/SAML.ts @@ -376,7 +376,7 @@ export class SAML { }); } - private static processValidateAction(req: IIncomingMessage, res: ServerResponse, service: IServiceProviderOptions, samlObject: ISAMLAction): void { + private static processValidateAction(req: IIncomingMessage, res: ServerResponse, service: IServiceProviderOptions, _samlObject: ISAMLAction): void { const serviceProvider = new SAMLServiceProvider(service); SAMLUtils.relayState = req.body.RelayState; serviceProvider.validateResponse(req.body.SAMLResponse, (err, profile/* , loggedOut*/) => { @@ -390,21 +390,15 @@ export class SAML { throw new Error('No user data collected from IdP response.'); } - let credentialToken = (profile.inResponseToId && profile.inResponseToId.value) || profile.inResponseToId || profile.InResponseTo || samlObject.credentialToken; + // create a random token to store the login result + // to test an IdP initiated login on localhost, use the following URL (assuming SimpleSAMLPHP on localhost:8080): + // http://localhost:8080/simplesaml/saml2/idp/SSOService.php?spentityid=http://localhost:3000/_saml/metadata/test-sp + const credentialToken = Random.id(); + const loginResult = { profile, }; - if (!credentialToken) { - // If the login was initiated by the IDP, then we don't have a credentialToken as there was no AuthorizeRequest on our side - // so we create a random token now to use the same url to end the login - // - // to test an IdP initiated login on localhost, use the following URL (assuming SimpleSAMLPHP on localhost:8080): - // http://localhost:8080/simplesaml/saml2/idp/SSOService.php?spentityid=http://localhost:3000/_saml/metadata/test-sp - credentialToken = Random.id(); - SAMLUtils.log('[SAML] Using random credentialToken: ', credentialToken); - } - this.storeCredential(credentialToken, loginResult); const url = `${ Meteor.absoluteUrl('home') }?saml_idp_credentialToken=${ credentialToken }`; res.writeHead(302, { diff --git a/app/meteor-accounts-saml/server/loginHandler.ts b/app/meteor-accounts-saml/server/loginHandler.ts index 0dcec76d04c3..84beec3091aa 100644 --- a/app/meteor-accounts-saml/server/loginHandler.ts +++ b/app/meteor-accounts-saml/server/loginHandler.ts @@ -11,7 +11,7 @@ const makeError = (message: string): Record => ({ }); Accounts.registerLoginHandler('saml', function(loginRequest) { - if (!loginRequest.saml || !loginRequest.credentialToken) { + if (!loginRequest.saml || !loginRequest.credentialToken || typeof loginRequest.credentialToken !== 'string') { return undefined; } diff --git a/app/metrics/server/lib/collectMetrics.js b/app/metrics/server/lib/collectMetrics.js index 9114e7b27ed7..66a24da4df91 100644 --- a/app/metrics/server/lib/collectMetrics.js +++ b/app/metrics/server/lib/collectMetrics.js @@ -31,6 +31,16 @@ const setPrometheusData = async () => { metrics.ddpAuthenticatedSessions.set(authenticatedSessions.length); metrics.ddpConnectedUsers.set(_.unique(authenticatedSessions.map((s) => s.userId)).length); + // Apps metrics + const { totalInstalled, totalActive, totalFailed } = getAppsStatistics(); + + metrics.totalAppsInstalled.set(totalInstalled || 0); + metrics.totalAppsEnabled.set(totalActive || 0); + metrics.totalAppsFailed.set(totalFailed || 0); + + const oplogQueue = getOplogInfo().mongo._oplogHandle?._entryQueue?.length || 0; + metrics.oplogQueue.set(oplogQueue); + const statistics = Statistics.findLast(); if (!statistics) { return; @@ -63,16 +73,6 @@ const setPrometheusData = async () => { metrics.totalDirectMessages.set(statistics.totalDirectMessages); metrics.totalLivechatMessages.set(statistics.totalLivechatMessages); - // Apps metrics - const { totalInstalled, totalActive, totalFailed } = getAppsStatistics(); - - metrics.totalAppsInstalled.set(totalInstalled || 0); - metrics.totalAppsEnabled.set(totalActive || 0); - metrics.totalAppsFailed.set(totalFailed || 0); - - const oplogQueue = getOplogInfo().mongo._oplogHandle?._entryQueue?.length || 0; - metrics.oplogQueue.set(oplogQueue); - metrics.pushQueue.set(statistics.pushQueue || 0); }; diff --git a/app/models/server/models/Messages.js b/app/models/server/models/Messages.js index 7e6768509c1c..0f29abb0de42 100644 --- a/app/models/server/models/Messages.js +++ b/app/models/server/models/Messages.js @@ -569,6 +569,7 @@ export class Messages extends Base { }, $unset: { blocks: 1, + tshow: 1, }, }; diff --git a/app/models/server/models/Rooms.js b/app/models/server/models/Rooms.js index 11317c6f47ee..d1eb3cf486bd 100644 --- a/app/models/server/models/Rooms.js +++ b/app/models/server/models/Rooms.js @@ -95,6 +95,22 @@ export class Rooms extends Base { return this.update({ _id: roomId }, { $unset: { lastMessage: { reactions: 1 } } }); } + unsetAllImportIds() { + const query = { + importIds: { + $exists: true, + }, + }; + + const update = { + $unset: { + importIds: 1, + }, + }; + + return this.update(query, update, { multi: true }); + } + updateLastMessageStar(roomId, userId, starred) { let update; const query = { _id: roomId }; @@ -489,7 +505,7 @@ export class Rooms extends Base { findByNameAndTypesNotInIds(name, types, ids, options) { const query = { _id: { - $ne: ids, + $nin: ids, }, t: { $in: types, diff --git a/app/models/server/models/Sessions.tests.js b/app/models/server/models/Sessions.tests.js index 117a2af4ae5f..2b8c7703531f 100644 --- a/app/models/server/models/Sessions.tests.js +++ b/app/models/server/models/Sessions.tests.js @@ -250,11 +250,13 @@ describe('Sessions Aggregates', () => { .then((testMongoUrl) => { process.env.MONGO_URL = testMongoUrl; }); }); - after(() => { mongoUnit.stop(); }); + after(() => { + mongoUnit.stop(); + }); } before(async () => { - const client = await MongoClient.connect(process.env.MONGO_URL); + const client = await MongoClient.connect(process.env.MONGO_URL, { useUnifiedTopology: true }); db = client.db('test'); after(() => { diff --git a/app/models/server/models/Statistics.js b/app/models/server/models/Statistics.js index ca95cd5642e6..014f8c8180d2 100644 --- a/app/models/server/models/Statistics.js +++ b/app/models/server/models/Statistics.js @@ -4,7 +4,7 @@ export class Statistics extends Base { constructor() { super('statistics'); - this.tryEnsureIndex({ createdAt: 1 }); + this.tryEnsureIndex({ createdAt: -1 }); } // FIND ONE diff --git a/app/models/server/models/Users.js b/app/models/server/models/Users.js index 36826d1465a1..6d9cf9d972b1 100644 --- a/app/models/server/models/Users.js +++ b/app/models/server/models/Users.js @@ -53,6 +53,7 @@ export class Users extends Base { this.tryEnsureIndex({ 'services.saml.inResponseTo': 1 }); this.tryEnsureIndex({ openBusinessHours: 1 }, { sparse: true }); this.tryEnsureIndex({ statusLivechat: 1 }, { sparse: true }); + this.tryEnsureIndex({ language: 1 }, { sparse: true }); } getLoginTokensByUserId(userId) { @@ -586,6 +587,15 @@ export class Users extends Base { return this.findOne(query, options); } + findOneByEmailAddressAndServiceNameIgnoringCase(emailAddress, userId, serviceName, options) { + const query = { + 'emails.address': String(emailAddress).trim().toLowerCase(), + [`services.${ serviceName }.id`]: userId, + }; + + return this.findOne(query, options); + } + findOneByUsername(username, options) { const query = { username }; @@ -1524,6 +1534,24 @@ Find users to send a message by email if: }, }); } + + findAllUsersWithPendingAvatar() { + const query = { + _pendingAvatarUrl: { + $exists: true, + }, + }; + + const options = { + fields: { + _id: 1, + name: 1, + _pendingAvatarUrl: 1, + }, + }; + + return this.find(query, options); + } } export default new Users(Meteor.users, true); diff --git a/app/models/server/raw/Roles.js b/app/models/server/raw/Roles.js index 523aa968057d..de771bcad9b0 100644 --- a/app/models/server/raw/Roles.js +++ b/app/models/server/raw/Roles.js @@ -8,13 +8,15 @@ export class RolesRaw extends BaseRaw { } async isUserInRoles(userId, roles, scope) { - roles = [].concat(roles); + if (!Array.isArray(roles)) { + roles = [roles]; + } for (let i = 0, total = roles.length; i < total; i++) { const roleName = roles[i]; // eslint-disable-next-line no-await-in-loop - const role = await this.findOne({ _id: roleName }); + const role = await this.findOne({ _id: roleName }, { scope: 1 }); const roleScope = (role && role.scope) || 'Users'; const model = this.models[roleScope]; diff --git a/app/models/server/raw/Users.js b/app/models/server/raw/Users.js index aa04c127fe22..c89ecd906823 100644 --- a/app/models/server/raw/Users.js +++ b/app/models/server/raw/Users.js @@ -68,7 +68,7 @@ export class UsersRaw extends BaseRaw { findByActiveUsersExcept(searchTerm, exceptions, options, searchFields, extraQuery = [], { startsWith = false, endsWith = false } = {}) { if (exceptions == null) { exceptions = []; } if (options == null) { options = {}; } - if (Array.isArray(exceptions)) { + if (!Array.isArray(exceptions)) { exceptions = [exceptions]; } @@ -388,6 +388,27 @@ export class UsersRaw extends BaseRaw { return this.col.aggregate(params).toArray(); } + getUserLanguages() { + const pipeline = [ + { + $match: { + language: { + $exists: true, + $ne: '', + }, + }, + }, + { + $group: { + _id: '$language', + total: { $sum: 1 }, + }, + }, + ]; + + return this.col.aggregate(pipeline).toArray(); + } + updateStatusText(_id, statusText) { const update = { $set: { diff --git a/app/notification-queue/server/NotificationQueue.ts b/app/notification-queue/server/NotificationQueue.ts index 7665a588c8ee..a1533e958a16 100644 --- a/app/notification-queue/server/NotificationQueue.ts +++ b/app/notification-queue/server/NotificationQueue.ts @@ -41,7 +41,14 @@ class NotificationClass { return; } - setTimeout(this.worker.bind(this), this.cyclePause); + setTimeout(() => { + try { + this.worker(); + } catch (e) { + console.error('Error sending notification', e); + this.executeWorkerLater(); + } + }, this.cyclePause); } async worker(counter = 0): Promise { diff --git a/app/oembed/server/jumpToMessage.js b/app/oembed/server/jumpToMessage.js index 4c14414f339b..961fbb656aaf 100644 --- a/app/oembed/server/jumpToMessage.js +++ b/app/oembed/server/jumpToMessage.js @@ -4,10 +4,11 @@ import QueryString from 'querystring'; import { Meteor } from 'meteor/meteor'; import _ from 'underscore'; -import { Messages } from '../../models'; -import { settings } from '../../settings'; -import { callbacks } from '../../callbacks'; +import { Messages, Rooms, Users } from '../../models/server'; +import { settings } from '../../settings/server'; +import { callbacks } from '../../callbacks/server'; import { getUserAvatarURL } from '../../utils/lib/getUserAvatarURL'; +import { canAccessRoom } from '../../authorization/server/functions/canAccessRoom'; const recursiveRemove = (message, deep = 1) => { if (message) { @@ -21,37 +22,62 @@ const recursiveRemove = (message, deep = 1) => { }; callbacks.add('beforeSaveMessage', (msg) => { - if (msg && msg.urls) { - msg.urls.forEach((item) => { - if (item.url.indexOf(Meteor.absoluteUrl()) === 0) { - const urlObj = URL.parse(item.url); - if (urlObj.query) { - const queryString = QueryString.parse(urlObj.query); - if (_.isString(queryString.msg)) { // Jump-to query param - const jumpToMessage = recursiveRemove(Messages.findOneById(queryString.msg)); - if (jumpToMessage) { - msg.attachments = msg.attachments || []; - - const index = msg.attachments.findIndex((a) => a.message_link === item.url); - if (index > -1) { - msg.attachments.splice(index, 1); - } - - msg.attachments.push({ - text: jumpToMessage.msg, - translations: jumpToMessage.translations, - author_name: jumpToMessage.alias || jumpToMessage.u.username, - author_icon: getUserAvatarURL(jumpToMessage.u.username), - message_link: item.url, - attachments: jumpToMessage.attachments || [], - ts: jumpToMessage.ts, - }); - item.ignoreParse = true; - } - } - } - } - }); + // if no message is present, or the message doesn't have any URL, skip + if (!msg || (!msg.urls || !msg.urls.length)) { + return msg; } + + const currentUser = Users.findOneById(msg.u._id); + + msg.urls.forEach((item) => { + // if the URL is not internal, skip + if (!item.url.includes(Meteor.absoluteUrl())) { + return; + } + + const urlObj = URL.parse(item.url); + + // if the URL doesn't have query params (doesn't reference message) skip + if (!urlObj.query) { + return; + } + + const { msg: msgId } = QueryString.parse(urlObj.query); + + if (!_.isString(msgId)) { + return; + } + + const jumpToMessage = recursiveRemove(Messages.findOneById(msgId)); + if (!jumpToMessage) { + return; + } + + // validates if user can see the message + // user has to belong to the room the message was first wrote in + const room = Rooms.findOneById(jumpToMessage.rid); + const canAccessRoomForUser = canAccessRoom(room, currentUser); + if (!canAccessRoomForUser) { + return; + } + + msg.attachments = msg.attachments || []; + const index = msg.attachments.findIndex((a) => a.message_link === item.url); + if (index > -1) { + msg.attachments.splice(index, 1); + } + + msg.attachments.push({ + text: jumpToMessage.msg, + translations: jumpToMessage.translations, + author_name: jumpToMessage.alias || jumpToMessage.u.username, + author_icon: getUserAvatarURL(jumpToMessage.u.username), + message_link: item.url, + attachments: jumpToMessage.attachments || [], + ts: jumpToMessage.ts, + }); + item.ignoreParse = true; + }); + return msg; }, callbacks.priority.LOW, 'jumpToMessage'); diff --git a/app/otr/client/index.js b/app/otr/client/index.js index 576a61e0f0ee..a24ea4f03ff6 100644 --- a/app/otr/client/index.js +++ b/app/otr/client/index.js @@ -1,3 +1,4 @@ +import './stylesheets/otr.css'; import './rocketchat.otr.room'; import './rocketchat.otr'; import './tabBar'; diff --git a/app/otr/client/rocketchat.otr.room.js b/app/otr/client/rocketchat.otr.room.js index 08da47733d76..221d6a9d0de5 100644 --- a/app/otr/client/rocketchat.otr.room.js +++ b/app/otr/client/rocketchat.otr.room.js @@ -13,6 +13,7 @@ import { OTR } from './rocketchat.otr'; import { Notifications } from '../../notifications'; import { modal } from '../../ui-utils'; import { getUidDirectMessage } from '../../ui-utils/client/lib/getUidDirectMessage'; +import { Presence } from '../../../client/lib/presence'; OTR.Room = class { constructor(userId, roomId) { @@ -178,7 +179,6 @@ OTR.Room = class { } onUserStream(type, data) { - const user = Meteor.users.findOne(data.userId); switch (type) { case 'handshake': let timeout = null; @@ -198,36 +198,40 @@ OTR.Room = class { }); }; - if (data.refresh && this.established.get()) { - this.reset(); - establishConnection(); - } else { - if (this.established.get()) { + (async () => { + const { username } = await Presence.get(data.userId); + if (data.refresh && this.established.get()) { this.reset(); + establishConnection(); + } else { + if (this.established.get()) { + this.reset(); + } + + modal.open({ + title: TAPi18n.__('OTR'), + text: TAPi18n.__('Username_wants_to_start_otr_Do_you_want_to_accept', { username }), + html: true, + showCancelButton: true, + allowOutsideClick: false, + confirmButtonText: TAPi18n.__('Yes'), + cancelButtonText: TAPi18n.__('No'), + }, (isConfirm) => { + if (isConfirm) { + establishConnection(); + } else { + Meteor.clearTimeout(timeout); + this.deny(); + } + }); } - modal.open({ - title: TAPi18n.__('OTR'), - text: TAPi18n.__('Username_wants_to_start_otr_Do_you_want_to_accept', { username: user.username }), - html: true, - showCancelButton: true, - allowOutsideClick: false, - confirmButtonText: TAPi18n.__('Yes'), - cancelButtonText: TAPi18n.__('No'), - }, (isConfirm) => { - if (isConfirm) { - establishConnection(); - } else { - Meteor.clearTimeout(timeout); - this.deny(); - } - }); - } + timeout = Meteor.setTimeout(() => { + this.establishing.set(false); + modal.close(); + }, 10000); + })(); - timeout = Meteor.setTimeout(() => { - this.establishing.set(false); - modal.close(); - }, 10000); break; diff --git a/app/otr/client/stylesheets/otr.css b/app/otr/client/stylesheets/otr.css new file mode 100644 index 000000000000..066075e5ec16 --- /dev/null +++ b/app/otr/client/stylesheets/otr.css @@ -0,0 +1,16 @@ +.message { + &.otr-ack { + .info { + color: lightgreen; + + &::before { + display: inline-block; + visibility: visible; + + content: "\e952"; + + font-family: 'fontello'; + } + } + } +} diff --git a/app/slackbridge/server/index.js b/app/slackbridge/server/index.js index 0b7f817c6ea7..713f1a300178 100644 --- a/app/slackbridge/server/index.js +++ b/app/slackbridge/server/index.js @@ -1,3 +1,4 @@ import './settings'; import './slackbridge'; import './slackbridge_import.server'; +import './removeChannelLinks'; diff --git a/app/slackbridge/server/removeChannelLinks.js b/app/slackbridge/server/removeChannelLinks.js new file mode 100644 index 000000000000..213636d07565 --- /dev/null +++ b/app/slackbridge/server/removeChannelLinks.js @@ -0,0 +1,29 @@ +import { Meteor } from 'meteor/meteor'; + +import { Rooms } from '../../models/server'; +import { hasRole } from '../../authorization'; +import { settings } from '../../settings'; + +Meteor.methods({ + removeSlackBridgeChannelLinks() { + const user = Meteor.user(); + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'removeSlackBridgeChannelLinks' }); + } + + if (!hasRole(user._id, 'admin')) { + throw new Meteor.Error('error-not-authorized', 'Not authorized', { method: 'removeSlackBridgeChannelLinks' }); + } + + if (settings.get('SlackBridge_Enabled') !== true) { + throw new Meteor.Error('SlackBridge_disabled'); + } + + Rooms.unsetAllImportIds(); + + return { + message: 'Slackbridge_channel_links_removed_successfully', + params: [], + }; + }, +}); diff --git a/app/slackbridge/server/settings.js b/app/slackbridge/server/settings.js index 2b2839eea11d..c22042f72f63 100644 --- a/app/slackbridge/server/settings.js +++ b/app/slackbridge/server/settings.js @@ -92,5 +92,15 @@ Meteor.startup(function() { }, i18nLabel: 'Reactions', }); + + this.add('SlackBridge_Remove_Channel_Links', 'removeSlackBridgeChannelLinks', { + type: 'action', + actionText: 'Remove_Channel_Links', + i18nDescription: 'SlackBridge_Remove_Channel_Links_Description', + enableQuery: { + _id: 'SlackBridge_Enabled', + value: true, + }, + }); }); }); diff --git a/app/statistics/server/lib/statistics.js b/app/statistics/server/lib/statistics.js index 3189328ce56e..3711557513f1 100644 --- a/app/statistics/server/lib/statistics.js +++ b/app/statistics/server/lib/statistics.js @@ -20,7 +20,7 @@ import { settings } from '../../../settings/server'; import { Info, getMongoInfo } from '../../../utils/server'; import { Migrations } from '../../../migrations/server'; import { getStatistics as federationGetStatistics } from '../../../federation/server/functions/dashboard'; -import { NotificationQueue } from '../../../models/server/raw'; +import { NotificationQueue, Users as UsersRaw } from '../../../models/server/raw'; import { readSecondaryPreferred } from '../../../../server/database/readSecondaryPreferred'; import { getAppsStatistics } from './getAppsStatistics'; import { getStatistics as getEnterpriseStatistics } from '../../../../ee/app/license/server'; @@ -35,6 +35,24 @@ const wizardFields = [ 'Register_Server', ]; +const getUserLanguages = (totalUsers) => { + const result = Promise.await(UsersRaw.getUserLanguages()); + + const languages = { + none: totalUsers, + }; + + result.forEach(({ _id, total }) => { + if (!_id) { + return; + } + languages[_id] = total; + languages.none -= total; + }); + + return languages; +}; + export const statistics = { get: function _getStatistics() { const readPreference = readSecondaryPreferred(Uploads.model.rawDatabase()); @@ -69,12 +87,12 @@ export const statistics = { statistics.activeGuests = Users.getActiveLocalGuestCount(); statistics.nonActiveUsers = Users.find({ active: false }).count(); statistics.appUsers = Users.find({ type: 'app' }).count(); - statistics.onlineUsers = Meteor.users.find({ statusConnection: 'online' }).count(); - statistics.awayUsers = Meteor.users.find({ statusConnection: 'away' }).count(); - // TODO: Get statuses from the `status` property. - statistics.busyUsers = Meteor.users.find({ statusConnection: 'busy' }).count(); + statistics.onlineUsers = Meteor.users.find({ status: 'online' }).count(); + statistics.awayUsers = Meteor.users.find({ status: 'away' }).count(); + statistics.busyUsers = Meteor.users.find({ status: 'busy' }).count(); statistics.totalConnectedUsers = statistics.onlineUsers + statistics.awayUsers; statistics.offlineUsers = statistics.totalUsers - statistics.onlineUsers - statistics.awayUsers - statistics.busyUsers; + statistics.userLanguages = getUserLanguages(statistics.totalUsers); // Room statistics statistics.totalRooms = Rooms.find().count(); diff --git a/app/theme/client/imports/general/base_old.css b/app/theme/client/imports/general/base_old.css index b34decf221cb..f33414a8e644 100644 --- a/app/theme/client/imports/general/base_old.css +++ b/app/theme/client/imports/general/base_old.css @@ -63,11 +63,11 @@ } &:first-child { - margin-top: 0; + margin-top: 15px; } &:last-child { - margin-bottom: 0; + margin-bottom: 15px; } &:first-child::before { diff --git a/app/theme/client/imports/general/variables.css b/app/theme/client/imports/general/variables.css index 79ff802069af..635786e6a0cc 100644 --- a/app/theme/client/imports/general/variables.css +++ b/app/theme/client/imports/general/variables.css @@ -61,6 +61,10 @@ --rc-color-primary-lightest: var(--color-gray-lightest); --rc-color-content: var(--color-white); --rc-color-link-active: var(--rc-color-button-primary); + --rc-color-announcement-text: #095ad2; + --rc-color-announcement-background: #d1ebfe; + --rc-color-announcement-text-hover: #01336b; + --rc-color-announcement-background-hover: #76b7fc; /* #endregion */ diff --git a/app/threads/client/components/ThreadView.tsx b/app/threads/client/components/ThreadView.tsx index 67db4f3d8631..58ba734892d6 100644 --- a/app/threads/client/components/ThreadView.tsx +++ b/app/threads/client/components/ThreadView.tsx @@ -36,7 +36,7 @@ const ThreadView = forwardRef(({ const t = useTranslation(); - const expandLabel = expanded ? t('collapse') : t('expand'); + const expandLabel = expanded ? t('Collapse') : t('Expand'); const expandIcon = expanded ? 'arrow-collapse' : 'arrow-expand'; const handleExpandActionClick = useCallback(() => { @@ -69,9 +69,9 @@ const ThreadView = forwardRef(({ - {hasExpand && } + {hasExpand && } - + diff --git a/app/threads/client/flextab/thread.js b/app/threads/client/flextab/thread.js index 7a0184fa621d..134af66c45b6 100644 --- a/app/threads/client/flextab/thread.js +++ b/app/threads/client/flextab/thread.js @@ -2,6 +2,7 @@ import _ from 'underscore'; import { Meteor } from 'meteor/meteor'; import { Mongo } from 'meteor/mongo'; import { Template } from 'meteor/templating'; +import { Session } from 'meteor/session'; import { HTML } from 'meteor/htmljs'; import { ReactiveDict } from 'meteor/reactive-dict'; import { Tracker } from 'meteor/tracker'; @@ -174,7 +175,7 @@ Template.thread.onRendered(function() { this.callbackRemove = () => callbacks.remove('streamNewMessage', `thread-${ rid }`); callbacks.add('streamNewMessage', _.debounce((msg) => { - if (rid !== msg.rid || msg.editedAt || msg.tmid !== tmid) { + if (Session.get('openedRoom') !== msg.rid || rid !== msg.rid || msg.editedAt || msg.tmid !== tmid) { return; } Meteor.call('readThreads', tmid); diff --git a/app/ui-cached-collection/client/models/CachedCollection.js b/app/ui-cached-collection/client/models/CachedCollection.js index a107eb98f0ff..5ca803847ae6 100644 --- a/app/ui-cached-collection/client/models/CachedCollection.js +++ b/app/ui-cached-collection/client/models/CachedCollection.js @@ -201,6 +201,10 @@ export class CachedCollection extends Emitter { const _updatedAt = new Date(record._updatedAt); record._updatedAt = _updatedAt; + if (record.lastMessage && typeof record.lastMessage._updatedAt === 'string') { + record.lastMessage._updatedAt = new Date(record.lastMessage._updatedAt); + } + if (_updatedAt > this.updatedAt) { this.updatedAt = _updatedAt; } diff --git a/app/ui-message/client/message.js b/app/ui-message/client/message.js index d7e20e547f24..bb4f59f70ed1 100644 --- a/app/ui-message/client/message.js +++ b/app/ui-message/client/message.js @@ -1,4 +1,5 @@ import _ from 'underscore'; +import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; import { Template } from 'meteor/templating'; import { Session } from 'meteor/session'; @@ -451,7 +452,7 @@ Template.message.helpers({ }, showStar() { const { msg } = this; - return msg.starred && !(msg.actionContext === 'starred' || this.context === 'starred'); + return msg.starred && msg.starred.length > 0 && msg.starred.find((star) => star._id === Meteor.userId()) && !(msg.actionContext === 'starred' || this.context === 'starred'); }, }); diff --git a/app/ui-utils/client/lib/Layout.js b/app/ui-utils/client/lib/Layout.js index 758f58a99fa7..2158d80f2a9b 100644 --- a/app/ui-utils/client/lib/Layout.js +++ b/app/ui-utils/client/lib/Layout.js @@ -1,14 +1,17 @@ import { Tracker } from 'meteor/tracker'; import { FlowRouter } from 'meteor/kadira:flow-router'; +import { ReactiveVar } from 'meteor/reactive-var'; export const Layout = new class RocketChatLayout { constructor() { + this.embedded = new ReactiveVar(); Tracker.autorun(() => { this.layout = FlowRouter.getQueryParam('layout'); + this.embedded.set(this.layout === 'embedded'); }); } isEmbedded() { - return FlowRouter.getQueryParam('layout') === 'embedded'; + return this.embedded.get(); } }(); diff --git a/app/ui-utils/client/lib/MessageAction.js b/app/ui-utils/client/lib/MessageAction.js index 6e87cc6a5831..c97b52956f91 100644 --- a/app/ui-utils/client/lib/MessageAction.js +++ b/app/ui-utils/client/lib/MessageAction.js @@ -14,7 +14,7 @@ import { createTemplateForComponent } from '../../../../client/reactAdapters'; import { messageArgs } from './messageArgs'; import { roomTypes, canDeleteMessage } from '../../../utils/client'; import { Messages, Rooms, Subscriptions } from '../../../models/client'; -import { hasAtLeastOnePermission } from '../../../authorization/client'; +import { hasAtLeastOnePermission, hasPermission } from '../../../authorization/client'; import { modal } from './modal'; const call = (method, ...args) => new Promise((resolve, reject) => { @@ -168,13 +168,22 @@ Meteor.startup(async function() { reply: msg._id, }); }, - condition({ subscription, room }) { + condition({ subscription, room, msg, u }) { if (subscription == null) { return false; } if (room.t === 'd' || room.t === 'l') { return false; } + + // Check if we already have a DM started with the message user (not ourselves) or we can start one + if (u._id !== msg.u._id && !hasPermission('create-d')) { + const dmRoom = Rooms.findOne({ _id: [u._id, msg.u._id].sort().join('') }); + if (!dmRoom || !Subscriptions.findOne({ rid: dmRoom._id, 'u._id': u._id })) { + return false; + } + } + return true; }, order: 0, diff --git a/app/ui-utils/client/lib/RoomHistoryManager.js b/app/ui-utils/client/lib/RoomHistoryManager.js index dbf04e1cd84f..9b134e079a24 100644 --- a/app/ui-utils/client/lib/RoomHistoryManager.js +++ b/app/ui-utils/client/lib/RoomHistoryManager.js @@ -12,6 +12,7 @@ import { ChatMessage, ChatSubscription, ChatRoom } from '../../../models'; import { call } from './callMethod'; import { filterMarkdown } from '../../../markdown/lib/markdown'; import { escapeHTML } from '../../../../lib/escapeHTML'; +import { getUserPreference } from '../../../utils/client'; export const normalizeThreadMessage = ({ ...message }) => { if (message.msg) { @@ -159,7 +160,8 @@ export const RoomHistoryManager = new class { room.unreadNotLoaded.set(result.unreadNotLoaded); room.firstUnread.set(result.firstUnread); - const wrapper = $('.messages-box .wrapper').get(0); + const wrapper = await waitUntilWrapperExists(); + if (wrapper) { previousHeight = wrapper.scrollHeight; scroll = wrapper.scrollTop; @@ -174,22 +176,25 @@ export const RoomHistoryManager = new class { room.loaded = 0; } - room.loaded += messages.length; + const showMessageInMainThread = getUserPreference(Meteor.userId(), 'showMessageInMainThread', false); + + const visibleMessages = messages.filter((msg) => !msg.tmid || showMessageInMainThread || msg.tshow); + + room.loaded += visibleMessages.length; if (messages.length < limit) { room.hasMore.set(false); } - if (wrapper) { - waitAfterFlush(() => { - if (wrapper.children[0].scrollHeight <= wrapper.offsetHeight) { - return this.getMore(rid); - } - const heightDiff = wrapper.scrollHeight - previousHeight; - wrapper.scrollTop = scroll + heightDiff; - }); + if (room.hasMore.get() && (visibleMessages.length === 0 || room.loaded < limit)) { + return this.getMore(rid); } + waitAfterFlush(() => { + const heightDiff = wrapper.scrollHeight - previousHeight; + wrapper.scrollTop = scroll + heightDiff; + }); + room.isLoading.set(false); waitAfterFlush(() => { readMessage.refreshUnreadMark(rid); diff --git a/app/ui-utils/client/lib/messageContext.js b/app/ui-utils/client/lib/messageContext.js index d10e7cd8ccbf..283cddf5bb9e 100644 --- a/app/ui-utils/client/lib/messageContext.js +++ b/app/ui-utils/client/lib/messageContext.js @@ -1,6 +1,7 @@ import { Meteor } from 'meteor/meteor'; import { Template } from 'meteor/templating'; import { FlowRouter } from 'meteor/kadira:flow-router'; +import { Tracker } from 'meteor/tracker'; import { Subscriptions, Rooms, Users } from '../../../models/client'; import { hasPermission } from '../../../authorization/client'; @@ -17,7 +18,7 @@ const fields = { name: 1, username: 1, 'settings.preferences.showMessageInMainTh export function messageContext({ rid } = Template.instance()) { const uid = Meteor.userId(); const user = Users.findOne({ _id: uid }, { fields }) || {}; - const instace = Template.instance(); + const instance = Template.instance(); const openThread = (e) => { const { rid, mid, tmid } = e.currentTarget.dataset; const room = Rooms.findOne({ _id: rid }); @@ -40,7 +41,7 @@ export function messageContext({ rid } = Template.instance()) { }); } : (msg, e) => { const { actionlink } = e.currentTarget.dataset; - actionLinks.run(actionlink, msg._id, instace, (err) => { + actionLinks.run(actionlink, msg._id, instance, (err) => { if (err) { handleError(err); } @@ -60,13 +61,12 @@ export function messageContext({ rid } = Template.instance()) { return { u: user, - room: Rooms.findOne({ _id: rid }, { - reactive: false, + room: Tracker.nonreactive(() => Rooms.findOne({ _id: rid }, { fields: { _updatedAt: 0, lastMessage: 0, }, - }), + })), subscription: Subscriptions.findOne({ rid }, { fields: { name: 1, diff --git a/app/ui-utils/client/lib/modal.js b/app/ui-utils/client/lib/modal.js index d46b8f8c4e7f..1971ba908b0e 100644 --- a/app/ui-utils/client/lib/modal.js +++ b/app/ui-utils/client/lib/modal.js @@ -209,16 +209,14 @@ Template.rc_modal.helpers({ Template.rc_modal.onRendered(function() { this.oldFocus = document.activeElement; - if (this.data.onRendered) { - this.data.onRendered(); - } - if (this.data.input) { $('.js-modal-input', this.firstNode).focus(); } else if (this.data.showConfirmButton && this.data.confirmOnEnter) { $('.js-confirm', this.firstNode).focus(); } - + if (this.data.onRendered) { + this.data.onRendered(); + } this.data.closeOnEscape && document.addEventListener('keydown', modal.onKeyDown); }); diff --git a/app/ui/client/index.js b/app/ui/client/index.js index 2e23faa91ac2..8a08c5cdcbf8 100644 --- a/app/ui/client/index.js +++ b/app/ui/client/index.js @@ -12,7 +12,6 @@ import './views/404/roomNotFound.html'; import './views/404/invalidSecretURL.html'; import './views/404/invalidInvite.html'; import './views/app/burger.html'; -import './views/app/createChannel.html'; import './views/app/editStatus.html'; import './views/app/editStatus.css'; import './views/app/home.html'; @@ -28,7 +27,6 @@ import './views/app/photoswipe.html'; import './views/cmsPage'; import './views/404/roomNotFound'; import './views/app/burger'; -import './views/app/createChannel'; import './views/app/CreateDirectMessage'; import './views/app/editStatus'; import './views/app/home'; diff --git a/app/ui/client/lib/fileUpload.js b/app/ui/client/lib/fileUpload.js index 3a559c3c2217..b8ad23505353 100644 --- a/app/ui/client/lib/fileUpload.js +++ b/app/ui/client/lib/fileUpload.js @@ -116,7 +116,7 @@ const getAudioUploadPreview = (file, preview) => `\
- +
`; @@ -132,7 +132,7 @@ const getVideoUploadPreview = (file, preview) => `\
- +
`; @@ -145,7 +145,7 @@ const getImageUploadPreview = (file, preview) => `\
- +
`; @@ -180,7 +180,7 @@ const getGenericUploadPreview = (file) => `\
- +
`; @@ -260,7 +260,7 @@ export const fileUpload = async (files, input, { rid, tmid }) => { confirmButtonText: t('Send'), cancelButtonText: t('Cancel'), html: true, - onRendered: () => $('#file-name').focus(), + onRendered: () => $('#file-description').focus(), }, async (isConfirm) => { if (!isConfirm) { return; diff --git a/app/ui/client/views/app/createChannel.html b/app/ui/client/views/app/createChannel.html deleted file mode 100644 index 752d3a98e3b5..000000000000 --- a/app/ui/client/views/app/createChannel.html +++ /dev/null @@ -1,217 +0,0 @@ - - - - - - - diff --git a/app/ui/client/views/app/createChannel.js b/app/ui/client/views/app/createChannel.js deleted file mode 100644 index fa3def7e50f7..000000000000 --- a/app/ui/client/views/app/createChannel.js +++ /dev/null @@ -1,437 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import { ReactiveVar } from 'meteor/reactive-var'; -import { Tracker } from 'meteor/tracker'; -import { Blaze } from 'meteor/blaze'; -import { FlowRouter } from 'meteor/kadira:flow-router'; -import { Template } from 'meteor/templating'; -import toastr from 'toastr'; -import _ from 'underscore'; - -import { settings } from '../../../../settings'; -import { callbacks } from '../../../../callbacks'; -import { t, roomTypes } from '../../../../utils'; -import { hasAllPermission } from '../../../../authorization'; -import { AutoComplete } from '../../../../meteor-autocomplete/client'; - -const acEvents = { - 'click .rc-popup-list__item'(e, t) { - t.ac.onItemClick(this, e); - }, - 'keydown [name="users"]'(e, t) { - if ([8, 46].includes(e.keyCode) && e.target.value === '') { - const users = t.selectedUsers; - const usersArr = users.get(); - usersArr.pop(); - return users.set(usersArr); - } - - t.ac.onKeyDown(e); - }, - 'keyup [name="users"]'(e, t) { - t.ac.onKeyUp(e); - }, - 'focus [name="users"]'(e, t) { - t.ac.onFocus(e); - }, - 'blur [name="users"]'(e, t) { - t.ac.onBlur(e); - }, -}; - -const validateChannelName = (name) => { - if (settings.get('UI_Allow_room_names_with_special_chars')) { - return true; - } - - const reg = new RegExp(`^${ settings.get('UTF8_Names_Validation') }$`); - return name.length === 0 || reg.test(name); -}; - -const filterNames = (old) => { - if (settings.get('UI_Allow_room_names_with_special_chars')) { - return old; - } - - const reg = new RegExp(`^${ settings.get('UTF8_Names_Validation') }$`); - return [...old.replace(' ', '').toLocaleLowerCase()].filter((f) => reg.test(f)).join(''); -}; - -Template.createChannel.helpers({ - autocomplete(key) { - const instance = Template.instance(); - const param = instance.ac[key]; - return typeof param === 'function' ? param.apply(instance.ac) : param; - }, - items() { - return Template.instance().ac.filteredList(); - }, - config() { - const filter = Template.instance().userFilter; - return { - filter: filter.get(), - noMatchTemplate: 'userSearchEmpty', - modifier(text) { - const f = filter.get(); - return `@${ f.length === 0 ? text : text.replace(new RegExp(filter.get()), function(part) { - return `${ part }`; - }) }`; - }, - }; - }, - selectedUsers() { - return Template.instance().selectedUsers.get(); - }, - inUse() { - return Template.instance().inUse.get(); - }, - invalidChannel() { - const instance = Template.instance(); - const invalid = instance.invalid.get(); - const inUse = instance.inUse.get(); - return invalid || inUse; - }, - typeLabel() { - return t(Template.instance().type.get() === 'p' ? t('Private_Channel') : t('Public_Channel')); - }, - typeDescription() { - return t(Template.instance().type.get() === 'p' ? t('Just_invited_people_can_access_this_channel') : t('Everyone_can_access_this_channel')); - }, - broadcast() { - return Template.instance().broadcast.get(); - }, - encrypted() { - return Template.instance().encrypted.get(); - }, - encryptedDisabled() { - return Template.instance().type.get() !== 'p' || Template.instance().broadcast.get(); - }, - e2eEnabled() { - return settings.get('E2E_Enable'); - }, - readOnly() { - return Template.instance().readOnly.get(); - }, - readOnlyDescription() { - return t(Template.instance().readOnly.get() ? t('Only_authorized_users_can_write_new_messages') : t('All_users_in_the_channel_can_write_new_messages')); - }, - cantCreateBothTypes() { - return !hasAllPermission(['create-c', 'create-p']); - }, - roomTypeIsP() { - return Template.instance().type.get() === 'p'; - }, - createIsDisabled() { - const instance = Template.instance(); - const invalid = instance.invalid.get(); - const extensions_invalid = instance.extensions_invalid.get(); - const inUse = instance.inUse.get(); - const name = instance.name.get(); - - if (name.length === 0 || invalid || inUse === true || inUse === undefined || extensions_invalid) { - return 'disabled'; - } - return ''; - }, - iconType() { - return Template.instance().type.get() === 'p' ? 'lock' : 'hashtag'; - }, - tokenAccessEnabled() { - return settings.get('API_Tokenpass_URL') !== ''; - }, - tokenIsDisabled() { - return Template.instance().type.get() !== 'p' ? 'disabled' : null; - }, - tokensRequired() { - return Template.instance().tokensRequired.get() && Template.instance().type.get() === 'p'; - }, - extensionsConfig() { - const instance = Template.instance(); - return { - validations: instance.extensions_validations, - submits: instance.extensions_submits, - change: instance.change, - }; - }, - roomTypesBeforeStandard() { - const orderLow = roomTypes.roomTypesOrder.filter((roomTypeOrder) => roomTypeOrder.identifier === 'c')[0].order; - return roomTypes.roomTypesOrder.filter( - (roomTypeOrder) => roomTypeOrder.order < orderLow, - ).map( - (roomTypeOrder) => roomTypes.getConfig(roomTypeOrder.identifier), - ).filter((roomType) => roomType.creationTemplate); - }, - roomTypesAfterStandard() { - const orderHigh = roomTypes.roomTypesOrder.filter((roomTypeOrder) => roomTypeOrder.identifier === 'd')[0].order; - return roomTypes.roomTypesOrder.filter( - (roomTypeOrder) => roomTypeOrder.order > orderHigh, - ).map( - (roomTypeOrder) => roomTypes.getConfig(roomTypeOrder.identifier), - ).filter((roomType) => roomType.creationTemplate); - }, -}); - -Template.createChannel.events({ - ...acEvents, - 'click .rc-tags__tag'({ target }, t) { - const { username } = Blaze.getData(target); - t.selectedUsers.set(t.selectedUsers.get().filter((user) => user.username !== username)); - }, - 'change [name=setTokensRequired]'(e, t) { - t.tokensRequired.set(e.currentTarget.checked); - t.change(); - }, - 'change [name="type"]'(e, t) { - t.type.set(e.target.checked ? e.target.value : 'c'); - t.change(); - }, - 'change [name="broadcast"]'(e, t) { - t.broadcast.set(e.target.checked); - t.change(); - }, - 'change [name="encrypted"]'(e, t) { - t.encrypted.set(e.target.checked); - t.change(); - }, - 'change [name="readOnly"]'(e, t) { - t.readOnly.set(e.target.checked); - }, - 'input [name="users"]'(e, t) { - const input = e.target; - const position = input.selectionEnd || input.selectionStart; - const { length } = input.value; - const modified = filterNames(input.value); - input.value = modified; - document.activeElement === input && e && /input/i.test(e.type) && (input.selectionEnd = position + input.value.length - length); - - t.userFilter.set(modified); - }, - 'input [name="name"]'(e, t) { - const input = e.target; - const position = input.selectionEnd || input.selectionStart; - const { length } = input.value; - const modified = filterNames(input.value); - - input.value = modified; - document.activeElement === input && e && /input/i.test(e.type) && (input.selectionEnd = position + input.value.length - length); - t.invalid.set(!validateChannelName(input.value)); - if (input.value !== t.name.get()) { - t.inUse.set(undefined); - t.checkChannel(input.value); - t.name.set(modified); - } - }, - 'submit .create-channel__content'(e, instance) { - e.preventDefault(); - e.stopPropagation(); - const name = e.target.name.value; - const type = instance.type.get(); - const readOnly = instance.readOnly.get(); - const broadcast = instance.broadcast.get(); - const encrypted = instance.encrypted.get(); - const isPrivate = type === 'p'; - - if (instance.invalid.get() || instance.inUse.get()) { - return e.target.name.focus(); - } - if (!Object.keys(instance.extensions_validations).map((key) => instance.extensions_validations[key]).reduce((valid, fn) => fn(instance) && valid, true)) { - return instance.extensions_invalid.set(true); - } - - const extraData = Object.keys(instance.extensions_submits) - .reduce((result, key) => ({ ...result, ...instance.extensions_submits[key](instance) }), { broadcast, encrypted }); - - Meteor.call(isPrivate ? 'createPrivateGroup' : 'createChannel', name, instance.selectedUsers.get().map((user) => user.username), readOnly, {}, extraData, function(err, result) { - if (err) { - if (err.error === 'error-invalid-name') { - instance.invalid.set(true); - return; - } - if (err.error === 'error-duplicate-channel-name') { - instance.inUse.set(true); - return; - } - if (err.error === 'error-invalid-room-name') { - toastr.error(t('error-invalid-room-name', { room_name: name })); - return; - } - toastr.error(err.message); - return; - } - - if (!isPrivate) { - callbacks.run('aftercreateCombined', { _id: result.rid, name: result.name }); - } - if (instance.data.onCreate) { - instance.data.onCreate(result); - } - - return FlowRouter.go(isPrivate ? 'group' : 'channel', { ...result }, FlowRouter.current().queryParams); - }); - return false; - }, -}); - -Template.createChannel.onRendered(function() { - const users = this.selectedUsers; - - this.firstNode.querySelector('[name="name"]').focus(); - this.ac.element = this.firstNode.querySelector('[name="users"]'); - this.ac.$element = $(this.ac.element); - this.ac.$element.on('autocompleteselect', function(e, { item }) { - const usersArr = users.get(); - usersArr.push(item); - users.set(usersArr); - }); -}); - -Template.createChannel.onCreated(function() { - this.selectedUsers = new ReactiveVar([]); - - const filter = { exceptions: [Meteor.user().username].concat(this.selectedUsers.get().map((u) => u.username)) }; - // this.onViewRead:??y(function() { - Tracker.autorun(() => { - filter.exceptions = [Meteor.user().username].concat(this.selectedUsers.get().map((u) => u.username)); - }); - this.extensions_validations = {}; - this.extensions_submits = {}; - this.name = new ReactiveVar(''); - this.type = new ReactiveVar(hasAllPermission(['create-p']) ? 'p' : 'c'); - this.readOnly = new ReactiveVar(false); - this.broadcast = new ReactiveVar(false); - this.encrypted = new ReactiveVar(settings.get('E2E_Enabled_Default_PrivateRooms')); - this.inUse = new ReactiveVar(undefined); - this.invalid = new ReactiveVar(false); - this.extensions_invalid = new ReactiveVar(false); - this.change = _.debounce(() => { - let valid = true; - Object.keys(this.extensions_validations).map((key) => this.extensions_validations[key]).forEach((f) => { valid = f(this) && valid; }); - this.extensions_invalid.set(!valid); - }, 300); - - Tracker.autorun(() => { - const broadcast = this.broadcast.get(); - if (broadcast) { - this.readOnly.set(true); - this.encrypted.set(false); - } - - const type = this.type.get(); - if (type !== 'p') { - this.encrypted.set(false); - } - }); - - this.userFilter = new ReactiveVar(''); - this.tokensRequired = new ReactiveVar(false); - this.checkChannel = _.debounce((name) => { - if (validateChannelName(name)) { - return Meteor.call('roomNameExists', name, (error, result) => { - if (error) { - return; - } - this.inUse.set(result); - }); - } - this.inUse.set(undefined); - }, 1000); - - this.ac = new AutoComplete( - { - selector: { - anchor: '.rc-input__label', - item: '.rc-popup-list__item', - container: '.rc-popup-list__list', - }, - position: 'fixed', - limit: 10, - inputDelay: 300, - rules: [ - { - // @TODO maybe change this 'collection' and/or template - - collection: 'UserAndRoom', - endpoint: 'users.autocomplete', - field: 'username', - matchAll: true, - filter, - doNotChangeWidth: false, - selector(match) { - return { term: match }; - }, - sort: 'username', - }, - ], - - }); - - // this.firstNode.querySelector('[name=name]').focus(); - // this.ac.element = this.firstNode.querySelector('[name=users]'); - // this.ac.$element = $(this.ac.element); - this.ac.tmplInst = this; -}); - -Template.tokenpass.onCreated(function() { - this.data.validations.tokenpass = (instance) => { - const result = (settings.get('API_Tokenpass_URL') !== '' && instance.tokensRequired.get() && instance.type.get() === 'p') && this.selectedTokens.get().length === 0; - this.invalid.set(result); - return !result; - }; - this.data.submits.tokenpass = () => ({ - tokenpass: { - require: this.requireAll.get() ? 'all' : 'any', - tokens: this.selectedTokens.get(), - }, - }); - this.balance = new ReactiveVar(''); - this.token = new ReactiveVar(''); - this.selectedTokens = new ReactiveVar([]); - this.invalid = new ReactiveVar(false); - this.requireAll = new ReactiveVar(true); -}); - -Template.tokenpass.helpers({ - selectedTokens() { - return Template.instance().selectedTokens.get(); - }, - invalid() { - return Template.instance().invalid.get(); - }, - addIsDisabled() { - const { balance, token } = Template.instance(); - return balance.get().length && token.get().length ? '' : 'disabled'; - }, - tokenRequiment() { - return Template.instance().requireAll.get() ? t('Require_all_tokens') : t('Require_any_token'); - }, - tokenRequimentDescription() { - return Template.instance().requireAll.get() ? t('All_added_tokens_will_be_required_by_the_user') : t('At_least_one_added_token_is_required_by_the_user'); - }, -}); - -Template.tokenpass.events({ - 'click [data-button=add]'(e, instance) { - const { balance, token, selectedTokens } = instance; - const text = token.get(); - const arr = selectedTokens.get(); - selectedTokens.set([...arr.filter((token) => token.token !== text), { token: text, balance: balance.get() }]); - balance.set(''); - token.set(''); - [...instance.findAll('input[type=text],input[type=number]')].forEach((el) => { el.value = ''; }); - instance.data.change(); - return false; - }, - 'click .rc-tags__tag'({ target }, t) { - const { token } = Blaze.getData(target); - t.selectedTokens.set(t.selectedTokens.get().filter((t) => t.token !== token)); - t.data.change(); - }, - 'input [name=tokenMinimumNeededBalance]'(e, i) { - i.balance.set(e.target.value); - }, - 'input [name=tokensRequired]'(e, i) { - i.token.set(e.target.value); - }, - 'change [name=tokenRequireAll]'(e, i) { - i.requireAll.set(e.currentTarget.checked); - }, -}); diff --git a/app/ui/client/views/app/room.js b/app/ui/client/views/app/room.js index ece88045b661..25df13abe97d 100644 --- a/app/ui/client/views/app/room.js +++ b/app/ui/client/views/app/room.js @@ -269,7 +269,7 @@ Template.roomOld.helpers({ }, messageboxData() { - const { sendToBottomIfNecessaryDebounced, subscription } = Template.instance(); + const { sendToBottomIfNecessary, subscription } = Template.instance(); const { _id: rid } = this; const isEmbedded = Layout.isEmbedded(); const showFormattingTips = settings.get('Message_ShowFormattingTips'); @@ -286,7 +286,7 @@ Template.roomOld.helpers({ chatMessages[rid].initializeInput(input, { rid }); }, - onResize: () => sendToBottomIfNecessaryDebounced && sendToBottomIfNecessaryDebounced(), + onResize: () => sendToBottomIfNecessary && sendToBottomIfNecessary(), onKeyUp: (...args) => chatMessages[rid] && chatMessages[rid].keyup.apply(chatMessages[rid], args), onKeyDown: (...args) => chatMessages[rid] && chatMessages[rid].keydown.apply(chatMessages[rid], args), onSend: (...args) => chatMessages[rid] && chatMessages[rid].send.apply(chatMessages[rid], args), @@ -432,9 +432,6 @@ Template.roomOld.helpers({ }, }); -let lastScrollTop; - - export const dropzoneEvents = { 'dragenter .dropzone'(e) { const types = e.originalEvent && e.originalEvent.dataTransfer && e.originalEvent.dataTransfer.types; @@ -552,15 +549,15 @@ Meteor.startup(() => { RoomHistoryManager.clear(template && template.data && template.data._id); }, 'load .gallery-item'(e, template) { - template.sendToBottomIfNecessaryDebounced(); + template.sendToBottomIfNecessary(); }, 'rendered .js-block-wrapper'(e, template) { - template.sendToBottomIfNecessaryDebounced(); + template.sendToBottomIfNecessary(); }, 'click .new-message'(event, instance) { instance.atBottom = true; - instance.sendToBottomIfNecessaryDebounced(); + instance.sendToBottomIfNecessary(); chatMessages[RoomManager.openedRoom].input.focus(); }, 'click .upload-progress-close'(e) { @@ -584,26 +581,26 @@ Meteor.startup(() => { 'scroll .wrapper': _.throttle(function(e, t) { const $roomLeader = $('.room-leader'); if ($roomLeader.length) { - if (e.target.scrollTop < lastScrollTop) { + if (e.target.scrollTop < t.lastScrollTop) { t.hideLeaderHeader.set(false); } else if (t.isAtBottom(100) === false && e.target.scrollTop > $roomLeader.height()) { t.hideLeaderHeader.set(true); } } - lastScrollTop = e.target.scrollTop; + t.lastScrollTop = e.target.scrollTop; const height = e.target.clientHeight; const isLoading = RoomHistoryManager.isLoading(this._id); const hasMore = RoomHistoryManager.hasMore(this._id); const hasMoreNext = RoomHistoryManager.hasMoreNext(this._id); if ((isLoading === false && hasMore === true) || hasMoreNext === true) { - if (hasMore === true && lastScrollTop <= height / 3) { + if (hasMore === true && t.lastScrollTop <= height / 3) { RoomHistoryManager.getMore(this._id); - } else if (hasMoreNext === true && Math.ceil(lastScrollTop) >= e.target.scrollHeight - height) { + } else if (hasMoreNext === true && Math.ceil(t.lastScrollTop) >= e.target.scrollHeight - height) { RoomHistoryManager.getMoreNext(this._id); } } - }, 300), + }, 100), 'click .time a'(e) { e.preventDefault(); @@ -765,8 +762,6 @@ Meteor.startup(() => { this.sendToBottom(); } }; - - this.sendToBottomIfNecessaryDebounced = _.debounce(this.sendToBottomIfNecessary, 10); }); // Update message to re-render DOM Template.roomOld.onDestroyed(function() { @@ -812,7 +807,7 @@ Meteor.startup(() => { }; template.sendToBottom = function() { - wrapper.scrollTop = wrapper.scrollHeight - wrapper.clientHeight; + wrapper.scrollTo(30, wrapper.scrollHeight); newMessage.className = 'new-message background-primary-action-color color-content-background-color not'; }; @@ -820,22 +815,14 @@ Meteor.startup(() => { template.atBottom = template.isAtBottom(100); }; + template.observer = new ResizeObserver(() => template.sendToBottomIfNecessary()); - if (window.MutationObserver) { - template.observer = new MutationObserver(() => template.sendToBottomIfNecessaryDebounced()); - - template.observer.observe(wrapperUl, { childList: true }); - } else { - wrapperUl.addEventListener('DOMSubtreeModified', () => template.sendToBottomIfNecessaryDebounced()); - } - - template.onWindowResize = () => template.sendToBottomIfNecessaryDebounced(); - - window.addEventListener('resize', template.onWindowResize); + template.observer.observe(wrapperUl); const wheelHandler = _.throttle(function() { template.checkIfScrollIsAtBottom(); }, 100); + wrapper.addEventListener('mousewheel', wheelHandler); wrapper.addEventListener('wheel', wheelHandler); @@ -853,7 +840,7 @@ Meteor.startup(() => { wrapper.addEventListener('scroll', wheelHandler); }); - lastScrollTop = $('.messages-box .wrapper').scrollTop(); + this.lastScrollTop = $('.messages-box .wrapper').scrollTop(); const rtl = $('html').hasClass('rtl'); @@ -896,6 +883,18 @@ Meteor.startup(() => { readMessage.read(rid); }, 500); + this.autorun(() => { + if (rid !== Session.get('openedRoom')) { + return; + } + + const room = Rooms.findOne({ _id: rid }); + + if (room?.t === 'l') { + roomTypes.getConfig(room.t).openCustomProfileTab(this, room, room.v.username); + } + }); + this.autorun(() => { if (!Object.values(roomTypes.roomTypes).map(({ route }) => route && route.name).filter(Boolean).includes(FlowRouter.getRouteName())) { return; @@ -983,9 +982,6 @@ Meteor.startup(() => { return FlowRouter.go('home'); } }); - - const observer = new ResizeObserver(template.sendToBottomIfNecessary); - observer.observe(this.firstNode.querySelector('.wrapper ul')); }); }); diff --git a/app/user-data-download/server/cronProcessDownloads.js b/app/user-data-download/server/cronProcessDownloads.js index e13c7c6ac528..2c35e4e06ed4 100644 --- a/app/user-data-download/server/cronProcessDownloads.js +++ b/app/user-data-download/server/cronProcessDownloads.js @@ -171,6 +171,9 @@ const getMessageData = function(msg, hideUsers, userData, usersMap) { case 'livechat-close': messageObject.msg = TAPi18n.__('Conversation_finished'); break; + case 'livechat-started': + messageObject.msg = TAPi18n.__('Chat_started'); + break; } } diff --git a/app/utils/rocketchat.info b/app/utils/rocketchat.info index 510b977ecfb6..936af68f3ebd 100644 --- a/app/utils/rocketchat.info +++ b/app/utils/rocketchat.info @@ -1,3 +1,3 @@ { - "version": "3.11.1" + "version": "3.12.0" } diff --git a/app/version-check/client/index.js b/app/version-check/client/index.js index ba77a6f67150..77c7f0ee476c 100644 --- a/app/version-check/client/index.js +++ b/app/version-check/client/index.js @@ -18,6 +18,7 @@ Meteor.startup(function() { firstBanner.textArguments = firstBanner.textArguments || []; banners.open({ + id: firstBanner.id, title: TAPi18n.__(firstBanner.title), text: TAPi18n.__(firstBanner.text, ...firstBanner.textArguments), modifiers: firstBanner.modifiers, diff --git a/app/videobridge/.eslintrc b/app/videobridge/.eslintrc deleted file mode 100644 index 826a8e171654..000000000000 --- a/app/videobridge/.eslintrc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "globals": { - "JitsiMeetExternalAPI": false - } -} \ No newline at end of file diff --git a/app/videobridge/client/index.js b/app/videobridge/client/index.js index 1e30d9f29985..e1d57007880e 100644 --- a/app/videobridge/client/index.js +++ b/app/videobridge/client/index.js @@ -1,9 +1,4 @@ -import './stylesheets/video.css'; -import './views/videoFlexTab.html'; import './views/bbbLiveView.html'; -import './views/videoFlexTabBbb.html'; -import './views/videoFlexTab'; -import './views/videoFlexTabBbb'; import './tabBar'; import './actionLink'; import '../lib/messageType'; diff --git a/app/videobridge/client/stylesheets/video.css b/app/videobridge/client/stylesheets/video.css deleted file mode 100644 index f9f00d45035e..000000000000 --- a/app/videobridge/client/stylesheets/video.css +++ /dev/null @@ -1,18 +0,0 @@ -.flex-tab { - .video-chat { - ul { - li { - margin-bottom: 20px; - } - } - } -} - -.video-chat { - .main-video { - iframe { - width: 100%; - min-height: 299px; - } - } -} diff --git a/app/videobridge/client/tabBar.tsx b/app/videobridge/client/tabBar.tsx index 3e71c3277505..55a591c2d6d8 100644 --- a/app/videobridge/client/tabBar.tsx +++ b/app/videobridge/client/tabBar.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, lazy } from 'react'; import { useStableArray } from '@rocket.chat/fuselage-hooks'; import { Option, Badge } from '@rocket.chat/fuselage'; @@ -7,6 +7,8 @@ import { addAction, ToolboxActionConfig } from '../../../client/views/room/lib/T import { useTranslation } from '../../../client/contexts/TranslationContext'; import Header from '../../../client/components/Header'; +const templateBBB = lazy(() => import('../../../client/views/room/contextualBar/Call/BBB')); + addAction('bbb_video', ({ room }) => { const enabled = useSetting('bigbluebutton_Enabled'); const t = useTranslation(); @@ -28,7 +30,7 @@ addAction('bbb_video', ({ room }) => { id: 'bbb_video', title: 'BBB Video Call', icon: 'phone', - template: 'videoFlexTabBbb', + template: templateBBB, order: live ? -1 : 0, renderAction: (props): React.ReactNode => {live ? ! : null} @@ -37,6 +39,8 @@ addAction('bbb_video', ({ room }) => { } : null), [enabled, groups, live, t]); }); +const templateJitsi = lazy(() => import('../../../client/views/room/contextualBar/Call/Jitsi')); + addAction('video', ({ room }) => { const enabled = useSetting('Jitsi_Enabled'); const t = useTranslation(); @@ -59,7 +63,8 @@ addAction('video', ({ room }) => { id: 'video', title: 'Call', icon: 'phone', - template: 'videoFlexTab', + template: templateJitsi, + full: true, order: live ? -1 : 0, renderAction: (props): React.ReactNode => {live && !} diff --git a/app/videobridge/client/views/videoFlexTab.html b/app/videobridge/client/views/videoFlexTab.html deleted file mode 100644 index c81e0f8b31e4..000000000000 --- a/app/videobridge/client/views/videoFlexTab.html +++ /dev/null @@ -1,16 +0,0 @@ - diff --git a/app/videobridge/client/views/videoFlexTab.js b/app/videobridge/client/views/videoFlexTab.js deleted file mode 100644 index 4d5cf1699906..000000000000 --- a/app/videobridge/client/views/videoFlexTab.js +++ /dev/null @@ -1,190 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import { Tracker } from 'meteor/tracker'; -import { Session } from 'meteor/session'; -import { Template } from 'meteor/templating'; -import { TimeSync } from 'meteor/mizzao:timesync'; - -import { settings } from '../../../settings'; -import { modal, call } from '../../../ui-utils/client'; -import { t } from '../../../utils/client'; -import { Users, Rooms } from '../../../models'; -import * as CONSTANTS from '../../constants'; - -Template.videoFlexTab.helpers({ - openInNewWindow() { - return settings.get('Jitsi_Open_New_Window'); - }, -}); - -Template.videoFlexTab.onCreated(function() { - this.tabBar = Template.currentData().tabBar; -}); -Template.videoFlexTab.onDestroyed(function() { - return this.stop && this.stop(); -}); - -Template.videoFlexTab.onRendered(function() { - this.api = null; - - const rid = Session.get('openedRoom'); - - const width = 'auto'; - const height = 500; - - const configOverwrite = { - desktopSharingChromeExtId: settings.get('Jitsi_Chrome_Extension'), - }; - const interfaceConfigOverwrite = {}; - - let jitsiRoomActive = null; - - const closePanel = () => { - // Reset things. Should probably be handled better in closeFlex() - $('.flex-tab').css('max-width', ''); - $('.main-content').css('right', ''); - - this.tabBar.close(); - - // TabBar.updateButton('video', { class: '' }); - }; - - const stop = () => { - if (this.intervalHandler) { - Meteor.defer(() => this.api && this.api.dispose()); - clearInterval(this.intervalHandler); - } - }; - - this.stop = stop; - - const update = async () => { - const { jitsiTimeout } = Rooms.findOne({ _id: rid }, { fields: { jitsiTimeout: 1 } }); - - if (jitsiTimeout && (TimeSync.serverTime() - new Date(jitsiTimeout) + CONSTANTS.TIMEOUT < CONSTANTS.DEBOUNCE)) { - return; - } - if (Meteor.status().connected) { - return call('jitsi:updateTimeout', rid); - } - closePanel(); - return this.stop(); - }; - - const start = async () => { - try { - const jitsiTimeout = await update(); - if (!jitsiTimeout) { - return; - } - clearInterval(this.intervalHandler); - this.intervalHandler = setInterval(update, CONSTANTS.HEARTBEAT); - // TabBar.updateButton('video', { class: 'red' }); - return jitsiTimeout; - } catch (error) { - console.error(error); - closePanel(); - throw error; - } - }; - - modal.open({ - title: t('Video_Conference'), - text: t('Start_video_call'), - type: 'warning', - showCancelButton: true, - confirmButtonText: t('Yes'), - cancelButtonText: t('Cancel'), - html: false, - }, (dismiss) => { - if (!dismiss) { - return closePanel(); - } - this.intervalHandler = null; - this.autorun(async () => { - if (!settings.get('Jitsi_Enabled')) { - return closePanel(); - } - - if (!this.tabBar.isOpen()) { - // TabBar.updateButton('video', { class: '' }); - return stop(); - } - - const domain = settings.get('Jitsi_Domain'); - let rname; - if (settings.get('Jitsi_URL_Room_Hash')) { - rname = settings.get('uniqueID') + rid; - } else { - const room = Rooms.findOne({ _id: rid }); - rname = encodeURIComponent(room.t === 'd' ? room.usernames.join(' x ') : room.name); - } - const jitsiRoom = settings.get('Jitsi_URL_Room_Prefix') + rname + settings.get('Jitsi_URL_Room_Suffix'); - const noSsl = !settings.get('Jitsi_SSL'); - const isEnabledTokenAuth = settings.get('Jitsi_Enabled_TokenAuth'); - - if (jitsiRoomActive !== null && jitsiRoomActive !== jitsiRoom) { - jitsiRoomActive = null; - - closePanel(); - - return stop(); - } - - const accessToken = isEnabledTokenAuth && await call('jitsi:generateAccessToken', rid); - - jitsiRoomActive = jitsiRoom; - - if (settings.get('Jitsi_Open_New_Window')) { - return Tracker.nonreactive(async () => { - await start(); - - const queryString = accessToken ? `?jwt=${ accessToken }` : ''; - - const newWindow = window.open(`${ (noSsl ? 'http://' : 'https://') + domain }/${ jitsiRoom }${ queryString }`, jitsiRoom); - if (newWindow) { - const closeInterval = setInterval(() => { - if (newWindow.closed === false) { - return; - } - closePanel(); - stop(); - clearInterval(closeInterval); - }, 300); - return newWindow.focus(); - } - }); - } - - await new Promise((resolve) => { - if (typeof JitsiMeetExternalAPI === 'undefined') { - const prefix = __meteor_runtime_config__.ROOT_URL_PATH_PREFIX || ''; - return $.getScript(`${ prefix }/packages/rocketchat_videobridge/client/public/external_api.js`, resolve); - } - resolve(); - }); - - if (typeof JitsiMeetExternalAPI !== 'undefined') { - // Keep it from showing duplicates when re-evaluated on variable change. - const name = Users.findOne(Meteor.userId(), { fields: { name: 1 } }); - if (!$('[id^=jitsiConference]').length) { - Tracker.nonreactive(async () => { - await start(); - - - this.api = new JitsiMeetExternalAPI(domain, jitsiRoom, width, height, this.$('.video-container').get(0), configOverwrite, interfaceConfigOverwrite, noSsl, accessToken); - - /* - * Hack to send after frame is loaded. - * postMessage converts to events in the jitsi meet iframe. - * For some reason those aren't working right. - */ - setTimeout(() => this.api.executeCommand('displayName', [name]), 5000); - }); - } - - // Execute any commands that might be reactive. Like name changing. - this.api && this.api.executeCommand('displayName', [name]); - } - }); - }); -}); diff --git a/app/videobridge/client/views/videoFlexTabBbb.html b/app/videobridge/client/views/videoFlexTabBbb.html deleted file mode 100644 index 5a6939e5dc42..000000000000 --- a/app/videobridge/client/views/videoFlexTabBbb.html +++ /dev/null @@ -1,16 +0,0 @@ - diff --git a/app/videobridge/client/views/videoFlexTabBbb.js b/app/videobridge/client/views/videoFlexTabBbb.js deleted file mode 100644 index a12581da3604..000000000000 --- a/app/videobridge/client/views/videoFlexTabBbb.js +++ /dev/null @@ -1,64 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import { Template } from 'meteor/templating'; - -import { settings } from '../../../settings'; -import { Rooms } from '../../../models'; -import { hasAllPermission } from '../../../authorization'; -import { popout } from '../../../ui-utils'; - -Template.videoFlexTabBbb.helpers({ - openInNewWindow() { - return settings.get('Jitsi_Open_New_Window'); - }, - - live() { - const isLive = Rooms.findOne({ _id: this.rid, 'streamingOptions.type': 'call' }, { fields: { streamingOptions: 1 } }) != null; - - if (isLive === false && popout.context) { - popout.close(); - } - - return isLive; - }, - - callManagement() { - const type = Rooms.findOne({ _id: this.rid }).t; - return type === 'd' || hasAllPermission('call-management') || hasAllPermission('call-management', this.rid); - }, -}); - -Template.videoFlexTabBbb.onCreated(function() { - this.tabBar = Template.currentData().tabBar; -}); - -Template.videoFlexTabBbb.events({ - 'click .js-join-meeting'(e) { - $(e.currentTarget).prop('disabled', true); - Meteor.call('bbbJoin', { rid: this.rid }, (err, result) => { - $(e.currentTarget).prop('disabled', false); - console.log(err, result); - if (result) { - popout.open({ - content: 'bbbLiveView', - data: { - source: result.url, - streamingOptions: result, - canOpenExternal: true, - showVideoControls: false, - }, - onCloseCallback: () => console.log('bye popout'), - }); - } - }); - // Get the link and open the iframe - }, - - 'click .js-end-meeting'(e) { - $(e.currentTarget).prop('disabled', true); - Meteor.call('bbbEnd', { rid: this.rid }, (err, result) => { - // $(e.currentTarget).prop('disabled', false); - console.log(err, result); - }); - // Get the link and open the iframe - }, -}); diff --git a/app/videobridge/server/methods/bbb.js b/app/videobridge/server/methods/bbb.js index b06045271d54..3695cc389cd3 100644 --- a/app/videobridge/server/methods/bbb.js +++ b/app/videobridge/server/methods/bbb.js @@ -111,14 +111,12 @@ Meteor.methods({ const endApiResult = HTTP.get(endApi); if (endApiResult.statusCode !== 200) { - // TODO improve error logging - console.log({ endApiResult }); - return; + saveStreamingOptions(rid, {}); + throw new Meteor.Error(endApiResult); } - const doc = parseString(endApiResult.content); - if (doc.response.returncode[0] === 'FAILED') { + if (['SUCCESS', 'FAILED'].includes(doc.response.returncode[0])) { saveStreamingOptions(rid, {}); } }, diff --git a/app/videobridge/server/settings.js b/app/videobridge/server/settings.js index 17b9daafaef9..ba5bb41cf06d 100644 --- a/app/videobridge/server/settings.js +++ b/app/videobridge/server/settings.js @@ -30,6 +30,18 @@ Meteor.startup(function() { }, }); + + this.add('bigbluebutton_Open_New_Window', false, { + type: 'boolean', + enableQuery: { + _id: 'bigbluebutton_Enabled', + value: true, + }, + i18nLabel: 'Always_open_in_new_window', + public: true, + }); + + this.add('bigbluebutton_enable_d', true, { type: 'boolean', i18nLabel: 'WebRTC_Enable_Direct', diff --git a/client/components/AutoCompleteDepartment.js b/client/components/AutoCompleteDepartment.js index acc8781d41a3..a7e8041cd69e 100644 --- a/client/components/AutoCompleteDepartment.js +++ b/client/components/AutoCompleteDepartment.js @@ -11,7 +11,7 @@ export const AutoCompleteDepartment = React.memo((props) => { const { label } = props; - const options = useMemo(() => (data && [{ value: 'All', label: label && t('All') }, ...data.departments.map((department) => ({ value: department._id, label: department.name }))]) || [{ value: 'All', label: label || t('All') }], [data, label, t]); + const options = useMemo(() => (data && [{ value: 'all', label: label && t('All') }, ...data.departments.map((department) => ({ value: department._id, label: department.name }))]) || [{ value: 'all', label: label || t('All') }], [data, label, t]); return + + + + design + + + + + rc-design + + ; + +export const AsTeamMember = () => + + + + design + + + + + rc-design + + ; + +export const WithDiscussion = () => + + + + design + + + + + rc-design + + + + + storybook + + ; diff --git a/client/components/Breadcrumbs/index.js b/client/components/Breadcrumbs/index.js new file mode 100644 index 000000000000..3c46d5773d43 --- /dev/null +++ b/client/components/Breadcrumbs/index.js @@ -0,0 +1,39 @@ +import React from 'react'; +import { Box, Icon } from '@rocket.chat/fuselage'; +import colors from '@rocket.chat/fuselage-tokens/colors'; +import { css } from '@rocket.chat/css-in-js'; + +const BreadcrumbsSeparator = () => /; +const BreadcrumbsIcon = ({ name, color, children }) => {name ? : children}; + +const BreadcrumbsLink = (props) => ; + +const BreadcrumbsText = (props) => ; + +const BreadcrumbsItem = (props) => ; + +const Breadcrumbs = ({ children }) => {children}; + +Object.assign(Breadcrumbs, { + Text: BreadcrumbsText, + Link: BreadcrumbsLink, + Icon: BreadcrumbsIcon, + Separator: BreadcrumbsSeparator, + Item: BreadcrumbsItem, +}); + +export default Breadcrumbs; diff --git a/client/components/CustomFieldsForm.js b/client/components/CustomFieldsForm.js index 486f8e8c1f31..fbe3d808461c 100644 --- a/client/components/CustomFieldsForm.js +++ b/client/components/CustomFieldsForm.js @@ -47,7 +47,7 @@ const CustomSelect = ({ label, name, required, options = {}, setState, state, cl const [selectError, setSelectError] = useState(''); const mappedOptions = useMemo(() => Object.values(options).map((value) => [value, value]), [options]); - const verify = useMemo(() => (!state.length && required ? t('The_field_is_required', label || name) : ''), [name, required, state.length, t]); + const verify = useMemo(() => (!state.length && required ? t('The_field_is_required', label || name) : ''), [name, label, required, state.length, t]); useEffect(() => { setCustomFieldsError((oldErrors) => (verify ? [...oldErrors, { name }] : oldErrors.filter((item) => item.name !== name))); diff --git a/client/components/DeleteWarningModal.tsx b/client/components/DeleteWarningModal.tsx index 94e1c0c7fad0..7feb96e97b30 100644 --- a/client/components/DeleteWarningModal.tsx +++ b/client/components/DeleteWarningModal.tsx @@ -1,9 +1,10 @@ -import { Button, ButtonGroup, Icon, Modal } from '@rocket.chat/fuselage'; +import { Box, Button, ButtonGroup, Icon, Modal } from '@rocket.chat/fuselage'; import React, { FC } from 'react'; import { useTranslation } from '../contexts/TranslationContext'; +import { withDoNotAskAgain, RequiredModalProps } from './withDoNotAskAgain'; -type DeleteWarningModalProps = { +type DeleteWarningModalProps = RequiredModalProps & { cancelText?: string; deleteText?: string; onDelete: () => void; @@ -16,6 +17,8 @@ const DeleteWarningModal: FC = ({ deleteText, onCancel, onDelete, + confirm = onDelete, + dontAskAgain, ...props }) => { const t = useTranslation(); @@ -30,12 +33,17 @@ const DeleteWarningModal: FC = ({ {children} - - - - + + {dontAskAgain} + + + + + ; }; +export const DeleteWarningModalDoNotAskAgain = withDoNotAskAgain(DeleteWarningModal); + export default DeleteWarningModal; diff --git a/client/components/MarkdownText.js b/client/components/MarkdownText.js deleted file mode 100644 index 76e724a659cb..000000000000 --- a/client/components/MarkdownText.js +++ /dev/null @@ -1,32 +0,0 @@ -import { Box } from '@rocket.chat/fuselage'; -import React, { useMemo } from 'react'; -import marked from 'marked'; -import dompurify from 'dompurify'; - -const renderer = new marked.Renderer(); - -marked.InlineLexer.rules.gfm.strong = /^\*\*(?=\S)([\s\S]*?\S)\*\*(?!\*)|^\*(?=\S)([\s\S]*?\S)\*(?!\*)/; -marked.InlineLexer.rules.gfm.em = /^__(?=\S)([\s\S]*?\S)__(?!_)|^_(?=\S)([\s\S]*?\S)_(?!_)/; - -const linkRenderer = renderer.link; -renderer.link = function(href, title, text) { - const html = linkRenderer.call(renderer, href, title, text); - return html.replace(/^ { - const html = content && typeof content === 'string' && marked(content, options); - return preserveHtml ? html : html && sanitizer(html, { ADD_ATTR: ['target'] }); - }, [content, preserveHtml, sanitizer]); - return __html ? : null; -} - -export default MarkdownText; diff --git a/client/components/MarkdownText.tsx b/client/components/MarkdownText.tsx new file mode 100644 index 000000000000..0308b2d37261 --- /dev/null +++ b/client/components/MarkdownText.tsx @@ -0,0 +1,96 @@ +import { Box } from '@rocket.chat/fuselage'; +import React, { FC, useMemo } from 'react'; +import marked from 'marked'; +import dompurify from 'dompurify'; + +type MarkdownTextParams = { + content: string; + variant: 'inline' | 'inlineWithoutBreaks' | 'document'; + preserveHtml: boolean; + withTruncatedText: boolean; +}; + +const documentRenderer = new marked.Renderer(); +const inlineRenderer = new marked.Renderer(); +const inlineWithoutBreaks = new marked.Renderer(); + +marked.InlineLexer.rules.gfm = { + ...marked.InlineLexer.rules.gfm, + strong: /^\*\*(?=\S)([\s\S]*?\S)\*\*(?!\*)|^\*(?=\S)([\s\S]*?\S)\*(?!\*)/, + em: /^__(?=\S)([\s\S]*?\S)__(?!_)|^_(?=\S)([\s\S]*?\S)_(?!_)/, +}; + +const linkMarked = (href: string | null, _title: string | null, text: string): string => + `${ text } `; +const paragraphMarked = (text: string): string => text; +const brMarked = (): string => ' '; +const listItemMarked = (text: string): string => { + const cleanText = text.replace(/|<\/p>/ig, ''); + return `
  • ${ cleanText }
  • `; +}; + +documentRenderer.link = linkMarked; +documentRenderer.listitem = listItemMarked; + +inlineRenderer.link = linkMarked; +inlineRenderer.paragraph = paragraphMarked; +inlineRenderer.listitem = listItemMarked; + +inlineWithoutBreaks.link = linkMarked; +inlineWithoutBreaks.paragraph = paragraphMarked; +inlineWithoutBreaks.br = brMarked; +inlineWithoutBreaks.listitem = listItemMarked; + +const defaultOptions = { + gfm: true, + headerIds: false, +}; + +const options = { + ...defaultOptions, + renderer: documentRenderer, +}; + +const inlineOptions = { + ...defaultOptions, + renderer: inlineRenderer, +}; + +const inlineWithoutBreaksOptions = { + ...defaultOptions, + renderer: inlineWithoutBreaks, +}; + +const MarkdownText: FC> = ({ + content, + variant = 'document', + withTruncatedText = false, + preserveHtml = false, + ...props +}) => { + const sanitizer = dompurify.sanitize; + + let markedOptions: {}; + + const withRichContent = variant; + switch (variant) { + case 'inline': + markedOptions = inlineOptions; + break; + case 'inlineWithoutBreaks': + markedOptions = inlineWithoutBreaksOptions; + break; + case 'document': + default: + markedOptions = options; + } + + const __html = useMemo(() => { + const html = content && typeof content === 'string' && marked(content, markedOptions); + return preserveHtml ? html : html && sanitizer(html, { ADD_ATTR: ['target'] }); + }, [content, preserveHtml, sanitizer, markedOptions]); + + return __html ? : null; +}; + +export default MarkdownText; diff --git a/client/components/Message/Actions/index.tsx b/client/components/Message/Actions/index.tsx index 332fcbd2ed35..2a7611c43a3b 100644 --- a/client/components/Message/Actions/index.tsx +++ b/client/components/Message/Actions/index.tsx @@ -15,10 +15,20 @@ type ActionOptions = { runAction?: RunAction; }; +const resolveLegacyIcon = (legacyIcon: string | undefined): string | undefined => { + if (legacyIcon === 'icon-videocam') { + return 'video'; + } + + return legacyIcon && legacyIcon.replace(/^icon-/, ''); +}; + export const Action: FC = ({ id, icon, i18nLabel, label, mid, runAction }) => { const t = useTranslation(); - return ; + const resolvedIcon = resolveLegacyIcon(icon); + + return ; }; const Actions: FC<{ actions: Array; runAction: RunAction; mid: string }> = ({ actions, runAction }) => {actions.map((action) => )}; diff --git a/client/components/Message/Attachments/Attachment.tsx b/client/components/Message/Attachments/Attachment.tsx index 4884b1912736..d4b7b7048df4 100644 --- a/client/components/Message/Attachments/Attachment.tsx +++ b/client/components/Message/Attachments/Attachment.tsx @@ -19,8 +19,10 @@ export type AttachmentPropsBase = { }; const Row: FC = (props) => ; + const Title: FC = (props) => ; -const Text: FC = (props) => ; +const TitleLink: FC<{ link: string; title?: string }> = ({ link, title }) => {title}; +const Text: FC = (props) => ; const Size: FC = ({ size, ...props }) => { const format = useFormatMemorySize(); @@ -33,9 +35,9 @@ const Collapse: FC = ({ collapsed = false return ; }; -const Download: FC = ({ title, ...props }) => { +const Download: FC = ({ title, href, ...props }) => { const t = useTranslation(); - return ; + return ; }; const Content: FC = ({ ...props }) => ; @@ -53,6 +55,7 @@ const Thumb: FC<{ url: string }> = memo(({ url }) => & { Row: FC; Title: FC; + TitleLink: FC<{ link: string; title?: string }>; Text: FC; Size: FC; Collapse: FC; @@ -78,6 +81,7 @@ Attachment.Image = Image; Attachment.Row = Row; Attachment.Title = Title; Attachment.Text = Text; +Attachment.TitleLink = TitleLink; Attachment.Size = Size; Attachment.Thumb = Thumb; diff --git a/client/components/Message/Attachments/DefaultAttachment.tsx b/client/components/Message/Attachments/DefaultAttachment.tsx index 6e36ff534345..fa3fb03a3d97 100644 --- a/client/components/Message/Attachments/DefaultAttachment.tsx +++ b/client/components/Message/Attachments/DefaultAttachment.tsx @@ -42,7 +42,7 @@ export type DefaultAttachmentProps = { const isActionAttachment = (attachment: AttachmentProps): attachment is ActionAttachmentProps => 'actions' in attachment; -const applyMarkdownIfRequires = (list: DefaultAttachmentProps['mrkdwn_in']) => (key: MarkdownFields, text: string): JSX.Element | string => (list?.includes(key) ? : text); +const applyMarkdownIfRequires = (list: DefaultAttachmentProps['mrkdwn_in'] = ['text', 'pretext']) => (key: MarkdownFields, text: string): JSX.Element | string => (list?.includes(key) ? : text); export const DefaultAttachment: FC = (attachment) => { const applyMardownFor = applyMarkdownIfRequires(attachment.mrkdwn_in); @@ -57,7 +57,7 @@ export const DefaultAttachment: FC = (attachment) => { {!collapsed && <> {attachment.text && {applyMardownFor('text', attachment.text)}} {/* {attachment.fields && ({ ...rest, value: })) : attachment.fields} />} */} - {attachment.fields && ({ ...rest, value: }))} />} + {attachment.fields && ({ ...rest, value: }))} />} {attachment.image_url && } {/* DEPRECATED */} {isActionAttachment(attachment) && } diff --git a/client/components/Message/Attachments/Files/AudioAttachment.tsx b/client/components/Message/Attachments/Files/AudioAttachment.tsx index e3c906cdf878..fb64ced102dd 100644 --- a/client/components/Message/Attachments/Files/AudioAttachment.tsx +++ b/client/components/Message/Attachments/Files/AudioAttachment.tsx @@ -30,7 +30,7 @@ export const AudioAttachment: FC = ({ {title} {size && } {collapse} - {hasDownload && link && } + {hasDownload && link && } { !collapsed &&