diff --git a/.circleci/config.yml.old b/.circleci/config.yml.old deleted file mode 100644 index f9a4a19578d6a..0000000000000 --- a/.circleci/config.yml.old +++ /dev/null @@ -1,518 +0,0 @@ -defaults: &defaults - working_directory: ~/repo - -attach_workspace: &attach_workspace - at: /tmp - -test-install-dependencies: &test-install-dependencies - name: Install dependencies - command: | - wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add - - sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv 9DA31620334BD75D9DCB49F368818C72E52529D4 - echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" | sudo tee /etc/apt/sources.list.d/google.list - echo "deb http://repo.mongodb.org/apt/debian stretch/mongodb-org/4.0 main" | sudo tee /etc/apt/sources.list.d/mongodb-org-4.0.list - sudo apt-get update - sudo apt-get install -y mongodb-org-shell google-chrome-stable - -test-run: &test-run - name: Run Tests - command: | - for i in $(seq 1 5); do mongo rocketchat --eval 'db.dropDatabase()' && npm test && s=0 && break || s=$? && sleep 1; done; (exit $s) - -test-npm-install: &test-npm-install - name: NPM install - command: | - npm install - -test-store_artifacts: &test-store_artifacts - path: .screenshots/ - -test-configure-replicaset: &test-configure-replicaset - name: Configure Replica Set - command: | - mongo --eval 'rs.initiate({_id:"rs0", members: [{"_id":1, "host":"localhost:27017"}]})' - mongo --eval 'rs.status()' - -test-restore-npm-cache: &test-restore-npm-cache - keys: - - node-modules-cache-{{ checksum ".circleci/config.yml" }}-{{ checksum "package.json" }} - -test-save-npm-cache: &test-save-npm-cache - key: node-modules-cache-{{ checksum ".circleci/config.yml" }}-{{ checksum "package.json" }} - paths: - - ./node_modules - -test-docker-image: &test-docker-image - circleci/node:8.16-stretch-browsers - -test-with-oplog: &test-with-oplog - <<: *defaults - environment: - TEST_MODE: "true" - MONGO_URL: mongodb://localhost:27017/rocketchat - MONGO_OPLOG_URL: mongodb://localhost:27017/local - - steps: - - attach_workspace: *attach_workspace - - checkout - - run: *test-install-dependencies - - run: *test-configure-replicaset - - restore_cache: *test-restore-npm-cache - - run: *test-npm-install - - run: *test-run - - save_cache: *test-save-npm-cache - - store_artifacts: *test-store_artifacts - -version: 2 -jobs: - build: - <<: *defaults - docker: - - image: circleci/node:8.16-stretch - - image: mongo:3.4 - - steps: - - checkout - - - restore_cache: - keys: - - node-modules-cache-{{ checksum ".circleci/config.yml" }}-{{ checksum "package.json" }} - - - restore_cache: - keys: - - meteor-{{ checksum ".circleci/config.yml" }}-{{ checksum ".meteor/release" }} - - - run: - name: Install Meteor - command: | - # Restore bin from cache - set +e - METEOR_SYMLINK_TARGET=$(readlink ~/.meteor/meteor) - METEOR_TOOL_DIRECTORY=$(dirname "$METEOR_SYMLINK_TARGET") - set -e - LAUNCHER=$HOME/.meteor/$METEOR_TOOL_DIRECTORY/scripts/admin/launch-meteor - if [ -e $LAUNCHER ] - then - echo "Cached Meteor bin found, restoring it" - sudo cp "$LAUNCHER" "/usr/local/bin/meteor" - else - echo "No cached Meteor bin found." - fi - - # only install meteor if bin isn't found - command -v meteor >/dev/null 2>&1 || curl https://install.meteor.com | sed s/--progress-bar/-sL/g | /bin/sh - - - run: - name: Versions - command: | - npm --versions - node -v - meteor --version - meteor npm --versions - meteor node -v - git version - - - run: - name: Meteor npm install - command: | - # rm -rf node_modules - # rm -f package-lock.json - meteor npm install - - - run: - name: Lint - command: | - meteor npm run lint - - - run: - name: Unit Test - command: | - MONGO_URL=mongodb://localhost:27017 meteor npm run testunit - - - restore_cache: - keys: - - meteor-cache-{{ checksum ".circleci/config.yml" }}-{{ checksum ".meteor/versions" }} - - # To reduce memory need during actual build, build the packages solely first - - run: - name: Build a Meteor cache - command: | - # to do this we can clear the main files and it build the rest - echo "" > server/main.js - echo "" > client/main.js - meteor build --server-only --debug --directory /tmp/build-temp - git checkout -- server/main.js client/main.js - - - run: - name: Build Rocket.Chat - environment: - TOOL_NODE_FLAGS: --max_old_space_size=4096 - command: | - if [[ $CIRCLE_TAG ]] || [[ $CIRCLE_BRANCH == 'develop' ]]; then - meteor reset; - fi - - export CIRCLE_PR_NUMBER="${CIRCLE_PR_NUMBER:-${CIRCLE_PULL_REQUEST##*/}}" - if [[ -z $CIRCLE_PR_NUMBER ]]; then - meteor build --server-only --directory /tmp/build-test - else - export METEOR_PROFILE=1000 - meteor build --server-only --directory --debug /tmp/build-test - fi; - - - run: - name: Prepare build - command: | - mkdir /tmp/build/ - cd /tmp/build-test - tar czf /tmp/build/Rocket.Chat.tar.gz bundle - cd /tmp/build-test/bundle/programs/server - npm install - - - save_cache: - key: node-modules-cache-{{ checksum ".circleci/config.yml" }}-{{ checksum "package.json" }} - paths: - - ./node_modules - - - save_cache: - key: meteor-cache-{{ checksum ".circleci/config.yml" }}-{{ checksum ".meteor/versions" }} - paths: - - ./.meteor/local - - - save_cache: - key: meteor-{{ checksum ".circleci/config.yml" }}-{{ checksum ".meteor/release" }} - paths: - - ~/.meteor - - - persist_to_workspace: - root: /tmp/ - paths: - - build-test - - build - - - store_artifacts: - path: /tmp/build - - - test-with-oplog-mongo-3-4: - <<: *test-with-oplog - docker: - - image: *test-docker-image - - image: mongo:3.4 - command: [mongod, --noprealloc, --smallfiles, --replSet=rs0] - - test-with-oplog-mongo-3-6: - <<: *test-with-oplog - docker: - - image: *test-docker-image - - image: mongo:3.6 - command: [mongod, --noprealloc, --smallfiles, --replSet=rs0] - - test-with-oplog-mongo-4-0: - <<: *test-with-oplog - docker: - - image: *test-docker-image - - image: mongo:4.0 - command: [mongod, --noprealloc, --smallfiles, --replSet=rs0] - - deploy: - <<: *defaults - docker: - - image: circleci/node:8.16-stretch - - steps: - - attach_workspace: - at: /tmp - - - checkout - - - run: - name: Install AWS cli - command: | - if [[ $CIRCLE_PULL_REQUESTS ]]; then exit 0; fi; - - sudo apt-get -y -qq update - sudo apt-get -y -qq install python3.5-dev - curl -O https://bootstrap.pypa.io/get-pip.py - python3.5 get-pip.py --user - export PATH=~/.local/bin:$PATH - pip install awscli --upgrade --user - - - run: - name: Publish assets - command: | - if [[ $CIRCLE_PULL_REQUESTS ]]; then exit 0; fi; - - export PATH=~/.local/bin:$PATH - export CIRCLE_TAG=${CIRCLE_TAG:=} - - aws s3 cp s3://rocketchat/sign.key.gpg .circleci/sign.key.gpg - - source .circleci/setartname.sh - source .circleci/setdeploydir.sh - bash .circleci/setupsig.sh - bash .circleci/namefiles.sh - - aws s3 cp $ROCKET_DEPLOY_DIR/ s3://download.rocket.chat/build/ --recursive - - bash .circleci/update-releases.sh - bash .circleci/snap.sh - bash .circleci/redhat-registry.sh - - image-build: - <<: *defaults - - docker: - - image: docker:17.05.0-ce-git - - steps: - - attach_workspace: - at: /tmp - - - checkout - - - setup_remote_docker - - - run: - name: Build Docker image - command: | - cd /tmp/build - tar xzf Rocket.Chat.tar.gz - rm Rocket.Chat.tar.gz - - export CIRCLE_TAG=${CIRCLE_TAG:=} - if [[ $CIRCLE_TAG ]]; then - docker login -u $DOCKER_USER -p $DOCKER_PASS - - echo "Build official Docker image" - cp ~/repo/.docker/Dockerfile . - docker build -t rocketchat/rocket.chat:$CIRCLE_TAG . - docker push rocketchat/rocket.chat:$CIRCLE_TAG - - echo "Build preview Docker image" - cp ~/repo/.docker-mongo/Dockerfile . - cp ~/repo/.docker-mongo/entrypoint.sh . - docker build -t rocketchat/rocket.chat.preview:$CIRCLE_TAG . - docker push rocketchat/rocket.chat.preview:$CIRCLE_TAG - - if echo "$CIRCLE_TAG" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' ; then - docker tag rocketchat/rocket.chat:$CIRCLE_TAG rocketchat/rocket.chat:latest - docker push rocketchat/rocket.chat:latest - - docker tag rocketchat/rocket.chat.preview:$CIRCLE_TAG rocketchat/rocket.chat.preview:latest - docker push rocketchat/rocket.chat.preview:latest - elif echo "$CIRCLE_TAG" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$' ; then - docker tag rocketchat/rocket.chat:$CIRCLE_TAG rocketchat/rocket.chat:release-candidate - docker push rocketchat/rocket.chat:release-candidate - - docker tag rocketchat/rocket.chat.preview:$CIRCLE_TAG rocketchat/rocket.chat.preview:release-candidate - docker push rocketchat/rocket.chat.preview:release-candidate - fi - - exit 0 - fi; - - if [[ $CIRCLE_BRANCH == 'develop' ]]; then - docker login -u $DOCKER_USER -p $DOCKER_PASS - - echo "Build official Docker image" - cp ~/repo/.docker/Dockerfile . - docker build -t rocketchat/rocket.chat:develop . - docker push rocketchat/rocket.chat:develop - - echo "Build preview Docker image" - cp ~/repo/.docker-mongo/Dockerfile . - cp ~/repo/.docker-mongo/entrypoint.sh . - docker build -t rocketchat/rocket.chat.preview:develop . - docker push rocketchat/rocket.chat.preview:develop - - exit 0 - fi; - - pr-build: - <<: *defaults - docker: - - image: circleci/node:8.16-stretch - - steps: - - checkout - - - restore_cache: - keys: - - node-modules-cache-{{ checksum ".circleci/config.yml" }}-{{ checksum "package.json" }} - - - restore_cache: - keys: - - meteor-{{ checksum ".circleci/config.yml" }}-{{ checksum ".meteor/release" }} - - - run: - name: Install Meteor - command: | - # Restore bin from cache - set +e - METEOR_SYMLINK_TARGET=$(readlink ~/.meteor/meteor) - METEOR_TOOL_DIRECTORY=$(dirname "$METEOR_SYMLINK_TARGET") - set -e - LAUNCHER=$HOME/.meteor/$METEOR_TOOL_DIRECTORY/scripts/admin/launch-meteor - if [ -e $LAUNCHER ] - then - echo "Cached Meteor bin found, restoring it" - sudo cp "$LAUNCHER" "/usr/local/bin/meteor" - else - echo "No cached Meteor bin found." - fi - - # only install meteor if bin isn't found - command -v meteor >/dev/null 2>&1 || curl https://install.meteor.com | sed s/--progress-bar/-sL/g | /bin/sh - - - run: - name: Versions - command: | - npm --versions - node -v - meteor --version - meteor npm --versions - meteor node -v - git version - - - run: - name: Meteor npm install - command: | - # rm -rf node_modules - # rm -f package-lock.json - meteor npm install - - - restore_cache: - keys: - - meteor-cache-{{ checksum ".circleci/config.yml" }}-{{ checksum ".meteor/versions" } - - - run: - name: Build Rocket.Chat - environment: - TOOL_NODE_FLAGS: --max_old_space_size=3072 - command: | - meteor build --server-only /tmp/build-pr - - - - persist_to_workspace: - root: /tmp/ - paths: - - build-pr - - - store_artifacts: - path: /tmp/build-pr - - pr-image-build: - <<: *defaults - - docker: - - image: docker:17.05.0-ce-git - - steps: - - attach_workspace: - at: /tmp - - - checkout - - - setup_remote_docker - - - run: - name: Build Docker image for PRs - command: | - export CIRCLE_PR_NUMBER="${CIRCLE_PR_NUMBER:-${CIRCLE_PULL_REQUEST##*/}}" - if [[ -z $CIRCLE_PR_NUMBER ]]; then - exit 0 - fi; - - cd /tmp/build-pr - tar xzf repo.tar.gz - rm repo.tar.gz - - docker login -u $DOCKER_USER -p $DOCKER_PASS - - echo "Build official Docker image" - cp ~/repo/.docker/Dockerfile . - docker build -t rocketchat/rocket.chat:pr-$CIRCLE_PR_NUMBER . - docker push rocketchat/rocket.chat:pr-$CIRCLE_PR_NUMBER - - #echo "Build preview Docker image" - #cp ~/repo/.docker-mongo/Dockerfile . - #cp ~/repo/.docker-mongo/entrypoint.sh . - #docker build -t rocketchat/rocket.chat.preview:pr-$CIRCLE_PR_NUMBER . - #docker push rocketchat/rocket.chat.preview:pr-$CIRCLE_PR_NUMBER - -workflows: - version: 2 - build-and-test: - jobs: - - hold-all: - type: approval - filters: - branches: - only: develop - tags: - only: /^[0-9]+\.[0-9]+\.[0-9]+(?:-(?:rc|beta)\.[0-9]+)?$/ - - build: - requires: - - hold-all - filters: - tags: - only: /^[0-9]+\.[0-9]+\.[0-9]+(?:-(?:rc|beta)\.[0-9]+)?$/ - - test-with-oplog-mongo-3-4: &test-mongo - requires: - - build - filters: - tags: - only: /^[0-9]+\.[0-9]+\.[0-9]+(?:-(?:rc|beta)\.[0-9]+)?$/ - - test-with-oplog-mongo-3-6: &test-mongo-no-pr - requires: - - build - filters: - branches: - only: develop - tags: - only: /^[0-9]+\.[0-9]+\.[0-9]+(?:-(?:rc|beta)\.[0-9]+)?$/ - - test-with-oplog-mongo-4-0: *test-mongo - - deploy: - requires: - - test-with-oplog-mongo-3-4 - - test-with-oplog-mongo-3-6 - - test-with-oplog-mongo-4-0 - filters: - branches: - only: develop - tags: - only: /^[0-9]+\.[0-9]+\.[0-9]+(?:-(?:rc|beta)\.[0-9]+)?$/ - - image-build: - requires: - - deploy - filters: - branches: - only: develop - tags: - only: /^[0-9]+\.[0-9]+\.[0-9]+(?:-(?:rc|beta)\.[0-9]+)?$/ - - hold: - type: approval - requires: - - build - filters: - branches: - ignore: develop - tags: - only: /^[0-9]+\.[0-9]+\.[0-9]+(?:-(?:rc|beta)\.[0-9]+)?$/ - - pr-build: - requires: - - hold - filters: - branches: - ignore: develop - tags: - only: /^[0-9]+\.[0-9]+\.[0-9]+(?:-(?:rc|beta)\.[0-9]+)?$/ - - pr-image-build: - requires: - - pr-build - filters: - branches: - ignore: develop - tags: - only: /^[0-9]+\.[0-9]+\.[0-9]+(?:-(?:rc|beta)\.[0-9]+)?$/ diff --git a/.circleci/namefiles.sh b/.circleci/namefiles.sh deleted file mode 100644 index f2fd572105f3b..0000000000000 --- a/.circleci/namefiles.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash -set -euvo pipefail -IFS=$'\n\t' - -FILENAME="$ROCKET_DEPLOY_DIR/rocket.chat-$ARTIFACT_NAME.tgz"; - -ln -s /tmp/build/Rocket.Chat.tar.gz "$FILENAME" -gpg --armor --detach-sign "$FILENAME" diff --git a/.circleci/redhat-registry.sh b/.circleci/redhat-registry.sh deleted file mode 100755 index a206af991c19b..0000000000000 --- a/.circleci/redhat-registry.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash -set -euvo pipefail -IFS=$'\n\t' - -if [[ $CIRCLE_TAG ]]; then - curl -X POST \ - https://connect.redhat.com/api/v2/projects/$REDHAT_REGISTRY_PID/build \ - -H "Authorization: Bearer $REDHAT_REGISTRY_KEY" \ - -H 'Cache-Control: no-cache' \ - -H 'Content-Type: application/json' \ - -d '{"tag":"'$CIRCLE_TAG'"}' -fi diff --git a/.circleci/setartname.sh b/.circleci/setartname.sh deleted file mode 100644 index acfdb3e032e6d..0000000000000 --- a/.circleci/setartname.sh +++ /dev/null @@ -1,23 +0,0 @@ -if [[ $CIRCLE_TAG ]]; then - export ARTIFACT_NAME="$(npm run version --silent)" -else - export ARTIFACT_NAME="$(npm run version --silent).$CIRCLE_BUILD_NUM" -fi - -if [[ $CIRCLE_TAG =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+ ]]; then - SNAP_CHANNEL=candidate - RC_RELEASE=candidate - RC_VERSION=$CIRCLE_TAG -elif [[ $CIRCLE_TAG =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - SNAP_CHANNEL=stable - RC_RELEASE=stable - RC_VERSION=$CIRCLE_TAG -else - SNAP_CHANNEL=edge - RC_RELEASE=develop - RC_VERSION="$(npm run version --silent)" -fi - -export SNAP_CHANNEL -export RC_RELEASE -export RC_VERSION diff --git a/.circleci/setdeploydir.sh b/.circleci/setdeploydir.sh deleted file mode 100644 index 2c49e4a7027ae..0000000000000 --- a/.circleci/setdeploydir.sh +++ /dev/null @@ -1,2 +0,0 @@ -export ROCKET_DEPLOY_DIR="/tmp/deploy" -mkdir -p $ROCKET_DEPLOY_DIR diff --git a/.circleci/setupsig.sh b/.circleci/setupsig.sh deleted file mode 100644 index 7b8f3820d745a..0000000000000 --- a/.circleci/setupsig.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash -set -euvo pipefail -IFS=$'\n\t' - -cp .circleci/sign.key.gpg /tmp -gpg --yes --batch --passphrase=$GPG_PASSWORD /tmp/sign.key.gpg -gpg --allow-secret-key-import --import /tmp/sign.key -rm /tmp/sign.key diff --git a/.circleci/snap.sh b/.circleci/snap.sh deleted file mode 100644 index afd047de0e15c..0000000000000 --- a/.circleci/snap.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/bin/bash -set -euvo pipefail -IFS=$'\n\t' - -# Add launchpad to known hosts - -mkdir -p $HOME/.ssh -ssh-keyscan -t rsa -H git.launchpad.net >> $HOME/.ssh/known_hosts - -echo "Preparing to trigger a snap release for $SNAP_CHANNEL channel" - -cd $PWD/.snapcraft - -# We need some meta data so it'll actually commit. This could be useful to have for debugging later. -echo -e "Tag: $CIRCLE_TAG\r\nBranch: $CIRCLE_BRANCH\r\nBuild: $CIRCLE_BUILD_NUM\r\nCommit: $CIRCLE_SHA1" > buildinfo - -# Clone launchpad repo for the channel down. -git clone -b $SNAP_CHANNEL --depth 1 git+ssh://rocket.chat.buildmaster@git.launchpad.net/rocket.chat launchpad - -# Rarely will change, but just incase we copy it all -cp -r resources buildinfo snap launchpad/ -sed s/#{RC_VERSION}/$RC_VERSION/ snap/snapcraft.yaml > launchpad/snap/snapcraft.yaml -sed s/#{RC_VERSION}/$RC_VERSION/ resources/prepareRocketChat > launchpad/resources/prepareRocketChat - -cd launchpad -git add resources snap buildinfo - -# Set commit author details -git config user.email "buildmaster@rocket.chat" -git config user.name "CircleCI" - -# Another place where basic meta data will live for at a glance info -git commit -m "CircleCI Build: $CIRCLE_BUILD_NUM CircleCI Commit: $CIRCLE_SHA1" - -# Push up up to the branch of choice. -git push origin $SNAP_CHANNEL - -# Clean up -cd .. -rm -rf launchpad diff --git a/.circleci/update-releases.sh b/.circleci/update-releases.sh deleted file mode 100644 index 1f5f527c4d086..0000000000000 --- a/.circleci/update-releases.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash -set -euvo pipefail -IFS=$'\n\t' - -curl -H "Content-Type: application/json" -H "X-Update-Token: $UPDATE_TOKEN" -d \ - "{\"commit\": \"$CIRCLE_SHA1\", \"tag\": \"$RC_VERSION\", \"branch\": \"$CIRCLE_BRANCH\", \"artifactName\": \"$ARTIFACT_NAME\", \"releaseType\": \"$RC_RELEASE\" }" \ - https://releases.rocket.chat/update - -# Makes build fail if the release isn't there -curl --fail https://releases.rocket.chat/$RC_VERSION/info diff --git a/.docker/Dockerfile.rhel b/.docker/Dockerfile.rhel index e1602b7f0ab10..05a902d361b53 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.10.5 +ENV RC_VERSION 3.11.0 MAINTAINER buildmaster@rocket.chat diff --git a/.eslintrc b/.eslintrc index d1d22fdb7fa5e..e486bf36405e9 100644 --- a/.eslintrc +++ b/.eslintrc @@ -83,7 +83,9 @@ "indent": "off", "no-extra-parens": "off", "no-spaced-func": "off", + "no-unused-vars": "off", "no-useless-constructor": "off", + "no-use-before-define": "off", "react/jsx-uses-react": "error", "react/jsx-uses-vars": "error", "react/jsx-no-undef": "error", @@ -99,6 +101,10 @@ "SwitchCase": 1 } ], + "@typescript-eslint/interface-name-prefix": [ + "error", + "always" + ], "@typescript-eslint/no-extra-parens": [ "error", "all", @@ -111,10 +117,9 @@ } ], "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/interface-name-prefix": [ - "error", - "always" - ] + "@typescript-eslint/no-unused-vars": ["error", { + "argsIgnorePattern": "^_" + }] }, "env": { "browser": true, diff --git a/.github/history.json b/.github/history.json index e8615c798281f..ef3d598155de3 100644 --- a/.github/history.json +++ b/.github/history.json @@ -53955,6 +53955,909 @@ } ] }, + "3.11.0-rc.0": { + "node_version": "12.18.4", + "npm_version": "6.14.8", + "apps_engine_version": "1.22.0-alpha.4469", + "mongo_versions": [ + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [ + { + "pr": "20311", + "title": "[FIX][ENTERPRISE] Auditing RoomAutocomplete", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo", + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "20221", + "title": "[NEW] Banner system and NPS", + "userLogin": "sampaiodiego", + "description": "More robust and scalable banner system for alerting users.", + "contributors": [ + "sampaiodiego", + "web-flow", + "tassoevan" + ] + }, + { + "pr": "20285", + "title": "[NEW] [Apps] IPreFileUpload event", + "userLogin": "lolimay", + "milestone": "3.11.0", + "contributors": [ + "ggazzo", + "lolimay", + "web-flow", + "d-gubert" + ] + }, + { + "pr": "20078", + "title": "[NEW][Apps] Apps Permission System", + "userLogin": "thassiov", + "contributors": [ + "thassiov", + "d-gubert", + "web-flow" + ] + }, + { + "pr": "20244", + "title": "[NEW][ENTERPRISE] Omnichannel Contact Manager as preferred agent for routing", + "userLogin": "murtaza98", + "description": "If the `Contact-Manager` is assigned to a Visitor, the chat will automatically get transferred to the respective Contact-Manager, provided the Contact-Manager is online. In-case the Contact-Manager is offline, the chat will be transferred to any other online agent.\r\nWe have provided a setting to control this auto-assignment feature\r\n![image](https://user-images.githubusercontent.com/34130764/104880961-8104d780-5986-11eb-9d87-82b99814b028.png)\r\n\r\nBehavior based-on Routing method\r\n\r\n1. Auto-selection, Load-Balancing, or External Service (`autoAssignAgent = true`)\r\n This is straightforward, \r\n - if the Contact-manager is online, the chat will be transferred to the Contact-Manger only\r\n - if the Contact-manager is offline, the chat will be transferred to any other online-agent based on the Routing system\r\n2. Manual-selection (`autoAssignAgent = false`)\r\n - If the Contact-Manager is online, the chat will appear in the Queue of Contact-Manager **ONLY**\r\n - If the Contact-Manager is offline, the chat will appear in the Queue of all related Agents/Manager ( like it's done right now )", + "milestone": "3.11.0", + "contributors": [ + "ggazzo", + "web-flow", + "renatobecker", + "murtaza98" + ] + }, + { + "pr": "20101", + "title": "[NEW] Email Inboxes for Omnichannel", + "userLogin": "rafaelblink", + "description": "With this new feature, email accounts will receive email messages(threads) which will be transformed into Omnichannel chats. It'll be possible to set up multiple email accounts, test the connection with email server(email provider) and define the behaviour of each account.\r\n\r\nhttps://user-images.githubusercontent.com/2493803/105430398-242d4980-5c32-11eb-835a-450c94837d23.mp4\r\n\r\n### New item on admin menu\r\n\r\n![image](https://user-images.githubusercontent.com/2493803/105428723-bc293400-5c2e-11eb-8c02-e8d36ea82726.png)\r\n\r\n\r\n### Send test email tooltip\r\n\r\n![image](https://user-images.githubusercontent.com/2493803/104366986-eaa16380-54f8-11eb-9ba7-831cfde2319c.png)\r\n\r\n\r\n### Inbox Info\r\n\r\n![image](https://user-images.githubusercontent.com/2493803/104366796-ab731280-54f8-11eb-9941-a3cc8eb610e1.png)\r\n\r\n### SMTP Info\r\n\r\n![image](https://user-images.githubusercontent.com/2493803/104366868-c47bc380-54f8-11eb-969e-ccc29070957c.png)\r\n\r\n### IMAP Info\r\n\r\n![image](https://user-images.githubusercontent.com/2493803/104366897-cd6c9500-54f8-11eb-80c4-97d5b0c002d5.png)\r\n\r\n### Messages\r\n\r\n![image](https://user-images.githubusercontent.com/2493803/105428971-45d90180-5c2f-11eb-992a-022a3df94471.png)", + "milestone": "3.11.0", + "contributors": [ + "rafaelblink", + "rodrigok" + ] + }, + { + "pr": "20201", + "title": "[NEW] Encrypted Discussions and new Encryption Permissions", + "userLogin": "pierre-lehnen-rc", + "contributors": [ + "pierre-lehnen-rc", + "gabriellsh", + "ggazzo" + ] + }, + { + "pr": "20246", + "title": "Language update from LingoHub 🤖 on 2021-01-18Z", + "userLogin": "lingohub[bot]", + "contributors": [ + null + ] + }, + { + "pr": "20306", + "title": "Regression: Unread superposing announcement.", + "userLogin": "gabriellsh", + "description": "### Before\r\n![image](https://user-images.githubusercontent.com/40830821/105412619-c2f67d80-5c13-11eb-8204-5932ea880c8a.png)\r\n\r\n\r\n### After\r\n![image](https://user-images.githubusercontent.com/40830821/105411176-d1439a00-5c11-11eb-8d1b-ea27c8485214.png)", + "contributors": [ + "gabriellsh" + ] + }, + { + "pr": "20290", + "title": "Regression: Announcement bar not showing properly Markdown content", + "userLogin": "dougfabris", + "description": "**Before**:\r\n![image](https://user-images.githubusercontent.com/27704687/105273746-a4907380-5b7a-11eb-8121-aff665251c44.png)\r\n\r\n**After**:\r\n![image](https://user-images.githubusercontent.com/27704687/105274050-2e404100-5b7b-11eb-93b2-b6282a7bed95.png)", + "milestone": "3.11.0", + "contributors": [ + "dougfabris", + "ggazzo", + "web-flow" + ] + }, + { + "pr": "20291", + "title": "Regression: Attachments", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "20272", + "title": "[FIX] Changed success message for adding custom sound.", + "userLogin": "Darshilp326", + "description": "https://user-images.githubusercontent.com/55157259/105151351-daf2d200-5b2b-11eb-8223-eae5d60f770d.mp4", + "contributors": [ + "Darshilp326" + ] + }, + { + "pr": "20259", + "title": "[FIX] Saving with blank email in edit user", + "userLogin": "RonLek", + "description": "Disallows showing a success popup when email field is made blank in Edit User and instead shows the relevant error popup.\r\n\r\n\r\nhttps://user-images.githubusercontent.com/28918901/104960749-dbd81680-59fa-11eb-9c7b-2b257936f894.mp4", + "contributors": [ + "RonLek" + ] + }, + { + "pr": "20287", + "title": "[FIX] Fields overflowing page", + "userLogin": "gabriellsh", + "description": "### Before\r\n![image](https://user-images.githubusercontent.com/40830821/105246952-c1b14c00-5b52-11eb-8671-cff88edf242d.png)\r\n\r\n### After\r\n![image](https://user-images.githubusercontent.com/40830821/105247125-0a690500-5b53-11eb-9f3c-d6a68108e336.png)", + "contributors": [ + "gabriellsh" + ] + }, + { + "pr": "20265", + "title": "[FIX] Jump to message", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo", + "web-flow" + ] + }, + { + "pr": "20280", + "title": "Regression: Lint warnings and some datepicker", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "20277", + "title": "[FIX] Room special name in prompts", + "userLogin": "aKn1ghtOut", + "description": "The \"Hide room\" and \"Leave Room\" confirmation prompts use the \"name\" key from the room info. When the setting \"\r\nAllow Special Characters in Room Names\" is enabled, the prompts show the normalized names instead of those that contain the special characters.\r\n\r\nChanged the value being used from name to fname, which always has the user-set name.\r\n\r\nPrevious:\r\n![Screenshot from 2021-01-20 15-52-29](https://user-images.githubusercontent.com/38764067/105161642-9b31e780-5b37-11eb-8b0c-ec4b1414c948.png)\r\n\r\nUpdated:\r\n![Screenshot from 2021-01-20 15-50-19](https://user-images.githubusercontent.com/38764067/105161627-966d3380-5b37-11eb-9812-3dd9352b4f95.png)", + "contributors": [ + "aKn1ghtOut" + ] + }, + { + "pr": "20267", + "title": "[FIX] Engagement dashboard graphs labels superposing each other", + "userLogin": "gabriellsh", + "description": "Now after a certain breakpoint, the graphs should stack vertically, and overlapping text rotated.\r\n\r\n![image](https://user-images.githubusercontent.com/40830821/105098926-93b40500-5a89-11eb-9a56-2fc3b1552914.png)", + "contributors": [ + "gabriellsh", + "web-flow" + ] + }, + { + "pr": "19996", + "title": "[FIX] Changed success message for ignoring member.", + "userLogin": "Darshilp326", + "description": "Different messages for ignoring/unignoring will be displayed.\r\n\r\nhttps://user-images.githubusercontent.com/55157259/103310307-4241c880-4a3d-11eb-8c6c-4c9b99d023db.mp4", + "contributors": [ + "Darshilp326", + "web-flow" + ] + }, + { + "pr": "20028", + "title": "[FIX] User info 'Full Name' translation keyword", + "userLogin": "Karting06", + "description": "Fix the `Full Name` translation keyword, so that it can be translated.", + "milestone": "3.11.0", + "contributors": [ + "Karting06" + ] + }, + { + "pr": "20029", + "title": "[FIX] ViewLogs title translation keyword", + "userLogin": "Karting06", + "description": "Fix `View Logs` title translation keyword to enable translation of the title", + "contributors": [ + "Karting06" + ] + }, + { + "pr": "20098", + "title": "[FIX] Fix error that occurs on changing archive status of room", + "userLogin": "aKn1ghtOut", + "description": "This PR fixes an issue that happens when you try to edit the info of a room, and save changes after changing the value of \"Archived\". The archive functionality is handled separately from other room settings. The archived key is not used in the saveRoomSettings method but was still being sent over. Hence, the request was being considered invalid. I deleted the \"archived\" key from the data being sent in the request, making the request valid again.", + "contributors": [ + "aKn1ghtOut", + "web-flow" + ] + }, + { + "pr": "20159", + "title": "[FIX] Remove duplicate blaze events call for EmojiActions from roomOld", + "userLogin": "aKn1ghtOut", + "description": "A few methods concerning Emojis are bound multiple times to the DOM using the Template events() call, once in the reactions init.js and the other time after they get exported from app/ui/client/views/app/lib/getCommonRoomEvents.js to whatever page binds all the functions. The getCommonRoomEvents methods are always bound, hence negating a need to bind in a lower-level component.", + "contributors": [ + "aKn1ghtOut", + "web-flow" + ] + }, + { + "pr": "20164", + "title": "[FIX] \"Open_thread\" English tooltip correction", + "userLogin": "aKn1ghtOut", + "description": "Remove unnecessary spaces from the translation key, and added English translation value for the key.", + "contributors": [ + "aKn1ghtOut" + ] + }, + { + "pr": "20172", + "title": "[IMPROVE] Rewrite Announcement as React component", + "userLogin": "dougfabris", + "milestone": "3.11.0", + "contributors": [ + "dougfabris", + "gabriellsh", + "ggazzo", + "web-flow" + ] + }, + { + "pr": "20199", + "title": "[FIX] Added Margin between status bullet and status label", + "userLogin": "yash-rajpal", + "description": "Added Margins between status bullet and status label", + "contributors": [ + "yash-rajpal" + ] + }, + { + "pr": "20220", + "title": "[FIX]Added success message on saving notification preference.", + "userLogin": "Darshilp326", + "description": "Added success message after saving notification preferences.\r\n\r\nhttps://user-images.githubusercontent.com/55157259/104774617-03ca3e80-579d-11eb-8fa4-990b108dd8d9.mp4", + "contributors": [ + "Darshilp326" + ] + }, + { + "pr": "20225", + "title": "[FIX] White screen after 2FA code entered", + "userLogin": "wggdeveloper", + "milestone": "3.10.5", + "contributors": [ + "wggdeveloper", + "web-flow" + ] + }, + { + "pr": "20228", + "title": "[FIX] Added context check for closing active tabbar for member-list", + "userLogin": "yash-rajpal", + "description": "When we click on a username and then click on see user's full profile, a tab gets active and shows us the user's profile, the problem occurs when the tab is still active and we try to see another user's profile. In this case, tabbar gets closed.\r\nTo resolve this, added context check for closing action of active tabbar.", + "contributors": [ + "yash-rajpal" + ] + }, + { + "pr": "20245", + "title": "[FIX] Incorrect translations ZN", + "userLogin": "moniang", + "milestone": "3.11.0", + "contributors": [ + "moniang" + ] + }, + { + "pr": "20255", + "title": "Regression: reactAttachments cpu", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "20196", + "title": "[FIX] Omnichannel - Contact Center form is not validating custom fields properly", + "userLogin": "rafaelblink", + "description": "The contact form is accepting undefined values in required custom fields when creating or editing contacts, and, the errror message isn't following Rocket.chat design system.\r\n\r\n### Before\r\n![image](https://user-images.githubusercontent.com/2493803/104522668-31688980-55dd-11eb-92c5-83f96073edc4.png)\r\n\r\n### After\r\n\r\n#### New\r\n![image](https://user-images.githubusercontent.com/2493803/104770494-68f74300-574f-11eb-94a3-c8fd73365308.png)\r\n\r\n\r\n#### Edit\r\n![image](https://user-images.githubusercontent.com/2493803/104770538-7b717c80-574f-11eb-829f-1ae304103369.png)", + "milestone": "3.11.0", + "contributors": [ + "rafaelblink", + "web-flow", + "renatobecker" + ] + }, + { + "pr": "20090", + "title": "[NEW][ENTERPRISE] Automatic transfer of unanswered conversations to another agent", + "userLogin": "murtaza98", + "milestone": "3.11.0", + "contributors": [ + "murtaza98", + "web-flow", + "renatobecker" + ] + }, + { + "pr": "20123", + "title": "Rewrite Message action links", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "20106", + "title": "Rewrite: Message Attachments", + "userLogin": "ggazzo", + "description": "![image](https://user-images.githubusercontent.com/5263975/104783709-69023d80-5765-11eb-968f-a2b93fdfb51e.png)", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "20222", + "title": "Regression: User Dropdown margin", + "userLogin": "gabriellsh", + "contributors": [ + "gabriellsh" + ] + }, + { + "pr": "20119", + "title": "Rewrite Broadcast", + "userLogin": "ggazzo", + "description": "![image](https://user-images.githubusercontent.com/5263975/104035912-7fcaf200-51b1-11eb-91df-228c23d97448.png)", + "contributors": [ + "ggazzo", + "gabriellsh", + "web-flow" + ] + }, + { + "pr": "20121", + "title": "[IMPROVE] Message Collection Hooks", + "userLogin": "tassoevan", + "description": "Integrating a list of messages into a React component imposes some challenges. Its content is provided by some REST API calls and live-updated by streamer events. To avoid too much coupling with React Hooks, the structures `RecordList`, `MessageList` and their derivatives are simple event emitters created and connected on components via some simple hooks, like `useThreadsList()` and `useRecordList()`.", + "milestone": "3.11.0", + "contributors": [ + "tassoevan", + "ggazzo", + "web-flow" + ] + }, + { + "pr": "19899", + "title": "[FIX] Invalid filters on the Omnichannel Analytics page", + "userLogin": "murtaza98", + "milestone": "3.11.0", + "contributors": [ + "murtaza98", + "web-flow", + "renatobecker" + ] + }, + { + "pr": "20180", + "title": "Regression: Info Page Icon style and usage graph breaking", + "userLogin": "gabriellsh", + "contributors": [ + "gabriellsh", + "ggazzo", + "web-flow" + ] + }, + { + "pr": "20200", + "title": "Chore: Change console.warning() to console.warn()", + "userLogin": "lucassartor", + "milestone": "3.10.4", + "contributors": [ + "lucassartor" + ] + }, + { + "pr": "20176", + "title": "[FIX] Room's list showing all rooms with same name", + "userLogin": "sampaiodiego", + "description": "Add a migration to fix the room's list for those who ran version 3.10.1 and got it scrambled when a new user was registered.", + "milestone": "3.10.4", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "20177", + "title": "Regression: Change sort icon", + "userLogin": "gabriellsh", + "description": "### Before\r\n![image](https://user-images.githubusercontent.com/40830821/104366414-1bcd6400-54f8-11eb-9fc7-c6f13f07a61e.png)\r\n\r\n### After\r\n![image](https://user-images.githubusercontent.com/40830821/104366542-4cad9900-54f8-11eb-83ca-acb99899515a.png)", + "contributors": [ + "gabriellsh" + ] + }, + { + "pr": "20181", + "title": "[FIX] Wrong userId when open own user profile", + "userLogin": "dougfabris", + "contributors": [ + "dougfabris" + ] + }, + { + "pr": "20124", + "title": "[FIX] Livechat.RegisterGuest method removing unset fields", + "userLogin": "renatobecker", + "description": "After changes made on https://github.com/RocketChat/Rocket.Chat/pull/19931, the `Livechat.RegisterGuest` method started removing properties from the visitor inappropriately. The properties that did not receive value were removed from the object.\r\nThose changes were made to support the new Contact Form, but now the form has its own method to deal with Contact data so those changes are no longer necessary.", + "milestone": "3.11.0", + "contributors": [ + "ggazzo", + "web-flow", + "renatobecker", + "rafaelblink" + ] + }, + { + "pr": "19900", + "title": "[IMPROVE] Rewrite Prune Messages as React component", + "userLogin": "dougfabris", + "milestone": "3.11.0", + "contributors": [ + "dougfabris", + "web-flow", + "ggazzo" + ] + }, + { + "pr": "20174", + "title": "[FIX] Change header's favorite icon to filled star", + "userLogin": "dougfabris", + "description": "### Before: \r\n![image](https://user-images.githubusercontent.com/27704687/104351819-a60bcd00-54e4-11eb-8b43-7d281a6e5dcb.png)\r\n\r\n### After:\r\n![image](https://user-images.githubusercontent.com/27704687/104351632-67761280-54e4-11eb-87ba-25b940494bb5.png)", + "contributors": [ + "dougfabris" + ] + }, + { + "pr": "19938", + "title": "[FIX] Initial values update on Account Preferences", + "userLogin": "dougfabris", + "milestone": "3.11.0", + "contributors": [ + "dougfabris", + "tassoevan", + "web-flow" + ] + }, + { + "pr": "19643", + "title": "[FIX] Unable to reset password by Email if upper case character is pr…", + "userLogin": "bhavayAnand9", + "milestone": "3.11.0", + "contributors": [ + "bhavayAnand9" + ] + }, + { + "pr": "18722", + "title": "[FIX] Video call message not translated", + "userLogin": "galshiff", + "description": "Fixed video call message not translated.", + "milestone": "3.11.0", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "19517", + "title": "[NEW] Server Info page", + "userLogin": "gabriellsh", + "milestone": "3.11.0", + "contributors": [ + "gabriellsh" + ] + }, + { + "pr": "20083", + "title": "[IMPROVE] Title for user avatar buttons", + "userLogin": "sushant52", + "description": "Made user avatar change buttons to be descriptive of what they do.", + "contributors": [ + "sushant52" + ] + }, + { + "pr": "20110", + "title": "[FIX] Admin User Info email verified status", + "userLogin": "bdelwood", + "milestone": "3.11.0", + "contributors": [ + "bdelwood", + "web-flow" + ] + }, + { + "pr": "20116", + "title": "[IMPROVE] Tooltip added for Kebab menu on chat header", + "userLogin": "yash-rajpal", + "description": "Added the missing Tooltip for kebab menu on chat header.\r\n![tooltip after](https://user-images.githubusercontent.com/58601732/104031406-b07f4b80-51f2-11eb-87a4-1e8da78a254f.gif)", + "contributors": [ + "yash-rajpal" + ] + }, + { + "pr": "20134", + "title": "[FIX] Translate keyword for 'Showing results of' in tables", + "userLogin": "Karting06", + "description": "Change translation keyword in order to allow the translation of `Showing results %s - %s of %s` in tables.", + "milestone": "3.11.0", + "contributors": [ + "Karting06" + ] + }, + { + "pr": "20021", + "title": "[FIX] Markdown added for Header Room topic", + "userLogin": "yash-rajpal", + "description": "With the new 3.10.0 version update the Links in topic section below room name were not working, for more info refer issue #20018", + "milestone": "3.11.0", + "contributors": [ + "yash-rajpal" + ] + }, + { + "pr": "20016", + "title": "[FIX] Status circle in profile section", + "userLogin": "yash-rajpal", + "description": "The Status Circle in status message text input is now centered vertically.", + "milestone": "3.11.0", + "contributors": [ + "yash-rajpal" + ] + }, + { + "pr": "19962", + "title": "[FIX] Normalize messages for users in endpoint chat.getStarredMessages", + "userLogin": "tiagoevanp", + "milestone": "3.11.0", + "contributors": [ + "tiagoevanp", + "tassoevan", + "web-flow" + ] + }, + { + "pr": "19942", + "title": "[FIX] minWidth in FileIcon to prevent layout to broke", + "userLogin": "dougfabris", + "description": "![image](https://user-images.githubusercontent.com/27704687/102934691-69b7f480-4483-11eb-995b-a8a9b72246aa.png)", + "milestone": "3.11.0", + "contributors": [ + "dougfabris", + "tassoevan", + "web-flow" + ] + }, + { + "pr": "19489", + "title": "[IMPROVE] Add extra SAML settings to update room subs and add private room subs.", + "userLogin": "tlskinneriv", + "description": "Added a SAML setting to support updating room subscriptions each time a user logs in via SAML.\r\nAdded a SAML setting to support including private rooms in SAML updated subscriptions (whether initial or on each logon).", + "milestone": "3.11.0", + "contributors": [ + "tlskinneriv", + "web-flow" + ] + }, + { + "pr": "20070", + "title": "[IMPROVE] Rewrite User Dropdown and Kebab menu.", + "userLogin": "gabriellsh", + "description": "![image](https://user-images.githubusercontent.com/40830821/103699786-3a74ad80-4f82-11eb-913e-2e09d5f7eac6.png)", + "contributors": [ + "gabriellsh", + "ggazzo", + "web-flow" + ] + }, + { + "pr": "20146", + "title": "Language update from LingoHub 🤖 on 2021-01-11Z", + "userLogin": "lingohub[bot]", + "contributors": [ + null, + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "20128", + "title": "[FIX] User registration updating wrong subscriptions", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "20117", + "title": "Rewrite Discussion Metric", + "userLogin": "ggazzo", + "description": "https://user-images.githubusercontent.com/5263975/104031909-23190880-51ac-11eb-93dd-5d4b5295886d.mp4", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "19777", + "title": "[IMPROVE] Don't use global search by default", + "userLogin": "ikyuchukov", + "description": "Global chat search is not set by default now.", + "contributors": [ + "i-kychukov", + "ikyuchukov", + "web-flow" + ] + }, + { + "pr": "20122", + "title": "[FIX] Tabbar is opened", + "userLogin": "ggazzo", + "milestone": "3.10.2", + "contributors": [ + "ggazzo", + "web-flow" + ] + }, + { + "pr": "20073", + "title": "[FIX] Actions from User Info panel", + "userLogin": "Darshilp326", + "description": "Users can be removed from channels without any error message.", + "milestone": "3.10.1", + "contributors": [ + "Darshilp326" + ] + }, + { + "pr": "20118", + "title": "Update password policy English translation", + "userLogin": "zdumitru", + "contributors": [ + "zdumitru", + "web-flow" + ] + }, + { + "pr": "20114", + "title": "[FIX] Messages being updated when not required after user changes his profile", + "userLogin": "sampaiodiego", + "milestone": "3.10.1", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "19953", + "title": "[FIX][ENTERPRISE] Omnichannel custom fields not storing additional form values ", + "userLogin": "rafaelblink", + "milestone": "3.10.1", + "contributors": [ + "rafaelblink", + "renatobecker", + "web-flow" + ] + }, + { + "pr": "20089", + "title": "[FIX] Omnichannel rooms breaking after return to queue or forward", + "userLogin": "gabriellsh", + "milestone": "3.10.1", + "contributors": [ + "gabriellsh", + "renatobecker", + "web-flow" + ] + }, + { + "pr": "19993", + "title": "[FIX] Meteor errors not translating for toast messages", + "userLogin": "gabriellsh", + "contributors": [ + "gabriellsh" + ] + }, + { + "pr": "19992", + "title": "[FIX] Profile picture changing with username", + "userLogin": "gabriellsh", + "description": "![bug avatar](https://user-images.githubusercontent.com/40830821/103305935-24e40e80-49eb-11eb-9e35-9bd4c167898a.gif)", + "contributors": [ + "gabriellsh" + ] + }, + { + "pr": "19937", + "title": "[FIX] Search list filter", + "userLogin": "gabriellsh", + "contributors": [ + "gabriellsh", + "tassoevan", + "web-flow" + ] + }, + { + "pr": "20045", + "title": "chore: Change return button", + "userLogin": "gabriellsh", + "contributors": [ + "gabriellsh", + "ggazzo", + "web-flow" + ] + }, + { + "pr": "20051", + "title": "Rewrite : Message Thread metrics", + "userLogin": "ggazzo", + "description": "![image](https://user-images.githubusercontent.com/5263975/103585504-e904e980-4ec1-11eb-8d8c-3113ac812ead.png)", + "contributors": [ + "ggazzo", + "web-flow" + ] + }, + { + "pr": "20093", + "title": "[FIX] Omnichannel raw model importing meteor dependency", + "userLogin": "renatobecker", + "milestone": "3.10.1", + "contributors": [ + "renatobecker" + ] + }, + { + "pr": "20061", + "title": "[FIX] User Audio notification preference not being applied", + "userLogin": "sampaiodiego", + "milestone": "3.10.1", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "20003", + "title": "[FIX] OAuth users being asked to change password on second login", + "userLogin": "pierre-lehnen-rc", + "milestone": "3.10.1", + "contributors": [ + "pierre-lehnen-rc", + "sampaiodiego" + ] + }, + { + "pr": "20055", + "title": "Bump axios from 0.18.0 to 0.18.1", + "userLogin": "dependabot[bot]", + "contributors": [ + "dependabot[bot]", + "web-flow" + ] + }, + { + "pr": "19916", + "title": "Add translation of Edit Status in all languages", + "userLogin": "sushant52", + "description": "Closes [#19915](https://github.com/RocketChat/Rocket.Chat/issues/19915)\r\nThe profile options menu is well translated in many languages. However, Edit Status is the only button which is not well translated. With this change, the whole profile options will be properly translated in a lot of languages.", + "contributors": [ + "sushant52", + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "20047", + "title": "Chore: Recover and update Storybook", + "userLogin": "tassoevan", + "description": "It reenables Storybook's usage.", + "milestone": "3.11.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "20041", + "title": "Chore: Add tests for the api/licenses.* endpoints", + "userLogin": "lucassartor", + "description": "Adding api tests for the new `licenses.*` endpoints (`licenses.get` and `licenses.add`)", + "contributors": [ + "lucassartor" + ] + }, + { + "pr": "20034", + "title": "Language update from LingoHub 🤖 on 2021-01-04Z", + "userLogin": "lingohub[bot]", + "contributors": [ + null, + "sampaiodiego" + ] + }, + { + "pr": "20022", + "title": "[FIX] Omnichannel Agents unable to take new chats in the queue", + "userLogin": "rafaelblink", + "milestone": "3.10.1", + "contributors": [ + "rafaelblink", + "renatobecker" + ] + }, + { + "pr": "20007", + "title": "[FIX] Omnichannel Business Hours form is not being rendered", + "userLogin": "rafaelblink", + "milestone": "3.10.1", + "contributors": [ + "rafaelblink", + "renatobecker", + "web-flow" + ] + }, + { + "pr": "20013", + "title": "Language update from LingoHub 🤖 on 2020-12-30Z", + "userLogin": "lingohub[bot]", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "19998", + "title": "Chore: Fix i18n duplicated keys", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "19965", + "title": "[FIX] Agent information panel not rendering", + "userLogin": "rafaelblink", + "milestone": "3.10.1", + "contributors": [ + "rafaelblink", + "renatobecker", + "web-flow" + ] + }, + { + "pr": "19997", + "title": "[FIX] Creation of Omnichannel rooms not working correctly through the Apps when the agent parameter is set", + "userLogin": "murtaza98", + "milestone": "3.10.1", + "contributors": [ + "murtaza98" + ] + }, + { + "pr": "19988", + "title": "Chore: add tests to api/instances.get endpoint ", + "userLogin": "lucassartor", + "contributors": [ + "lucassartor", + "web-flow" + ] + } + ] + }, "3.8.6": { "node_version": "12.18.4", "npm_version": "6.14.8", @@ -53966,6 +54869,28 @@ ], "pull_requests": [] }, + "3.8.7": { + "node_version": "12.18.4", + "npm_version": "6.14.8", + "apps_engine_version": "1.19.0", + "mongo_versions": [ + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [] + }, + "3.8.8": { + "node_version": "12.18.4", + "npm_version": "6.14.8", + "apps_engine_version": "1.19.0", + "mongo_versions": [ + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [] + }, "3.9.5": { "node_version": "12.18.4", "npm_version": "6.14.8", @@ -53977,6 +54902,28 @@ ], "pull_requests": [] }, + "3.9.6": { + "node_version": "12.18.4", + "npm_version": "6.14.8", + "apps_engine_version": "1.21.0-alpha.4235", + "mongo_versions": [ + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [] + }, + "3.9.7": { + "node_version": "12.18.4", + "npm_version": "6.14.8", + "apps_engine_version": "1.21.0-alpha.4235", + "mongo_versions": [ + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [] + }, "3.10.5": { "node_version": "12.18.4", "npm_version": "6.14.8", @@ -53987,6 +54934,315 @@ "4.0" ], "pull_requests": [] + }, + "3.11.0-rc.1": { + "node_version": "12.18.4", + "npm_version": "6.14.8", + "apps_engine_version": "1.22.0-alpha.4469", + "mongo_versions": [ + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [ + { + "pr": "20430", + "title": "Security sync", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "20343", + "title": "Regression: Fix Cron statistics TypeError", + "userLogin": "RonLek", + "contributors": [ + "RonLek" + ] + } + ] + }, + "3.11.0-rc.2": { + "node_version": "12.18.4", + "npm_version": "6.14.8", + "apps_engine_version": "1.22.0-alpha.4469", + "mongo_versions": [ + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [ + { + "pr": "20450", + "title": "Regression: Bio page not rendering", + "userLogin": "MartinSchoeler", + "contributors": [ + "MartinSchoeler" + ] + }, + { + "pr": "20435", + "title": "regression: Announcement link open in new tab", + "userLogin": "tiagoevanp", + "contributors": [ + "tiagoevanp", + "ggazzo", + "web-flow" + ] + } + ] + }, + "3.11.0-rc.3": { + "node_version": "12.18.4", + "npm_version": "6.14.8", + "apps_engine_version": "1.22.0-alpha.4534", + "mongo_versions": [ + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [ + { + "pr": "20457", + "title": "[FIX] Sidebar palette color broken on IE", + "userLogin": "dougfabris", + "description": "![image](https://user-images.githubusercontent.com/27704687/106056093-0a29b600-60cd-11eb-8038-eabbc0d8fb03.png)", + "milestone": "3.10.6", + "contributors": [ + "dougfabris" + ] + }, + { + "pr": "20490", + "title": "[FIX] RoomManager validation broken on IE", + "userLogin": "dougfabris", + "milestone": "3.10.6", + "contributors": [ + "dougfabris" + ] + }, + { + "pr": "20482", + "title": "Update Apps-Engine version", + "userLogin": "d-gubert", + "description": "Update Apps-Engine version with some fixes for the current RC cycle.", + "milestone": "3.11.0", + "contributors": [ + "d-gubert" + ] + } + ] + }, + "3.11.0-rc.4": { + "node_version": "12.18.4", + "npm_version": "6.14.8", + "apps_engine_version": "1.22.0-alpha.4545", + "mongo_versions": [ + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [ + { + "pr": "20506", + "title": "[FIX][Apps] Don't show the \"review permissions\" modal when there's none to review", + "userLogin": "thassiov", + "milestone": "3.11.0", + "contributors": [ + "thassiov", + "d-gubert", + "web-flow" + ] + }, + { + "pr": "20491", + "title": "Update Apps-Engine and permissions translations", + "userLogin": "d-gubert", + "description": "Update Apps-Engine version and apply changes in translations for the changed permissions. Please review the texts on the translation files to make sure they're clear.", + "milestone": "3.11.0", + "contributors": [ + "d-gubert", + "lolimay", + "thassiov", + "web-flow" + ] + }, + { + "pr": "20492", + "title": "Regression: Add tests to new banners REST endpoints", + "userLogin": "lucassartor", + "description": "Add tests for the new `banners.*` endpoints: `banners.getNew` and `banners.dismiss`.", + "contributors": [ + "lucassartor", + "web-flow" + ] + }, + { + "pr": "20509", + "title": "[IMPROVE] Autofocus on directory", + "userLogin": "MartinSchoeler", + "contributors": [ + "MartinSchoeler", + "web-flow" + ] + }, + { + "pr": "20510", + "title": "Update \"Industry\" setting", + "userLogin": "gabriellsh", + "contributors": [ + "gabriellsh" + ] + }, + { + "pr": "20495", + "title": "Regression: Fix duplicate email messages in multiple instances", + "userLogin": "renatobecker", + "milestone": "3.11.0", + "contributors": [ + "ggazzo", + "web-flow", + "renatobecker" + ] + } + ] + }, + "3.11.0-rc.5": { + "node_version": "12.18.4", + "npm_version": "6.14.8", + "apps_engine_version": "1.22.0-alpha.4545", + "mongo_versions": [ + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [ + { + "pr": "20517", + "title": "Regression: Fix banners sync data types", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "20433", + "title": "Regression: Fixed update room avatar issue.", + "userLogin": "Darshilp326", + "description": "Users can now update their room avatar without any error.\r\n\r\nhttps://user-images.githubusercontent.com/55157259/105951602-560d3880-6096-11eb-97a5-b5eb9a28b58d.mp4", + "milestone": "3.11.0", + "contributors": [ + "Darshilp326", + "d-gubert", + "web-flow" + ] + }, + { + "pr": "20434", + "title": "Regression: ESLint Warning - explicit-function-return-type", + "userLogin": "aditya-mitra", + "description": "Added explicit Return Type (Promise) on the function to fix eslint warning (`explicit-function-return-type`)", + "contributors": [ + "aditya-mitra", + "web-flow" + ] + } + ] + }, + "3.11.0-rc.6": { + "node_version": "12.18.4", + "npm_version": "6.14.8", + "apps_engine_version": "1.22.1", + "mongo_versions": [ + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [ + { + "pr": "20531", + "title": "Regression: Set image sizes based on rotation", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego", + "ggazzo" + ] + }, + { + "pr": "20523", + "title": "Regression: Apps-Engine - Convert streams to buffers on file upload", + "userLogin": "d-gubert", + "description": "This is an implementation to accommodate the changes in API for the `IPreFileUpload` hook in the Apps-Engine. Explanation on the reasoning for it is here https://github.com/RocketChat/Rocket.Chat.Apps-engine/pull/376", + "milestone": "3.11.0", + "contributors": [ + "d-gubert", + "sampaiodiego" + ] + }, + { + "pr": "20516", + "title": "Regression: Room not scrolling to bottom", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo", + "d-gubert", + "web-flow" + ] + }, + { + "pr": "20514", + "title": "Regression: NPS", + "userLogin": "tassoevan", + "contributors": [ + "tassoevan", + "ggazzo", + "sampaiodiego" + ] + }, + { + "pr": "20511", + "title": "Regression: Fix e2e paused state", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "20393", + "title": "Regression: Custom field labels are not displayed properly on Omnichannel Contact Profile form", + "userLogin": "rafaelblink", + "description": "### Before\r\n![image](https://user-images.githubusercontent.com/2493803/105780399-20116c80-5f4f-11eb-9620-0901472e453b.png)\r\n\r\n![image](https://user-images.githubusercontent.com/2493803/105780420-2e5f8880-5f4f-11eb-8e93-8115ebc685be.png)\r\n\r\n### After\r\n\r\n![image](https://user-images.githubusercontent.com/2493803/105780832-1ccab080-5f50-11eb-8042-188dd0c41904.png)\r\n\r\n![image](https://user-images.githubusercontent.com/2493803/105780911-500d3f80-5f50-11eb-96e0-7df3f179dbd5.png)", + "milestone": "3.11.0", + "contributors": [ + "rafaelblink", + "renatobecker", + "web-flow" + ] + } + ] + }, + "3.11.0-rc.7": { + "node_version": "12.18.4", + "npm_version": "6.14.8", + "apps_engine_version": "1.22.1", + "mongo_versions": [ + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [] + }, + "3.11.0": { + "node_version": "12.18.4", + "npm_version": "6.14.8", + "apps_engine_version": "1.22.1", + "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 272c40ac57d51..27c376f35f62c 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -31,7 +31,7 @@ jobs: cat $GITHUB_EVENT_PATH - name: Use Node.js 12.18.4 - uses: actions/setup-node@v1 + uses: actions/setup-node@v2 with: node-version: "12.18.4" @@ -51,7 +51,7 @@ jobs: - name: Cache cypress id: cache-cypress - uses: actions/cache@v1 + uses: actions/cache@v2 with: path: /home/runner/.cache/Cypress key: ${{ runner.OS }}-cache-cypress-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('.github/workflows/build_and_test.yml') }} @@ -59,19 +59,19 @@ jobs: - name: Cache node modules if: steps.cache-cypress.outputs.cache-hit == 'true' id: cache-nodemodules - uses: actions/cache@v1 + uses: actions/cache@v2 with: path: node_modules key: ${{ runner.OS }}-node_modules-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('.github/workflows/build_and_test.yml') }} - name: Cache meteor local - uses: actions/cache@v1 + uses: actions/cache@v2 with: path: ./.meteor/local key: ${{ runner.OS }}-meteor_cache-${{ hashFiles('.meteor/versions') }}-${{ hashFiles('.github/workflows/build_and_test.yml') }} - name: Cache meteor - uses: actions/cache@v1 + uses: actions/cache@v2 with: path: ~/.meteor key: ${{ runner.OS }}-meteor-${{ hashFiles('.meteor/release') }}-${{ hashFiles('.github/workflows/build_and_test.yml') }} @@ -160,13 +160,13 @@ jobs: tar czf Rocket.Chat.test.tar.gz ./build-test - name: Store build for tests - uses: actions/upload-artifact@v1 + uses: actions/upload-artifact@v2 with: name: build-test path: /tmp/Rocket.Chat.test.tar.gz - name: Store build - uses: actions/upload-artifact@v1 + uses: actions/upload-artifact@v2 with: name: build path: /tmp/build @@ -187,7 +187,7 @@ jobs: mongoDBVersion: ${{ matrix.mongodb-version }} --noprealloc --smallfiles --replSet=rs0 - name: Restore build for tests - uses: actions/download-artifact@v1 + uses: actions/download-artifact@v2 with: name: build-test path: /tmp @@ -199,7 +199,7 @@ jobs: cd - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 + uses: actions/setup-node@v2 with: node-version: ${{ matrix.node-version }} @@ -216,7 +216,7 @@ jobs: - name: Cache cypress id: cache-cypress - uses: actions/cache@v1 + uses: actions/cache@v2 with: path: /home/runner/.cache/Cypress key: ${{ runner.OS }}-cache-cypress-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('.github/workflows/build_and_test.yml') }} @@ -224,7 +224,7 @@ jobs: - name: Cache node modules if: steps.cache-cypress.outputs.cache-hit == 'true' id: cache-nodemodules - uses: actions/cache@v1 + uses: actions/cache@v2 with: path: node_modules key: ${{ runner.OS }}-build-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('.github/workflows/build_and_test.yml') }} @@ -282,25 +282,25 @@ jobs: - name: Cache node modules id: cache-nodemodules - uses: actions/cache@v1 + uses: actions/cache@v2 with: path: node_modules key: ${{ runner.OS }}-node_modules-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('.github/workflows/build_and_test.yml') }} - name: Cache meteor local - uses: actions/cache@v1 + uses: actions/cache@v2 with: path: ./.meteor/local key: ${{ runner.OS }}-meteor_cache-${{ hashFiles('.meteor/versions') }}-${{ hashFiles('.github/workflows/build_and_test.yml') }} - name: Cache meteor - uses: actions/cache@v1 + uses: actions/cache@v2 with: path: ~/.meteor key: ${{ runner.OS }}-meteor-${{ hashFiles('.meteor/release') }}-${{ hashFiles('.github/workflows/build_and_test.yml') }} - name: Use Node.js 12.18.4 - uses: actions/setup-node@v1 + uses: actions/setup-node@v2 with: node-version: "12.18.4" @@ -331,7 +331,6 @@ jobs: meteor npm --versions meteor node -v git version - echo $GITHUB_REF - name: npm install if: steps.cache-nodemodules.outputs.cache-hit != 'true' @@ -373,7 +372,7 @@ jobs: - uses: actions/checkout@v2 - name: Restore build - uses: actions/download-artifact@v1 + uses: actions/download-artifact@v2 with: name: build path: /tmp/build @@ -388,28 +387,58 @@ jobs: UPDATE_TOKEN: ${{ secrets.UPDATE_TOKEN }} run: | if [[ '${{ github.event_name }}' = 'release' ]]; then - export CIRCLE_TAG="${GITHUB_REF#*tags/}" - export CIRCLE_BRANCH="" + GIT_TAG="${GITHUB_REF#*tags/}" + GIT_BRANCH="" + ARTIFACT_NAME="$(npm run version --silent)" + RC_VERSION=$GIT_TAG + + if [[ $GIT_TAG =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+ ]]; then + SNAP_CHANNEL=candidate + RC_RELEASE=candidate + elif [[ $GIT_TAG =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + SNAP_CHANNEL=stable + RC_RELEASE=stable + fi else - export CIRCLE_TAG="" - export CIRCLE_BRANCH="${GITHUB_REF#*heads/}" + GIT_TAG="" + GIT_BRANCH="${GITHUB_REF#*heads/}" + ARTIFACT_NAME="$(npm run version --silent).$GITHUB_SHA" + RC_VERSION="$(npm run version --silent)" + SNAP_CHANNEL=edge + RC_RELEASE=develop fi; + ROCKET_DEPLOY_DIR="/tmp/deploy" + FILENAME="$ROCKET_DEPLOY_DIR/rocket.chat-$ARTIFACT_NAME.tgz"; - export CIRCLE_SHA1=$GITHUB_SHA - export CIRCLE_BUILD_NUM=$GITHUB_SHA + aws s3 cp s3://rocketchat/sign.key.gpg .github/sign.key.gpg - aws s3 cp s3://rocketchat/sign.key.gpg .circleci/sign.key.gpg + mkdir -p $ROCKET_DEPLOY_DIR - source .circleci/setartname.sh - source .circleci/setdeploydir.sh - bash .circleci/setupsig.sh - bash .circleci/namefiles.sh + cp .github/sign.key.gpg /tmp + gpg --yes --batch --passphrase=$GPG_PASSWORD /tmp/sign.key.gpg + gpg --allow-secret-key-import --import /tmp/sign.key + rm /tmp/sign.key + + ln -s /tmp/build/Rocket.Chat.tar.gz "$FILENAME" + gpg --armor --detach-sign "$FILENAME" aws s3 cp $ROCKET_DEPLOY_DIR/ s3://download.rocket.chat/build/ --recursive - bash .circleci/update-releases.sh - # bash .circleci/snap.sh - bash .circleci/redhat-registry.sh + curl -H "Content-Type: application/json" -H "X-Update-Token: $UPDATE_TOKEN" -d \ + "{\"commit\": \"$GITHUB_SHA\", \"tag\": \"$RC_VERSION\", \"branch\": \"$GIT_BRANCH\", \"artifactName\": \"$ARTIFACT_NAME\", \"releaseType\": \"$RC_RELEASE\" }" \ + https://releases.rocket.chat/update + + # Makes build fail if the release isn't there + curl --fail https://releases.rocket.chat/$RC_VERSION/info + + if [[ $GIT_TAG ]]; then + curl -X POST \ + https://connect.redhat.com/api/v2/projects/$REDHAT_REGISTRY_PID/build \ + -H "Authorization: Bearer $REDHAT_REGISTRY_KEY" \ + -H 'Cache-Control: no-cache' \ + -H 'Content-Type: application/json' \ + -d '{"tag":"'$GIT_TAG'"}' + fi image-build: runs-on: ubuntu-latest @@ -432,7 +461,7 @@ jobs: password: ${{ secrets.DOCKER_PASS }} - name: Restore build - uses: actions/download-artifact@v1 + uses: actions/download-artifact@v2 with: name: build path: /tmp/build @@ -458,22 +487,22 @@ jobs: if: github.event_name == 'release' run: | cd /tmp/build - CIRCLE_TAG="${GITHUB_REF#*tags/}" + GIT_TAG="${GITHUB_REF#*tags/}" if [[ '${{ matrix.release }}' = 'preview' ]]; then IMAGE="${IMAGE}.preview" fi; - docker build -t ${IMAGE}:$CIRCLE_TAG . - docker push ${IMAGE}:$CIRCLE_TAG + docker build -t ${IMAGE}:$GIT_TAG . + docker push ${IMAGE}:$GIT_TAG - if echo "$CIRCLE_TAG" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' ; then + if echo "$GIT_TAG" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' ; then RELEASE="latest" - elif echo "$CIRCLE_TAG" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$' ; then + elif echo "$GIT_TAG" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$' ; then RELEASE="release-candidate" fi - docker tag ${IMAGE}:$CIRCLE_TAG ${IMAGE}:${RELEASE} + docker tag ${IMAGE}:$GIT_TAG ${IMAGE}:${RELEASE} docker push ${IMAGE}:${RELEASE} - name: Build Docker image for develop @@ -500,7 +529,7 @@ jobs: - uses: actions/checkout@v2 - name: Use Node.js 12.18.4 - uses: actions/setup-node@v1 + uses: actions/setup-node@v2 with: node-version: "12.18.4" @@ -513,7 +542,11 @@ jobs: - name: Build Docker images run: | # defines image tag - if [[ $GITHUB_REF == refs/tags/* ]]; then IMAGE_TAG="${GITHUB_REF#refs/tags/}"; else IMAGE_TAG="${GITHUB_REF#refs/heads/}"; fi + if [[ $GITHUB_REF == refs/tags/* ]]; then + IMAGE_TAG="${GITHUB_REF#refs/tags/}" + else + IMAGE_TAG="${GITHUB_REF#refs/heads/}" + fi # first install repo dependencies npm i diff --git a/.scripts/check-i18n.js b/.scripts/check-i18n.js index 63ce67c15a5e7..68da0fbe91a70 100644 --- a/.scripts/check-i18n.js +++ b/.scripts/check-i18n.js @@ -1,92 +1,111 @@ const fs = require('fs'); +const path = require('path'); const fg = require('fast-glob'); -const checkFiles = async (path, source, fix = false) => { - const sourceFile = JSON.parse(fs.readFileSync(`${ path }${ source }`, 'utf8')); +const regexVar = /__[a-zA-Z_]+__/g; - const regexVar = /__[a-zA-Z_]+__/g; +const validateKeys = (json, usedKeys) => + usedKeys + .filter(({ key }) => typeof json[key] !== 'undefined') + .reduce((prev, cur) => { + const { key, replaces } = cur; - const usedKeys = Object.entries(sourceFile) - .map(([key, value]) => { - const replaces = value.match(regexVar); - return { - key, - replaces, - }; - }) - .filter(({ replaces }) => !!replaces); + const miss = replaces.filter((replace) => json[key] && json[key].indexOf(replace) === -1); - const validateKeys = (json) => - usedKeys - .filter(({ key }) => typeof json[key] !== 'undefined') - .reduce((prev, cur) => { - const { key, replaces } = cur; + if (miss.length > 0) { + prev.push({ key, miss }); + } - const miss = replaces.filter((replace) => json[key] && json[key].indexOf(replace) === -1); + return prev; + }, []); - if (miss.length > 0) { - prev.push({ key, miss }); - } +const removeMissingKeys = (i18nFiles, usedKeys) => { + i18nFiles.forEach((file) => { + const json = JSON.parse(fs.readFileSync(file, 'utf8')); + if (Object.keys(json).length === 0) { + return; + } - return prev; - }, []); + validateKeys(json, usedKeys) + .forEach(({ key }) => { + json[key] = null; + }); - const i18nFiles = await fg([`${ path }/**/*.i18n.json`]); + fs.writeFileSync(file, JSON.stringify(json, null, 2)); + }); +}; - const removeMissingKeys = () => { - i18nFiles.forEach((file) => { - const json = JSON.parse(fs.readFileSync(file, 'utf8')); - if (Object.keys(json).length === 0) { - return; - } +const checkUniqueKeys = (content, json, filename) => { + const matchKeys = content.matchAll(/^\s+"([^"]+)"/mg); - validateKeys(json) - .forEach(({ key }) => { - json[key] = null; - }); + const allKeys = [...matchKeys]; - fs.writeFileSync(file, JSON.stringify(json, null, 2)); - }); - }; + if (allKeys.length !== Object.keys(json).length) { + throw new Error(`Duplicated keys found on file ${ filename }`); + } +}; + +const validate = (i18nFiles, usedKeys) => { + const totalErrors = i18nFiles + .reduce((errors, file) => { + const content = fs.readFileSync(file, 'utf8'); + const json = JSON.parse(content); + + checkUniqueKeys(content, json, file); - const validate = () => { - let totalErrors = 0; - i18nFiles.filter((file) => { - const json = JSON.parse(fs.readFileSync(file, 'utf8')); + // console.log('json, usedKeys2', json, usedKeys); - const result = validateKeys(json); + const result = validateKeys(json, usedKeys); if (result.length === 0) { - return true; + return errors; } - totalErrors += result.length; - console.log('\n## File', file, `(${ result.length } errors)`); result.forEach(({ key, miss }) => { console.log('\n- Key:', key, '\n Missing variables:', miss.join(', ')); }); - return false; + return errors + result.length; + }, 0); + + if (totalErrors > 0) { + throw new Error(`\n${ totalErrors } errors found`); + } +}; + +const checkFiles = async (sourcePath, sourceFile, fix = false) => { + const content = fs.readFileSync(path.join(sourcePath, sourceFile), 'utf8'); + const sourceContent = JSON.parse(content); + + checkUniqueKeys(content, sourceContent, sourceFile); + + const usedKeys = Object.entries(sourceContent) + .map(([key, value]) => { + const replaces = value.match(regexVar); + return { + key, + replaces, + }; }); - if (totalErrors > 0) { - throw new Error(`\n${ totalErrors } errors found`); - } - }; + const keysWithInterpolation = usedKeys + .filter(({ replaces }) => !!replaces); + + const i18nFiles = await fg([`${ sourcePath }/**/*.i18n.json`]); if (fix) { - return removeMissingKeys(); + return removeMissingKeys(i18nFiles, keysWithInterpolation); } - validate(); + validate(i18nFiles, keysWithInterpolation); }; (async () => { try { - await checkFiles('./packages/rocketchat-i18n', '/i18n/en.i18n.json', process.argv[2] === '--fix'); + await checkFiles('./packages/rocketchat-i18n/i18n', 'en.i18n.json', process.argv[2] === '--fix'); } catch (e) { console.error(e); process.exit(1); diff --git a/.snapcraft/resources/prepareRocketChat b/.snapcraft/resources/prepareRocketChat index 025043c3eefe5..6976db3231418 100755 --- a/.snapcraft/resources/prepareRocketChat +++ b/.snapcraft/resources/prepareRocketChat @@ -1,6 +1,6 @@ #!/bin/bash -curl -SLf "https://releases.rocket.chat/3.10.5/download/" -o rocket.chat.tgz +curl -SLf "https://releases.rocket.chat/3.11.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 66bd42c6c69e8..d5421e734e9d5 100644 --- a/.snapcraft/snap/snapcraft.yaml +++ b/.snapcraft/snap/snapcraft.yaml @@ -7,7 +7,7 @@ # 5. `snapcraft snap` name: rocketchat-server -version: 3.10.5 +version: 3.11.0 summary: Rocket.Chat server description: Have your own Slack like online chat, built with Meteor. https://rocket.chat/ confinement: strict diff --git a/.storybook/logo.svg b/.storybook/logo.svg new file mode 100644 index 0000000000000..6ae18fa4b93eb --- /dev/null +++ b/.storybook/logo.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + diff --git a/.storybook/main.js b/.storybook/main.js index c1730b0e818e5..ecfbbfab389ff 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -5,8 +5,6 @@ module.exports = { '../ee/**/*.stories.js', ], addons: [ - '@storybook/addon-actions', - '@storybook/addon-knobs', - '@storybook/addon-viewport', + '@storybook/addon-essentials', ], }; diff --git a/.storybook/manager.js b/.storybook/manager.js new file mode 100644 index 0000000000000..6e9aadd1ffd34 --- /dev/null +++ b/.storybook/manager.js @@ -0,0 +1,17 @@ +import colorTokens from '@rocket.chat/fuselage-tokens/colors'; +import { addons } from '@storybook/addons'; +import { create } from '@storybook/theming/create'; + +import manifest from '../package.json'; +import logo from './logo.svg'; + +addons.setConfig({ + theme: create({ + base: 'light', + brandTitle: manifest.name, + brandImage: logo, + brandUrl: manifest.homepage, + colorPrimary: colorTokens.n500, + colorSecondary: colorTokens.b500, + }), +}); diff --git a/.storybook/preview.js b/.storybook/preview.js index 39bdd05fd4839..b82a2b1e56b70 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -1,13 +1,24 @@ -import { withKnobs } from '@storybook/addon-knobs'; +import { DocsPage, DocsContainer } from '@storybook/addon-docs/blocks'; import { addDecorator, addParameters } from '@storybook/react'; import { rocketChatDecorator } from './decorators'; addDecorator(rocketChatDecorator); -addDecorator(withKnobs); addParameters({ + backgrounds: { + grid: { + cellSize: 4, + cellAmount: 4, + opacity: 0.5, + }, + }, + docs: { + container: DocsContainer, + page: DocsPage, + }, options: { - showRoots: true, + storySort: ([, a], [, b]) => + a.kind.localeCompare(b.kind), }, }); diff --git a/HISTORY.md b/HISTORY.md index 938d7fd19b656..fde576deab134 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,6 +1,505 @@ +# 3.11.0 +`2021-01-31 · 8 🎉 · 9 🚀 · 52 🐛 · 44 🔍 · 32 👩‍💻👨‍💻` + +### Engine versions +- Node: `12.18.4` +- NPM: `6.14.8` +- MongoDB: `3.4, 3.6, 4.0` +- Apps-Engine: `1.22.1` + +### 🎉 New features + + +- **Apps:** Apps Permission System ([#20078](https://github.com/RocketChat/Rocket.Chat/pull/20078)) + +- **Apps:** IPreFileUpload event ([#20285](https://github.com/RocketChat/Rocket.Chat/pull/20285)) + +- **ENTERPRISE:** Automatic transfer of unanswered conversations to another agent ([#20090](https://github.com/RocketChat/Rocket.Chat/pull/20090)) + +- **ENTERPRISE:** Omnichannel Contact Manager as preferred agent for routing ([#20244](https://github.com/RocketChat/Rocket.Chat/pull/20244)) + + If the `Contact-Manager` is assigned to a Visitor, the chat will automatically get transferred to the respective Contact-Manager, provided the Contact-Manager is online. In-case the Contact-Manager is offline, the chat will be transferred to any other online agent. + We have provided a setting to control this auto-assignment feature + ![image](https://user-images.githubusercontent.com/34130764/104880961-8104d780-5986-11eb-9d87-82b99814b028.png) + + Behavior based-on Routing method + + 1. Auto-selection, Load-Balancing, or External Service (`autoAssignAgent = true`) + This is straightforward, + - if the Contact-manager is online, the chat will be transferred to the Contact-Manger only + - if the Contact-manager is offline, the chat will be transferred to any other online-agent based on the Routing system + 2. Manual-selection (`autoAssignAgent = false`) + - If the Contact-Manager is online, the chat will appear in the Queue of Contact-Manager **ONLY** + - If the Contact-Manager is offline, the chat will appear in the Queue of all related Agents/Manager ( like it's done right now ) + +- Banner system and NPS ([#20221](https://github.com/RocketChat/Rocket.Chat/pull/20221)) + + More robust and scalable banner system for alerting users. + +- Email Inboxes for Omnichannel ([#20101](https://github.com/RocketChat/Rocket.Chat/pull/20101)) + + With this new feature, email accounts will receive email messages(threads) which will be transformed into Omnichannel chats. It'll be possible to set up multiple email accounts, test the connection with email server(email provider) and define the behaviour of each account. + + https://user-images.githubusercontent.com/2493803/105430398-242d4980-5c32-11eb-835a-450c94837d23.mp4 + + ### New item on admin menu + + ![image](https://user-images.githubusercontent.com/2493803/105428723-bc293400-5c2e-11eb-8c02-e8d36ea82726.png) + + + ### Send test email tooltip + + ![image](https://user-images.githubusercontent.com/2493803/104366986-eaa16380-54f8-11eb-9ba7-831cfde2319c.png) + + + ### Inbox Info + + ![image](https://user-images.githubusercontent.com/2493803/104366796-ab731280-54f8-11eb-9941-a3cc8eb610e1.png) + + ### SMTP Info + + ![image](https://user-images.githubusercontent.com/2493803/104366868-c47bc380-54f8-11eb-969e-ccc29070957c.png) + + ### IMAP Info + + ![image](https://user-images.githubusercontent.com/2493803/104366897-cd6c9500-54f8-11eb-80c4-97d5b0c002d5.png) + + ### Messages + + ![image](https://user-images.githubusercontent.com/2493803/105428971-45d90180-5c2f-11eb-992a-022a3df94471.png) + +- Encrypted Discussions and new Encryption Permissions ([#20201](https://github.com/RocketChat/Rocket.Chat/pull/20201)) + +- Server Info page ([#19517](https://github.com/RocketChat/Rocket.Chat/pull/19517)) + +### 🚀 Improvements + + +- Add extra SAML settings to update room subs and add private room subs. ([#19489](https://github.com/RocketChat/Rocket.Chat/pull/19489) by [@tlskinneriv](https://github.com/tlskinneriv)) + + Added a SAML setting to support updating room subscriptions each time a user logs in via SAML. + Added a SAML setting to support including private rooms in SAML updated subscriptions (whether initial or on each logon). + +- Autofocus on directory ([#20509](https://github.com/RocketChat/Rocket.Chat/pull/20509)) + +- Don't use global search by default ([#19777](https://github.com/RocketChat/Rocket.Chat/pull/19777) by [@i-kychukov](https://github.com/i-kychukov) & [@ikyuchukov](https://github.com/ikyuchukov)) + + Global chat search is not set by default now. + +- Message Collection Hooks ([#20121](https://github.com/RocketChat/Rocket.Chat/pull/20121)) + + Integrating a list of messages into a React component imposes some challenges. Its content is provided by some REST API calls and live-updated by streamer events. To avoid too much coupling with React Hooks, the structures `RecordList`, `MessageList` and their derivatives are simple event emitters created and connected on components via some simple hooks, like `useThreadsList()` and `useRecordList()`. + +- Rewrite Announcement as React component ([#20172](https://github.com/RocketChat/Rocket.Chat/pull/20172)) + +- Rewrite Prune Messages as React component ([#19900](https://github.com/RocketChat/Rocket.Chat/pull/19900)) + +- Rewrite User Dropdown and Kebab menu. ([#20070](https://github.com/RocketChat/Rocket.Chat/pull/20070)) + + ![image](https://user-images.githubusercontent.com/40830821/103699786-3a74ad80-4f82-11eb-913e-2e09d5f7eac6.png) + +- Title for user avatar buttons ([#20083](https://github.com/RocketChat/Rocket.Chat/pull/20083) by [@sushant52](https://github.com/sushant52)) + + Made user avatar change buttons to be descriptive of what they do. + +- Tooltip added for Kebab menu on chat header ([#20116](https://github.com/RocketChat/Rocket.Chat/pull/20116) by [@yash-rajpal](https://github.com/yash-rajpal)) + + Added the missing Tooltip for kebab menu on chat header. + ![tooltip after](https://user-images.githubusercontent.com/58601732/104031406-b07f4b80-51f2-11eb-87a4-1e8da78a254f.gif) + +### 🐛 Bug fixes + + +- "Open_thread" English tooltip correction ([#20164](https://github.com/RocketChat/Rocket.Chat/pull/20164) by [@aKn1ghtOut](https://github.com/aKn1ghtOut)) + + Remove unnecessary spaces from the translation key, and added English translation value for the key. + +- **Apps:** Don't show the "review permissions" modal when there's none to review ([#20506](https://github.com/RocketChat/Rocket.Chat/pull/20506)) + +- **ENTERPRISE:** Auditing RoomAutocomplete ([#20311](https://github.com/RocketChat/Rocket.Chat/pull/20311)) + +- **ENTERPRISE:** Omnichannel custom fields not storing additional form values ([#19953](https://github.com/RocketChat/Rocket.Chat/pull/19953)) + +- Actions from User Info panel ([#20073](https://github.com/RocketChat/Rocket.Chat/pull/20073) by [@Darshilp326](https://github.com/Darshilp326)) + + Users can be removed from channels without any error message. + +- Added context check for closing active tabbar for member-list ([#20228](https://github.com/RocketChat/Rocket.Chat/pull/20228) by [@yash-rajpal](https://github.com/yash-rajpal)) + + When we click on a username and then click on see user's full profile, a tab gets active and shows us the user's profile, the problem occurs when the tab is still active and we try to see another user's profile. In this case, tabbar gets closed. + To resolve this, added context check for closing action of active tabbar. + +- Added Margin between status bullet and status label ([#20199](https://github.com/RocketChat/Rocket.Chat/pull/20199) by [@yash-rajpal](https://github.com/yash-rajpal)) + + Added Margins between status bullet and status label + +- Added success message on saving notification preference. ([#20220](https://github.com/RocketChat/Rocket.Chat/pull/20220) by [@Darshilp326](https://github.com/Darshilp326)) + + Added success message after saving notification preferences. + + https://user-images.githubusercontent.com/55157259/104774617-03ca3e80-579d-11eb-8fa4-990b108dd8d9.mp4 + +- Admin User Info email verified status ([#20110](https://github.com/RocketChat/Rocket.Chat/pull/20110) by [@bdelwood](https://github.com/bdelwood)) + +- Agent information panel not rendering ([#19965](https://github.com/RocketChat/Rocket.Chat/pull/19965)) + +- Change header's favorite icon to filled star ([#20174](https://github.com/RocketChat/Rocket.Chat/pull/20174)) + + ### Before: + ![image](https://user-images.githubusercontent.com/27704687/104351819-a60bcd00-54e4-11eb-8b43-7d281a6e5dcb.png) + + ### After: + ![image](https://user-images.githubusercontent.com/27704687/104351632-67761280-54e4-11eb-87ba-25b940494bb5.png) + +- Changed success message for adding custom sound. ([#20272](https://github.com/RocketChat/Rocket.Chat/pull/20272) by [@Darshilp326](https://github.com/Darshilp326)) + + https://user-images.githubusercontent.com/55157259/105151351-daf2d200-5b2b-11eb-8223-eae5d60f770d.mp4 + +- Changed success message for ignoring member. ([#19996](https://github.com/RocketChat/Rocket.Chat/pull/19996) by [@Darshilp326](https://github.com/Darshilp326)) + + Different messages for ignoring/unignoring will be displayed. + + https://user-images.githubusercontent.com/55157259/103310307-4241c880-4a3d-11eb-8c6c-4c9b99d023db.mp4 + +- Creation of Omnichannel rooms not working correctly through the Apps when the agent parameter is set ([#19997](https://github.com/RocketChat/Rocket.Chat/pull/19997)) + +- Engagement dashboard graphs labels superposing each other ([#20267](https://github.com/RocketChat/Rocket.Chat/pull/20267)) + + Now after a certain breakpoint, the graphs should stack vertically, and overlapping text rotated. + + ![image](https://user-images.githubusercontent.com/40830821/105098926-93b40500-5a89-11eb-9a56-2fc3b1552914.png) + +- Fields overflowing page ([#20287](https://github.com/RocketChat/Rocket.Chat/pull/20287)) + + ### Before + ![image](https://user-images.githubusercontent.com/40830821/105246952-c1b14c00-5b52-11eb-8671-cff88edf242d.png) + + ### After + ![image](https://user-images.githubusercontent.com/40830821/105247125-0a690500-5b53-11eb-9f3c-d6a68108e336.png) + +- Fix error that occurs on changing archive status of room ([#20098](https://github.com/RocketChat/Rocket.Chat/pull/20098) by [@aKn1ghtOut](https://github.com/aKn1ghtOut)) + + This PR fixes an issue that happens when you try to edit the info of a room, and save changes after changing the value of "Archived". The archive functionality is handled separately from other room settings. The archived key is not used in the saveRoomSettings method but was still being sent over. Hence, the request was being considered invalid. I deleted the "archived" key from the data being sent in the request, making the request valid again. + +- Incorrect translations ZN ([#20245](https://github.com/RocketChat/Rocket.Chat/pull/20245) by [@moniang](https://github.com/moniang)) + +- Initial values update on Account Preferences ([#19938](https://github.com/RocketChat/Rocket.Chat/pull/19938)) + +- Invalid filters on the Omnichannel Analytics page ([#19899](https://github.com/RocketChat/Rocket.Chat/pull/19899)) + +- Jump to message ([#20265](https://github.com/RocketChat/Rocket.Chat/pull/20265)) + +- Livechat.RegisterGuest method removing unset fields ([#20124](https://github.com/RocketChat/Rocket.Chat/pull/20124)) + + After changes made on https://github.com/RocketChat/Rocket.Chat/pull/19931, the `Livechat.RegisterGuest` method started removing properties from the visitor inappropriately. The properties that did not receive value were removed from the object. + Those changes were made to support the new Contact Form, but now the form has its own method to deal with Contact data so those changes are no longer necessary. + +- Markdown added for Header Room topic ([#20021](https://github.com/RocketChat/Rocket.Chat/pull/20021) by [@yash-rajpal](https://github.com/yash-rajpal)) + + With the new 3.10.0 version update the Links in topic section below room name were not working, for more info refer issue #20018 + +- Messages being updated when not required after user changes his profile ([#20114](https://github.com/RocketChat/Rocket.Chat/pull/20114)) + +- Meteor errors not translating for toast messages ([#19993](https://github.com/RocketChat/Rocket.Chat/pull/19993)) + +- minWidth in FileIcon to prevent layout to broke ([#19942](https://github.com/RocketChat/Rocket.Chat/pull/19942)) + + ![image](https://user-images.githubusercontent.com/27704687/102934691-69b7f480-4483-11eb-995b-a8a9b72246aa.png) + +- Normalize messages for users in endpoint chat.getStarredMessages ([#19962](https://github.com/RocketChat/Rocket.Chat/pull/19962)) + +- OAuth users being asked to change password on second login ([#20003](https://github.com/RocketChat/Rocket.Chat/pull/20003)) + +- Omnichannel - Contact Center form is not validating custom fields properly ([#20196](https://github.com/RocketChat/Rocket.Chat/pull/20196)) + + The contact form is accepting undefined values in required custom fields when creating or editing contacts, and, the errror message isn't following Rocket.chat design system. + + ### Before + ![image](https://user-images.githubusercontent.com/2493803/104522668-31688980-55dd-11eb-92c5-83f96073edc4.png) + + ### After + + #### New + ![image](https://user-images.githubusercontent.com/2493803/104770494-68f74300-574f-11eb-94a3-c8fd73365308.png) + + + #### Edit + ![image](https://user-images.githubusercontent.com/2493803/104770538-7b717c80-574f-11eb-829f-1ae304103369.png) + +- Omnichannel Agents unable to take new chats in the queue ([#20022](https://github.com/RocketChat/Rocket.Chat/pull/20022)) + +- Omnichannel Business Hours form is not being rendered ([#20007](https://github.com/RocketChat/Rocket.Chat/pull/20007)) + +- Omnichannel raw model importing meteor dependency ([#20093](https://github.com/RocketChat/Rocket.Chat/pull/20093)) + +- Omnichannel rooms breaking after return to queue or forward ([#20089](https://github.com/RocketChat/Rocket.Chat/pull/20089)) + +- Profile picture changing with username ([#19992](https://github.com/RocketChat/Rocket.Chat/pull/19992)) + + ![bug avatar](https://user-images.githubusercontent.com/40830821/103305935-24e40e80-49eb-11eb-9e35-9bd4c167898a.gif) + +- Remove duplicate blaze events call for EmojiActions from roomOld ([#20159](https://github.com/RocketChat/Rocket.Chat/pull/20159) by [@aKn1ghtOut](https://github.com/aKn1ghtOut)) + + A few methods concerning Emojis are bound multiple times to the DOM using the Template events() call, once in the reactions init.js and the other time after they get exported from app/ui/client/views/app/lib/getCommonRoomEvents.js to whatever page binds all the functions. The getCommonRoomEvents methods are always bound, hence negating a need to bind in a lower-level component. + +- Room special name in prompts ([#20277](https://github.com/RocketChat/Rocket.Chat/pull/20277) by [@aKn1ghtOut](https://github.com/aKn1ghtOut)) + + The "Hide room" and "Leave Room" confirmation prompts use the "name" key from the room info. When the setting " + Allow Special Characters in Room Names" is enabled, the prompts show the normalized names instead of those that contain the special characters. + + Changed the value being used from name to fname, which always has the user-set name. + + Previous: + ![Screenshot from 2021-01-20 15-52-29](https://user-images.githubusercontent.com/38764067/105161642-9b31e780-5b37-11eb-8b0c-ec4b1414c948.png) + + Updated: + ![Screenshot from 2021-01-20 15-50-19](https://user-images.githubusercontent.com/38764067/105161627-966d3380-5b37-11eb-9812-3dd9352b4f95.png) + +- Room's list showing all rooms with same name ([#20176](https://github.com/RocketChat/Rocket.Chat/pull/20176)) + + Add a migration to fix the room's list for those who ran version 3.10.1 and got it scrambled when a new user was registered. + +- RoomManager validation broken on IE ([#20490](https://github.com/RocketChat/Rocket.Chat/pull/20490)) + +- Saving with blank email in edit user ([#20259](https://github.com/RocketChat/Rocket.Chat/pull/20259) by [@RonLek](https://github.com/RonLek)) + + Disallows showing a success popup when email field is made blank in Edit User and instead shows the relevant error popup. + + + https://user-images.githubusercontent.com/28918901/104960749-dbd81680-59fa-11eb-9c7b-2b257936f894.mp4 + +- Search list filter ([#19937](https://github.com/RocketChat/Rocket.Chat/pull/19937)) + +- Sidebar palette color broken on IE ([#20457](https://github.com/RocketChat/Rocket.Chat/pull/20457)) + + ![image](https://user-images.githubusercontent.com/27704687/106056093-0a29b600-60cd-11eb-8038-eabbc0d8fb03.png) + +- Status circle in profile section ([#20016](https://github.com/RocketChat/Rocket.Chat/pull/20016) by [@yash-rajpal](https://github.com/yash-rajpal)) + + The Status Circle in status message text input is now centered vertically. + +- Tabbar is opened ([#20122](https://github.com/RocketChat/Rocket.Chat/pull/20122)) + +- Translate keyword for 'Showing results of' in tables ([#20134](https://github.com/RocketChat/Rocket.Chat/pull/20134) by [@Karting06](https://github.com/Karting06)) + + Change translation keyword in order to allow the translation of `Showing results %s - %s of %s` in tables. + +- Unable to reset password by Email if upper case character is pr… ([#19643](https://github.com/RocketChat/Rocket.Chat/pull/19643) by [@bhavayAnand9](https://github.com/bhavayAnand9)) + +- User Audio notification preference not being applied ([#20061](https://github.com/RocketChat/Rocket.Chat/pull/20061)) + +- User info 'Full Name' translation keyword ([#20028](https://github.com/RocketChat/Rocket.Chat/pull/20028) by [@Karting06](https://github.com/Karting06)) + + Fix the `Full Name` translation keyword, so that it can be translated. + +- User registration updating wrong subscriptions ([#20128](https://github.com/RocketChat/Rocket.Chat/pull/20128)) + +- Video call message not translated ([#18722](https://github.com/RocketChat/Rocket.Chat/pull/18722)) + + Fixed video call message not translated. + +- ViewLogs title translation keyword ([#20029](https://github.com/RocketChat/Rocket.Chat/pull/20029) by [@Karting06](https://github.com/Karting06)) + + Fix `View Logs` title translation keyword to enable translation of the title + +- White screen after 2FA code entered ([#20225](https://github.com/RocketChat/Rocket.Chat/pull/20225) by [@wggdeveloper](https://github.com/wggdeveloper)) + +- Wrong userId when open own user profile ([#20181](https://github.com/RocketChat/Rocket.Chat/pull/20181)) + +
+🔍 Minor changes + + +- Add translation of Edit Status in all languages ([#19916](https://github.com/RocketChat/Rocket.Chat/pull/19916) by [@sushant52](https://github.com/sushant52)) + + Closes [#19915](https://github.com/RocketChat/Rocket.Chat/issues/19915) + The profile options menu is well translated in many languages. However, Edit Status is the only button which is not well translated. With this change, the whole profile options will be properly translated in a lot of languages. + +- Bump axios from 0.18.0 to 0.18.1 ([#20055](https://github.com/RocketChat/Rocket.Chat/pull/20055) by [@dependabot[bot]](https://github.com/dependabot[bot])) + +- Chore: Add tests for the api/licenses.* endpoints ([#20041](https://github.com/RocketChat/Rocket.Chat/pull/20041)) + + Adding api tests for the new `licenses.*` endpoints (`licenses.get` and `licenses.add`) + +- Chore: add tests to api/instances.get endpoint ([#19988](https://github.com/RocketChat/Rocket.Chat/pull/19988)) + +- Chore: Change console.warning() to console.warn() ([#20200](https://github.com/RocketChat/Rocket.Chat/pull/20200)) + +- chore: Change return button ([#20045](https://github.com/RocketChat/Rocket.Chat/pull/20045)) + +- Chore: Fix i18n duplicated keys ([#19998](https://github.com/RocketChat/Rocket.Chat/pull/19998)) + +- Chore: Recover and update Storybook ([#20047](https://github.com/RocketChat/Rocket.Chat/pull/20047)) + + It reenables Storybook's usage. + +- Language update from LingoHub 🤖 on 2020-12-30Z ([#20013](https://github.com/RocketChat/Rocket.Chat/pull/20013)) + +- Language update from LingoHub 🤖 on 2021-01-04Z ([#20034](https://github.com/RocketChat/Rocket.Chat/pull/20034)) + +- Language update from LingoHub 🤖 on 2021-01-11Z ([#20146](https://github.com/RocketChat/Rocket.Chat/pull/20146)) + +- Language update from LingoHub 🤖 on 2021-01-18Z ([#20246](https://github.com/RocketChat/Rocket.Chat/pull/20246)) + +- Regression: Add tests to new banners REST endpoints ([#20492](https://github.com/RocketChat/Rocket.Chat/pull/20492)) + + Add tests for the new `banners.*` endpoints: `banners.getNew` and `banners.dismiss`. + +- Regression: Announcement bar not showing properly Markdown content ([#20290](https://github.com/RocketChat/Rocket.Chat/pull/20290)) + + **Before**: + ![image](https://user-images.githubusercontent.com/27704687/105273746-a4907380-5b7a-11eb-8121-aff665251c44.png) + + **After**: + ![image](https://user-images.githubusercontent.com/27704687/105274050-2e404100-5b7b-11eb-93b2-b6282a7bed95.png) + +- regression: Announcement link open in new tab ([#20435](https://github.com/RocketChat/Rocket.Chat/pull/20435)) + +- Regression: Apps-Engine - Convert streams to buffers on file upload ([#20523](https://github.com/RocketChat/Rocket.Chat/pull/20523)) + + This is an implementation to accommodate the changes in API for the `IPreFileUpload` hook in the Apps-Engine. Explanation on the reasoning for it is here https://github.com/RocketChat/Rocket.Chat.Apps-engine/pull/376 + +- Regression: Attachments ([#20291](https://github.com/RocketChat/Rocket.Chat/pull/20291)) + +- Regression: Bio page not rendering ([#20450](https://github.com/RocketChat/Rocket.Chat/pull/20450)) + +- Regression: Change sort icon ([#20177](https://github.com/RocketChat/Rocket.Chat/pull/20177)) + + ### Before + ![image](https://user-images.githubusercontent.com/40830821/104366414-1bcd6400-54f8-11eb-9fc7-c6f13f07a61e.png) + + ### After + ![image](https://user-images.githubusercontent.com/40830821/104366542-4cad9900-54f8-11eb-83ca-acb99899515a.png) + +- Regression: Custom field labels are not displayed properly on Omnichannel Contact Profile form ([#20393](https://github.com/RocketChat/Rocket.Chat/pull/20393)) + + ### Before + ![image](https://user-images.githubusercontent.com/2493803/105780399-20116c80-5f4f-11eb-9620-0901472e453b.png) + + ![image](https://user-images.githubusercontent.com/2493803/105780420-2e5f8880-5f4f-11eb-8e93-8115ebc685be.png) + + ### After + + ![image](https://user-images.githubusercontent.com/2493803/105780832-1ccab080-5f50-11eb-8042-188dd0c41904.png) + + ![image](https://user-images.githubusercontent.com/2493803/105780911-500d3f80-5f50-11eb-96e0-7df3f179dbd5.png) + +- Regression: ESLint Warning - explicit-function-return-type ([#20434](https://github.com/RocketChat/Rocket.Chat/pull/20434) by [@aditya-mitra](https://github.com/aditya-mitra)) + + Added explicit Return Type (Promise) on the function to fix eslint warning (`explicit-function-return-type`) + +- Regression: Fix banners sync data types ([#20517](https://github.com/RocketChat/Rocket.Chat/pull/20517)) + +- Regression: Fix Cron statistics TypeError ([#20343](https://github.com/RocketChat/Rocket.Chat/pull/20343) by [@RonLek](https://github.com/RonLek)) + +- Regression: Fix duplicate email messages in multiple instances ([#20495](https://github.com/RocketChat/Rocket.Chat/pull/20495)) + +- Regression: Fix e2e paused state ([#20511](https://github.com/RocketChat/Rocket.Chat/pull/20511)) + +- Regression: Fixed update room avatar issue. ([#20433](https://github.com/RocketChat/Rocket.Chat/pull/20433) by [@Darshilp326](https://github.com/Darshilp326)) + + Users can now update their room avatar without any error. + + https://user-images.githubusercontent.com/55157259/105951602-560d3880-6096-11eb-97a5-b5eb9a28b58d.mp4 + +- Regression: Info Page Icon style and usage graph breaking ([#20180](https://github.com/RocketChat/Rocket.Chat/pull/20180)) + +- Regression: Lint warnings and some datepicker ([#20280](https://github.com/RocketChat/Rocket.Chat/pull/20280)) + +- Regression: NPS ([#20514](https://github.com/RocketChat/Rocket.Chat/pull/20514)) + +- Regression: reactAttachments cpu ([#20255](https://github.com/RocketChat/Rocket.Chat/pull/20255)) + +- Regression: Room not scrolling to bottom ([#20516](https://github.com/RocketChat/Rocket.Chat/pull/20516)) + +- Regression: Set image sizes based on rotation ([#20531](https://github.com/RocketChat/Rocket.Chat/pull/20531)) + +- Regression: Unread superposing announcement. ([#20306](https://github.com/RocketChat/Rocket.Chat/pull/20306)) + + ### Before + ![image](https://user-images.githubusercontent.com/40830821/105412619-c2f67d80-5c13-11eb-8204-5932ea880c8a.png) + + + ### After + ![image](https://user-images.githubusercontent.com/40830821/105411176-d1439a00-5c11-11eb-8d1b-ea27c8485214.png) + +- Regression: User Dropdown margin ([#20222](https://github.com/RocketChat/Rocket.Chat/pull/20222)) + +- Rewrite : Message Thread metrics ([#20051](https://github.com/RocketChat/Rocket.Chat/pull/20051)) + + ![image](https://user-images.githubusercontent.com/5263975/103585504-e904e980-4ec1-11eb-8d8c-3113ac812ead.png) + +- Rewrite Broadcast ([#20119](https://github.com/RocketChat/Rocket.Chat/pull/20119)) + + ![image](https://user-images.githubusercontent.com/5263975/104035912-7fcaf200-51b1-11eb-91df-228c23d97448.png) + +- Rewrite Discussion Metric ([#20117](https://github.com/RocketChat/Rocket.Chat/pull/20117)) + + https://user-images.githubusercontent.com/5263975/104031909-23190880-51ac-11eb-93dd-5d4b5295886d.mp4 + +- Rewrite Message action links ([#20123](https://github.com/RocketChat/Rocket.Chat/pull/20123)) + +- Rewrite: Message Attachments ([#20106](https://github.com/RocketChat/Rocket.Chat/pull/20106)) + + ![image](https://user-images.githubusercontent.com/5263975/104783709-69023d80-5765-11eb-968f-a2b93fdfb51e.png) + +- Security sync ([#20430](https://github.com/RocketChat/Rocket.Chat/pull/20430)) + +- Update "Industry" setting ([#20510](https://github.com/RocketChat/Rocket.Chat/pull/20510)) + +- Update Apps-Engine and permissions translations ([#20491](https://github.com/RocketChat/Rocket.Chat/pull/20491)) + + Update Apps-Engine version and apply changes in translations for the changed permissions. Please review the texts on the translation files to make sure they're clear. + +- Update Apps-Engine version ([#20482](https://github.com/RocketChat/Rocket.Chat/pull/20482)) + + Update Apps-Engine version with some fixes for the current RC cycle. + +- Update password policy English translation ([#20118](https://github.com/RocketChat/Rocket.Chat/pull/20118) by [@zdumitru](https://github.com/zdumitru)) + +
+ +### 👩‍💻👨‍💻 Contributors 😍 + +- [@Darshilp326](https://github.com/Darshilp326) +- [@Karting06](https://github.com/Karting06) +- [@RonLek](https://github.com/RonLek) +- [@aKn1ghtOut](https://github.com/aKn1ghtOut) +- [@aditya-mitra](https://github.com/aditya-mitra) +- [@bdelwood](https://github.com/bdelwood) +- [@bhavayAnand9](https://github.com/bhavayAnand9) +- [@dependabot[bot]](https://github.com/dependabot[bot]) +- [@i-kychukov](https://github.com/i-kychukov) +- [@ikyuchukov](https://github.com/ikyuchukov) +- [@moniang](https://github.com/moniang) +- [@sushant52](https://github.com/sushant52) +- [@tlskinneriv](https://github.com/tlskinneriv) +- [@wggdeveloper](https://github.com/wggdeveloper) +- [@yash-rajpal](https://github.com/yash-rajpal) +- [@zdumitru](https://github.com/zdumitru) + +### 👩‍💻👨‍💻 Core Team 🤓 + +- [@MartinSchoeler](https://github.com/MartinSchoeler) +- [@d-gubert](https://github.com/d-gubert) +- [@dougfabris](https://github.com/dougfabris) +- [@gabriellsh](https://github.com/gabriellsh) +- [@ggazzo](https://github.com/ggazzo) +- [@lolimay](https://github.com/lolimay) +- [@lucassartor](https://github.com/lucassartor) +- [@murtaza98](https://github.com/murtaza98) +- [@pierre-lehnen-rc](https://github.com/pierre-lehnen-rc) +- [@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) +- [@thassiov](https://github.com/thassiov) +- [@tiagoevanp](https://github.com/tiagoevanp) + # 3.10.5 -`2021-01-26 · 1 🐛 · 1 👩‍💻👨‍💻` +`2021-01-27 · 1 🐛 · 1 👩‍💻👨‍💻` ### Engine versions - Node: `12.18.4` diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md new file mode 100644 index 0000000000000..9ab0a11c366a4 --- /dev/null +++ b/KNOWN_ISSUES.md @@ -0,0 +1,6 @@ +## `registerFieldTemplate` is deprecated + hmm it's true :(, we don't encourage this type of customization anymore, it ends up opening some security holes, we prefer the use of UIKit. If you feel any difficulty let us know +## `attachment.actions` is deprecated + same reason above +## `attachment PDF preview` is no longer being rendered + it is temporarily disabled, nowadays is huge effort render the previews and requires the download of the entire file on the client. We are working to improve this :) \ No newline at end of file diff --git a/app/action-links/client/index.js b/app/action-links/client/index.js index c09886562a2d9..89bf0fccfb2cc 100644 --- a/app/action-links/client/index.js +++ b/app/action-links/client/index.js @@ -1,6 +1,4 @@ import { actionLinks } from './lib/actionLinks'; -import './init'; -import './stylesheets/actionLinks.css'; export { actionLinks, diff --git a/app/action-links/client/init.js b/app/action-links/client/init.js deleted file mode 100644 index f21776e7c2c06..0000000000000 --- a/app/action-links/client/init.js +++ /dev/null @@ -1,33 +0,0 @@ -import { Blaze } from 'meteor/blaze'; -import { Template } from 'meteor/templating'; - -import { handleError } from '../../utils/client'; -import { fireGlobalEvent, Layout } from '../../ui-utils/client'; -import { messageArgs } from '../../ui-utils/client/lib/messageArgs'; -import { actionLinks } from './lib/actionLinks'; - - -Template.roomOld.events({ - 'click [data-actionlink]'(event, instance) { - event.preventDefault(); - event.stopPropagation(); - - const data = Blaze.getData(event.currentTarget); - const { msg } = messageArgs(data); - if (Layout.isEmbedded()) { - return fireGlobalEvent('click-action-link', { - actionlink: $(event.currentTarget).data('actionlink'), - value: msg._id, - message: msg, - }); - } - - if (msg._id) { - actionLinks.run($(event.currentTarget).data('actionlink'), msg._id, instance, (err) => { - if (err) { - handleError(err); - } - }); - } - }, -}); diff --git a/app/action-links/client/stylesheets/actionLinks.css b/app/action-links/client/stylesheets/actionLinks.css deleted file mode 100644 index 1b5a9977c5f27..0000000000000 --- a/app/action-links/client/stylesheets/actionLinks.css +++ /dev/null @@ -1,32 +0,0 @@ -.message { - & .actionLinks { - margin-top: 4px; - margin-bottom: 4px; - padding: 0; - - text-align: center; - - & li { - position: relative; - - display: inline; - - padding-right: 2px; - - list-style: none; - - cursor: pointer; - - & .action-link { - margin: 0 2px; - padding: 5px; - - border-radius: 7px; - } - } - - & li:last-child::after { - content: none; - } - } -} diff --git a/app/api/server/index.js b/app/api/server/index.js index 568664ede60f0..82d23266efb0c 100644 --- a/app/api/server/index.js +++ b/app/api/server/index.js @@ -38,5 +38,7 @@ import './v1/oauthapps'; import './v1/custom-sounds'; import './v1/custom-user-status'; import './v1/instances'; +import './v1/banners'; +import './v1/email-inbox'; export { API, APIClass, defaultRateLimiterOptions } from './api'; diff --git a/app/api/server/lib/emailInbox.js b/app/api/server/lib/emailInbox.js new file mode 100644 index 0000000000000..43d7a0e8f64ff --- /dev/null +++ b/app/api/server/lib/emailInbox.js @@ -0,0 +1,79 @@ +import { EmailInbox } from '../../../models/server/raw'; +import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { Users } from '../../../models'; + +export async function findEmailInboxes({ userId, query = {}, pagination: { offset, count, sort } }) { + if (!await hasPermissionAsync(userId, 'manage-email-inbox')) { + throw new Error('error-not-allowed'); + } + const cursor = EmailInbox.find(query, { + sort: sort || { name: 1 }, + skip: offset, + limit: count, + }); + + const total = await cursor.count(); + + const emailInboxes = await cursor.toArray(); + + return { + emailInboxes, + count: emailInboxes.length, + offset, + total, + }; +} + +export async function findOneEmailInbox({ userId, _id }) { + if (!await hasPermissionAsync(userId, 'manage-email-inbox')) { + throw new Error('error-not-allowed'); + } + return EmailInbox.findOneById(_id); +} + +export async function insertOneOrUpdateEmailInbox(userId, emailInboxParams) { + const { _id, active, name, email, description, senderInfo, department, smtp, imap } = emailInboxParams; + + if (!_id) { + emailInboxParams._createdAt = new Date(); + emailInboxParams._updatedAt = new Date(); + emailInboxParams._createdBy = Users.findOne(userId, { fields: { username: 1 } }); + return EmailInbox.insertOne(emailInboxParams); + } + + const emailInbox = await findOneEmailInbox({ userId, id: _id }); + + if (!emailInbox) { + throw new Error('error-invalid-email-inbox'); + } + + const updateEmailInbox = { + $set: { + active, + name, + email, + description, + senderInfo, + smtp, + imap, + _updatedAt: new Date(), + }, + }; + + if (department === 'All') { + updateEmailInbox.$unset = { + department: 1, + }; + } else { + updateEmailInbox.$set.department = department; + } + + return EmailInbox.updateOne({ _id }, updateEmailInbox); +} + +export async function findOneEmailInboxByEmail({ userId, email }) { + if (!await hasPermissionAsync(userId, 'manage-email-inbox')) { + throw new Error('error-not-allowed'); + } + return EmailInbox.findOne({ email }); +} diff --git a/app/api/server/v1/banners.ts b/app/api/server/v1/banners.ts new file mode 100644 index 0000000000000..07a8077d72463 --- /dev/null +++ b/app/api/server/v1/banners.ts @@ -0,0 +1,50 @@ +import { Promise } from 'meteor/promise'; +import { Meteor } from 'meteor/meteor'; +import { Match, check } from 'meteor/check'; + +import { API } from '../api'; +import { Banner } from '../../../../server/sdk'; +import { BannerPlatform } from '../../../../definition/IBanner'; + +API.v1.addRoute('banners.getNew', { authRequired: true }, { + get() { + check(this.queryParams, Match.ObjectIncluding({ + platform: String, + bid: Match.Maybe(String), + })); + + const { platform, bid: bannerId } = this.queryParams; + if (!platform) { + throw new Meteor.Error('error-missing-param', 'The required "platform" param is missing.'); + } + + if (!Object.values(BannerPlatform).includes(platform)) { + throw new Meteor.Error('error-unknown-platform', 'Platform is unknown.'); + } + + const banners = Promise.await(Banner.getNewBannersForUser(this.userId, platform, bannerId)); + + return API.v1.success({ banners }); + }, +}); + +API.v1.addRoute('banners.dismiss', { authRequired: true }, { + post() { + check(this.bodyParams, Match.ObjectIncluding({ + bannerId: String, + })); + + const { bannerId } = this.bodyParams; + + if (!bannerId || !bannerId.trim()) { + throw new Meteor.Error('error-missing-param', 'The required "bannerId" param is missing.'); + } + + try { + Promise.await(Banner.dismiss(this.userId, bannerId)); + return API.v1.success(); + } catch (e) { + return API.v1.failure(); + } + }, +}); diff --git a/app/api/server/v1/chat.js b/app/api/server/v1/chat.js index 447c10bd5d57a..a4ed9765ebe4e 100644 --- a/app/api/server/v1/chat.js +++ b/app/api/server/v1/chat.js @@ -655,6 +655,9 @@ API.v1.addRoute('chat.getStarredMessages', { authRequired: true }, { sort, }, })); + + messages.messages = normalizeMessagesForUser(messages.messages, this.userId); + return API.v1.success(messages); }, }); diff --git a/app/api/server/v1/email-inbox.js b/app/api/server/v1/email-inbox.js new file mode 100644 index 0000000000000..e7452fc5ffe1e --- /dev/null +++ b/app/api/server/v1/email-inbox.js @@ -0,0 +1,131 @@ +import { check, Match } from 'meteor/check'; + +import { API } from '../api'; +import { findEmailInboxes, findOneEmailInbox, insertOneOrUpdateEmailInbox } from '../lib/emailInbox'; +import { hasPermission } from '../../../authorization/server/functions/hasPermission'; +import { EmailInbox } from '../../../models'; +import Users from '../../../models/server/models/Users'; +import { sendTestEmailToInbox } from '../../../../server/features/EmailInbox/EmailInbox_Outgoing'; + +API.v1.addRoute('email-inbox.list', { authRequired: true }, { + get() { + const { offset, count } = this.getPaginationItems(); + const { sort, query } = this.parseJsonQuery(); + const emailInboxes = Promise.await(findEmailInboxes({ userId: this.userId, query, pagination: { offset, count, sort } })); + + return API.v1.success(emailInboxes); + }, +}); + +API.v1.addRoute('email-inbox', { authRequired: true }, { + post() { + if (!hasPermission(this.userId, 'manage-email-inbox')) { + throw new Error('error-not-allowed'); + } + check(this.bodyParams, { + _id: Match.Maybe(String), + name: String, + email: String, + active: Boolean, + description: Match.Maybe(String), + senderInfo: Match.Maybe(String), + department: Match.Maybe(String), + smtp: Match.ObjectIncluding({ + password: String, + port: Number, + secure: Boolean, + server: String, + username: String, + }), + imap: Match.ObjectIncluding({ + password: String, + port: Number, + secure: Boolean, + server: String, + username: String, + }), + }); + + const emailInboxParams = this.bodyParams; + + const { _id } = emailInboxParams; + + Promise.await(insertOneOrUpdateEmailInbox(this.userId, emailInboxParams)); + + return API.v1.success({ _id }); + }, +}); + +API.v1.addRoute('email-inbox/:_id', { authRequired: true }, { + get() { + check(this.urlParams, { + _id: String, + }); + + const { _id } = this.urlParams; + if (!_id) { throw new Error('error-invalid-param'); } + const emailInboxes = Promise.await(findOneEmailInbox({ userId: this.userId, _id })); + + return API.v1.success(emailInboxes); + }, + delete() { + if (!hasPermission(this.userId, 'manage-email-inbox')) { + throw new Error('error-not-allowed'); + } + check(this.urlParams, { + _id: String, + }); + + const { _id } = this.urlParams; + if (!_id) { throw new Error('error-invalid-param'); } + + const emailInboxes = EmailInbox.findOneById(_id); + + if (!emailInboxes) { + return API.v1.notFound(); + } + EmailInbox.removeById(_id); + return API.v1.success({ _id }); + }, +}); + +API.v1.addRoute('email-inbox.search', { authRequired: true }, { + get() { + if (!hasPermission(this.userId, 'manage-email-inbox')) { + throw new Error('error-not-allowed'); + } + check(this.queryParams, { + email: String, + }); + + const { email } = this.queryParams; + const emailInbox = Promise.await(EmailInbox.findOne({ email })); + + return API.v1.success({ emailInbox }); + }, +}); + +API.v1.addRoute('email-inbox.send-test/:_id', { authRequired: true }, { + post() { + if (!hasPermission(this.userId, 'manage-email-inbox')) { + throw new Error('error-not-allowed'); + } + check(this.urlParams, { + _id: String, + }); + + const { _id } = this.urlParams; + if (!_id) { throw new Error('error-invalid-param'); } + const emailInbox = Promise.await(findOneEmailInbox({ userId: this.userId, _id })); + + if (!emailInbox) { + return API.v1.notFound(); + } + + const user = Users.findOneById(this.userId); + + Promise.await(sendTestEmailToInbox(emailInbox, user)); + + return API.v1.success({ _id }); + }, +}); diff --git a/app/api/server/v1/rooms.js b/app/api/server/v1/rooms.js index 35707c919b82d..9cd0126c713b3 100644 --- a/app/api/server/v1/rooms.js +++ b/app/api/server/v1/rooms.js @@ -234,7 +234,7 @@ API.v1.addRoute('rooms.leave', { authRequired: true }, { API.v1.addRoute('rooms.createDiscussion', { authRequired: true }, { post() { - const { prid, pmid, reply, t_name, users } = this.bodyParams; + const { prid, pmid, reply, t_name, users, encrypted } = this.bodyParams; if (!prid) { return API.v1.failure('Body parameter "prid" is required.'); } @@ -245,12 +245,17 @@ API.v1.addRoute('rooms.createDiscussion', { authRequired: true }, { return API.v1.failure('Body parameter "users" must be an array.'); } + if (encrypted !== undefined && typeof encrypted !== 'boolean') { + return API.v1.failure('Body parameter "encrypted" must be a boolean when included.'); + } + const discussion = Meteor.runAsUser(this.userId, () => Meteor.call('createDiscussion', { prid, pmid, t_name, reply, users: users || [], + encrypted, })); return API.v1.success({ discussion }); diff --git a/app/apps/client/orchestrator.js b/app/apps/client/orchestrator.js index 796737853c580..adb0e0837bd00 100644 --- a/app/apps/client/orchestrator.js +++ b/app/apps/client/orchestrator.js @@ -65,11 +65,12 @@ class AppClientOrchestrator { getAppsFromMarketplace = async () => { const appsOverviews = await APIClient.get('apps', { marketplace: 'true' }); - return appsOverviews.map(({ latest, price, pricingPlans, purchaseType }) => ({ + return appsOverviews.map(({ latest, price, pricingPlans, purchaseType, permissions }) => ({ ...latest, price, pricingPlans, purchaseType, + permissions, })); } @@ -125,20 +126,22 @@ class AppClientOrchestrator { return languages; } - installApp = async (appId, version) => { + installApp = async (appId, version, permissionsGranted) => { const { app } = await APIClient.post('apps/', { appId, marketplace: true, version, + permissionsGranted, }); return app; } - updateApp = async (appId, version) => { + updateApp = async (appId, version, permissionsGranted) => { const { app } = await APIClient.post(`apps/${ appId }`, { appId, marketplace: true, version, + permissionsGranted, }); return app; } diff --git a/app/apps/server/bridges/listeners.js b/app/apps/server/bridges/listeners.js index c387ff7559806..9ae81d2c23fdd 100644 --- a/app/apps/server/bridges/listeners.js +++ b/app/apps/server/bridges/listeners.js @@ -41,10 +41,7 @@ export class AppListenerBridge { case AppInterface.IPostLivechatGuestSaved: case AppInterface.IPostLivechatRoomSaved: return 'livechatEvent'; - case AppInterface.IUIKitInteractionHandler: - case AppInterface.IUIKitLivechatInteractionHandler: - case AppInterface.IPostExternalComponentOpened: - case AppInterface.IPostExternalComponentClosed: + default: return 'defaultEvent'; } })(); diff --git a/app/apps/server/communication/rest.js b/app/apps/server/communication/rest.js index e211ceea27996..fdf18d8d87bf4 100644 --- a/app/apps/server/communication/rest.js +++ b/app/apps/server/communication/rest.js @@ -23,23 +23,20 @@ export class AppsRestApi { this.loadAPI(); } - _handleFile(request, fileField) { + _handleMultipartFormData(request) { const busboy = new Busboy({ headers: request.headers }); - return Meteor.wrapAsync((callback) => { + const formFields = {}; busboy.on('file', Meteor.bindEnvironment((fieldname, file) => { - if (fieldname !== fileField) { - return callback(new Meteor.Error('invalid-field', `Expected the field "${ fileField }" but got "${ fieldname }" instead.`)); - } - const fileData = []; file.on('data', Meteor.bindEnvironment((data) => { fileData.push(data); })); - file.on('end', Meteor.bindEnvironment(() => callback(undefined, Buffer.concat(fileData)))); + file.on('end', Meteor.bindEnvironment(() => { formFields[fieldname] = Buffer.concat(fileData); })); })); - + busboy.on('field', (fieldname, val) => { formFields[fieldname] = val; }); + busboy.on('finish', Meteor.bindEnvironment(() => callback(undefined, formFields))); request.pipe(busboy); })(); } @@ -58,7 +55,7 @@ export class AppsRestApi { addManagementRoutes() { const orchestrator = this._orch; const manager = this._manager; - const fileHandler = this._handleFile; + const multipartFormDataHandler = this._handleMultipartFormData; const handleError = (message, e) => { // when there is no `response` field in the error, it means the request @@ -171,6 +168,7 @@ export class AppsRestApi { post() { let buff; let marketplaceInfo; + let permissionsGranted; if (this.bodyParams.url) { if (settings.get('Apps_Framework_Development_Mode') !== true) { @@ -190,6 +188,10 @@ export class AppsRestApi { } buff = result.content; + + if (this.bodyParams.downloadOnly) { + return API.v1.success({ buff }); + } } else if (this.bodyParams.appId && this.bodyParams.marketplace && this.bodyParams.version) { const baseUrl = orchestrator.getMarketplaceUrl(); @@ -233,6 +235,7 @@ export class AppsRestApi { buff = downloadResult.content; marketplaceInfo = marketplaceResult.data[0]; + permissionsGranted = this.bodyParams.permissionsGranted; } catch (err) { return API.v1.failure(err.message); } @@ -241,14 +244,23 @@ export class AppsRestApi { return API.v1.failure({ error: 'Direct installation of an App is disabled.' }); } - buff = fileHandler(this.request, 'app'); + const formData = multipartFormDataHandler(this.request); + buff = formData?.app; + permissionsGranted = (() => { + try { + const permissions = JSON.parse(formData?.permissions || ''); + return permissions.length ? permissions : undefined; + } catch { + return undefined; + } + })(); } if (!buff) { return API.v1.failure({ error: 'Failed to get a file to install for the App. ' }); } - const aff = Promise.await(manager.add(buff, true, marketplaceInfo)); + const aff = Promise.await(manager.add(buff, { marketplaceInfo, permissionsGranted, enable: true })); const info = aff.getAppInfo(); if (aff.hasStorageError()) { @@ -428,18 +440,16 @@ export class AppsRestApi { const baseUrl = orchestrator.getMarketplaceUrl(); const headers = getDefaultHeaders(); - const token = getWorkspaceAccessToken(); - if (token) { - headers.Authorization = `Bearer ${ token }`; - } + const token = getWorkspaceAccessToken(true, 'marketplace:download', false); let result; try { - result = HTTP.get(`${ baseUrl }/v2/apps/${ this.bodyParams.appId }/download/${ this.bodyParams.version }`, { + result = HTTP.get(`${ baseUrl }/v2/apps/${ this.bodyParams.appId }/download/${ this.bodyParams.version }?token=${ token }`, { headers, npmRequestOptions: { encoding: null }, }); } catch (e) { + console.log(e, e.response.content.toString()); orchestrator.getRocketChatLogger().error('Error getting the App from the Marketplace:', e.response.data); return API.v1.internalError(); } @@ -459,14 +469,14 @@ export class AppsRestApi { return API.v1.failure({ error: 'Direct updating of an App is disabled.' }); } - buff = fileHandler(this.request, 'app'); + buff = multipartFormDataHandler(this.request)?.app; } if (!buff) { return API.v1.failure({ error: 'Failed to get a file to install for the App. ' }); } - const aff = Promise.await(manager.update(buff)); + const aff = Promise.await(manager.update(buff, this.bodyParams.permissionsGranted)); const info = aff.getAppInfo(); if (aff.hasStorageError()) { diff --git a/app/apps/server/communication/uikit.js b/app/apps/server/communication/uikit.js index 15cd35338c5bd..3010447e93051 100644 --- a/app/apps/server/communication/uikit.js +++ b/app/apps/server/communication/uikit.js @@ -8,6 +8,7 @@ import { AppInterface } from '@rocket.chat/apps-engine/definition/metadata'; import { Users } from '../../../models/server'; import { settings } from '../../../settings/server'; import { Apps } from '../orchestrator'; +import { UiKitCoreApp } from '../../../../server/sdk'; const apiServer = express(); @@ -60,126 +61,231 @@ router.use((req, res, next) => { apiServer.use('/api/apps/ui.interaction/', router); -export class AppUIKitInteractionApi { - constructor(orch) { - this.orch = orch; +const getPayloadForType = (type, req) => { + if (type === UIKitIncomingInteractionType.BLOCK) { + const { + type, + actionId, + triggerId, + mid, + rid, + payload, + container, + } = req.body; + + const { visitor, user } = req; + const room = rid; // orch.getConverters().get('rooms').convertById(rid); + const message = mid; + + return { + type, + container, + actionId, + message, + triggerId, + payload, + user, + visitor, + room, + }; + } + + if (type === UIKitIncomingInteractionType.VIEW_CLOSED) { + const { + type, + actionId, + payload: { + view, + isCleared, + }, + } = req.body; + + const { user } = req; + + return { + type, + actionId, + user, + payload: { + view, + isCleared, + }, + }; + } + + if (type === UIKitIncomingInteractionType.VIEW_SUBMIT) { + const { + type, + actionId, + triggerId, + payload, + } = req.body; + + const { user } = req; + + return { + type, + actionId, + triggerId, + payload, + user, + }; + } + + throw new Error('Type not supported'); +}; + +router.post('/:appId', async (req, res, next) => { + const { + appId, + } = req.params; + + const isCore = await UiKitCoreApp.isRegistered(appId); + if (!isCore) { + return next(); + } + + const { + type, + } = req.body; + + try { + const payload = { + ...getPayloadForType(type, req), + appId, + }; + + const result = await UiKitCoreApp[type](payload); + + res.send(result); + } catch (e) { + console.error('ops', e); + res.status(500).send({ error: e.message }); + } +}); + +const appsRoutes = (orch) => (req, res) => { + const { + appId, + } = req.params; + + const { + type, + } = req.body; - router.post('/:appId', (req, res) => { + switch (type) { + case UIKitIncomingInteractionType.BLOCK: { const { + type, + actionId, + triggerId, + mid, + rid, + payload, + container, + } = req.body; + + const { visitor } = req; + const room = orch.getConverters().get('rooms').convertById(rid); + const user = orch.getConverters().get('users').convertToApp(req.user); + const message = mid && orch.getConverters().get('messages').convertById(mid); + + const action = { + type, + container, + appId, + actionId, + message, + triggerId, + payload, + user, + visitor, + room, + }; + + try { + const eventInterface = !visitor ? AppInterface.IUIKitInteractionHandler : AppInterface.IUIKitLivechatInteractionHandler; + + const result = Promise.await(orch.triggerEvent(eventInterface, action)); + + res.send(result); + } catch (e) { + res.status(500).send(e.message); + } + break; + } + + case UIKitIncomingInteractionType.VIEW_CLOSED: { + const { + type, + actionId, + payload: { + view, + isCleared, + }, + } = req.body; + + const user = orch.getConverters().get('users').convertToApp(req.user); + + const action = { + type, appId, - } = req.params; + actionId, + user, + payload: { + view, + isCleared, + }, + }; + + try { + Promise.await(orch.triggerEvent('IUIKitInteractionHandler', action)); + + res.sendStatus(200); + } catch (e) { + console.error(e); + res.status(500).send(e.message); + } + break; + } + case UIKitIncomingInteractionType.VIEW_SUBMIT: { const { type, + actionId, + triggerId, + payload, } = req.body; - switch (type) { - case UIKitIncomingInteractionType.BLOCK: { - const { - type, - actionId, - triggerId, - mid, - rid, - payload, - container, - } = req.body; - - const { visitor } = req; - const room = this.orch.getConverters().get('rooms').convertById(rid); - const user = this.orch.getConverters().get('users').convertToApp(req.user); - const message = mid && this.orch.getConverters().get('messages').convertById(mid); - - const action = { - type, - container, - appId, - actionId, - message, - triggerId, - payload, - user, - visitor, - room, - }; - - try { - const eventInterface = !visitor ? AppInterface.IUIKitInteractionHandler : AppInterface.IUIKitLivechatInteractionHandler; - - const result = Promise.await(this.orch.triggerEvent(eventInterface, action)); - - res.send(result); - } catch (e) { - res.status(500).send(e.message); - } - break; - } - - case UIKitIncomingInteractionType.VIEW_CLOSED: { - const { - type, - actionId, - payload: { - view, - isCleared, - }, - } = req.body; - - const user = this.orch.getConverters().get('users').convertToApp(req.user); - - const action = { - type, - appId, - actionId, - user, - payload: { - view, - isCleared, - }, - }; - - try { - Promise.await(this.orch.triggerEvent('IUIKitInteractionHandler', action)); - - res.sendStatus(200); - } catch (e) { - console.log(e); - res.status(500).send(e.message); - } - break; - } - - case UIKitIncomingInteractionType.VIEW_SUBMIT: { - const { - type, - actionId, - triggerId, - payload, - } = req.body; - - const user = this.orch.getConverters().get('users').convertToApp(req.user); - - const action = { - type, - appId, - actionId, - triggerId, - payload, - user, - }; - - try { - const result = Promise.await(this.orch.triggerEvent('IUIKitInteractionHandler', action)); - - res.send(result); - } catch (e) { - res.status(500).send(e.message); - } - break; - } + const user = orch.getConverters().get('users').convertToApp(req.user); + + const action = { + type, + appId, + actionId, + triggerId, + payload, + user, + }; + + try { + const result = Promise.await(orch.triggerEvent('IUIKitInteractionHandler', action)); + + res.send(result); + } catch (e) { + res.status(500).send(e.message); } + break; + } + } + + // TODO: validate payloads per type +}; + +export class AppUIKitInteractionApi { + constructor(orch) { + this.orch = orch; - // TODO: validate payloads per type - }); + router.post('/:appId', appsRoutes(orch)); } } diff --git a/app/authorization/server/startup.js b/app/authorization/server/startup.js index a2015e46816f6..7d5efbdca1679 100644 --- a/app/authorization/server/startup.js +++ b/app/authorization/server/startup.js @@ -52,6 +52,7 @@ Meteor.startup(function() { { _id: 'leave-c', roles: ['admin', 'user', 'bot', 'anonymous', 'app'] }, { _id: 'leave-p', roles: ['admin', 'user', 'bot', 'anonymous', 'app'] }, { _id: 'manage-assets', roles: ['admin'] }, + { _id: 'manage-email-inbox', roles: ['admin'] }, { _id: 'manage-emoji', roles: ['admin'] }, { _id: 'manage-user-status', roles: ['admin'] }, { _id: 'manage-outgoing-integrations', roles: ['admin'] }, @@ -120,6 +121,7 @@ Meteor.startup(function() { { _id: 'edit-livechat-room-customfields', roles: ['livechat-manager', 'livechat-agent', 'admin'] }, { _id: 'send-omnichannel-chat-transcript', roles: ['livechat-manager', 'admin'] }, { _id: 'mail-messages', roles: ['admin'] }, + { _id: 'toggle-room-e2e-encryption', roles: ['owner'] }, ]; for (const permission of permissions) { diff --git a/app/channel-settings/server/functions/saveRoomEncrypted.ts b/app/channel-settings/server/functions/saveRoomEncrypted.ts new file mode 100644 index 0000000000000..4160f1f29541f --- /dev/null +++ b/app/channel-settings/server/functions/saveRoomEncrypted.ts @@ -0,0 +1,20 @@ +import { Meteor } from 'meteor/meteor'; +import { Match } from 'meteor/check'; +import type { WriteOpResult } from 'mongodb'; + +import { Rooms, Messages } from '../../../models/server'; +import type { IUser } from '../../../../definition/IUser'; + +export const saveRoomEncrypted = function(rid: string, encrypted: boolean, user: IUser, sendMessage = true): Promise { + if (!Match.test(rid, String)) { + throw new Meteor.Error('invalid-room', 'Invalid room', { + function: 'RocketChat.saveRoomEncrypted', + }); + } + + const update = Rooms.saveEncryptedById(rid, encrypted); + if (update && sendMessage) { + Messages.createRoomSettingsChangedWithTypeRoomIdMessageAndUser(`room_e2e_${ encrypted ? 'enabled' : 'disabled' }`, rid, user.username, user, {}); + } + return update; +}; diff --git a/app/channel-settings/server/methods/saveRoomSettings.js b/app/channel-settings/server/methods/saveRoomSettings.js index a418862d6fba5..e524637e8b969 100644 --- a/app/channel-settings/server/methods/saveRoomSettings.js +++ b/app/channel-settings/server/methods/saveRoomSettings.js @@ -15,6 +15,7 @@ import { saveRoomReadOnly } from '../functions/saveRoomReadOnly'; import { saveReactWhenReadOnly } from '../functions/saveReactWhenReadOnly'; import { saveRoomSystemMessages } from '../functions/saveRoomSystemMessages'; import { saveRoomTokenpass } from '../functions/saveRoomTokens'; +import { saveRoomEncrypted } from '../functions/saveRoomEncrypted'; import { saveStreamingOptions } from '../functions/saveStreamingOptions'; import { RoomSettingsEnum, roomTypes } from '../../../utils'; @@ -56,12 +57,21 @@ const validators = { }); } }, - encrypted({ value, room }) { - if (value !== room.encrypted && !roomTypes.getConfig(room.t).allowRoomSettingChange(room, RoomSettingsEnum.E2E)) { - throw new Meteor.Error('error-action-not-allowed', 'Only groups or direct channels can enable encryption', { - method: 'saveRoomSettings', - action: 'Change_Room_Encrypted', - }); + encrypted({ userId, value, room, rid }) { + if (value !== room.encrypted) { + if (!roomTypes.getConfig(room.t).allowRoomSettingChange(room, RoomSettingsEnum.E2E)) { + throw new Meteor.Error('error-action-not-allowed', 'Only groups or direct channels can enable encryption', { + method: 'saveRoomSettings', + action: 'Change_Room_Encrypted', + }); + } + + if (room.t !== 'd' && !hasPermission(userId, 'toggle-room-e2e-encryption', rid)) { + throw new Meteor.Error('error-action-not-allowed', 'You do not have permission to toggle E2E encryption', { + method: 'saveRoomSettings', + action: 'Change_Room_Encrypted', + }); + } } }, retentionEnabled({ userId, value, room, rid }) { @@ -198,8 +208,8 @@ const settingSavers = { retentionOverrideGlobal({ value, rid }) { Rooms.saveRetentionOverrideGlobalById(rid, value); }, - encrypted({ value, rid }) { - Rooms.saveEncryptedById(rid, value); + encrypted({ value, room, rid, user }) { + saveRoomEncrypted(rid, value, user, Boolean(room.encrypted) !== Boolean(value)); }, favorite({ value, rid }) { Rooms.saveFavoriteById(rid, value.favorite, value.defaultValue); diff --git a/app/cloud/server/functions/buildRegistrationData.js b/app/cloud/server/functions/buildRegistrationData.js index 25baa7fac0e04..348702ae93f36 100644 --- a/app/cloud/server/functions/buildRegistrationData.js +++ b/app/cloud/server/functions/buildRegistrationData.js @@ -26,6 +26,8 @@ export function buildWorkspaceRegistrationData() { const website = settings.get('Website'); + const npsEnabled = settings.get('NPS_survey_enabled'); + const agreePrivacyTerms = settings.get('Cloud_Service_Agree_PrivacyTerms'); const { organizationType, industry, size: orgSize, country, language, serverType: workspaceType } = stats.wizard; @@ -53,5 +55,6 @@ export function buildWorkspaceRegistrationData() { licenseVersion: LICENSE_VERSION, enterpriseReady: true, setupComplete: settings.get('Show_Setup_Wizard') === 'completed', + npsEnabled, }; } diff --git a/app/cloud/server/functions/syncWorkspace.js b/app/cloud/server/functions/syncWorkspace.js index e116a8fcc5f07..bb2220d3c95f3 100644 --- a/app/cloud/server/functions/syncWorkspace.js +++ b/app/cloud/server/functions/syncWorkspace.js @@ -6,6 +6,7 @@ import { getWorkspaceAccessToken } from './getWorkspaceAccessToken'; import { getWorkspaceLicense } from './getWorkspaceLicense'; import { Settings } from '../../../models'; import { settings } from '../../../settings'; +import { NPS, Banner } from '../../../../server/sdk'; export function syncWorkspace(reconnectCheck = false) { const { workspaceRegistered, connectToCloud } = retrieveRegistrationStatus(); @@ -45,10 +46,45 @@ export function syncWorkspace(reconnectCheck = false) { } const { data } = result; + if (!data) { + return true; + } - if (data && data.publicKey) { + if (data.publicKey) { Settings.updateValueById('Cloud_Workspace_PublicKey', data.publicKey); } + if (data.nps) { + const { + id: npsId, + startAt, + expireAt, + } = data.nps; + + Promise.await(NPS.create({ + npsId, + startAt: new Date(startAt), + expireAt: new Date(expireAt), + })); + } + + // add banners + if (data.banners) { + for (const banner of data.banners) { + const { + createdAt, + expireAt, + startAt, + } = banner; + + Promise.await(Banner.create({ + ...banner, + createdAt: new Date(createdAt), + expireAt: new Date(expireAt), + startAt: new Date(startAt), + })); + } + } + return true; } diff --git a/app/custom-sounds/client/lib/CustomSounds.js b/app/custom-sounds/client/lib/CustomSounds.js index 27b09ca5a50d8..78c3e87931546 100644 --- a/app/custom-sounds/client/lib/CustomSounds.js +++ b/app/custom-sounds/client/lib/CustomSounds.js @@ -85,7 +85,9 @@ class CustomSoundsClass { } audio.pause(); - audio.currentTime = 0; + if (audio.currentTime !== 0) { + audio.currentTime = 0; + } } } diff --git a/app/discussion/client/index.js b/app/discussion/client/index.js index fcd3eb9a9d847..d48455ac350e0 100644 --- a/app/discussion/client/index.js +++ b/app/discussion/client/index.js @@ -8,6 +8,3 @@ import './discussionFromMessageBox'; import './tabBar'; import '../lib/discussionRoomType'; - -// Style -import './public/stylesheets/discussion.css'; diff --git a/app/discussion/client/public/stylesheets/discussion.css b/app/discussion/client/public/stylesheets/discussion.css deleted file mode 100644 index 812ec0727c33a..0000000000000 --- a/app/discussion/client/public/stylesheets/discussion.css +++ /dev/null @@ -1,45 +0,0 @@ -.message-thread, -.message-discussion { - display: flex; - - padding: 0.5rem 0; - align-items: center; - - flex-wrap: wrap; -} - -.message-thread { - .reply-counter { - color: var(--color-gray); - } -} - -.discussion-reply-lm, -.reply-counter { - color: var(--color-white); - - font-size: 12px; - font-style: italic; - - flex-grow: 0; - flex-shrink: 0; -} - -.reply-counter { - font-weight: 600; - margin-inline-start: 8px; -} - -.discussion-reply-lm { - padding: 4px 8px; - - color: var(--color-gray); -} - -.discussions-list .load-more { - text-align: center; - text-transform: lowercase; - - font-style: italic; - line-height: 40px; -} diff --git a/app/discussion/client/tabBar.ts b/app/discussion/client/tabBar.ts index 4eedbeed66219..e41c36d8bf654 100644 --- a/app/discussion/client/tabBar.ts +++ b/app/discussion/client/tabBar.ts @@ -1,8 +1,10 @@ -import { useMemo, lazy, LazyExoticComponent, FC } from 'react'; +import { useMemo, lazy } from 'react'; import { addAction } from '../../../client/views/room/lib/Toolbox'; import { useSetting } from '../../../client/contexts/SettingsContext'; +const template = lazy(() => import('../../../client/views/room/contextualBar/Discussions')); + addAction('discussions', () => { const discussionEnabled = useSetting('Discussion_enabled'); @@ -11,7 +13,7 @@ addAction('discussions', () => { id: 'discussions', title: 'Discussions', icon: 'discussion', - template: lazy(() => import('../../../client/views/room/contextualBar/Discussions')) as LazyExoticComponent, + template, full: true, order: 1, } : null), [discussionEnabled]); diff --git a/app/discussion/client/views/creationDialog/CreateDiscussion.html b/app/discussion/client/views/creationDialog/CreateDiscussion.html index 4452789d1d78b..9eff9c8e57ad7 100644 --- a/app/discussion/client/views/creationDialog/CreateDiscussion.html +++ b/app/discussion/client/views/creationDialog/CreateDiscussion.html @@ -32,7 +32,17 @@ {{/unless}} - +
+ +
diff --git a/app/discussion/client/views/creationDialog/CreateDiscussion.js b/app/discussion/client/views/creationDialog/CreateDiscussion.js index 629d1f90d1d20..ee4c6ca0d083b 100755 --- a/app/discussion/client/views/creationDialog/CreateDiscussion.js +++ b/app/discussion/client/views/creationDialog/CreateDiscussion.js @@ -14,9 +14,17 @@ import { AutoComplete } from '../../../../meteor-autocomplete/client'; import './CreateDiscussion.html'; Template.CreateDiscussion.helpers({ + encrypted() { + return Template.instance().encrypted.get(); + }, onSelectUser() { return Template.instance().onSelectUser; }, + messageDisable() { + if (Template.instance().encrypted.get()) { + return 'disabled'; + } + }, disabled() { if (Template.instance().selectParent.get()) { return 'disabled'; @@ -90,6 +98,9 @@ Template.CreateDiscussion.events({ 'input #discussion_name'(e, t) { t.discussionName.set(e.target.value); }, + 'input #encrypted'(e, t) { + t.encrypted.set(!t.encrypted.get()); + }, 'input #discussion_message'(e, t) { const { value } = e.target; t.reply.set(value); @@ -101,15 +112,17 @@ Template.CreateDiscussion.events({ const { pmid } = instance; const t_name = instance.discussionName.get(); const users = instance.selectedUsers.get().map(({ username }) => username).filter((value, index, self) => self.indexOf(value) === index); + const encrypted = instance.encrypted.get(); const prid = instance.parentChannelId.get(); - const reply = instance.reply.get(); + const reply = encrypted ? undefined : instance.reply.get(); if (!prid) { const errorText = TAPi18n.__('Invalid_room_name', `${ parentChannel }...`); return toastr.error(errorText); } - const result = await call('createDiscussion', { prid, pmid, t_name, reply, users }); + const result = await call('createDiscussion', { prid, pmid, t_name, users, encrypted, reply }); + // callback to enable tracking callbacks.run('afterDiscussion', Meteor.user(), result); @@ -144,6 +157,7 @@ Template.CreateDiscussion.onCreated(function() { this.pmid = msg && msg._id; + this.encrypted = new ReactiveVar(room?.encrypted || false); this.parentChannel = new ReactiveVar(roomName); this.parentChannelId = new ReactiveVar(room && room.rid); diff --git a/app/discussion/server/methods/createDiscussion.js b/app/discussion/server/methods/createDiscussion.js index e2e0c1f2ca220..b9d28a0d58df7 100644 --- a/app/discussion/server/methods/createDiscussion.js +++ b/app/discussion/server/methods/createDiscussion.js @@ -1,5 +1,7 @@ import { Meteor } from 'meteor/meteor'; import { Random } from 'meteor/random'; +import { Match } from 'meteor/check'; +import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; import { hasAtLeastOnePermission, canSendMessage } from '../../../authorization/server'; import { Messages, Rooms } from '../../../models/server'; @@ -35,7 +37,7 @@ const mentionMessage = (rid, { _id, username, name }, message_embedded) => { return Messages.insert(welcomeMessage); }; -const create = ({ prid, pmid, t_name, reply, users, user }) => { +const create = ({ prid, pmid, t_name, reply, users, user, encrypted }) => { // if you set both, prid and pmid, and the rooms doesnt match... should throw an error) let message = false; if (pmid) { @@ -67,6 +69,22 @@ const create = ({ prid, pmid, t_name, reply, users, user }) => { throw new Meteor.Error('error-nested-discussion', 'Cannot create nested discussions', { method: 'DiscussionCreation' }); } + if (!Match.Maybe(encrypted, Boolean)) { + throw new Meteor.Error('error-invalid-arguments', 'Invalid encryption state', { + method: 'DiscussionCreation', + }); + } + + if (typeof encrypted !== 'boolean') { + encrypted = p_room.encrypted; + } + + if (encrypted && reply) { + throw new Meteor.Error('error-invalid-arguments', 'Encrypted discussions must not receive an initial reply.', { + method: 'DiscussionCreation', + }); + } + if (pmid) { const discussionAlreadyExists = Rooms.findOne({ prid, @@ -86,11 +104,15 @@ const create = ({ prid, pmid, t_name, reply, users, user }) => { const invitedUsers = message ? [message.u.username, ...users] : users; const type = roomTypes.getConfig(p_room.t).getDiscussionType(); + const description = p_room.encrypted ? '' : message.msg; + const topic = p_room.name; + const discussion = createRoom(type, name, user.username, [...new Set(invitedUsers)], false, { fname: t_name, - description: message.msg, // TODO discussions remove - topic: p_room.name, // TODO discussions remove + description, // TODO discussions remove + topic, // TODO discussions remove prid, + encrypted, }, { // overrides name validation to allow anything, because discussion's name is randomly generated nameValidationRegex: /.*/, @@ -98,6 +120,9 @@ const create = ({ prid, pmid, t_name, reply, users, user }) => { let discussionMsg; if (pmid) { + if (p_room.encrypted) { + message.msg = TAPi18n.__('Encrypted_message'); + } mentionMessage(discussion._id, user, attachMessage(message, p_room)); discussionMsg = createDiscussionMessage(message.rid, user, discussion._id, t_name, attachMessage(message, p_room)); @@ -122,8 +147,9 @@ Meteor.methods({ * @param {string} reply - The reply, optional * @param {string} t_name - discussion name * @param {string[]} users - users to be added + * @param {boolean} encrypted - if the discussion's e2e encryption should be enabled. */ - createDiscussion({ prid, pmid, t_name, reply, users }) { + createDiscussion({ prid, pmid, t_name, reply, users, encrypted }) { if (!settings.get('Discussion_enabled')) { throw new Meteor.Error('error-action-not-allowed', 'You are not allowed to create a discussion', { method: 'createDiscussion' }); } @@ -137,6 +163,6 @@ Meteor.methods({ throw new Meteor.Error('error-action-not-allowed', 'You are not allowed to create a discussion', { method: 'createDiscussion' }); } - return create({ uid, prid, pmid, t_name, reply, users, user: Meteor.user() }); + return create({ uid, prid, pmid, t_name, reply, users, user: Meteor.user(), encrypted }); }, }); diff --git a/app/e2e/client/rocketchat.e2e.js b/app/e2e/client/rocketchat.e2e.js index 173bcc0c6d574..e2867bfba7bae 100644 --- a/app/e2e/client/rocketchat.e2e.js +++ b/app/e2e/client/rocketchat.e2e.js @@ -24,8 +24,9 @@ import { import { Rooms, Subscriptions, Messages } from '../../models'; import { promises } from '../../promises/client'; import { settings } from '../../settings'; -import { Notifications } from '../../notifications'; -import { Layout, call, modal, alerts } from '../../ui-utils'; +import { Notifications } from '../../notifications/client'; +import { Layout, call, modal } from '../../ui-utils'; +import * as banners from '../../../client/lib/banners'; import './events.js'; import './tabbar'; @@ -33,6 +34,13 @@ import './tabbar'; let failedToDecodeKey = false; let showingE2EAlert = false; +const waitUntilFind = (fn) => new Promise((resolve) => { + Tracker.autorun((c) => { + const result = fn(); + return result && resolve(result) && c.stop(); + }); +}); + class E2E { constructor() { this.started = false; @@ -45,6 +53,15 @@ class E2E { }); } + log(...msg) { + console.log('[E2E]', ...msg); + } + + error(...msg) { + console.error('[E2E]', ...msg); + } + + isEnabled() { return this.enabled.get(); } @@ -57,43 +74,32 @@ class E2E { return this.readyPromise; } + getE2ERoom(rid) { + return this.instancesByRoomId[rid]; + } + + removeInstanceByRoomId(rid) { + delete this.instancesByRoomId[rid]; + } + async getInstanceByRoomId(roomId) { - if (!this.enabled.get()) { - return; - } + await this.ready(); - const room = Rooms.findOne({ + const room = await waitUntilFind(() => Rooms.findOne({ _id: roomId, - }); + })); - if (!room) { + if (room.t !== 'd' && room.t !== 'p') { return; } - if (room.encrypted !== true && room.e2eKeyId == null) { + if (room.encrypted !== true && !room.e2eKeyId) { return; } - if (!this.instancesByRoomId[roomId]) { - const subscription = Subscriptions.findOne({ - rid: roomId, - }); - - if (!subscription || (subscription.t !== 'd' && subscription.t !== 'p')) { - return; - } - - this.instancesByRoomId[roomId] = new E2ERoom(Meteor.userId(), roomId, subscription.t); - } - - const e2eRoom = this.instancesByRoomId[roomId]; - - await this.ready(); + this.instancesByRoomId[roomId] = this.instancesByRoomId[roomId] ?? new E2ERoom(Meteor.userId(), roomId, room.t); - if (e2eRoom) { - await e2eRoom.handshake(); - return e2eRoom; - } + return this.instancesByRoomId[roomId]; } async startClient() { @@ -101,6 +107,8 @@ class E2E { return; } + this.log('startClient -> STARTED'); + this.started = true; let public_key = Meteor._localStorage.getItem('public_key'); let private_key = Meteor._localStorage.getItem('private_key'); @@ -180,18 +188,18 @@ class E2E { } this.readyPromise.resolve(); + this.log('startClient -> Done'); + this.log('decryptPendingSubscriptions'); - this.setupListeners(); - - this.decryptPendingMessages(); this.decryptPendingSubscriptions(); + this.log('decryptPendingSubscriptions -> Done'); } async stopClient() { - console.log('E2E -> Stop Client'); + this.log('-> Stop Client'); // This flag is used to avoid closing unrelated alerts. if (showingE2EAlert) { - alerts.close(); + banners.close(); } Meteor._localStorage.removeItem('public_key'); @@ -208,27 +216,6 @@ class E2E { }); } - setupListeners() { - Notifications.onUser('e2ekeyRequest', async (roomId, keyId) => { - const e2eRoom = await this.getInstanceByRoomId(roomId); - if (!e2eRoom) { - return; - } - - e2eRoom.provideKeyToUser(keyId); - }); - - Subscriptions.after.update((userId, doc) => { - this.decryptSubscription(doc); - }); - - Subscriptions.after.insert((userId, doc) => { - this.decryptSubscription(doc); - }); - - promises.add('onClientMessageReceived', (msg) => this.decryptMessage(msg), promises.priority.HIGH); - } - async changePassword(newPassword) { await call('e2e.setUserPublicAndPrivateKeys', { public_key: Meteor._localStorage.getItem('public_key'), @@ -247,7 +234,7 @@ class E2E { this.db_public_key = public_key; this.db_private_key = private_key; } catch (error) { - return console.error('E2E -> Error fetching RSA keys: ', error); + return this.error('Error fetching RSA keys: ', error); } } @@ -259,7 +246,7 @@ class E2E { Meteor._localStorage.setItem('private_key', private_key); } catch (error) { - return console.error('E2E -> Error importing private key: ', error); + return this.error('Error importing private key: ', error); } } @@ -270,7 +257,7 @@ class E2E { key = await generateRSAKey(); this.privateKey = key.privateKey; } catch (error) { - return console.error('E2E -> Error generating key: ', error); + return this.error('Error generating key: ', error); } try { @@ -278,7 +265,7 @@ class E2E { Meteor._localStorage.setItem('public_key', JSON.stringify(publicKey)); } catch (error) { - return console.error('E2E -> Error exporting public key: ', error); + return this.error('Error exporting public key: ', error); } try { @@ -286,7 +273,7 @@ class E2E { Meteor._localStorage.setItem('private_key', JSON.stringify(privateKey)); } catch (error) { - return console.error('E2E -> Error exporting private key: ', error); + return this.error('Error exporting private key: ', error); } this.requestSubscriptionKeys(); @@ -311,7 +298,7 @@ class E2E { return EJSON.stringify(joinVectorAndEcryptedData(vector, encodedPrivateKey)); } catch (error) { - return console.error('E2E -> Error encrypting encodedPrivateKey: ', error); + return this.error('Error encrypting encodedPrivateKey: ', error); } } @@ -325,14 +312,14 @@ class E2E { try { baseKey = await importRawKey(toArrayBuffer(password)); } catch (error) { - return console.error('E2E -> Error creating a key based on user password: ', error); + return this.error('Error creating a key based on user password: ', error); } // Derive a key from the password try { return await deriveKey(toArrayBuffer(Meteor.userId()), baseKey); } catch (error) { - return console.error('E2E -> Error deriving baseKey: ', error); + return this.error('Error deriving baseKey: ', error); } } @@ -399,15 +386,11 @@ class E2E { } async decryptMessage(message) { - if (!this.isEnabled()) { - return message; - } - if (message.t !== 'e2e' || message.e2e === 'done') { return message; } - const e2eRoom = await this.getInstanceByRoomId(message.rid); + const e2eRoom = this.getE2ERoom(message.rid); if (!e2eRoom) { return message; @@ -427,62 +410,31 @@ class E2E { } async decryptPendingMessages() { - if (!this.isEnabled()) { - return; - } - return Messages.find({ t: 'e2e', e2e: 'pending' }).forEach(async ({ _id, ...msg }) => { Messages.direct.update({ _id }, await this.decryptMessage(msg)); }); } - async decryptSubscription(subscription) { - if (!this.isEnabled()) { - return; - } - - if (!subscription.lastMessage || subscription.lastMessage.t !== 'e2e' || subscription.lastMessage.e2e === 'done') { - return; - } - - const e2eRoom = await this.getInstanceByRoomId(subscription.rid); - - if (!e2eRoom) { - return; - } - - const data = await e2eRoom.decrypt(subscription.lastMessage.msg); - if (!data) { - return; - } - - Subscriptions.direct.update({ - _id: subscription._id, - }, { - $set: { - 'lastMessage.msg': data.text, - 'lastMessage.e2e': 'done', - }, - }); + async decryptSubscription(rid) { + const e2eRoom = await this.getInstanceByRoomId(rid); + this.log('decryptPendingSubscriptions ->', rid); + e2eRoom?.decryptPendingSubscription(); } async decryptPendingSubscriptions() { Subscriptions.find({ - 'lastMessage.t': 'e2e', - 'lastMessage.e2e': { - $ne: 'done', - }, - }).forEach(this.decryptSubscription.bind(this)); + encrypted: true, + }).forEach((room) => this.decryptSubscription(room._id)); } openAlert(config) { showingE2EAlert = true; - alerts.open(config); + banners.open(config); } closeAlert() { if (showingE2EAlert) { - alerts.close(); + banners.close(); } showingE2EAlert = false; } @@ -490,6 +442,15 @@ class E2E { export const e2e = new E2E(); +const handle = async (roomId, keyId) => { + const e2eRoom = await e2e.getInstanceByRoomId(roomId); + if (!e2eRoom) { + return; + } + + e2eRoom.provideKeyToUser(keyId); +}; + Meteor.startup(function() { Tracker.autorun(function() { if (Meteor.userId()) { @@ -505,33 +466,81 @@ Meteor.startup(function() { } }); - // Encrypt messages before sending - promises.add('onClientBeforeSendMessage', async function(message) { - if (!message.rid) { - return Promise.resolve(message); + let observable = null; + Tracker.autorun(() => { + if (!e2e.isReady()) { + promises.remove('onClientMessageReceived', 'e2e-decript-message'); + Notifications.unUser('e2ekeyRequest', handle); + observable?.stop(); + return promises.remove('onClientBeforeSendMessage', 'e2e'); } - const room = Rooms.findOne({ - _id: message.rid, - }); - if (!room || room.encrypted !== true) { - return Promise.resolve(message); - } + Notifications.onUser('e2ekeyRequest', handle); - const e2eRoom = await e2e.getInstanceByRoomId(message.rid); - if (!e2eRoom) { - return Promise.resolve(message); - } - // Should encrypt this message. - return e2eRoom - .encrypt(message) - .then((msg) => { - message.msg = msg; - message.t = 'e2e'; - message.e2e = 'pending'; + observable = Subscriptions.find().observe({ + changed: async (doc) => { + if (!doc.encrypted && !doc.E2EKey) { + return e2e.removeInstanceByRoomId(doc.rid); + } + const e2eRoom = await e2e.getInstanceByRoomId(doc.rid); + + if (!e2eRoom) { + return; + } + + + doc.encrypted ? e2eRoom.enable() : e2eRoom.pause(); + + // Cover private groups and direct messages + if (!e2eRoom.isSupportedRoomType(doc.t)) { + return e2eRoom.disable(); + } + + + if (doc.E2EKey && e2eRoom.isWaitingKeys()) { + return e2eRoom.keyReceived(); + } + if (!e2eRoom.isReady()) { + return; + } + e2eRoom.decryptPendingSubscription(); + }, + added: async (doc) => { + if (!doc.encrypted && !doc.E2EKey) { + return; + } + return e2e.getInstanceByRoomId(doc.rid); + }, + removed: (doc) => { + e2e.removeInstanceByRoomId(doc.rid); + }, + }); + + promises.add('onClientMessageReceived', (msg) => { + const e2eRoom = e2e.getE2ERoom(msg.rid); + if (!e2eRoom || !e2eRoom.shouldConvertReceivedMessages()) { + return msg; + } + return e2e.decryptMessage(msg); + }, promises.priority.HIGH, 'e2e-decript-message'); + + // Encrypt messages before sending + promises.add('onClientBeforeSendMessage', async function(message) { + const e2eRoom = e2e.getE2ERoom(message.rid); + if (!e2eRoom || !e2eRoom.shouldConvertSentMessages()) { return message; - }); - }, promises.priority.HIGH); + } + // Should encrypt this message. + return e2eRoom + .encrypt(message) + .then((msg) => { + message.msg = msg; + message.t = 'e2e'; + message.e2e = 'pending'; + return message; + }); + }, promises.priority.HIGH, 'e2e'); + }); }); diff --git a/app/e2e/client/rocketchat.e2e.room.js b/app/e2e/client/rocketchat.e2e.room.js index c9776f49fb2d3..83ac0bf3aa2f3 100644 --- a/app/e2e/client/rocketchat.e2e.room.js +++ b/app/e2e/client/rocketchat.e2e.room.js @@ -1,13 +1,13 @@ import _ from 'underscore'; import { Base64 } from 'meteor/base64'; -import { ReactiveVar } from 'meteor/reactive-var'; import { EJSON } from 'meteor/ejson'; import { Random } from 'meteor/random'; +import { Session } from 'meteor/session'; import { TimeSync } from 'meteor/mizzao:timesync'; +import { Emitter } from '@rocket.chat/emitter'; import { e2e } from './rocketchat.e2e'; import { - Deferred, toString, toArrayBuffer, joinVectorAndEcryptedData, @@ -22,79 +22,230 @@ import { importRSAKey, readFileAsArrayBuffer, } from './helper'; -import { Notifications } from '../../notifications'; -import { Rooms, Subscriptions } from '../../models'; +import { Notifications } from '../../notifications/client'; +import { Rooms, Subscriptions, Messages } from '../../models'; import { call } from '../../ui-utils'; import { roomTypes, RoomSettingsEnum } from '../../utils'; -export class E2ERoom { +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', + WAITING_KEYS: 'WAITING_KEYS', + KEYS_RECEIVED: 'KEYS_RECEIVED', + READY: 'READY', + ERROR: 'ERROR', +}; + +const KEY_ID = Symbol('keyID'); + +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; + 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; + 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; + default: + return next; + } +}; + +export class E2ERoom extends Emitter { + log(...msg) { + if (this.roomId === Session.get('openedRoom')) { + console.log('[E2E ROOM]', `[STATE: ${ this.state }]`, `[RID: ${ this.roomId }]`, ...msg); + } + } + + error(...msg) { + if (this.roomId === Session.get('openedRoom')) { + console.error('[E2E ROOM]', `[STATE: ${ this.state }]`, `[RID: ${ this.roomId }]`, ...msg); + } + } + + setState(state) { + const prev = this.state; + + const next = reduce(prev, state); + if (!next) { + this.error(`invalid state ${ prev } -> ${ state }`); + return; + } + this.state = state; + this.emit('STATE_CHANGED', prev, next, this); + this.emit(state, this); + } + constructor(userId, roomId, t) { + super(); + this.state = undefined; + // this.error = undefined; + this.userId = userId; this.roomId = roomId; - this.typeOfRoom = t; - this.establishing = new ReactiveVar(false); - this._ready = new ReactiveVar(false); - this.readyPromise = new Deferred(); - this.readyPromise.then(() => { - this._ready.set(true); - this.establishing.set(false); + this.typeOfRoom = t; - Notifications.onRoom(this.roomId, 'e2ekeyRequest', async (keyId) => { - this.provideKeyToUser(keyId); - }); + this.once(E2E_ROOM_STATES.READY, () => this.decryptPendingMessages()); + this.once(E2E_ROOM_STATES.READY, () => this.decryptPendingSubscription()); + this.on('STATE_CHANGED', (prev) => { + if (this.roomId === Session.get('openedRoom')) { + this.log(`[PREV: ${ prev }]`, 'State CHANGED'); + } }); + this.on('STATE_CHANGED', () => this.handshake()); + this.setState(E2E_ROOM_STATES.NOT_STARTED); } - // Initiates E2E Encryption - async handshake() { - if (!e2e.isReady()) { - return; - } - if (this._ready.get()) { - return; - } + disable() { + this.setState(E2E_ROOM_STATES.DISABLED); + } - if (this.establishing.get()) { - return this.readyPromise; - } + keyReceived() { + this.setState(E2E_ROOM_STATES.KEYS_RECEIVED); + } - console.log('E2E -> Initiating handshake'); + 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.establishing.set(true); + enable() { + ![E2E_ROOM_STATES.READY].includes(this.state) && this.setState(E2E_ROOM_STATES.READY); + } - // Cover private groups and direct messages - if (!this.isSupportedRoomType(this.typeOfRoom)) { - return; - } + shouldConvertSentMessages() { + return this.isReady() && ! this.isPaused(); + } - // Fetch encrypted session key from subscription model - let groupKey; - try { - groupKey = Subscriptions.findOne({ rid: this.roomId }).E2EKey; - } catch (error) { - return console.error('E2E -> Error fetching group key: ', error); - } + shouldConvertReceivedMessages() { + return this.isReady(); + } - if (groupKey) { - await this.importGroupKey(groupKey); - this.readyPromise.resolve(); - return true; - } + isDisabled() { + 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); + return el; + }); + } + + isReady() { + return [E2E_ROOM_STATES.PAUSED, E2E_ROOM_STATES.READY].includes(this.state); + } + + isWaitingKeys() { + return this.state === E2E_ROOM_STATES.WAITING_KEYS; + } + + get keyID() { + return this[KEY_ID]; + } + + set keyID(keyID) { + this[KEY_ID] = keyID; + } - const room = Rooms.findOne({ _id: this.roomId }); + async decryptPendingSubscription() { + const subscription = Subscriptions.findOne({ + rid: this.roomId, + }); - if (!room.e2eKeyId) { - await this.createGroupKey(); - this.readyPromise.resolve(); - return true; + const data = await (subscription.lastMessage?.msg && this.decrypt(subscription.lastMessage.msg)); + if (!data?.text) { + this.log('decryptPendingSubscriptions nothing to do'); + return; } - console.log('E2E -> Requesting room key'); - // TODO: request group key + Subscriptions.direct.update({ + _id: subscription._id, + }, { + $set: { + 'lastMessage.msg': data.text, + 'lastMessage.e2e': 'done', + }, + }); + this.log('decryptPendingSubscriptions Done'); + } + + async decryptPendingMessages() { + return Messages.find({ rid: this.roomId, t: 'e2e', e2e: 'pending' }).forEach(async ({ _id, ...msg }) => { + Messages.direct.update({ _id }, await this.decryptMessage(msg)); + }); + } - Notifications.notifyUsersOfRoom(this.roomId, 'e2ekeyRequest', this.roomId, room.e2eKeyId); + // Initiates E2E Encryption + async handshake() { + switch (this.state) { + case E2E_ROOM_STATES.KEYS_RECEIVED: + case E2E_ROOM_STATES.NOT_STARTED: + this.setState(E2E_ROOM_STATES.ESTABLISHING); + try { + const groupKey = Subscriptions.findOne({ rid: this.roomId }).E2EKey; + if (groupKey) { + await this.importGroupKey(groupKey); + return this.setState(E2E_ROOM_STATES.READY); + } + } catch (error) { + this.setState(E2E_ROOM_STATES.ERROR); + // this.error = error; + return this.error('Error fetching group key: ', error); + } + + try { + const room = Rooms.findOne({ _id: this.roomId }); + if (!room.e2eKeyId) { // TODO CHECK_PERMISSION + this.setState(E2E_ROOM_STATES.CREATING_KEYS); + await this.createGroupKey(); + return this.setState(E2E_ROOM_STATES.READY); + } + this.setState(E2E_ROOM_STATES.WAITING_KEYS); + this.log('Requesting room key'); + Notifications.notifyUsersOfRoom(this.roomId, 'e2ekeyRequest', this.roomId, room.e2eKeyId); + } catch (error) { + // this.error = error; + this.setState(E2E_ROOM_STATES.ERROR); + } + } } isSupportedRoomType(type) { @@ -102,7 +253,7 @@ export class E2ERoom { } async importGroupKey(groupKey) { - console.log('E2E -> Importing room key'); + this.log('Importing room key ->', this.roomId); // Get existing group key // const keyID = groupKey.slice(0, 12); groupKey = groupKey.slice(12); @@ -113,7 +264,7 @@ export class E2ERoom { const decryptedKey = await decryptRSA(e2e.privateKey, groupKey); this.sessionKeyExportedString = toString(decryptedKey); } catch (error) { - return console.error('E2E -> Error decrypting group key: ', error); + return this.error('Error decrypting group key: ', error); } this.keyID = Base64.encode(this.sessionKeyExportedString).slice(0, 12); @@ -124,68 +275,59 @@ export class E2ERoom { // Key has been obtained. E2E is now in session. this.groupSessionKey = key; } catch (error) { - return console.error('E2E -> Error importing group key: ', error); + return this.error('Error importing group key: ', error); } } async createGroupKey() { - console.log('E2E -> Creating room key'); + this.log('Creating room key'); // Create group key - let key; try { - key = await generateAESKey(); - this.groupSessionKey = key; + this.groupSessionKey = await generateAESKey(); } catch (error) { - return console.error('E2E -> Error generating group key: ', error); + console.error('Error generating group key: ', error); + throw error; } - let sessionKeyExported; try { - sessionKeyExported = await exportJWKKey(this.groupSessionKey); + const sessionKeyExported = await exportJWKKey(this.groupSessionKey); + this.sessionKeyExportedString = JSON.stringify(sessionKeyExported); + this.keyID = Base64.encode(this.sessionKeyExportedString).slice(0, 12); + + await call('e2e.setRoomKeyID', this.roomId, this.keyID); + await this.encryptKeyForOtherParticipants(); } catch (error) { - return console.error('E2E -> Error exporting group key: ', error); + this.error('Error exporting group key: ', error); + throw error; } - - this.sessionKeyExportedString = JSON.stringify(sessionKeyExported); - this.keyID = Base64.encode(this.sessionKeyExportedString).slice(0, 12); - - await call('e2e.setRoomKeyID', this.roomId, this.keyID); - - await this.encryptKeyForOtherParticipants(); } async encryptKeyForOtherParticipants() { // Encrypt generated session key for every user in room and publish to subscription model. - let users; try { - users = await call('e2e.getUsersOfRoomWithoutKey', this.roomId); + const { users } = await call('e2e.getUsersOfRoomWithoutKey', this.roomId); + users.forEach((user) => this.encryptForParticipant(user)); } catch (error) { - return console.error('E2E -> Error getting room users: ', error); + return this.error('Error getting room users: ', error); } - - users.users.forEach((user) => this.encryptForParticipand(user)); } - async encryptForParticipand(user) { - if (user.e2e.public_key) { - let userKey; - try { - userKey = await importRSAKey(JSON.parse(user.e2e.public_key), ['encrypt']); - } catch (error) { - return console.error('E2E -> Error importing user key: ', error); - } - // const vector = crypto.getRandomValues(new Uint8Array(16)); - - // Encrypt session key for this user with his/her public key - let encryptedUserKey; - try { - encryptedUserKey = await encryptRSA(userKey, toArrayBuffer(this.sessionKeyExportedString)); - } catch (error) { - return console.error('E2E -> Error encrypting user key: ', error); - } + async encryptForParticipant(user) { + let userKey; + try { + userKey = await importRSAKey(JSON.parse(user.e2e.public_key), ['encrypt']); + } catch (error) { + return this.error('Error importing user key: ', error); + } + // const vector = crypto.getRandomValues(new Uint8Array(16)); + // Encrypt session key for this user with his/her public key + try { + const encryptedUserKey = await encryptRSA(userKey, toArrayBuffer(this.sessionKeyExportedString)); // Key has been encrypted. Publish to that user's subscription model for this room. await call('e2e.updateGroupKey', this.roomId, user._id, this.keyID + Base64.encode(new Uint8Array(encryptedUserKey))); + } catch (error) { + return this.error('Error encrypting user key: ', error); } } @@ -202,7 +344,7 @@ export class E2ERoom { try { result = await encryptAES(vector, this.groupSessionKey, fileArrayBuffer); } catch (error) { - return console.error('E2E -> Error encrypting group key: ', error); + return this.error('Error encrypting group key: ', error); } const output = joinVectorAndEcryptedData(vector, result); @@ -223,7 +365,7 @@ export class E2ERoom { try { return await decryptAES(vector, this.groupSessionKey, cipherText); } catch (error) { - console.error('E2E -> Error decrypting file: ', error); + this.error('Error decrypting file: ', error); return false; } @@ -244,7 +386,7 @@ export class E2ERoom { try { result = await encryptAES(vector, this.groupSessionKey, data); } catch (error) { - return console.error('E2E -> Error encrypting message: ', error); + return this.error('Error encrypting message: ', error); } return this.keyID + Base64.encode(joinVectorAndEcryptedData(vector, result)); @@ -265,11 +407,30 @@ export class E2ERoom { userId: this.userId, ts, })); - const enc = this.encryptText(data); - return enc; + + return this.encryptText(data); } // Decrypt messages + + async decryptMessage(message) { + if (message.t !== 'e2e' || message.e2e === 'done') { + return message; + } + + const data = await this.decrypt(message.msg); + + if (!data?.text) { + return message; + } + + return { + ...message, + msg: data.text, + e2e: 'done', + }; + } + async decrypt(message) { if (!this.isSupportedRoomType(this.typeOfRoom)) { return message; @@ -289,7 +450,7 @@ export class E2ERoom { const result = await decryptAES(vector, this.groupSessionKey, cipherText); return EJSON.parse(new TextDecoder('UTF-8').decode(new Uint8Array(result))); } catch (error) { - return console.error('E2E -> Error decrypting message: ', error, message); + return this.error('Error decrypting message: ', error, message); } } diff --git a/app/e2e/client/tabbar.ts b/app/e2e/client/tabbar.ts index b3bdfa7c8cbe2..8a45bf4bdb1b7 100644 --- a/app/e2e/client/tabbar.ts +++ b/app/e2e/client/tabbar.ts @@ -5,10 +5,13 @@ import { addAction } from '../../../client/views/room/lib/Toolbox'; import { useSetting } from '../../../client/contexts/SettingsContext'; import { usePermission } from '../../../client/contexts/AuthorizationContext'; import { useMethod } from '../../../client/contexts/ServerContext'; +import { e2e } from './rocketchat.e2e'; addAction('e2e', ({ room }) => { const e2eEnabled = useSetting('E2E_Enable'); - const hasPermission = usePermission('edit-room', room._id); + const e2eReady = e2e.isReady() || room.encrypted; + const e2ePermission = room.t === 'd' || usePermission('toggle-room-e2e-encryption', room._id); + const hasPermission = usePermission('edit-room', room._id) && e2ePermission && e2eReady; const toggleE2E = useMethod('saveRoomSettings'); const action = useMutableCallback(() => { diff --git a/app/e2e/server/beforeCreateRoom.js b/app/e2e/server/beforeCreateRoom.js index 29ee95653d42c..ce3b21ad69355 100644 --- a/app/e2e/server/beforeCreateRoom.js +++ b/app/e2e/server/beforeCreateRoom.js @@ -3,9 +3,9 @@ import { settings } from '../../settings/server'; callbacks.add('beforeCreateRoom', ({ type, extraData }) => { if ( - (type === 'd' && settings.get('E2E_Enabled_Default_DirectRooms')) - || (type === 'p' && settings.get('E2E_Enabled_Default_PrivateRooms')) + settings.get('E2E_Enabled') && ((type === 'd' && settings.get('E2E_Enabled_Default_DirectRooms')) + || (type === 'p' && settings.get('E2E_Enabled_Default_PrivateRooms'))) ) { - extraData.encrypted = true; + extraData.encrypted = extraData.encrypted ?? true; } }); diff --git a/app/file-upload/server/lib/FileUpload.js b/app/file-upload/server/lib/FileUpload.js index 73c5ba261ac3f..b6e2d36bd7708 100644 --- a/app/file-upload/server/lib/FileUpload.js +++ b/app/file-upload/server/lib/FileUpload.js @@ -10,6 +10,7 @@ import { UploadFS } from 'meteor/jalik:ufs'; import { Match } from 'meteor/check'; import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; import filesize from 'filesize'; +import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions'; import { settings } from '../../../settings/server'; import Uploads from '../../../models/server/models/Uploads'; @@ -25,6 +26,8 @@ import { canAccessRoom } from '../../../authorization/server/functions/canAccess import { fileUploadIsValidContentType } from '../../../utils/lib/fileUploadRestrictions'; import { isValidJWT, generateJWT } from '../../../utils/server/lib/JWTHelper'; import { Messages } from '../../../models/server'; +import { AppEvents, Apps } from '../../../apps/server'; +import { streamToBuffer } from './streamToBuffer'; const cookie = new Cookies(); let maxFileSize = 0; @@ -55,7 +58,7 @@ export const FileUpload = { }, options, FileUpload[`default${ type }`]())); }, - validateFileUpload(file) { + validateFileUpload({ file, content }) { if (!Match.test(file.rid, String)) { return false; } @@ -93,10 +96,21 @@ export const FileUpload = { throw new Meteor.Error('error-invalid-file-type', reason); } + // App IPreFileUpload event hook + try { + Promise.await(Apps.triggerEvent(AppEvents.IPreFileUpload, { file, content })); + } catch (error) { + if (error instanceof AppsEngineException) { + throw new Meteor.Error('error-app-prevented', error.message); + } + + throw error; + } + return true; }, - validateAvatarUpload(file) { + validateAvatarUpload({ file }) { if (!Match.test(file.rid, String) && !Match.test(file.userId, String)) { return false; } @@ -267,16 +281,18 @@ export const FileUpload = { return fut.return(); } + const rotated = typeof metadata.orientation !== 'undefined' && metadata.orientation !== 1; + const identify = { format: metadata.format, size: { - width: metadata.width, - height: metadata.height, + width: rotated ? metadata.height : metadata.width, + height: rotated ? metadata.width : metadata.height, }, }; const reorientation = (cb) => { - if (!metadata.orientation || metadata.orientation === 1 || settings.get('FileUpload_RotateImages') !== true) { + if (!rotated || settings.get('FileUpload_RotateImages') !== true) { return cb(); } s.rotate() @@ -583,12 +599,14 @@ export class FileUploadClass { } insert(fileData, streamOrBuffer, cb) { - fileData.size = parseInt(fileData.size) || 0; + if (streamOrBuffer instanceof stream) { + streamOrBuffer = Promise.await(streamToBuffer(streamOrBuffer)); + } // Check if the fileData matches store filter const filter = this.store.getFilter(); if (filter && filter.check) { - filter.check(fileData); + filter.check({ file: fileData, content: streamOrBuffer }); } return this._doInsert(fileData, streamOrBuffer, cb); diff --git a/app/file-upload/server/lib/streamToBuffer.ts b/app/file-upload/server/lib/streamToBuffer.ts new file mode 100644 index 0000000000000..34dc1c434a32a --- /dev/null +++ b/app/file-upload/server/lib/streamToBuffer.ts @@ -0,0 +1,11 @@ +import { Readable } from 'stream'; + +export const streamToBuffer = (stream: Readable): Promise => new Promise((resolve) => { + const chunks: Array = []; + + stream + .on('data', (data) => chunks.push(data)) + .on('end', () => resolve(Buffer.concat(chunks))) + // force stream to resume data flow in case it was explicitly paused before + .resume(); +}); diff --git a/app/lib/lib/MessageTypes.js b/app/lib/lib/MessageTypes.js index 8c6364f0ab13d..aa265826a419a 100644 --- a/app/lib/lib/MessageTypes.js +++ b/app/lib/lib/MessageTypes.js @@ -159,6 +159,26 @@ Meteor.startup(function() { }; }, }); + MessageTypes.registerType({ + id: 'room_e2e_enabled', + system: true, + message: 'This_room_encryption_has_been_enabled_by__username_', + data(message) { + return { + username: message.u.username, + }; + }, + }); + MessageTypes.registerType({ + id: 'room_e2e_disabled', + system: true, + message: 'This_room_encryption_has_been_disabled_by__username_', + data(message) { + return { + username: message.u.username, + }; + }, + }); }); export const MessageTypesValues = [ @@ -210,4 +230,12 @@ export const MessageTypesValues = [ key: 'room_changed_avatar', i18nLabel: 'Message_HideType_room_changed_avatar', }, + { + key: 'room_e2e_enabled', + i18nLabel: 'Message_HideType_room_enabled_encryption', + }, + { + key: 'room_e2e_disabled', + i18nLabel: 'Message_HideType_room_disabled_encryption', + }, ]; diff --git a/app/lib/server/lib/interceptDirectReplyEmails.js b/app/lib/server/lib/interceptDirectReplyEmails.js index 0af20d2e2d196..0ef5361b66d6c 100644 --- a/app/lib/server/lib/interceptDirectReplyEmails.js +++ b/app/lib/server/lib/interceptDirectReplyEmails.js @@ -1,144 +1,29 @@ import { Meteor } from 'meteor/meteor'; -import IMAP from 'imap'; import POP3Lib from 'poplib'; import { simpleParser } from 'mailparser'; import { settings } from '../../../settings'; +import { IMAPInterceptor } from '../../../../server/email/IMAPInterceptor'; import { processDirectEmail } from '.'; -export class IMAPIntercepter { - constructor() { - this.imap = new IMAP({ +export class IMAPIntercepter extends IMAPInterceptor { + constructor(imapConfig, options = {}) { + imapConfig = { user: settings.get('Direct_Reply_Username'), password: settings.get('Direct_Reply_Password'), host: settings.get('Direct_Reply_Host'), port: settings.get('Direct_Reply_Port'), debug: settings.get('Direct_Reply_Debug') ? console.log : false, tls: !settings.get('Direct_Reply_IgnoreTLS'), - connTimeout: 30000, - keepalive: true, - }); - - this.delete = settings.get('Direct_Reply_Delete'); - - // On successfully connected. - this.imap.on('ready', Meteor.bindEnvironment(() => { - if (this.imap.state !== 'disconnected') { - this.openInbox(Meteor.bindEnvironment((err) => { - if (err) { - throw err; - } - // fetch new emails & wait [IDLE] - this.getEmails(); - - // If new message arrived, fetch them - this.imap.on('mail', Meteor.bindEnvironment(() => { - this.getEmails(); - })); - })); - } else { - console.log('IMAP didnot connected.'); - this.imap.end(); - } - })); - - this.imap.on('error', (err) => { - console.log('Error occurred ...'); - throw err; - }); - } - - openInbox(cb) { - this.imap.openBox('INBOX', false, cb); - } - - start() { - this.imap.connect(); - } - - isActive() { - if (this.imap && this.imap.state && this.imap.state === 'disconnected') { - return false; - } - - return true; - } - - stop(callback = new Function()) { - this.imap.end(); - this.imap.once('end', callback); - } - - restart() { - this.stop(() => { - console.log('Restarting IMAP ....'); - this.start(); - }); - } - - // Fetch all UNSEEN messages and pass them for further processing - getEmails() { - this.imap.search(['UNSEEN'], Meteor.bindEnvironment((err, newEmails) => { - if (err) { - console.log(err); - throw err; - } - - // newEmails => array containing serials of unseen messages - if (newEmails.length > 0) { - const f = this.imap.fetch(newEmails, { - // fetch headers & first body part. - bodies: ['HEADER.FIELDS (FROM TO DATE MESSAGE-ID)', '1'], - struct: true, - markSeen: true, - }); - - f.on('message', Meteor.bindEnvironment((msg, seqno) => { - const email = {}; - - msg.on('body', (stream, info) => { - let headerBuffer = ''; - let bodyBuffer = ''; - - stream.on('data', (chunk) => { - if (info.which === '1') { - bodyBuffer += chunk.toString('utf8'); - } else { - headerBuffer += chunk.toString('utf8'); - } - }); + ...imapConfig, + }; - stream.once('end', () => { - if (info.which === '1') { - email.body = bodyBuffer; - } else { - // parse headers - email.headers = IMAP.parseHeader(headerBuffer); + options.deleteAfterRead = settings.get('Direct_Reply_Delete'); - email.headers.to = email.headers.to[0]; - email.headers.date = email.headers.date[0]; - email.headers.from = email.headers.from[0]; - } - }); - }); + super(imapConfig, options); - // On fetched each message, pass it further - msg.once('end', Meteor.bindEnvironment(() => { - // delete message from inbox - if (this.delete) { - this.imap.seq.addFlags(seqno, 'Deleted', (err) => { - if (err) { console.log(`Mark deleted error: ${ err }`); } - }); - } - processDirectEmail(email); - })); - })); - f.once('error', (err) => { - console.log(`Fetch error: ${ err }`); - }); - } - })); + this.on('email', Meteor.bindEnvironment((email) => processDirectEmail(email))); } } diff --git a/app/lib/server/startup/settings.js b/app/lib/server/startup/settings.js index 717de7fd81767..cc65e6c70b251 100644 --- a/app/lib/server/startup/settings.js +++ b/app/lib/server/startup/settings.js @@ -979,11 +979,16 @@ settings.addGroup('General', function() { public: true, }); }); - return this.section('Stream_Cast', function() { + this.section('Stream_Cast', function() { return this.add('Stream_Cast_Address', '', { type: 'string', }); }); + this.section('NPS', function() { + this.add('NPS_survey_enabled', true, { + type: 'boolean', + }); + }); }); settings.addGroup('Message', function() { @@ -1554,16 +1559,16 @@ settings.addGroup('Setup_Wizard', function() { type: 'select', values: [ { - key: 'advocacy', - i18nLabel: 'Advocacy', + key: 'aerospaceDefense', + i18nLabel: 'Aerospace_and_Defense', }, { key: 'blockchain', i18nLabel: 'Blockchain', }, { - key: 'helpCenter', - i18nLabel: 'Help_Center', + key: 'contactCenter', + i18nLabel: 'Contact_Center', }, { key: 'manufacturing', @@ -1589,10 +1594,6 @@ settings.addGroup('Setup_Wizard', function() { key: 'entertainment', i18nLabel: 'Entertainment', }, - { - key: 'publicRelations', - i18nLabel: 'Public_Relations', - }, { key: 'religious', i18nLabel: 'Religious', @@ -1609,29 +1610,25 @@ settings.addGroup('Setup_Wizard', function() { key: 'realEstate', i18nLabel: 'Real_Estate', }, - { - key: 'tourism', - i18nLabel: 'Tourism', - }, { key: 'telecom', i18nLabel: 'Telecom', }, { key: 'consumerGoods', - i18nLabel: 'Consumer_Goods', + i18nLabel: 'Consumer_Packaged_Goods', }, { key: 'financialServices', i18nLabel: 'Financial_Services', }, { - key: 'healthcarePharmaceutical', - i18nLabel: 'Healthcare_and_Pharmaceutical', + key: 'healthcare', + i18nLabel: 'Healthcare', }, { - key: 'industry', - i18nLabel: 'Industry', + key: 'pharmaceutical', + i18nLabel: 'Pharmaceutical', }, { key: 'media', @@ -1649,6 +1646,18 @@ settings.addGroup('Setup_Wizard', function() { key: 'technologyProvider', i18nLabel: 'Technology_Provider', }, + { + key: 'hospitalityBusinness', + i18nLabel: 'Hospitality_Businness', + }, + { + key: 'itSecurity', + i18nLabel: 'It_Security', + }, + { + key: 'utilities', + i18nLabel: 'Utilities', + }, { key: 'other', i18nLabel: 'Other', diff --git a/app/livechat/client/ui.js b/app/livechat/client/ui.js index 3f534e1ddb66a..069254af70238 100644 --- a/app/livechat/client/ui.js +++ b/app/livechat/client/ui.js @@ -15,7 +15,7 @@ Tracker.autorun((c) => { AccountBox.addItem({ name: 'Omnichannel', - icon: 'omnichannel', + icon: 'headset', href: '/omnichannel/current', sideNav: 'omnichannelFlex', condition: () => settings.get('Livechat_enabled') && hasAllPermission('view-livechat-manager'), diff --git a/app/livechat/client/views/app/tabbar/visitorInfo.html b/app/livechat/client/views/app/tabbar/visitorInfo.html index a4624bef2a704..c46ed0b1ec51d 100644 --- a/app/livechat/client/views/app/tabbar/visitorInfo.html +++ b/app/livechat/client/views/app/tabbar/visitorInfo.html @@ -39,6 +39,8 @@

{{_ "Conversation"}}

    {{#with room}} {{#if servedBy}}
  • {{_ "Agent"}}: {{servedBy.username}}
  • {{/if}} + {{#if email}}
  • {{_ "Email_Inbox"}}: {{email.inbox}}
  • {{/if}} + {{#if email}}
  • {{_ "Email_subject"}}: {{email.subject}}
  • {{/if}} {{#if facebook}}
  • {{_ "Facebook_Page"}}: {{facebook.page.name}}
  • {{/if}} {{#if sms}}
  • {{_ "SMS_Enabled"}}
  • {{/if}} {{#if topic}}
  • {{_ "Topic"}}: {{{markdown topic}}}
  • {{/if}} diff --git a/app/livechat/client/views/app/tabbar/visitorInfo.js b/app/livechat/client/views/app/tabbar/visitorInfo.js index 46e11e7f52f97..f061207a35af6 100644 --- a/app/livechat/client/views/app/tabbar/visitorInfo.js +++ b/app/livechat/client/views/app/tabbar/visitorInfo.js @@ -202,7 +202,8 @@ Template.visitorInfo.helpers({ }, canSendTranscript() { - return hasPermission('send-omnichannel-chat-transcript'); + const room = Template.instance().room.get(); + return !room.email && hasPermission('send-omnichannel-chat-transcript'); }, roomClosedDateTime() { diff --git a/app/livechat/server/api/lib/inquiries.js b/app/livechat/server/api/lib/inquiries.js index eefba5f313e88..e56392d69b145 100644 --- a/app/livechat/server/api/lib/inquiries.js +++ b/app/livechat/server/api/lib/inquiries.js @@ -43,7 +43,15 @@ export async function findInquiries({ userId, department: filterDepartment, stat const filter = { ...status && { status }, - ...department && { department }, + $or: [ + { + $and: [ + { defaultAgent: { $exists: true } }, + { 'defaultAgent.agentId': userId }, + ], + }, + { ...department && { department } }, + ], }; const cursor = LivechatInquiry.find(filter, options); diff --git a/app/livechat/server/api/v1/contact.js b/app/livechat/server/api/v1/contact.js index 10880b2829135..f98b721c55fad 100644 --- a/app/livechat/server/api/v1/contact.js +++ b/app/livechat/server/api/v1/contact.js @@ -1,11 +1,13 @@ import { Match, check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; import { API } from '../../../../api/server'; -import { Livechat } from '../../lib/Livechat'; +import { Contacts } from '../../lib/Contacts'; import { LivechatVisitors, } from '../../../../models'; + API.v1.addRoute('omnichannel/contact', { authRequired: true }, { post() { try { @@ -15,15 +17,11 @@ API.v1.addRoute('omnichannel/contact', { authRequired: true }, { name: String, email: Match.Maybe(String), phone: Match.Maybe(String), - livechatData: Match.Maybe(Object), + customFields: Match.Maybe(Object), contactManager: Match.Maybe(Object), }); - const contactParams = this.bodyParams; - if (this.bodyParams.phone) { - contactParams.phone = { number: this.bodyParams.phone }; - } - const contact = Livechat.registerGuest(contactParams); + const contact = Contacts.registerContact(this.bodyParams); return API.v1.success({ contact }); } catch (e) { @@ -40,3 +38,31 @@ API.v1.addRoute('omnichannel/contact', { authRequired: true }, { return API.v1.success({ contact }); }, }); + + +API.v1.addRoute('omnichannel/contact.search', { authRequired: true }, { + get() { + try { + check(this.queryParams, { + email: Match.Maybe(String), + phone: Match.Maybe(String), + }); + + const { email, phone } = this.queryParams; + + if (!email && !phone) { + throw new Meteor.Error('error-invalid-params'); + } + + const query = Object.assign({}, { + ...email && { visitorEmails: { address: email } }, + ...phone && { phone: { phoneNumber: phone } }, + }); + + const contact = Promise.await(LivechatVisitors.findOne(query)); + return API.v1.success({ contact }); + } catch (e) { + return API.v1.failure(e); + } + }, +}); diff --git a/app/livechat/server/business-hour/AbstractBusinessHour.ts b/app/livechat/server/business-hour/AbstractBusinessHour.ts index 82f9bbc490595..32093d843eea1 100644 --- a/app/livechat/server/business-hour/AbstractBusinessHour.ts +++ b/app/livechat/server/business-hour/AbstractBusinessHour.ts @@ -55,7 +55,7 @@ export abstract class AbstractBusinessHourType { businessHourData.active = Boolean(businessHourData.active); businessHourData = this.convertWorkHours(businessHourData); if (businessHourData._id) { - await this.BusinessHourRepository.updateOne(businessHourData._id, businessHourData); + await this.BusinessHourRepository.updateOne({ _id: businessHourData._id }, { $set: businessHourData }); return businessHourData._id; } const { insertedId } = await this.BusinessHourRepository.insertOne(businessHourData); diff --git a/app/livechat/server/hooks/beforeGetNextAgent.js b/app/livechat/server/hooks/beforeDelegateAgent.js similarity index 64% rename from app/livechat/server/hooks/beforeGetNextAgent.js rename to app/livechat/server/hooks/beforeDelegateAgent.js index 6151c771f8cee..101d53e797d21 100644 --- a/app/livechat/server/hooks/beforeGetNextAgent.js +++ b/app/livechat/server/hooks/beforeDelegateAgent.js @@ -3,7 +3,13 @@ import { callbacks } from '../../../callbacks'; import { settings } from '../../../settings'; import { Users, LivechatDepartmentAgents } from '../../../models'; -callbacks.add('livechat.beforeGetNextAgent', (department) => { +callbacks.add('livechat.beforeDelegateAgent', (options = {}) => { + const { department, agent } = options; + + if (agent) { + return agent; + } + if (!settings.get('Livechat_assign_new_conversation_to_bot')) { return null; } @@ -13,4 +19,4 @@ callbacks.add('livechat.beforeGetNextAgent', (department) => { } return Users.getNextBotAgent(); -}, callbacks.priority.HIGH, 'livechat-before-get-next-agent'); +}, callbacks.priority.HIGH, 'livechat-before-delegate-agent'); diff --git a/app/livechat/server/index.js b/app/livechat/server/index.js index 435627266cf42..9d2c6fdf3b355 100644 --- a/app/livechat/server/index.js +++ b/app/livechat/server/index.js @@ -6,7 +6,7 @@ import '../lib/messageTypes'; import './config'; import './roomType'; import './hooks/beforeCloseRoom'; -import './hooks/beforeGetNextAgent'; +import './hooks/beforeDelegateAgent'; import './hooks/leadCapture'; import './hooks/markRoomResponded'; import './hooks/offlineMessage'; diff --git a/app/livechat/server/lib/Contacts.js b/app/livechat/server/lib/Contacts.js new file mode 100644 index 0000000000000..7e0f94e10d7c8 --- /dev/null +++ b/app/livechat/server/lib/Contacts.js @@ -0,0 +1,65 @@ +import { check } from 'meteor/check'; +import s from 'underscore.string'; + +import { + LivechatVisitors, + LivechatCustomField, +} from '../../../models'; + + +export const Contacts = { + + registerContact({ token, name, email, phone, username, customFields = {}, contactManager = {} } = {}) { + check(token, String); + + let contactId; + const updateUser = { + $set: { + token, + }, + }; + + const user = LivechatVisitors.getVisitorByToken(token, { fields: { _id: 1 } }); + + if (user) { + contactId = user._id; + } else { + if (!username) { + username = LivechatVisitors.getNextVisitorUsername(); + } + + let existingUser = null; + + if (s.trim(email) !== '' && (existingUser = LivechatVisitors.findOneGuestByEmailAddress(email))) { + contactId = existingUser._id; + } else { + const userData = { + username, + ts: new Date(), + }; + + contactId = LivechatVisitors.insert(userData); + } + } + + updateUser.$set.name = name; + updateUser.$set.phone = (phone && [{ phoneNumber: phone }]) || null; + updateUser.$set.visitorEmails = (email && [{ address: email }]) || null; + + const allowedCF = LivechatCustomField.find({ scope: 'visitor' }).map(({ _id }) => _id); + + const livechatData = Object.keys(customFields) + .filter((key) => allowedCF.includes(key) && customFields[key] !== '' && customFields[key] !== undefined) + .reduce((obj, key) => { + obj[key] = customFields[key]; + return obj; + }, {}); + + updateUser.$set.livechatData = livechatData; + updateUser.$set.contactManager = (contactManager?.username && { username: contactManager.username }) || null; + + LivechatVisitors.updateById(contactId, updateUser); + + return contactId; + }, +}; diff --git a/app/livechat/server/lib/Helper.js b/app/livechat/server/lib/Helper.js index 81c5a3482fa72..e762f51f0db54 100644 --- a/app/livechat/server/lib/Helper.js +++ b/app/livechat/server/lib/Helper.js @@ -1,7 +1,9 @@ import { Meteor } from 'meteor/meteor'; +import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; import { Match, check } from 'meteor/check'; import { LivechatTransferEventType } from '@rocket.chat/apps-engine/definition/livechat'; +import { hasRole } from '../../../authorization'; import { Messages, LivechatRooms, Rooms, Subscriptions, Users, LivechatInquiry, LivechatDepartment, LivechatDepartmentAgents } from '../../../models/server'; import { Livechat } from './Livechat'; import { RoutingManager } from './RoutingManager'; @@ -9,6 +11,15 @@ import { callbacks } from '../../../callbacks/server'; import { settings } from '../../../settings'; import { Apps, AppEvents } from '../../../apps/server'; import notifications from '../../../notifications/server/lib/Notifications'; +import { sendNotification } from '../../../lib/server'; + +export const allowAgentSkipQueue = (agent) => { + check(agent, Match.ObjectIncluding({ + agentId: String, + })); + + return hasRole(agent.agentId, 'bot'); +}; export const createLivechatRoom = (rid, name, guest, roomInfo = {}, extraData = {}) => { check(rid, String); @@ -199,6 +210,61 @@ export const dispatchAgentDelegated = (rid, agentId) => { }); }; +export const dispatchInquiryQueued = (inquiry, agent) => { + if (!inquiry?._id) { + return; + } + + const { department, rid, v } = inquiry; + const room = LivechatRooms.findOneById(rid); + Meteor.defer(() => callbacks.run('livechat.chatQueued', room)); + + if (RoutingManager.getConfig().autoAssignAgent) { + return; + } + + if (!agent || !allowAgentSkipQueue(agent)) { + LivechatInquiry.queueInquiry(inquiry._id); + } + + // Alert only the online agents of the queued request + const onlineAgents = Livechat.getOnlineAgents(department, agent); + + const notificationUserName = v && (v.name || v.username); + + onlineAgents.forEach((agent) => { + if (agent.agentId) { + agent = Users.findOneById(agent.agentId); + } + const { _id, active, emails, language, status, statusConnection, username } = agent; + sendNotification({ + // fake a subscription in order to make use of the function defined above + subscription: { + rid, + t: 'l', + u: { + _id, + }, + receiver: [{ + active, + emails, + language, + status, + statusConnection, + username, + }], + }, + sender: v, + hasMentionToAll: true, // consider all agents to be in the room + hasMentionToHere: false, + message: Object.assign({}, { u: v }), + notificationMessage: TAPi18n.__('User_started_a_new_conversation', { username: notificationUserName }, language), + room: Object.assign(room, { name: TAPi18n.__('New_chat_in_queue', {}, language) }), + mentionIds: [], + }); + }); +}; + export const forwardRoomToAgent = async (room, transferData) => { if (!room || !room.open) { return false; @@ -351,13 +417,13 @@ export const normalizeTransferredByData = (transferredBy, room) => { }; export const checkServiceStatus = ({ guest, agent }) => { - if (agent) { - const { agentId } = agent; - const users = Users.findOnlineAgents(agentId); - return users && users.count() > 0; + if (!agent) { + return Livechat.online(guest.department); } - return Livechat.online(guest.department); + const { agentId } = agent; + const users = Users.findOnlineAgents(agentId); + return users && users.count() > 0; }; export const userCanTakeInquiry = (user) => { diff --git a/app/livechat/server/lib/Livechat.js b/app/livechat/server/lib/Livechat.js index dbd53debd3c02..9e407614bd186 100644 --- a/app/livechat/server/lib/Livechat.js +++ b/app/livechat/server/lib/Livechat.js @@ -63,7 +63,7 @@ export const Livechat = { } const onlineAgents = Livechat.getOnlineAgents(department); - return (onlineAgents && onlineAgents.count() > 0) || settings.get('Livechat_accept_chats_with_no_agents'); + return onlineAgents && onlineAgents.count() > 0; }, getNextAgent(department) { @@ -78,7 +78,11 @@ export const Livechat = { return Users.findAgents(); }, - getOnlineAgents(department) { + getOnlineAgents(department, agent) { + if (agent?.agentId) { + return Users.findOnlineAgents(agent.agentId); + } + if (department) { return LivechatDepartmentAgents.getOnlineForDepartment(department); } @@ -192,7 +196,7 @@ export const Livechat = { return true; }, - registerGuest({ token, name, email, department, phone, username, livechatData, contactManager, connectionData } = {}) { + registerGuest({ token, name, email, department, phone, username, connectionData } = {}) { check(token, String); let userId; @@ -200,7 +204,6 @@ export const Livechat = { $set: { token, }, - $unset: { }, }; const user = LivechatVisitors.getVisitorByToken(token, { fields: { _id: 1 } }); @@ -235,45 +238,29 @@ export const Livechat = { } } - if (name) { - updateUser.$set.name = name; - } - if (phone) { updateUser.$set.phone = [ { phoneNumber: phone.number }, ]; - } else { - updateUser.$unset.phone = 1; } if (email && email.trim() !== '') { updateUser.$set.visitorEmails = [ { address: email }, ]; - } else { - updateUser.$unset.visitorEmails = 1; } - if (livechatData) { - updateUser.$set.livechatData = livechatData; - } else { - updateUser.$unset.livechatData = 1; - } - - if (contactManager) { - updateUser.$set.contactManager = contactManager; - } else { - updateUser.$unset.contactManager = 1; + if (name) { + updateUser.$set.name = name; } if (!department) { - updateUser.$unset.department = 1; + Object.assign(updateUser, { $unset: { department: 1 } }); } else { const dep = LivechatDepartment.findOneByIdOrName(department); updateUser.$set.department = dep && dep._id; } - if (_.isEmpty(updateUser.$unset)) { delete updateUser.$unset; } + LivechatVisitors.updateById(userId, updateUser); return userId; @@ -639,7 +626,6 @@ export const Livechat = { try { this.saveTransferHistory(room, transferData); RoutingManager.unassignAgent(inquiry, departmentId); - Meteor.defer(() => callbacks.run('livechat.chatQueued', LivechatRooms.findOneById(rid))); } catch (e) { console.error(e); throw new Meteor.Error('error-returning-inquiry', 'Error returning inquiry to the queue', { method: 'livechat:returnRoomAsInquiry' }); diff --git a/app/livechat/server/lib/QueueManager.js b/app/livechat/server/lib/QueueManager.js index 46ed51b7a14e6..febd9cbf59ba5 100644 --- a/app/livechat/server/lib/QueueManager.js +++ b/app/livechat/server/lib/QueueManager.js @@ -1,11 +1,21 @@ import { Meteor } from 'meteor/meteor'; import { Match, check } from 'meteor/check'; -import { LivechatRooms, LivechatInquiry } from '../../../models/server'; +import { LivechatRooms, LivechatInquiry, Users } from '../../../models/server'; import { checkServiceStatus, createLivechatRoom, createLivechatInquiry } from './Helper'; import { callbacks } from '../../../callbacks/server'; import { RoutingManager } from './RoutingManager'; + +const queueInquiry = async (room, inquiry, defaultAgent) => { + const inquiryAgent = RoutingManager.delegateAgent(defaultAgent, inquiry); + await callbacks.run('livechat.beforeRouteChat', inquiry, inquiryAgent); + inquiry = LivechatInquiry.findOneById(inquiry._id); + + if (inquiry.status === 'ready') { + return RoutingManager.delegateInquiry(inquiry, inquiryAgent); + } +}; export const QueueManager = { async requestRoom({ guest, message, roomInfo, agent, extraData }) { check(message, Match.ObjectIncluding({ @@ -26,23 +36,40 @@ export const QueueManager = { const name = (roomInfo && roomInfo.fname) || guest.name || guest.username; const room = LivechatRooms.findOneById(createLivechatRoom(rid, name, guest, roomInfo, extraData)); - let inquiry = LivechatInquiry.findOneById(createLivechatInquiry({ rid, name, guest, message, extraData })); + const inquiry = LivechatInquiry.findOneById(createLivechatInquiry({ rid, name, guest, message, extraData })); LivechatRooms.updateRoomCount(); - if (!agent) { - agent = RoutingManager.getMethod().delegateAgent(agent, inquiry); + await queueInquiry(room, inquiry, agent); + return room; + }, + + async unarchiveRoom(archivedRoom = {}) { + const { _id: rid, open, closedAt, fname: name, servedBy, v, departmentId: department, lastMessage: message } = archivedRoom; + if (!rid || !closedAt || !!open) { + return archivedRoom; } - inquiry = await callbacks.run('livechat.beforeRouteChat', inquiry, agent); - if (inquiry.status === 'ready') { - return RoutingManager.delegateInquiry(inquiry, agent); + const oldInquiry = LivechatInquiry.findOneByRoomId(rid); + if (oldInquiry) { + LivechatInquiry.removeByRoomId(rid); } - if (inquiry.status === 'queued') { - Meteor.defer(() => callbacks.run('livechat.chatQueued', room)); + const guest = { + ...v, + ...department && { department }, + }; + + let defaultAgent; + if (servedBy && Users.findOneOnlineAgentByUsername(servedBy.username)) { + defaultAgent = { agentId: servedBy._id, username: servedBy.username }; } + LivechatRooms.unarchiveOneById(rid); + const room = LivechatRooms.findOneById(rid); + const inquiry = LivechatInquiry.findOneById(createLivechatInquiry({ rid, name, guest, message })); + + await queueInquiry(room, inquiry, defaultAgent); return room; }, }; diff --git a/app/livechat/server/lib/RoutingManager.js b/app/livechat/server/lib/RoutingManager.js index 93b9d1565cf55..566741ed1b776 100644 --- a/app/livechat/server/lib/RoutingManager.js +++ b/app/livechat/server/lib/RoutingManager.js @@ -5,6 +5,7 @@ import { settings } from '../../../settings/server'; import { createLivechatSubscription, dispatchAgentDelegated, + dispatchInquiryQueued, forwardRoomToAgent, forwardRoomToDepartment, removeAgentFromSubscription, @@ -37,14 +38,8 @@ export const RoutingManager = { return this.getMethod().config || {}; }, - async getNextAgent(department) { - let agent = callbacks.run('livechat.beforeGetNextAgent', department); - - if (!agent) { - agent = await this.getMethod().getNextAgent(department); - } - - return agent; + async getNextAgent(department, ignoreAgentId) { + return this.getMethod().getNextAgent(department, ignoreAgentId); }, async delegateInquiry(inquiry, agent) { @@ -85,7 +80,7 @@ export const RoutingManager = { }, unassignAgent(inquiry, departmentId) { - const { _id, rid, department } = inquiry; + const { rid, department } = inquiry; const room = LivechatRooms.findOneById(rid); if (!room || !room.open) { @@ -110,8 +105,7 @@ export const RoutingManager = { dispatchAgentDelegated(rid, null); } - LivechatInquiry.queueInquiry(_id); - this.getMethod().delegateAgent(null, inquiry); + dispatchInquiryQueued(inquiry); return true; }, @@ -161,6 +155,16 @@ export const RoutingManager = { return false; }, + + delegateAgent(agent, inquiry) { + const defaultAgent = callbacks.run('livechat.beforeDelegateAgent', { agent, department: inquiry?.department }); + if (defaultAgent) { + LivechatInquiry.setDefaultAgentById(inquiry._id, defaultAgent); + } + + dispatchInquiryQueued(inquiry, defaultAgent); + return defaultAgent; + }, }; settings.get('Livechat_Routing_Method', function(key, value) { diff --git a/app/livechat/server/lib/routing/AutoSelection.js b/app/livechat/server/lib/routing/AutoSelection.js index 4157159f169da..39d0d211df6e0 100644 --- a/app/livechat/server/lib/routing/AutoSelection.js +++ b/app/livechat/server/lib/routing/AutoSelection.js @@ -19,16 +19,12 @@ class AutoSelection { }; } - getNextAgent(department) { + getNextAgent(department, ignoreAgentId) { if (department) { - return LivechatDepartmentAgents.getNextAgentForDepartment(department); + return LivechatDepartmentAgents.getNextAgentForDepartment(department, ignoreAgentId); } - return Users.getNextAgent(); - } - - delegateAgent(agent) { - return agent; + return Users.getNextAgent(ignoreAgentId); } } diff --git a/app/livechat/server/lib/routing/External.js b/app/livechat/server/lib/routing/External.js index 4cb86e82d9956..d3771871970f8 100644 --- a/app/livechat/server/lib/routing/External.js +++ b/app/livechat/server/lib/routing/External.js @@ -18,10 +18,14 @@ class ExternalQueue { }; } - getNextAgent(department) { + getNextAgent(department, ignoreAgentId) { for (let i = 0; i < 10; i++) { try { - const queryString = department ? `?departmentId=${ department }` : ''; + let queryString = department ? `?departmentId=${ department }` : ''; + if (ignoreAgentId) { + const ignoreAgentIdParam = `ignoreAgentId=${ ignoreAgentId }`; + queryString = queryString.startsWith('?') ? `${ queryString }&${ ignoreAgentIdParam }` : `?${ ignoreAgentIdParam }`; + } const result = HTTP.call('GET', `${ settings.get('Livechat_External_Queue_URL') }${ queryString }`, { headers: { 'User-Agent': 'RocketChat Server', @@ -47,10 +51,6 @@ class ExternalQueue { } throw new Meteor.Error('no-agent-online', 'Sorry, no online agents'); } - - delegateAgent(agent) { - return agent; - } } RoutingManager.registerMethod('External', ExternalQueue); diff --git a/app/livechat/server/lib/routing/ManualSelection.js b/app/livechat/server/lib/routing/ManualSelection.js index 1c14fe05e1857..94100bfd95f05 100644 --- a/app/livechat/server/lib/routing/ManualSelection.js +++ b/app/livechat/server/lib/routing/ManualSelection.js @@ -1,11 +1,4 @@ -import { Meteor } from 'meteor/meteor'; -import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; - -import { Livechat } from '../Livechat'; import { RoutingManager } from '../RoutingManager'; -import { sendNotification } from '../../../../lib/server'; -import { LivechatRooms, LivechatInquiry, Users } from '../../../../models/server'; -import { callbacks } from '../../../../callbacks/server'; /* Manual Selection Queuing Method: * @@ -32,61 +25,6 @@ class ManualSelection { getNextAgent() { } - - delegateAgent(agent, inquiry) { - const { department, rid, v } = inquiry; - const allAgents = Livechat.getAgents(department); - - if (allAgents.count() === 0) { - throw new Meteor.Error('no-agent-available', 'Sorry, no available agents.'); - } - - // remove agent from room in case the rooms is being transferred or returned to the Queue - LivechatRooms.removeAgentByRoomId(rid); - LivechatInquiry.queueInquiry(inquiry._id); - - // Alert only the online agents of the queued request - const onlineAgents = Livechat.getOnlineAgents(department); - - const room = LivechatRooms.findOneById(rid); - const notificationUserName = v && (v.name || v.username); - - onlineAgents.forEach((agent) => { - if (agent.agentId) { - agent = Users.findOneById(agent.agentId); - } - const { _id, active, emails, language, status, statusConnection, username } = agent; - sendNotification({ - // fake a subscription in order to make use of the function defined above - subscription: { - rid, - t: 'l', - u: { - _id, - }, - receiver: [{ - active, - emails, - language, - status, - statusConnection, - username, - }], - }, - sender: v, - hasMentionToAll: true, // consider all agents to be in the room - hasMentionToHere: false, - message: Object.assign({}, { u: v }), - notificationMessage: TAPi18n.__('User_started_a_new_conversation', { username: notificationUserName }, language), - room: Object.assign(room, { name: TAPi18n.__('New_chat_in_queue', {}, language) }), - mentionIds: [], - }); - }); - - Meteor.defer(() => callbacks.run('livechat.chatQueued', room)); - - return agent; - } } RoutingManager.registerMethod('Manual_Selection', ManualSelection); diff --git a/app/livechat/server/roomAccessValidator.compatibility.js b/app/livechat/server/roomAccessValidator.compatibility.js index 9e65fb506ccbb..9eb7929fdf8a5 100644 --- a/app/livechat/server/roomAccessValidator.compatibility.js +++ b/app/livechat/server/roomAccessValidator.compatibility.js @@ -41,7 +41,15 @@ export const validators = [ const filter = { rid: room._id, - ...departmentIds && departmentIds.length > 0 && { department: { $in: departmentIds } }, + $or: [ + { + $and: [ + { defaultAgent: { $exists: true } }, + { 'defaultAgent.agentId': user._id }, + ], + }, + { ...departmentIds && departmentIds.length > 0 && { department: { $in: departmentIds } } }, + ], }; const inquiry = LivechatInquiry.findOne(filter, { fields: { status: 1 } }); diff --git a/app/mailer/server/api.js b/app/mailer/server/api.js index 9b93aa4abd272..395af628b7d19 100644 --- a/app/mailer/server/api.js +++ b/app/mailer/server/api.js @@ -109,7 +109,7 @@ export const sendNoWrap = ({ to, from, replyTo, subject, html, text, headers }) } if (!text) { - text = stripHtml(html); + text = stripHtml(html).result; } if (settings.get('email_plain_text_only')) { @@ -127,7 +127,7 @@ export const send = ({ to, from, replyTo, subject, html, text, data, headers }) subject: replace(subject, data), text: text ? replace(text, data) - : stripHtml(replace(html, data)), + : stripHtml(replace(html, data)).result, html: wrap(html, data), headers, }); diff --git a/app/markdown/lib/parser/marked/marked.js b/app/markdown/lib/parser/marked/marked.js index e6923893b335d..f930a3369bb92 100644 --- a/app/markdown/lib/parser/marked/marked.js +++ b/app/markdown/lib/parser/marked/marked.js @@ -103,7 +103,6 @@ export const marked = (message, { smartLists, smartypants, renderer, - sanitize: true, highlight, }); diff --git a/app/message-action/client/index.js b/app/message-action/client/index.js deleted file mode 100644 index 7a623b88f1efe..0000000000000 --- a/app/message-action/client/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import './messageAction.html'; -import './messageAction'; -import './stylesheets/messageAction.css'; diff --git a/app/message-action/client/messageAction.html b/app/message-action/client/messageAction.html deleted file mode 100644 index 8d6c735667721..0000000000000 --- a/app/message-action/client/messageAction.html +++ /dev/null @@ -1,31 +0,0 @@ - diff --git a/app/message-action/client/messageAction.js b/app/message-action/client/messageAction.js deleted file mode 100644 index 025c06db224ad..0000000000000 --- a/app/message-action/client/messageAction.js +++ /dev/null @@ -1,13 +0,0 @@ -import { Template } from 'meteor/templating'; - -Template.messageAction.helpers({ - isButton() { - return this.type === 'button'; - }, - areButtonsHorizontal() { - return Template.parentData(1).button_alignment === 'horizontal'; - }, - jsActionButtonClassname(processingType) { - return `js-actionButton-${ processingType || 'sendMessage' }`; - }, -}); diff --git a/app/message-action/client/stylesheets/messageAction.css b/app/message-action/client/stylesheets/messageAction.css deleted file mode 100644 index 5ff7235f858aa..0000000000000 --- a/app/message-action/client/stylesheets/messageAction.css +++ /dev/null @@ -1,64 +0,0 @@ -.attachment { - & .action { - margin-top: 2px; - } - - & .text-button { - - position: relative; - - display: inline-flex; - - min-width: 0; - max-width: 220px; - - height: 28px; - margin: 2px 2px 2px 0; - padding: 0 10px; - - cursor: pointer; - user-select: none; - - text-align: center; - - vertical-align: middle; - white-space: nowrap; - - text-decoration: none; - - color: #2c2d30; - - border: 2px solid lightgray; - border-radius: 4px; - - outline: none; - - background: rgb(250, 250, 250); - - font-size: 13px; - - font-weight: 500; - align-items: center; - -webkit-appearance: none; - justify-content: center; - -webkit-tap-highlight-color: transparent; - } - - & .overflow-ellipsis { - display: block; - - overflow: hidden; - - white-space: nowrap; - - text-overflow: ellipsis; - } - - & .image-button { - max-height: 200px; - } - - & .horizontal-buttons { - display: inline; - } -} diff --git a/app/message-action/index.js b/app/message-action/index.js deleted file mode 100644 index 40a7340d38877..0000000000000 --- a/app/message-action/index.js +++ /dev/null @@ -1 +0,0 @@ -export * from './client/index'; diff --git a/app/message-attachments/client/index.js b/app/message-attachments/client/index.js index be13cfc60615e..3dfb9dc373616 100644 --- a/app/message-attachments/client/index.js +++ b/app/message-attachments/client/index.js @@ -1,6 +1 @@ -import './messageAttachment.html'; -import './messageAttachment'; -import './renderField.html'; -import './stylesheets/messageAttachments.css'; - export { registerFieldTemplate } from './renderField'; diff --git a/app/message-attachments/client/messageAttachment.html b/app/message-attachments/client/messageAttachment.html deleted file mode 100644 index 5eb34a0081231..0000000000000 --- a/app/message-attachments/client/messageAttachment.html +++ /dev/null @@ -1,178 +0,0 @@ - diff --git a/app/message-attachments/client/messageAttachment.js b/app/message-attachments/client/messageAttachment.js deleted file mode 100644 index 25be637864619..0000000000000 --- a/app/message-attachments/client/messageAttachment.js +++ /dev/null @@ -1,150 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import { Template } from 'meteor/templating'; - -import { DateFormat } from '../../lib'; -import { getURL } from '../../utils/client'; -import { createCollapseable } from '../../ui-utils'; -import { renderMessageBody } from '../../../client/lib/renderMessageBody'; - -const colors = { - good: '#35AC19', - warning: '#FCB316', - danger: '#D30230', -}; - -async function renderPdfToCanvas(canvasId, pdfLink) { - const isSafari = /constructor/i.test(window.HTMLElement) - || ((p) => p.toString() === '[object SafariRemoteNotification]')(!window.safari - || (typeof window.safari !== 'undefined' && window.safari.pushNotification)); - - if (isSafari) { - const [, version] = /Version\/([0-9]+)/.exec(navigator.userAgent) || [null, 0]; - if (version <= 12) { - return; - } - } - - if (!pdfLink || !/\.pdf$/i.test(pdfLink)) { - return; - } - pdfLink = getURL(pdfLink); - - const canvas = document.getElementById(canvasId); - if (!canvas) { - return; - } - - const pdfjsLib = await import('pdfjs-dist'); - pdfjsLib.GlobalWorkerOptions.workerSrc = `${ Meteor.absoluteUrl() }pdf.worker.min.js`; - - const loader = document.getElementById(`js-loading-${ canvasId }`); - - if (loader) { - loader.style.display = 'block'; - } - - const pdf = await pdfjsLib.getDocument(pdfLink).promise; - const page = await pdf.getPage(1); - const scale = 0.5; - const viewport = page.getViewport({ scale }); - const context = canvas.getContext('2d'); - canvas.height = viewport.height; - canvas.width = viewport.width; - await page.render({ - canvasContext: context, - viewport, - }).promise; - - if (loader) { - loader.style.display = 'none'; - } - - canvas.style.maxWidth = '-webkit-fill-available'; - canvas.style.maxWidth = '-moz-available'; - canvas.style.display = 'block'; -} - -createCollapseable(Template.messageAttachment, (instance) => (instance.data && (instance.data.collapsed || (instance.data.settings && instance.data.settings.collapseMediaByDefault))) || false); - -Template.messageAttachment.helpers({ - parsedText() { - return renderMessageBody({ - msg: this.text, - }); - }, - markdownInPretext() { - return this.mrkdwn_in && this.mrkdwn_in.includes('pretext'); - }, - parsedPretext() { - return renderMessageBody({ - msg: this.pretext, - }); - }, - loadImage() { - if (this.downloadImages) { - return true; - } - - if (this.settings.autoImageLoad === false) { - return false; - } - - if (this.settings.saveMobileBandwidth === true) { - return false; - } - - return true; - }, - getImageHeight(height = 200) { - return height; - }, - color() { - return colors[this.color] || this.color; - }, - time() { - const messageDate = new Date(this.ts); - const today = new Date(); - if (messageDate.toDateString() === today.toDateString()) { - return DateFormat.formatTime(this.ts); - } - return DateFormat.formatDateAndTime(this.ts); - }, - injectIndex(data, previousIndex, index) { - data.index = `${ previousIndex }.attachments.${ index }`; - }, - injectSettings(data, settings) { - data.settings = settings; - }, - injectMessage(data, { rid, _id }) { - data.msg = { _id, rid }; - }, - injectCollapsedMedia(data) { - const { collapsedMedia } = data; - Object.assign(this, { collapsedMedia }); - return this; - }, - isFile() { - return this.type === 'file'; - }, - isPDF() { - if ( - this.type === 'file' - && this.title_link.endsWith('.pdf') - && Template.parentData(1).msg.file - ) { - this.fileId = Template.parentData(1).msg.file._id; - return true; - } - return false; - }, - getURL, -}); - -Template.messageAttachment.onRendered(function() { - const { msg } = Template.parentData(1); - this.autorun(() => { - if (msg && msg.file && msg.file.type === 'application/pdf' && !this.collapsedMedia.get()) { - Meteor.defer(() => { renderPdfToCanvas(msg.file._id, msg.attachments[0].title_link); }); - } - }); -}); diff --git a/app/message-attachments/client/renderField.html b/app/message-attachments/client/renderField.html deleted file mode 100644 index d9a5fbf1ecd42..0000000000000 --- a/app/message-attachments/client/renderField.html +++ /dev/null @@ -1,20 +0,0 @@ - diff --git a/app/message-attachments/client/renderField.js b/app/message-attachments/client/renderField.js index 4a62768d9ed62..f82e79aa9c6c8 100644 --- a/app/message-attachments/client/renderField.js +++ b/app/message-attachments/client/renderField.js @@ -1,11 +1,3 @@ -import { Template } from 'meteor/templating'; -import { Blaze } from 'meteor/blaze'; - -import { Markdown } from '../../markdown/client'; -import { escapeHTML } from '../../../lib/escapeHTML'; - -const renderers = {}; - /** * The field templates will be rendered non-reactive for all messages by the messages-list (@see rocketchat-nrr) * Thus, we cannot provide helpers or events to the template, but we need to register this interactivity at the parent @@ -15,48 +7,6 @@ const renderers = {}; * @param helpers * @param events */ -export function registerFieldTemplate(fieldType, templateName, events) { - renderers[fieldType] = templateName; - - // propagate helpers and events to the room template, changing the selectors - // loop at events. For each event (like 'click .accept'), copy the function to a function of the room events. - // While doing that, add the fieldType as class selector to the events function in order to avoid naming clashes - if (events != null) { - const uniqueEvents = {}; - // rename the event handlers so they are unique in the "parent" template to which the events bubble - for (const property in events) { - if (events.hasOwnProperty(property)) { - const event = property.substr(0, property.indexOf(' ')); - const selector = property.substr(property.indexOf(' ') + 1); - Object.defineProperty(uniqueEvents, - `${ event } .${ fieldType } ${ selector }`, - { - value: events[property], - enumerable: true, // assign as a own property - }); - } - } - Template.roomOld.events(uniqueEvents); - } +export function registerFieldTemplate() { + console.warn('registerFieldTemplate DEPRECATED'); } - -// onRendered is not being executed (no idea why). Consequently, we cannot use Blaze.renderWithData(), since we don't -// have access to the DOM outside onRendered. Therefore, we can only translate the content of the field to HTML and -// embed it non-reactively. -// This in turn means that onRendered of the field template will not be processed either. -// I guess it may have someting to do with rocketchat-nrr -Template.renderField.helpers({ - specializedRendering({ hash: { field, message } }) { - let html = ''; - if (field.type && renderers[field.type]) { - html = Blaze.toHTMLWithData(Template[renderers[field.type]], { field, message }); - } else { - // consider the value already formatted as html - html = escapeHTML(field.value); - } - return `
    ${ html }
    `; - }, - markdown(text) { - return Markdown.parse(text); - }, -}); diff --git a/app/message-attachments/client/stylesheets/messageAttachments.css b/app/message-attachments/client/stylesheets/messageAttachments.css deleted file mode 100644 index 2ff918cdf8e89..0000000000000 --- a/app/message-attachments/client/stylesheets/messageAttachments.css +++ /dev/null @@ -1,163 +0,0 @@ -html.rtl .attachment { - direction: rtl; - - & .attachment-block { - padding-right: 15px; - padding-left: 0; - - & .attachment-block-border { - right: 0; - left: auto; - } - } - - & .attachment-thumb { - padding-top: 10px; - padding-right: 5px; - } - - & .attachment-download-icon { - margin-right: 5px; - margin-left: auto; - } -} - -.attachment { - & .attachment-block { - position: relative; - - margin: 5px 0; - padding-left: 15px; - - & .attachment-block-border { - position: absolute; - top: 0; - bottom: 0; - left: 0; - - width: 2px; - - border-radius: 8px; - } - } - - & .attachment-author { - font-size: 0.95rem; - font-weight: 600; - line-height: 1.2rem; - - & > a { - font-weight: 600; - } - - & img { - max-width: 16px; - max-height: 16px; - margin-right: 2px; - margin-bottom: -2px; - } - - & .time, - & .time-link { - font-size: 0.8em; - font-weight: normal; - } - } - - & .attachment-title { - - color: #1d74f5; - - font-size: 1.02rem; - font-weight: 500; - line-height: 1.5rem; - } - - & .attachment-text { - padding: 3px 0; - - line-height: 1rem; - } - - & .attachment-image { - margin-top: 4px; - - line-height: 0; - } - - & .attachment-fields { - display: flex; - - margin-top: 4px; - - align-items: center; - flex-wrap: wrap; - - & .attachment-field { - flex: 1 0 100%; - - padding-top: 5px; - padding-bottom: 5px; - - &.attachment-field-short { - display: inline-block; - - flex: 1 1; - - margin-right: 12px; - } - - & .attachment-field-title { - font-weight: 600; - line-height: 1rem; - } - } - } - - & .attachment-thumb { - padding-top: 5px; - padding-right: 10px; - - line-height: 0; - - & img { - max-width: 100px; - } - } - - & .attachment-flex { - display: flex; - align-items: flex-start; - - & .attachment-flex-column-grow { - word-break: break-word; - flex-grow: 1; - } - } - - & .attachment-small-content { - max-width: 700px; - } - - & .attachment-download-icon { - padding: 0 5px; - } - - & .attachment-canvas { - display: none; - } - - & .attachment-pdf-loading { - display: none; - - font-size: 1.5rem; - - svg { - animation: spin 1s linear infinite; - } - } - - & .actions-container { - margin-top: 6px; - } -} diff --git a/app/meteor-accounts-saml/server/definition/ISAMLGlobalSettings.ts b/app/meteor-accounts-saml/server/definition/ISAMLGlobalSettings.ts index b1b3f468ff513..126a300eea07d 100644 --- a/app/meteor-accounts-saml/server/definition/ISAMLGlobalSettings.ts +++ b/app/meteor-accounts-saml/server/definition/ISAMLGlobalSettings.ts @@ -8,4 +8,6 @@ export interface ISAMLGlobalSettings { roleAttributeSync: boolean; userDataFieldMap: string; usernameNormalize: string; + channelsAttributeUpdate: boolean; + includePrivateChannelsInUpdate: boolean; } diff --git a/app/meteor-accounts-saml/server/lib/SAML.ts b/app/meteor-accounts-saml/server/lib/SAML.ts index eed3cd628eeb8..277084414a552 100644 --- a/app/meteor-accounts-saml/server/lib/SAML.ts +++ b/app/meteor-accounts-saml/server/lib/SAML.ts @@ -72,7 +72,7 @@ export class SAML { } public static insertOrUpdateSAMLUser(userObject: ISAMLUser): {userId: string; token: string} { - const { roleAttributeSync, generateUsername, immutableProperty, nameOverwrite, mailOverwrite } = SAMLUtils.globalSettings; + const { roleAttributeSync, generateUsername, immutableProperty, nameOverwrite, mailOverwrite, channelsAttributeUpdate } = SAMLUtils.globalSettings; let customIdentifierMatch = false; let customIdentifierAttributeName: string | null = null; @@ -144,7 +144,7 @@ export class SAML { const userId = Accounts.insertUserDoc({}, newUser); user = Users.findOne(userId); - if (userObject.channels) { + if (userObject.channels && channelsAttributeUpdate !== true) { SAML.subscribeToSAMLChannels(userObject.channels, user); } } @@ -186,6 +186,10 @@ export class SAML { updateData.roles = globalRoles; } + if (userObject.channels && channelsAttributeUpdate === true) { + SAML.subscribeToSAMLChannels(userObject.channels, user); + } + Users.update({ _id: user._id, }, { @@ -444,6 +448,7 @@ export class SAML { } private static subscribeToSAMLChannels(channels: Array, user: IUser): void { + const { includePrivateChannelsInUpdate } = SAMLUtils.globalSettings; try { for (let roomName of channels) { roomName = roomName.trim(); @@ -452,15 +457,24 @@ export class SAML { } const room = Rooms.findOneByNameAndType(roomName, 'c', {}); - if (!room) { + const privRoom = Rooms.findOneByNameAndType(roomName, 'p', {}); + + if (privRoom && includePrivateChannelsInUpdate === true) { + addUserToRoom(privRoom._id, user); + continue; + } + + if (room) { + addUserToRoom(room._id, user); + continue; + } + + if (!room && !privRoom) { // If the user doesn't have an username yet, we can't create new rooms for them if (user.username) { createRoom('c', roomName, user.username); } - continue; } - - addUserToRoom(room._id, user); } } catch (err) { console.error(err); diff --git a/app/meteor-accounts-saml/server/lib/Utils.ts b/app/meteor-accounts-saml/server/lib/Utils.ts index d31421d45c2bf..86aa5c7cf3108 100644 --- a/app/meteor-accounts-saml/server/lib/Utils.ts +++ b/app/meteor-accounts-saml/server/lib/Utils.ts @@ -27,6 +27,8 @@ const globalSettings: ISAMLGlobalSettings = { roleAttributeSync: false, userDataFieldMap: '{"username":"username", "email":"email", "cn": "name"}', usernameNormalize: 'None', + channelsAttributeUpdate: false, + includePrivateChannelsInUpdate: false, }; export class SAMLUtils { @@ -73,6 +75,8 @@ export class SAMLUtils { globalSettings.nameOverwrite = Boolean(samlConfigs.nameOverwrite); globalSettings.mailOverwrite = Boolean(samlConfigs.mailOverwrite); globalSettings.roleAttributeSync = Boolean(samlConfigs.roleAttributeSync); + globalSettings.channelsAttributeUpdate = Boolean(samlConfigs.channelsAttributeUpdate); + globalSettings.includePrivateChannelsInUpdate = Boolean(samlConfigs.includePrivateChannelsInUpdate); if (samlConfigs.immutableProperty && typeof samlConfigs.immutableProperty === 'string') { globalSettings.immutableProperty = samlConfigs.immutableProperty; diff --git a/app/meteor-accounts-saml/server/lib/settings.ts b/app/meteor-accounts-saml/server/lib/settings.ts index 1b217b0a15467..b18b509c772de 100644 --- a/app/meteor-accounts-saml/server/lib/settings.ts +++ b/app/meteor-accounts-saml/server/lib/settings.ts @@ -57,6 +57,8 @@ export const getSamlConfigs = function(service: string): Record { logoutRequestTemplate: settings.get(`${ service }_LogoutRequest_template`), metadataCertificateTemplate: settings.get(`${ service }_MetadataCertificate_template`), metadataTemplate: settings.get(`${ service }_Metadata_template`), + channelsAttributeUpdate: settings.get(`${ service }_channels_update`), + includePrivateChannelsInUpdate: settings.get(`${ service }_include_private_channels_update`), }; }; @@ -271,6 +273,20 @@ export const addSettings = function(name: string): void { section: 'SAML_Section_3_Behavior', i18nLabel: 'SAML_Custom_Logout_Behaviour', }); + settings.add(`SAML_Custom_${ name }_channels_update`, false, { + type: 'boolean', + group: 'SAML', + section: 'SAML_Section_3_Behavior', + i18nLabel: 'SAML_Custom_channels_update', + i18nDescription: 'SAML_Custom_channels_update_description', + }); + settings.add(`SAML_Custom_${ name }_include_private_channels_update`, false, { + type: 'boolean', + group: 'SAML', + section: 'SAML_Section_3_Behavior', + i18nLabel: 'SAML_Custom_include_private_channels_update', + i18nDescription: 'SAML_Custom_include_private_channels_update_description', + }); // Roles Settings settings.add(`SAML_Custom_${ name }_default_user_role`, 'user', { diff --git a/app/models/server/index.js b/app/models/server/index.js index fecf4de953b85..efcd3790302ff 100644 --- a/app/models/server/index.js +++ b/app/models/server/index.js @@ -39,6 +39,7 @@ import ReadReceipts from './models/ReadReceipts'; import LivechatExternalMessage from './models/LivechatExternalMessages'; import OmnichannelQueue from './models/OmnichannelQueue'; import Analytics from './models/Analytics'; +import EmailInbox from './models/EmailInbox'; export { AppsLogsModel } from './models/apps-logs-model'; export { AppsPersistenceModel } from './models/apps-persistence-model'; @@ -90,4 +91,5 @@ export { LivechatInquiry, Analytics, OmnichannelQueue, + EmailInbox, }; diff --git a/app/models/server/models/EmailInbox.js b/app/models/server/models/EmailInbox.js new file mode 100644 index 0000000000000..490628be33837 --- /dev/null +++ b/app/models/server/models/EmailInbox.js @@ -0,0 +1,27 @@ +import { Base } from './_Base'; + +export class EmailInbox extends Base { + constructor() { + super('email_inbox'); + + this.tryEnsureIndex({ email: 1 }, { unique: true }); + } + + findOneById(_id, options) { + return this.findOne(_id, options); + } + + create(data) { + return this.insert(data); + } + + updateById(_id, data) { + return this.update({ _id }, data); + } + + removeById(_id) { + return this.remove(_id); + } +} + +export default new EmailInbox(); diff --git a/app/models/server/models/EmailMessageHistory.js b/app/models/server/models/EmailMessageHistory.js new file mode 100644 index 0000000000000..03115c1388a54 --- /dev/null +++ b/app/models/server/models/EmailMessageHistory.js @@ -0,0 +1,10 @@ +import { Base } from './_Base'; + +export class EmailMessageHistory extends Base { + constructor() { + super('email_message_history'); + this.tryEnsureIndex({ createdAt: 1 }, { expireAfterSeconds: 60 * 60 * 24 }); + } +} + +export default new EmailMessageHistory(); diff --git a/app/models/server/models/LivechatDepartmentAgents.js b/app/models/server/models/LivechatDepartmentAgents.js index d18cb831c8ef8..48e27eff06150 100644 --- a/app/models/server/models/LivechatDepartmentAgents.js +++ b/app/models/server/models/LivechatDepartmentAgents.js @@ -54,7 +54,7 @@ export class LivechatDepartmentAgents extends Base { this.remove({ departmentId }); } - getNextAgentForDepartment(departmentId) { + getNextAgentForDepartment(departmentId, ignoreAgentId) { const agents = this.findByDepartmentId(departmentId).fetch(); if (agents.length === 0) { @@ -70,6 +70,7 @@ export class LivechatDepartmentAgents extends Base { username: { $in: onlineUsernames, }, + ...ignoreAgentId && { agentId: { $ne: ignoreAgentId } }, }; const sort = { @@ -137,7 +138,7 @@ export class LivechatDepartmentAgents extends Base { return this.find(query); } - getNextBotForDepartment(departmentId) { + getNextBotForDepartment(departmentId, ignoreAgentId) { const agents = this.findByDepartmentId(departmentId).fetch(); if (agents.length === 0) { @@ -152,6 +153,7 @@ export class LivechatDepartmentAgents extends Base { username: { $in: botUsernames, }, + ...ignoreAgentId && { agentId: { $ne: ignoreAgentId } }, }; const sort = { diff --git a/app/models/server/models/LivechatInquiry.js b/app/models/server/models/LivechatInquiry.js index 9dc6251a17cb8..8d2aef5ec4f5c 100644 --- a/app/models/server/models/LivechatInquiry.js +++ b/app/models/server/models/LivechatInquiry.js @@ -63,13 +63,12 @@ export class LivechatInquiry extends Base { /* * mark inquiry as queued */ - queueInquiry(inquiryId, defaultAgent) { + queueInquiry(inquiryId) { return this.update({ _id: inquiryId, }, { $set: { status: 'queued', - ...defaultAgent && { defaultAgent }, }, }); } @@ -109,6 +108,16 @@ export class LivechatInquiry extends Base { return this.update(query, update); } + setDefaultAgentById(inquiryId, defaultAgent) { + return this.update({ + _id: inquiryId, + }, { + $set: { + defaultAgent, + }, + }); + } + setNameByRoomId(rid, name) { const query = { rid }; diff --git a/app/models/server/models/LivechatRooms.js b/app/models/server/models/LivechatRooms.js index dcaeff7d7721e..a57b5e9f3581d 100644 --- a/app/models/server/models/LivechatRooms.js +++ b/app/models/server/models/LivechatRooms.js @@ -19,6 +19,7 @@ export class LivechatRooms extends Base { this.tryEnsureIndex({ closedAt: 1 }, { sparse: true }); this.tryEnsureIndex({ servedBy: 1 }, { sparse: true }); this.tryEnsureIndex({ 'v.token': 1 }, { sparse: true }); + this.tryEnsureIndex({ 'v.token': 1, 'email.thread': 1 }, { sparse: true }); this.tryEnsureIndex({ 'v._id': 1 }, { sparse: true }); } @@ -168,6 +169,28 @@ export class LivechatRooms extends Base { return this.findOne(query, options); } + findOneByVisitorTokenAndEmailThread(visitorToken, emailThread, options) { + const query = { + t: 'l', + 'v.token': visitorToken, + 'email.thread': emailThread, + }; + + return this.findOne(query, options); + } + + findOneOpenByVisitorTokenAndEmailThread(visitorToken, emailThread, options) { + const query = { + t: 'l', + open: true, + 'v.token': visitorToken, + 'email.thread': emailThread, + }; + + return this.findOne(query, options); + } + + findOneLastServedAndClosedByVisitorToken(visitorToken, options = {}) { const query = { t: 'l', @@ -652,6 +675,45 @@ export class LivechatRooms extends Base { return this.update(query, update); } + setAutoTransferredAtById(roomId) { + const query = { + _id: roomId, + }; + const update = { + $set: { + autoTransferredAt: new Date(), + }, + }; + + return this.update(query, update); + } + + setAutoTransferOngoingById(roomId) { + const query = { + _id: roomId, + }; + const update = { + $set: { + autoTransferOngoing: true, + }, + }; + + return this.update(query, update); + } + + unsetAutoTransferOngoingById(roomId) { + const query = { + _id: roomId, + }; + const update = { + $unset: { + autoTransferOngoing: 1, + }, + }; + + return this.update(query, update); + } + changeVisitorByRoomId(roomId, { _id, username, token }) { const query = { _id: roomId, @@ -667,6 +729,26 @@ export class LivechatRooms extends Base { return this.update(query, update); } + + unarchiveOneById(roomId) { + const query = { + _id: roomId, + t: 'l', + }; + const update = { + $set: { + open: true, + }, + $unset: { + servedBy: 1, + closedAt: 1, + closedBy: 1, + closer: 1, + }, + }; + + return this.update(query, update); + } } export default new LivechatRooms(Rooms.model, true); diff --git a/app/models/server/models/Users.js b/app/models/server/models/Users.js index 00538da0b98da..36826d1465a1d 100644 --- a/app/models/server/models/Users.js +++ b/app/models/server/models/Users.js @@ -174,8 +174,11 @@ export class Users extends Base { return this.find(query); } - getNextAgent() { - const query = queryStatusAgentOnline(); + getNextAgent(ignoreAgentId) { + const extraFilters = { + ...ignoreAgentId && { _id: { $ne: ignoreAgentId } }, + }; + const query = queryStatusAgentOnline(extraFilters); const collectionObj = this.model.rawCollection(); const findAndModify = Meteor.wrapAsync(collectionObj.findAndModify, collectionObj); @@ -201,11 +204,12 @@ export class Users extends Base { return null; } - getNextBotAgent() { + getNextBotAgent(ignoreAgentId) { const query = { roles: { $all: ['bot', 'livechat-agent'], }, + ...ignoreAgentId && { _id: { $ne: ignoreAgentId } }, }; const collectionObj = this.model.rawCollection(); diff --git a/app/models/server/raw/Banners.ts b/app/models/server/raw/Banners.ts new file mode 100644 index 0000000000000..a00df313e1202 --- /dev/null +++ b/app/models/server/raw/Banners.ts @@ -0,0 +1,35 @@ +import { Collection, Cursor, FindOneOptions } from 'mongodb'; + +import { BannerPlatform, IBanner } from '../../../../definition/IBanner'; +import { BaseRaw } from './BaseRaw'; + +type T = IBanner; +export class BannersRaw extends BaseRaw { + constructor( + public readonly col: Collection, + public readonly trash?: Collection, + ) { + super(col, trash); + + this.col.createIndexes([ + { key: { platform: 1, startAt: 1, expireAt: 1 } }, + ]); + } + + findActiveByRoleOrId(roles: string[], platform: BannerPlatform, bannerId?: string, options?: FindOneOptions): Cursor { + const today = new Date(); + + const query = { + ...bannerId && { _id: bannerId }, + platform, + startAt: { $lte: today }, + expireAt: { $gte: today }, + $or: [ + { roles: { $in: roles } }, + { roles: { $exists: false } }, + ], + }; + + return this.col.find(query, options); + } +} diff --git a/app/models/server/raw/BannersDismiss.ts b/app/models/server/raw/BannersDismiss.ts new file mode 100644 index 0000000000000..78d4ae2cf4ae8 --- /dev/null +++ b/app/models/server/raw/BannersDismiss.ts @@ -0,0 +1,27 @@ +import { Collection, Cursor, FindOneOptions } from 'mongodb'; + +import { IBannerDismiss } from '../../../../definition/IBanner'; +import { BaseRaw } from './BaseRaw'; + +type T = IBannerDismiss; +export class BannersDismissRaw extends BaseRaw { + constructor( + public readonly col: Collection, + public readonly trash?: Collection, + ) { + super(col, trash); + + this.col.createIndexes([ + { key: { userId: 1, bannerId: 1 } }, + ]); + } + + findByUserIdAndBannerId(userId: string, bannerIds: string[], options?: FindOneOptions): Cursor { + const query = { + userId, + bannerId: { $in: bannerIds }, + }; + + return this.col.find(query, options); + } +} diff --git a/app/models/server/raw/BaseRaw.ts b/app/models/server/raw/BaseRaw.ts index f602de361df0a..f26ef02922b44 100644 --- a/app/models/server/raw/BaseRaw.ts +++ b/app/models/server/raw/BaseRaw.ts @@ -1,4 +1,39 @@ -import { Collection, FindOneOptions, Cursor, WriteOpResult, DeleteWriteOpResultObject, FilterQuery, UpdateQuery, UpdateOneOptions } from 'mongodb'; +import { + Collection, + CollectionInsertOneOptions, + Cursor, + DeleteWriteOpResultObject, + FilterQuery, + FindOneOptions, + InsertOneWriteOpResult, + ObjectID, + ObjectId, + OptionalId, + UpdateManyOptions, + UpdateOneOptions, + UpdateQuery, + UpdateWriteOpResult, + WithId, + WriteOpResult, +} from 'mongodb'; + +// [extracted from @types/mongo] TypeScript Omit (Exclude to be specific) does not work for objects with an "any" indexed type, and breaks discriminated unions +type EnhancedOmit = string | number extends keyof T + ? T // T has indexed type e.g. { _id: string; [k: string]: any; } or it is "any" + : T extends any + ? Pick> // discriminated unions + : never; + +// [extracted from @types/mongo] +type ExtractIdType = TSchema extends { _id: infer U } // user has defined a type for _id + ? {} extends U + ? Exclude + : unknown extends U + ? ObjectId + : U + : ObjectId; + +type ModelOptionalId = EnhancedOmit & { _id?: ExtractIdType }; interface ITrash { __collection__: string; @@ -70,6 +105,24 @@ export class BaseRaw implements IBaseRaw { return this.col.update(filter, update, options); } + updateOne(filter: FilterQuery, update: UpdateQuery | Partial, options?: UpdateOneOptions & { multi?: boolean }): Promise { + return this.col.updateOne(filter, update, options); + } + + updateMany(filter: FilterQuery, update: UpdateQuery | Partial, options?: UpdateManyOptions): Promise { + return this.col.updateMany(filter, update, options); + } + + insertOne(doc: ModelOptionalId, options?: CollectionInsertOneOptions): Promise>> { + if (!doc._id || typeof doc._id !== 'string') { + const oid = new ObjectID(); + doc = { _id: oid.toHexString(), ...doc }; + } + + // TODO reavaluate following type casting + return this.col.insertOne(doc as unknown as OptionalId, options); + } + removeById(_id: string): Promise { const query: object = { _id }; return this.col.deleteOne(query); diff --git a/app/models/server/raw/EmailInbox.ts b/app/models/server/raw/EmailInbox.ts new file mode 100644 index 0000000000000..1d8d008242fa8 --- /dev/null +++ b/app/models/server/raw/EmailInbox.ts @@ -0,0 +1,6 @@ +import { BaseRaw } from './BaseRaw'; +import { IEmailInbox } from '../../../../definition/IEmailInbox'; + +export class EmailInboxRaw extends BaseRaw { + // +} diff --git a/app/models/server/raw/EmailMessageHistory.ts b/app/models/server/raw/EmailMessageHistory.ts new file mode 100644 index 0000000000000..9201d1b3a344c --- /dev/null +++ b/app/models/server/raw/EmailMessageHistory.ts @@ -0,0 +1,13 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { BaseRaw } from './BaseRaw'; +import { IEmailMessageHistory } from '../../../../definition/IEmailMessageHistory'; + +export class EmailMessageHistoryRaw extends BaseRaw { + insertOne({ _id, email }: IEmailMessageHistory) { + return this.col.insertOne({ + _id, + email, + createdAt: new Date(), + }); + } +} diff --git a/app/models/server/raw/LivechatBusinessHours.ts b/app/models/server/raw/LivechatBusinessHours.ts index 09993b1697f5f..b48e53a841569 100644 --- a/app/models/server/raw/LivechatBusinessHours.ts +++ b/app/models/server/raw/LivechatBusinessHours.ts @@ -57,20 +57,6 @@ export class LivechatBusinessHoursRaw extends BaseRaw { }); } - async updateOne(_id: string, data: Omit): Promise { - const query = { - _id, - }; - - const update = { - $set: { - ...data, - }, - }; - - return this.col.updateOne(query, update); - } - // TODO: Remove this function after remove the deprecated method livechat:saveOfficeHours async updateDayOfGlobalBusinessHour(day: Omit): Promise { return this.col.updateOne({ diff --git a/app/models/server/raw/Nps.ts b/app/models/server/raw/Nps.ts new file mode 100644 index 0000000000000..715628e7146e6 --- /dev/null +++ b/app/models/server/raw/Nps.ts @@ -0,0 +1,90 @@ +import { UpdateWriteOpResult, Collection } from 'mongodb'; + +import { BaseRaw } from './BaseRaw'; +import { INps, NPSStatus } from '../../../../definition/INps'; + +type T = INps; +export class NpsRaw extends BaseRaw { + constructor( + public readonly col: Collection, + public readonly trash?: Collection, + ) { + super(col, trash); + + this.col.createIndexes([ + { key: { status: 1, expireAt: 1 } }, + ]); + } + + // get expired surveys still in progress + async getOpenExpiredAndStartSending(): Promise { + const today = new Date(); + + const query = { + status: NPSStatus.OPEN, + expireAt: { $lte: today }, + }; + const update = { + $set: { + status: NPSStatus.SENDING, + }, + }; + const { value } = await this.col.findOneAndUpdate(query, update, { sort: { expireAt: 1 } }); + + return value; + } + + // get expired surveys already sending results + async getOpenExpiredAlreadySending(): Promise { + const today = new Date(); + + const query = { + status: NPSStatus.SENDING, + expireAt: { $lte: today }, + }; + + return this.col.findOne(query); + } + + updateStatusById(_id: INps['_id'], status: INps['status']): Promise { + const update = { + $set: { + status, + }, + }; + return this.col.updateOne({ _id }, update); + } + + save({ _id, startAt, expireAt, createdBy, status }: Pick): Promise { + return this.col.updateOne({ + _id, + }, { + $set: { + startAt, + _updatedAt: new Date(), + }, + $setOnInsert: { + expireAt, + createdBy, + createdAt: new Date(), + status, + }, + }, { + upsert: true, + }); + } + + closeAllByStatus(status: NPSStatus): Promise { + const query = { + status, + }; + + const update = { + $set: { + status: NPSStatus.CLOSED, + }, + }; + + return this.col.updateMany(query, update); + } +} diff --git a/app/models/server/raw/NpsVote.ts b/app/models/server/raw/NpsVote.ts new file mode 100644 index 0000000000000..31d2a7543ccf6 --- /dev/null +++ b/app/models/server/raw/NpsVote.ts @@ -0,0 +1,100 @@ +import { ObjectId, Collection, Cursor, FindOneOptions, UpdateWriteOpResult } from 'mongodb'; + +import { INpsVote, INpsVoteStatus } from '../../../../definition/INps'; +import { BaseRaw } from './BaseRaw'; + +type T = INpsVote; +export class NpsVoteRaw extends BaseRaw { + constructor( + public readonly col: Collection, + public readonly trash?: Collection, + ) { + super(col, trash); + + this.col.createIndexes([ + { key: { npsId: 1, status: 1, sentAt: 1 } }, + { key: { npsId: 1, identifier: 1 } }, + ]); + } + + findNotSentByNpsId(npsId: string, options?: FindOneOptions): Cursor { + const query = { + npsId, + status: INpsVoteStatus.NEW, + }; + return this.col + .find(query, options) + .sort({ ts: 1 }) + .limit(1000); + } + + findByNpsIdAndStatus(npsId: string, status: INpsVoteStatus, options?: FindOneOptions): Cursor { + const query = { + npsId, + status, + }; + return this.col.find(query, options); + } + + findByNpsId(npsId: string, options?: FindOneOptions): Cursor { + const query = { + npsId, + }; + return this.col.find(query, options); + } + + save(vote: Omit): Promise { + const { + npsId, + identifier, + } = vote; + + const query = { + npsId, + identifier, + }; + const update = { + $set: { + ...vote, + _updatedAt: new Date(), + }, + $setOnInsert: { + _id: new ObjectId().toHexString(), + }, + }; + + return this.col.updateOne(query, update, { upsert: true }); + } + + updateVotesToSent(voteIds: string[]): Promise { + const query = { + _id: { $in: voteIds }, + }; + const update = { + $set: { + status: INpsVoteStatus.SENT, + }, + }; + return this.col.updateMany(query, update); + } + + updateOldSendingToNewByNpsId(npsId: string): Promise { + const fiveMinutes = new Date(); + fiveMinutes.setMinutes(fiveMinutes.getMinutes() - 5); + + const query = { + npsId, + status: INpsVoteStatus.SENDING, + sentAt: { $lt: fiveMinutes }, + }; + const update = { + $set: { + status: INpsVoteStatus.NEW, + }, + $unset: { + sentAt: 1 as 1, // why do you do this to me TypeScript? + }, + }; + return this.col.updateMany(query, update); + } +} diff --git a/app/models/server/raw/Users.js b/app/models/server/raw/Users.js index 83fcd70f84b9a..aa04c127fe222 100644 --- a/app/models/server/raw/Users.js +++ b/app/models/server/raw/Users.js @@ -133,9 +133,9 @@ export class UsersRaw extends BaseRaw { return this.col.distinct('federation.origin', { federation: { $exists: true } }); } - async getNextLeastBusyAgent(department) { + async getNextLeastBusyAgent(department, ignoreAgentId) { const aggregate = [ - { $match: { status: { $exists: true, $ne: 'offline' }, statusLivechat: 'available', roles: 'livechat-agent' } }, + { $match: { status: { $exists: true, $ne: 'offline' }, statusLivechat: 'available', roles: 'livechat-agent', ...ignoreAgentId && { _id: { $ne: ignoreAgentId } } } }, { $lookup: { from: 'rocketchat_subscription', let: { id: '$_id' }, @@ -191,7 +191,7 @@ export class UsersRaw extends BaseRaw { const aggregate = [ { $match: { _id: userId, status: { $exists: true, $ne: 'offline' }, statusLivechat: 'available', roles: 'livechat-agent' } }, { $lookup: { from: 'rocketchat_subscription', localField: '_id', foreignField: 'u._id', as: 'subs' } }, - { $project: { agentId: '$_id', username: 1, lastAssignTime: 1, lastRoutingTime: 1, 'queueInfo.chats': { $size: '$subs' } } }, + { $project: { agentId: '$_id', username: 1, lastAssignTime: 1, lastRoutingTime: 1, 'queueInfo.chats': { $size: { $filter: { input: '$subs', as: 'sub', cond: { $eq: ['$$sub.t', 'l'] } } } } } }, { $sort: { 'queueInfo.chats': 1, lastAssignTime: 1, lastRoutingTime: 1, username: 1 } }, ]; diff --git a/app/models/server/raw/index.ts b/app/models/server/raw/index.ts index 7c2a4d92ab58f..87ecbad097d1b 100644 --- a/app/models/server/raw/index.ts +++ b/app/models/server/raw/index.ts @@ -63,6 +63,10 @@ import { IntegrationHistoryRaw } from './IntegrationHistory'; import IntegrationHistoryModel from '../models/IntegrationHistory'; import OmnichannelQueueModel from '../models/OmnichannelQueue'; import { OmnichannelQueueRaw } from './OmnichannelQueue'; +import EmailInboxModel from '../models/EmailInbox'; +import { EmailInboxRaw } from './EmailInbox'; +import EmailMessageHistoryModel from '../models/EmailMessageHistory'; +import { EmailMessageHistoryRaw } from './EmailMessageHistory'; import { api } from '../../../../server/sdk/api'; import { initWatchers } from '../../../../server/modules/watchers/watchers.module'; @@ -100,6 +104,8 @@ export const InstanceStatus = new InstanceStatusRaw(InstanceStatusModel.model.ra export const IntegrationHistory = new IntegrationHistoryRaw(IntegrationHistoryModel.model.rawCollection(), trashCollection); export const Sessions = new SessionsRaw(SessionsModel.model.rawCollection(), trashCollection); export const OmnichannelQueue = new OmnichannelQueueRaw(OmnichannelQueueModel.model.rawCollection(), trashCollection); +export const EmailInbox = new EmailInboxRaw(EmailInboxModel.model.rawCollection(), trashCollection); +export const EmailMessageHistory = new EmailMessageHistoryRaw(EmailMessageHistoryModel.model.rawCollection(), trashCollection); const map = { [Messages.col.collectionName]: MessagesModel, @@ -116,6 +122,7 @@ const map = { [InstanceStatus.col.collectionName]: InstanceStatusModel, [IntegrationHistory.col.collectionName]: IntegrationHistoryModel, [Integrations.col.collectionName]: IntegrationsModel, + [EmailInbox.col.collectionName]: EmailInboxModel, }; if (!process.env.DISABLE_DB_WATCH) { @@ -134,6 +141,7 @@ if (!process.env.DISABLE_DB_WATCH) { InstanceStatus, IntegrationHistory, Integrations, + EmailInbox, }; initWatchers(models, api.broadcastLocal.bind(api), (model, fn) => { diff --git a/app/otr/client/tabBar.ts b/app/otr/client/tabBar.ts index 2cfdc6dcdeff4..65760ebabc877 100644 --- a/app/otr/client/tabBar.ts +++ b/app/otr/client/tabBar.ts @@ -1,9 +1,11 @@ -import { useMemo, lazy, LazyExoticComponent, FC, useEffect } from 'react'; +import { useMemo, lazy, useEffect } from 'react'; import { OTR } from './rocketchat.otr'; import { useSetting } from '../../../client/contexts/SettingsContext'; import { addAction } from '../../../client/views/room/lib/Toolbox'; +const template = lazy(() => import('../../../client/views/room/contextualBar/OTR')); + addAction('otr', () => { const enabled = useSetting('OTR_Enable'); @@ -24,7 +26,7 @@ addAction('otr', () => { id: 'otr', title: 'OTR', icon: 'key', - template: lazy(() => import('../../../client/views/room/contextualBar/OTR')) as LazyExoticComponent, + template, order: 13, full: true, } : null), [shouldAddAction]); diff --git a/app/reactions/client/init.js b/app/reactions/client/init.js index 8e9d5420eba00..3d4f9e33a271c 100644 --- a/app/reactions/client/init.js +++ b/app/reactions/client/init.js @@ -1,6 +1,5 @@ import { Meteor } from 'meteor/meteor'; import { Blaze } from 'meteor/blaze'; -import { Template } from 'meteor/templating'; import { roomTypes } from '../../utils/client'; import { Rooms, Subscriptions } from '../../models'; @@ -60,8 +59,6 @@ export const EmojiEvents = { }, }; -Template.roomOld.events(EmojiEvents); - Meteor.startup(function() { MessageAction.addButton({ id: 'reaction-message', diff --git a/app/search/client/provider/result.html b/app/search/client/provider/result.html index c60f5dd87d8c4..ab0624e35c65b 100644 --- a/app/search/client/provider/result.html +++ b/app/search/client/provider/result.html @@ -3,7 +3,7 @@
    {{#if globalSearchEnabled}} {{/if}}
    diff --git a/app/statistics/server/lib/statistics.js b/app/statistics/server/lib/statistics.js index f92dbe7ecf602..3189328ce56ee 100644 --- a/app/statistics/server/lib/statistics.js +++ b/app/statistics/server/lib/statistics.js @@ -71,8 +71,10 @@ export const statistics = { 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.totalConnectedUsers = statistics.onlineUsers + statistics.awayUsers; - statistics.offlineUsers = statistics.totalUsers - statistics.onlineUsers - statistics.awayUsers; + statistics.offlineUsers = statistics.totalUsers - statistics.onlineUsers - statistics.awayUsers - statistics.busyUsers; // Room statistics statistics.totalRooms = Rooms.find().count(); diff --git a/app/theme/client/imports/components/alerts.css b/app/theme/client/imports/components/alerts.css deleted file mode 100644 index 96634f0645e7a..0000000000000 --- a/app/theme/client/imports/components/alerts.css +++ /dev/null @@ -1,105 +0,0 @@ -.rc-alerts { - position: relative; - - z-index: 10; - - display: flex; - - padding: var(--alerts-padding-vertical) var(--alerts-padding); - - animation-name: dropdown-show; - animation-duration: 0.3s; - - text-align: center; - - color: var(--alerts-color); - - background: var(--alerts-background); - - font-size: var(--alerts-font-size); - align-items: center; - flex-grow: 0; - - &--danger { - background: var(--rc-color-error); - } - - &--alert { - background: var(--rc-color-alert); - } - - &--large > &__content { - flex-direction: column; - - text-align: start; - } - - &--has-action { - cursor: pointer; - } - - &__icon { - cursor: pointer; - - color: inherit; - - flex-grow: 0; - - &--close { - transform: rotate(45deg); - } - } - - &__title { - font-weight: bold; - } - - &__title, - &__line { - display: block; - - overflow: hidden; - - white-space: nowrap; - text-overflow: ellipsis; - - flex-grow: 0; - } - - &__content > &__title + &__line { - margin: 0 var(--alerts-padding-vertical); - } - - &--large > &__content > &__title + &__line { - margin: 0; - } - - &--large > &__icon { - font-size: 30px; - } - - &--large > &__big-icon { - width: 40px; - height: 40px; - - font-size: 40px; - } - - &--large { - padding: var(--alerts-padding-vertical-large) var(--alerts-padding); - justify-content: start; - } - - &__content { - display: flex; - overflow: hidden; - - flex-direction: row; - - flex: 1; - - margin: 0 var(--alerts-padding-vertical); - - justify-content: center; - } -} diff --git a/app/theme/client/imports/components/header.css b/app/theme/client/imports/components/header.css index 51ddd7f60099c..ebf39c94879a8 100644 --- a/app/theme/client/imports/components/header.css +++ b/app/theme/client/imports/components/header.css @@ -159,6 +159,7 @@ text-overflow: ellipsis; } } + .tab-bugtton-icon--team { font-size: 28px; } @@ -187,6 +188,7 @@ flex-direction: row; align-items: center; } + &--burger { display: flex; diff --git a/app/theme/client/imports/general/base.css b/app/theme/client/imports/general/base.css index ef811d4c56515..5ee2fca3327f8 100644 --- a/app/theme/client/imports/general/base.css +++ b/app/theme/client/imports/general/base.css @@ -241,7 +241,12 @@ button { height: auto !important; } + .rc-alerts { display: none !important; } } + +.gallery-item { + cursor: pointer; +} diff --git a/app/theme/client/imports/general/base_old.css b/app/theme/client/imports/general/base_old.css index b48cdd16ebdd1..b34decf221cb8 100644 --- a/app/theme/client/imports/general/base_old.css +++ b/app/theme/client/imports/general/base_old.css @@ -8,8 +8,6 @@ } .rc-old code { - display: block; - margin: 5px 0; padding: 0.5em; @@ -893,134 +891,6 @@ animation: highlight 6s infinite; } -.rc-old .fixed-title { - display: flex; - - height: calc(var(--header-min-height) + 1px); - padding: 0 10px 0 20px; - - border-width: 0 0 1px; - align-items: center; - flex-flow: row nowrap; - flex-shrink: 0; - - &.visible h2 { - overflow: visible; - } - - & h2 { - overflow: hidden; - flex: 1; - - width: 100%; - - white-space: nowrap; - text-overflow: ellipsis; - - font-size: 22px; - font-weight: 500; - line-height: 29px; - - & .icon-at, - & .icon-hash, - & .icon-lock { - margin-right: -7px; - } - - & .icon-star, - & .icon-star-empty { - margin-right: -4px; - } - - & .iframe-toolbar { - white-space: nowrap; - flex-grow: 0; - } - } - - & .submit { - display: flex; - - & .button { - margin-left: 1rem; - - white-space: nowrap; - } - } - - & .animated-hidden { - display: none; - visibility: hidden; - } - - & input[type='text'] { - width: calc(100% - 100px); - margin-top: -4px; - margin-left: -3px; - - vertical-align: top; - - font-size: 20px; - } - - & .icon-pencil { - display: inline-block; - - margin-top: -7px; - - vertical-align: text-top; - - font-size: 16px; - } -} - -.rc-old .announcement { - display: flex; - - overflow: hidden; - - height: 40px; - padding: 0 20px; - - cursor: pointer; - text-align: center; - - color: var(--rc-color-content); - background-color: var(--primary-background-color); - - font-size: 1.2em; - line-height: 40px; - flex-flow: row nowrap; - - &.warning { - background-color: var(--rc-color-alert); - } - - &.error { - background-color: var(--rc-color-alert-message-warning); - } - - & ~ .container-bars { - top: 45px; - } - - a { - text-decoration: underline; - - color: currentColor; - } - - p { - overflow: hidden; - flex: auto; - - width: 0; /* Grow via flex. */ - - white-space: nowrap; - text-overflow: ellipsis; - } -} - .cms-page { display: flex; flex-direction: column; @@ -1440,17 +1310,16 @@ } .rc-old .container-bars { - position: absolute; + position: relative; z-index: 2; - top: 5px; /* --header-height */ - right: 10px; - left: 10px; - display: flex; + display: none; visibility: hidden; overflow: hidden; flex-direction: column; + margin: 5px 10px 0; + transition: transform 0.4s ease, visibility 0.3s ease, opacity 0.3s ease; transform: translateY(-10px); @@ -1464,6 +1333,7 @@ font-weight: bold; &.show { + display: flex; visibility: visible; transform: translateY(0); diff --git a/app/theme/client/main.css b/app/theme/client/main.css index c2368b20c2f4b..81aea4d296da8 100644 --- a/app/theme/client/main.css +++ b/app/theme/client/main.css @@ -32,7 +32,6 @@ @import 'imports/components/avatar.css'; @import 'imports/components/badge.css'; @import 'imports/components/popover.css'; -@import 'imports/components/alerts.css'; @import 'imports/components/popout.css'; @import 'imports/components/modal.css'; @import 'imports/components/chip.css'; diff --git a/app/threads/client/flextab/threadlist.tsx b/app/threads/client/flextab/threadlist.tsx index 1fe2ab0b93e44..45bf968663312 100644 --- a/app/threads/client/flextab/threadlist.tsx +++ b/app/threads/client/flextab/threadlist.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, lazy, LazyExoticComponent, FC } from 'react'; +import React, { useMemo, lazy, LazyExoticComponent, FC, ReactNode } from 'react'; import { BadgeProps } from '@rocket.chat/fuselage'; import { addAction } from '../../../../client/views/room/lib/Toolbox'; @@ -28,7 +28,7 @@ addAction('thread', (options) => { title: 'Threads', icon: 'thread', template, - renderAction: (props) => { + renderAction: (props): ReactNode => { const unread = room.tunread?.length > 99 ? '99+' : room.tunread?.length; const variant = getVariant(room.tunreadUser?.length, room.tunreadGroup?.length); return diff --git a/app/ui-clean-history/client/index.js b/app/ui-clean-history/client/index.js index 678ec8d399021..6d726d28bbb11 100644 --- a/app/ui-clean-history/client/index.js +++ b/app/ui-clean-history/client/index.js @@ -1,4 +1 @@ import './lib/startup'; -import './views/cleanHistory.html'; -import './views/cleanHistory'; -import './views/stylesheets/cleanHistory.css'; diff --git a/app/ui-clean-history/client/lib/startup.ts b/app/ui-clean-history/client/lib/startup.ts index 56575338c6741..96b9b8343b88e 100644 --- a/app/ui-clean-history/client/lib/startup.ts +++ b/app/ui-clean-history/client/lib/startup.ts @@ -1,18 +1,20 @@ -import { useMemo } from 'react'; +import { useMemo, lazy } from 'react'; import { addAction } from '../../../../client/views/room/lib/Toolbox'; import { usePermission } from '../../../../client/contexts/AuthorizationContext'; +const template = lazy(() => import('../../../../client/views/room/contextualBar/PruneMessages')); + addAction('clean-history', ({ room }) => { const hasPermission = usePermission('clean-channel-history', room._id); return useMemo(() => (hasPermission ? { groups: ['channel', 'group', 'direct'], id: 'clean-history', - anonymous: true, + full: true, title: 'Prune_Messages', icon: 'eraser', - template: 'cleanHistory', + template, order: 250, } : null), [hasPermission]); }); diff --git a/app/ui-clean-history/client/views/cleanHistory.html b/app/ui-clean-history/client/views/cleanHistory.html deleted file mode 100644 index 27f9a9a7bc64a..0000000000000 --- a/app/ui-clean-history/client/views/cleanHistory.html +++ /dev/null @@ -1,158 +0,0 @@ - diff --git a/app/ui-clean-history/client/views/cleanHistory.js b/app/ui-clean-history/client/views/cleanHistory.js deleted file mode 100644 index 3eb7b8960d9b0..0000000000000 --- a/app/ui-clean-history/client/views/cleanHistory.js +++ /dev/null @@ -1,351 +0,0 @@ -import { Tracker } from 'meteor/tracker'; -import { Blaze } from 'meteor/blaze'; -import { ReactiveVar } from 'meteor/reactive-var'; -import { Session } from 'meteor/session'; -import { Template } from 'meteor/templating'; -import moment from 'moment'; - -import { ChatRoom } from '../../../models'; -import { t, roomTypes } from '../../../utils'; -import { settings } from '../../../settings'; -import { modal, call } from '../../../ui-utils'; -import { AutoComplete } from '../../../meteor-autocomplete/client'; - -const getRoomName = function() { - const room = ChatRoom.findOne(Session.get('openedRoom')); - if (!room) { - return; - } - if (room.name) { - return `#${ room.name }`; - } - - return t('conversation_with_s', roomTypes.getRoomName(room.t, room)); -}; - -const purgeWorker = function(roomId, oldest, latest, inclusive, limit, excludePinned, ignoreDiscussion, filesOnly, fromUsers, ignoreThreads) { - return call('cleanRoomHistory', { - roomId, - latest, - oldest, - inclusive, - limit, - excludePinned, - ignoreDiscussion, - filesOnly, - fromUsers, - ignoreThreads, - }); -}; - - -const getTimeZoneOffset = function() { - const offset = new Date().getTimezoneOffset(); - const absOffset = Math.abs(offset); - return `${ offset < 0 ? '+' : '-' }${ `00${ Math.floor(absOffset / 60) }`.slice(-2) }:${ `00${ absOffset % 60 }`.slice(-2) }`; -}; - - -const filterNames = (old) => { - const reg = new RegExp(`^${ settings.get('UTF8_Names_Validation') }$`); - return [...old.replace(' ', '').toLocaleLowerCase()].filter((f) => reg.test(f)).join(''); -}; - -Template.cleanHistory.helpers({ - roomId() { - const room = ChatRoom.findOne(Session.get('openedRoom')); - return room && room._id; - }, - roomName() { - return getRoomName(); - }, - warningBox() { - return Template.instance().warningBox.get(); - }, - validate() { - return Template.instance().validate.get(); - }, - filesOnly() { - return Template.instance().cleanHistoryFilesOnly.get(); - }, - busy() { - return Template.instance().cleanHistoryBusy.get(); - }, - finished() { - return Template.instance().cleanHistoryFinished.get(); - }, - prunedCount() { - return Template.instance().cleanHistoryPrunedCount.get(); - }, - 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(); - }, - 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(); - }, - isSingular(prunedCount) { - return prunedCount === 1; - }, -}); - -Template.cleanHistory.onCreated(function() { - this.warningBox = new ReactiveVar(''); - this.validate = new ReactiveVar(''); - this.selectedUsers = new ReactiveVar([]); - this.userFilter = new ReactiveVar(''); - - this.cleanHistoryFromDate = new ReactiveVar(''); - this.cleanHistoryFromTime = new ReactiveVar(''); - this.cleanHistoryToDate = new ReactiveVar(''); - this.cleanHistoryToTime = new ReactiveVar(''); - this.cleanHistorySelectedUsers = new ReactiveVar([]); - this.cleanHistoryInclusive = new ReactiveVar(false); - this.cleanHistoryExcludePinned = new ReactiveVar(false); - this.cleanHistoryFilesOnly = new ReactiveVar(false); - - this.ignoreDiscussion = new ReactiveVar(false); - this.ignoreThreads = new ReactiveVar(false); - - this.cleanHistoryBusy = new ReactiveVar(false); - this.cleanHistoryFinished = new ReactiveVar(false); - this.cleanHistoryPrunedCount = new ReactiveVar(0); - - this.ac = new AutoComplete( - { - selector: { - item: '.rc-popup-list__item', - container: '.rc-popup-list__list', - }, - - limit: 10, - inputDelay: 300, - rules: [ - { - collection: 'UserAndRoom', - endpoint: 'users.autocomplete', - field: 'username', - matchAll: true, - doNotChangeWidth: false, - selector(match) { - return { term: match }; - }, - sort: 'username', - }, - ], - - }); - this.ac.tmplInst = this; -}); - -Template.cleanHistory.onRendered(function() { - const users = this.selectedUsers; - const selUsers = this.cleanHistorySelectedUsers; - - this.ac.element = this.firstNode.parentElement.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); - selUsers.set(usersArr); - }); - - Tracker.autorun(() => { - const metaFromDate = this.cleanHistoryFromDate.get(); - const metaFromTime = this.cleanHistoryFromTime.get(); - const metaToDate = this.cleanHistoryToDate.get(); - const metaToTime = this.cleanHistoryToTime.get(); - const metaSelectedUsers = this.cleanHistorySelectedUsers.get(); - const metaCleanHistoryExcludePinned = this.cleanHistoryExcludePinned.get(); - const metaCleanHistoryFilesOnly = this.cleanHistoryFilesOnly.get(); - - let fromDate = new Date('0001-01-01T00:00:00Z'); - let toDate = new Date('9999-12-31T23:59:59Z'); - - if (metaFromDate) { - fromDate = new Date(`${ metaFromDate }T${ metaFromTime || '00:00' }:00${ getTimeZoneOffset() }`); - } - - if (metaToDate) { - toDate = new Date(`${ metaToDate }T${ metaToTime || '00:00' }:00${ getTimeZoneOffset() }`); - } - - const exceptPinned = metaCleanHistoryExcludePinned ? ` ${ t('except_pinned', {}) }` : ''; - const ifFrom = metaSelectedUsers.length ? ` ${ t('if_they_are_from', { - postProcess: 'sprintf', - sprintf: [metaSelectedUsers.map((element) => element.username).join(', ')], - }) }` : ''; - const filesOrMessages = t(metaCleanHistoryFilesOnly ? 'files' : 'messages', {}); - - if (metaFromDate && metaToDate) { - this.warningBox.set(t('Prune_Warning_between', { - postProcess: 'sprintf', - sprintf: [filesOrMessages, getRoomName(), moment(fromDate).format('L LT'), moment(toDate).format('L LT')], - }) + exceptPinned + ifFrom); - } else if (metaFromDate) { - this.warningBox.set(t('Prune_Warning_after', { - postProcess: 'sprintf', - sprintf: [filesOrMessages, getRoomName(), moment(fromDate).format('L LT')], - }) + exceptPinned + ifFrom); - } else if (metaToDate) { - this.warningBox.set(t('Prune_Warning_before', { - postProcess: 'sprintf', - sprintf: [filesOrMessages, getRoomName(), moment(toDate).format('L LT')], - }) + exceptPinned + ifFrom); - } else { - this.warningBox.set(t('Prune_Warning_all', { - postProcess: 'sprintf', - sprintf: [filesOrMessages, getRoomName()], - }) + exceptPinned + ifFrom); - } - - if (fromDate > toDate) { - return this.validate.set(t('Newer_than_may_not_exceed_Older_than', { - postProcess: 'sprintf', - sprintf: [], - })); - } - if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) { - return this.validate.set(t('error-invalid-date', { - postProcess: 'sprintf', - sprintf: [], - })); - } - this.validate.set(''); - }); -}); - -Template.cleanHistory.events({ - 'change [name=from__date]'(e, instance) { - instance.cleanHistoryFromDate.set(e.target.value); - }, - 'change [name=from__time]'(e, instance) { - instance.cleanHistoryFromTime.set(e.target.value); - }, - 'change [name=to__date]'(e, instance) { - instance.cleanHistoryToDate.set(e.target.value); - }, - 'change [name=to__time]'(e, instance) { - instance.cleanHistoryToTime.set(e.target.value); - }, - 'change [name=inclusive]'(e, instance) { - instance.cleanHistoryInclusive.set(e.target.checked); - }, - 'change [name=excludePinned]'(e, instance) { - instance.cleanHistoryExcludePinned.set(e.target.checked); - }, - 'change [name=filesOnly]'(e, instance) { - instance.cleanHistoryFilesOnly.set(e.target.checked); - }, - 'change [name=ignoreDiscussion]'(e, instance) { - instance.ignoreDiscussion.set(e.target.checked); - }, - 'change [name=ignoreThreads]'(e, instance) { - instance.ignoreThreads.set(e.target.checked); - }, - 'click .js-prune'(e, instance) { - modal.open({ - title: t('Are_you_sure'), - text: t('Prune_Modal'), - type: 'warning', - showCancelButton: true, - confirmButtonColor: '#DD6B55', - confirmButtonText: t('Yes_prune_them'), - cancelButtonText: t('Cancel'), - closeOnConfirm: true, - html: false, - }, async function() { - instance.cleanHistoryBusy.set(true); - const metaFromDate = instance.cleanHistoryFromDate.get(); - const metaFromTime = instance.cleanHistoryFromTime.get(); - const metaToDate = instance.cleanHistoryToDate.get(); - const metaToTime = instance.cleanHistoryToTime.get(); - const metaSelectedUsers = instance.cleanHistorySelectedUsers.get(); - const metaCleanHistoryInclusive = instance.cleanHistoryInclusive.get(); - const metaCleanHistoryExcludePinned = instance.cleanHistoryExcludePinned.get(); - const metaCleanHistoryFilesOnly = instance.cleanHistoryFilesOnly.get(); - const ignoreDiscussion = instance.ignoreDiscussion.get(); - const ignoreThreads = instance.ignoreThreads.get(); - - let fromDate = new Date('0001-01-01T00:00:00Z'); - let toDate = new Date('9999-12-31T23:59:59Z'); - - if (metaFromDate) { - fromDate = new Date(`${ metaFromDate }T${ metaFromTime || '00:00' }:00${ getTimeZoneOffset() }`); - } - - if (metaToDate) { - toDate = new Date(`${ metaToDate }T${ metaToTime || '00:00' }:00${ getTimeZoneOffset() }`); - } - - const roomId = Session.get('openedRoom'); - const users = metaSelectedUsers.map((element) => element.username); - const limit = 2000; - let count = 0; - let result; - do { - result = await purgeWorker(roomId, fromDate, toDate, metaCleanHistoryInclusive, limit, metaCleanHistoryExcludePinned, ignoreDiscussion, metaCleanHistoryFilesOnly, users, ignoreThreads); // eslint-disable-line no-await-in-loop - count += result; - } while (result === limit); - - instance.cleanHistoryPrunedCount.set(count); - instance.cleanHistoryFinished.set(true); - }); - }, - 'click .rc-input--usernames .rc-tags__tag'({ target }, t) { - const { username } = Blaze.getData(target); - t.selectedUsers.set(t.selectedUsers.get().filter((user) => user.username !== username)); - t.cleanHistorySelectedUsers.set(t.selectedUsers.get()); - }, - 'click .rc-popup-list__item'(e, t) { - t.ac.onItemClick(this, e); - }, - '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); - }, - 'keydown [name="users"]'(e, t) { - if ([8, 46].includes(e.keyCode) && e.target.value === '') { - const users = t.selectedUsers; - const usersArr = users.get(); - usersArr.pop(); - t.cleanHistorySelectedUsers.set(usersArr); - 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); - }, -}); diff --git a/app/ui-clean-history/client/views/stylesheets/cleanHistory.css b/app/ui-clean-history/client/views/stylesheets/cleanHistory.css deleted file mode 100644 index 57048d469b902..0000000000000 --- a/app/ui-clean-history/client/views/stylesheets/cleanHistory.css +++ /dev/null @@ -1,59 +0,0 @@ -.rc-datetime__left { - display: inline-block; - - width: 52%; -} - -.rc-datetime__right { - display: inline-block; - - width: calc(48% - 0.3rem); -} - -.rc-user-info__pruning { - position: absolute; - top: 50%; - left: 50%; - - transform: translate(-50%, calc(-50% - 2rem)); -} - -.pruning__header { - text-align: center; - - font-weight: 900; -} - -.pruning-wrapper { - text-align: center; - - color: var(--rc-color-link-active); - - &.prune__finished { - color: #12c212; - } - - & .rc-icon--loading { - width: 16rem; - height: 16rem; - margin: 1rem 0; - - animation: spin 2s linear infinite; - } - - & .rc-icon--check { - font-size: 1rem; - } - - & .pruning__text { - margin-top: -17rem; - - font-size: 3.5em; - - line-height: 16rem; - } - - & .pruning__text-sub { - margin-top: calc(-8rem + 1.5em); - } -} diff --git a/app/ui-master/client/main.html b/app/ui-master/client/main.html index 6209bf400630a..7b657a62c76aa 100644 --- a/app/ui-master/client/main.html +++ b/app/ui-master/client/main.html @@ -1,5 +1,4 @@ -