diff --git a/.docker/Dockerfile b/.docker/Dockerfile index 99c8cbef0c485..0c55289a84ed7 100644 --- a/.docker/Dockerfile +++ b/.docker/Dockerfile @@ -8,6 +8,7 @@ RUN groupadd -g 65533 -r rocketchat \ && mkdir -p /app/uploads \ && chown rocketchat:rocketchat /app/uploads \ && apt-get update \ + && apt-get -y upgrade \ && apt-get install -y --no-install-recommends fontconfig # --chown requires Docker 17.12 and works only on Linux diff --git a/.eslintignore b/.eslintignore index 24f6298dbc9df..56c36cc7ce0f4 100644 --- a/.eslintignore +++ b/.eslintignore @@ -20,3 +20,5 @@ ee/server/services/dist/** !/.mocharc.js !/client/.eslintrc.js !/ee/client/.eslintrc.js +app/utils/client/lib/sha1.js +app/analytics/server/trackEvents.js diff --git a/.github/dependabot.yml b/.github/dependabot_deactivated.yml similarity index 100% rename from .github/dependabot.yml rename to .github/dependabot_deactivated.yml diff --git a/.github/workflows/auto_catchup.yml b/.github/workflows/auto_catchup.yml new file mode 100644 index 0000000000000..e48c9da44db03 --- /dev/null +++ b/.github/workflows/auto_catchup.yml @@ -0,0 +1,37 @@ +name: Upstream catchup from Rocketchat develop + +on: + schedule: + - cron: '0 0 * * 1' #on every Monday + +jobs: + catchup: + name: Upstream Catchup + runs-on: ubuntu-latest + + steps: + + - name: Catchup Info + run: | + echo "GITHUB_ACTION: $GITHUB_ACTION" + echo "GITHUB_ACTOR: $GITHUB_ACTOR" + echo "GITHUB_REF: $GITHUB_REF" + echo "github.event_name: ${{ github.event_name }}" + cat $GITHUB_EVENT_PATH + - name: Checkout + uses: actions/checkout@v2 + - name: Set up Node + uses: actions/setup-node@v1 + with: + node-version: 12 + - name: Create Catchup PR + uses: shubhsherl/create-catchup-pr@v0.1.0 + with: + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + SOURCE_BRANCH: "master" + SOURCE_REPO: "RocketChat" + TARGET_BRANCH: "develop_pwa" + GITHUB_REPO: ${{github.repository}} + TITLE: "[Upstream Catchup] Merge RC:master to develop_pwa" + BODY: "Weekly Catchup PR to merge RC:master in develop_pwa." + DRAFT: "true" diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 26125d9dcfc68..28c5c1bacd0b5 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -7,7 +7,7 @@ on: branches: '**' push: branches: - - develop + - develop_pwa env: CI: true @@ -273,136 +273,139 @@ jobs: # commit: true # token: ${{ secrets.GITHUB_TOKEN }} - build-image-pr: - runs-on: ubuntu-latest - if: github.event.pull_request.head.repo.full_name == github.repository - - strategy: - matrix: - release: ["official", "preview"] - - steps: - - uses: actions/checkout@v2 - - - name: Login to GitHub Container Registry - uses: docker/login-action@v1 - with: - registry: ghcr.io - username: ${{ secrets.CR_USER }} - password: ${{ secrets.CR_PAT }} - - - name: Free disk space - run: | - sudo swapoff -a - sudo rm -f /swapfile - sudo apt clean - docker rmi $(docker image ls -aq) - df -h - - # - name: Cache node modules - # id: cache-nodemodules - # uses: actions/cache@v2 - # with: - # path: | - # ./node_modules - # ./ee/server/services/node_modules - # key: ${{ runner.OS }}-node_modules-4-${{ hashFiles('**/package-lock.json', '.github/workflows/build_and_test.yml') }} - - - name: Cache meteor local - uses: actions/cache@v2 - with: - path: ./.meteor/local - key: ${{ runner.OS }}-meteor_cache-${{ hashFiles('.meteor/versions', '.github/workflows/build_and_test.yml') }} - - - name: Cache meteor - uses: actions/cache@v2 - with: - path: ~/.meteor - key: ${{ runner.OS }}-meteor-${{ hashFiles('.meteor/release', '.github/workflows/build_and_test.yml') }} - - - name: Use Node.js 12.22.1 - uses: actions/setup-node@v2 - with: - node-version: "12.22.1" - - - name: Install Meteor - run: | - # Restore bin from cache - set +e - METEOR_SYMLINK_TARGET=$(readlink ~/.meteor/meteor) - METEOR_TOOL_DIRECTORY=$(dirname "$METEOR_SYMLINK_TARGET") - set -e - LAUNCHER=$HOME/.meteor/$METEOR_TOOL_DIRECTORY/scripts/admin/launch-meteor - if [ -e $LAUNCHER ] - then - echo "Cached Meteor bin found, restoring it" - sudo cp "$LAUNCHER" "/usr/local/bin/meteor" - else - echo "No cached Meteor bin found." - fi - - # only install meteor if bin isn't found - command -v meteor >/dev/null 2>&1 || curl https://install.meteor.com | sed s/--progress-bar/-sL/g | /bin/sh - - - name: Versions - run: | - npm --versions - node -v - meteor --version - meteor npm --versions - meteor node -v - git version - - - name: npm install - # if: steps.cache-nodemodules.outputs.cache-hit != 'true' - run: | - meteor npm install - - # To reduce memory need during actual build, build the packages solely first - # - name: Build a Meteor cache - # run: | - # # to do this we can clear the main files and it build the rest - # echo "" > server/main.js - # echo "" > client/main.js - # sed -i.backup 's/rocketchat:livechat/#rocketchat:livechat/' .meteor/packages - # meteor build --server-only --debug --directory /tmp/build-temp - # git checkout -- server/main.js client/main.js .meteor/packages - - - name: Build Rocket.Chat - run: | - meteor build --server-only --directory /tmp/build-pr - - - name: Build Docker image for PRs - run: | - cd /tmp/build-pr - - LOWERCASE_REPOSITORY=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") - IMAGE_NAME="rocket.chat" - if [[ '${{ matrix.release }}' = 'preview' ]]; then - IMAGE_NAME="${IMAGE_NAME}.preview" - fi; - - IMAGE_NAME="ghcr.io/${LOWERCASE_REPOSITORY}/${IMAGE_NAME}:pr-${{ github.event.number }}" - - echo "Build official Docker image ${IMAGE_NAME}" - - DOCKER_PATH="${GITHUB_WORKSPACE}/.docker" - if [[ '${{ matrix.release }}' = 'preview' ]]; then - DOCKER_PATH="${DOCKER_PATH}-mongo" - fi; - - echo "Build ${{ matrix.release }} Docker image" - cp ${DOCKER_PATH}/Dockerfile . - if [ -e ${DOCKER_PATH}/entrypoint.sh ]; then - cp ${DOCKER_PATH}/entrypoint.sh . - fi; - - docker build -t $IMAGE_NAME . - docker push $IMAGE_NAME + # build-image-pr: + # runs-on: ubuntu-latest + # if: github.event.pull_request.head.repo.full_name == github.repository + + # strategy: + # matrix: + # release: ["official", "preview"] + + # steps: + # - uses: actions/checkout@v2 + + # - name: Login to GitHub Container Registry + # uses: docker/login-action@v1 + # with: + # registry: ghcr.io + # username: ${{ secrets.CR_USER }} + # password: ${{ secrets.CR_PAT }} + + # - name: Free disk space + # run: | + # sudo swapoff -a + # sudo rm -f /swapfile + # sudo apt clean + # docker rmi $(docker image ls -aq) + # df -h + + # # - name: Cache node modules + # # id: cache-nodemodules + # # uses: actions/cache@v2 + # # with: + # # path: | + # # ./node_modules + # # ./ee/server/services/node_modules + # # key: ${{ runner.OS }}-node_modules-4-${{ hashFiles('**/package-lock.json', '.github/workflows/build_and_test.yml') }} + + # - name: Cache meteor local + # uses: actions/cache@v2 + # with: + # path: ./.meteor/local + # key: ${{ runner.OS }}-meteor_cache-${{ hashFiles('.meteor/versions', '.github/workflows/build_and_test.yml') }} + + # - name: Cache meteor + # uses: actions/cache@v2 + # with: + # path: ~/.meteor + # key: ${{ runner.OS }}-meteor-${{ hashFiles('.meteor/release', '.github/workflows/build_and_test.yml') }} + + # - name: Use Node.js 12.22.1 + # uses: actions/setup-node@v2 + # with: + # node-version: "12.22.1" + + # - name: Install Meteor + # run: | + # # Restore bin from cache + # set +e + # METEOR_SYMLINK_TARGET=$(readlink ~/.meteor/meteor) + # METEOR_TOOL_DIRECTORY=$(dirname "$METEOR_SYMLINK_TARGET") + # set -e + # LAUNCHER=$HOME/.meteor/$METEOR_TOOL_DIRECTORY/scripts/admin/launch-meteor + # if [ -e $LAUNCHER ] + # then + # echo "Cached Meteor bin found, restoring it" + # sudo cp "$LAUNCHER" "/usr/local/bin/meteor" + # else + # echo "No cached Meteor bin found." + # fi + + # # only install meteor if bin isn't found + # command -v meteor >/dev/null 2>&1 || curl https://install.meteor.com | sed s/--progress-bar/-sL/g | /bin/sh + + # - name: Versions + # run: | + # npm --versions + # node -v + # meteor --version + # meteor npm --versions + # meteor node -v + # git version + + # - name: npm install + # # if: steps.cache-nodemodules.outputs.cache-hit != 'true' + # run: | + # meteor npm install + + # # To reduce memory need during actual build, build the packages solely first + # # - name: Build a Meteor cache + # # run: | + # # # to do this we can clear the main files and it build the rest + # # echo "" > server/main.js + # # echo "" > client/main.js + # # sed -i.backup 's/rocketchat:livechat/#rocketchat:livechat/' .meteor/packages + # # meteor build --server-only --debug --directory /tmp/build-temp + # # git checkout -- server/main.js client/main.js .meteor/packages + + # - name: Build Rocket.Chat + # run: | + # meteor build --server-only --directory /tmp/build-pr + + # - name: Build Docker image for PRs + # run: | + # cd /tmp/build-pr + + # LOWERCASE_REPOSITORY=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") + # IMAGE_NAME="rocket.chat" + # if [[ '${{ matrix.release }}' = 'preview' ]]; then + # IMAGE_NAME="${IMAGE_NAME}.preview" + # fi; + + # IMAGE_NAME="ghcr.io/${LOWERCASE_REPOSITORY}/${IMAGE_NAME}:pr-${{ github.event.number }}" + + # echo "Build official Docker image ${IMAGE_NAME}" + + # DOCKER_PATH="${GITHUB_WORKSPACE}/.docker" + # if [[ '${{ matrix.release }}' = 'preview' ]]; then + # DOCKER_PATH="${DOCKER_PATH}-mongo" + # fi; + + # echo "Build ${{ matrix.release }} Docker image" + # cp ${DOCKER_PATH}/Dockerfile . + # if [ -e ${DOCKER_PATH}/entrypoint.sh ]; then + # cp ${DOCKER_PATH}/entrypoint.sh . + # fi; + + # docker build -t $IMAGE_NAME . + # docker push $IMAGE_NAME deploy: runs-on: ubuntu-latest - if: github.event_name == 'release' || github.ref == 'refs/heads/develop' + # WideChat does not need this + if: github.event_name == 'DEACTIVATED' + + # if: github.event_name == 'release' || github.ref == 'refs/heads/develop' needs: test steps: @@ -480,6 +483,7 @@ jobs: image-build: runs-on: ubuntu-latest + if: github.event_name == 'DEACTIVATED' needs: deploy strategy: @@ -555,13 +559,12 @@ jobs: docker build -t ${IMAGE}:develop . docker push ${IMAGE}:develop - services-image-build: + # TODO: configure for merges to develop when ready + # NOTE: Right now it will only push on every merge into develop_pwa because we have a standing PR from it -> develop. + widechat-ecr-push: runs-on: ubuntu-latest - needs: deploy - - strategy: - matrix: - service: ["account", "authorization", "ddp-streamer", "presence", "stream-hub"] + if: ${{ github.ref == 'refs/heads/develop_pwa' && github.event_name == 'push' }} + needs: test steps: - uses: actions/checkout@v2 @@ -571,42 +574,72 @@ jobs: with: node-version: "12.22.1" - - name: Login to DockerHub - uses: docker/login-action@v1 + - name: Parse for branch + run: | + git_ref=${{ github.ref }} + arrRef=(${git_ref//\// }) + branch=${arrRef[2]} + echo "branch is this: $branch" + echo "PR_TAG=latest-$branch" >> $GITHUB_ENV + + - name: Restore build + uses: actions/download-artifact@v1 with: - username: ${{ secrets.DOCKER_USER }} - password: ${{ secrets.DOCKER_PASS }} + name: build + path: /tmp/build - - name: Build Docker images + - name: Unpack build run: | - # defines image tag - if [[ $GITHUB_REF == refs/tags/* ]]; then - IMAGE_TAG="${GITHUB_REF#refs/tags/}" - else - IMAGE_TAG="${GITHUB_REF#refs/heads/}" - fi + cd /tmp/build + tar xzf Rocket.Chat.tar.gz + rm Rocket.Chat.tar.gz + cp "${GITHUB_WORKSPACE}/.docker/Dockerfile" . - # first install repo dependencies - npm i + - name: Configure AWS credentials in DEV + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-1 - # then micro services dependencies - cd ./ee/server/services - npm i - npm run build + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v1 - echo "Building Docker image for service: ${{ matrix.service }}:${IMAGE_TAG}" + - name: Build, tag, and push image to Amazon ECR in DEV + id: build-image-dev + env: + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + ECR_REPOSITORY: widechat + IMAGE_TAG: ${{ env.PR_TAG }} - docker build --build-arg SERVICE=${{ matrix.service }} -t rocketchat/${{ matrix.service }}-service:${IMAGE_TAG} . + run: | + # Build a docker container and push it to ECR + cd /tmp/build + docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG . + docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + echo "::set-output name=DEV_IMAGE::$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" - docker push rocketchat/${{ matrix.service }}-service:${IMAGE_TAG} + ## Temporarily we will deploy the PR tags to PROD as well as DEV + - name: Configure AWS credentials in PROD + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_PROD_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_PROD_SECRET_ACCESS_KEY }} + aws-region: us-east-1 - if [[ $GITHUB_REF == refs/tags/* ]]; then - if echo "$IMAGE_TAG" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' ; then - RELEASE="latest" - elif echo "$IMAGE_TAG" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$' ; then - RELEASE="release-candidate" - fi + - name: Login to Amazon ECR + id: login-ecr-prod + uses: aws-actions/amazon-ecr-login@v1 - docker tag rocketchat/${{ matrix.service }}-service:${IMAGE_TAG} rocketchat/${{ matrix.service }}-service:${RELEASE} - docker push rocketchat/${{ matrix.service }}-service:${RELEASE} - fi + - name: tag, and push image to Amazon ECR in PROD + id: tag-image + env: + ECR_REGISTRY: ${{ steps.login-ecr-prod.outputs.registry }} + ECR_REPOSITORY: widechat + IMAGE_TAG: ${{ env.PR_TAG }} + + run: | + # Tag docker image and push it to ECR + docker tag ${{ steps.build-image-dev.outputs.DEV_IMAGE }} $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG diff --git a/.github/workflows/ecr_push.yml b/.github/workflows/ecr_push.yml new file mode 100644 index 0000000000000..cc5e1005f86ba --- /dev/null +++ b/.github/workflows/ecr_push.yml @@ -0,0 +1,88 @@ +on: workflow_dispatch + +name: PUSH to Amazon ECR + +jobs: + widechat-ecr-push: + name: ecr-push + runs-on: ubuntu-latest + # if: ${{ github.base_ref == 'develop' && github.event_name == 'pull_request' }} + # needs: test + + steps: + - name: Checkout + uses: actions/checkout@v2 + + # - name: Parse for PR num + # run: | + # git_ref=${{ github.ref }} + # arrRef=(${git_ref//\// }) + # pr_num=${arrRef[2]} + # echo "pr_num is this: $pr_num" + # echo "PR_TAG=pr-num-$pr_num" >> $GITHUB_ENV + + - name: Download artifact + uses: dawidd6/action-download-artifact@v2 + with: + workflow: build_and_test.yml + workflow_conclusion: success + branch: develop_pwa + name: build + path: /tmp/build + + - name: Unpack build + run: | + cd /tmp/build + tar xzf Rocket.Chat.tar.gz + rm Rocket.Chat.tar.gz + cp "${GITHUB_WORKSPACE}/.docker/Dockerfile" . + + - name: Configure AWS credentials in DEV + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-1 + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v1 + + - name: Build, tag, and push image to Amazon ECR in DEV + id: build-image-dev + env: + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + ECR_REPOSITORY: widechat + IMAGE_TAG: ear-test + + run: | + # Build a docker container and push it to ECR + cd /tmp/build + docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG . + docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + echo "::set-output name=DEV_IMAGE::$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" + + ## Temporarily we will deploy the PR tags to PROD as well as DEV + - name: Configure AWS credentials in PROD + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_PROD_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_PROD_SECRET_ACCESS_KEY }} + aws-region: us-east-1 + + - name: Login to Amazon ECR + id: login-ecr-prod + uses: aws-actions/amazon-ecr-login@v1 + + - name: tag, and push image to Amazon ECR in PROD + id: tag-image + env: + ECR_REGISTRY: ${{ steps.login-ecr-prod.outputs.registry }} + ECR_REPOSITORY: widechat + IMAGE_TAG: ear-test + + run: | + # Tag docker image and push it to ECR + docker tag ${{ steps.build-image-dev.outputs.DEV_IMAGE }} $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + echo "::set-output name=image::$ECR_PROD_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml.widechatDisabled similarity index 100% rename from .github/workflows/stale.yml rename to .github/workflows/stale.yml.widechatDisabled diff --git a/.sandstorm/sandstorm-pkgdef.capnp b/.sandstorm/sandstorm-pkgdef.capnp new file mode 100644 index 0000000000000..f3157af26c0c3 --- /dev/null +++ b/.sandstorm/sandstorm-pkgdef.capnp @@ -0,0 +1,116 @@ +@0xbbbe049af795122e; + +using Spk = import "/sandstorm/package.capnp"; +# This imports: +# $SANDSTORM_HOME/latest/usr/include/sandstorm/package.capnp +# Check out that file to see the full, documented package definition format. + +const pkgdef :Spk.PackageDefinition = ( + # The package definition. Note that the spk tool looks specifically for the + # "pkgdef" constant. + + id = "vfnwptfn02ty21w715snyyczw0nqxkv3jvawcah10c6z7hj1hnu0", + # Your app ID is actually its public key. The private key was placed in + # your keyring. All updates must be signed with the same key. + + manifest = ( + # This manifest is included in your app package to tell Sandstorm + # about your app. + + appTitle = (defaultText = "Rocket.Chat"), + + appVersion = 122, # Increment this for every release. + + appMarketingVersion = (defaultText = "0.74.0-develop"), + + # Human-readable representation of appVersion. Should match the way you + # identify versions of your app in documentation and marketing. + + actions = [ + # Define your "new document" handlers here. + ( title = (defaultText = "New Rocket.Chat"), + command = .myCommand + # The command to run when starting for the first time. (".myCommand" + # is just a constant defined at the bottom of the file.) + ) + ], + + continueCommand = .myCommand, + # This is the command called to start your app back up after it has been + # shut down for inactivity. Here we're using the same command as for + # starting a new instance, but you could use different commands for each + # case. + + metadata = ( + icons = ( + appGrid = (svg = embed "rocket.chat-128.svg"), + grain = (svg = embed "rocket.chat-24.svg"), + market = (svg = embed "rocket.chat-150.svg"), + ), + + website = "https://rocket.chat", + codeUrl = "https://github.com/RocketChat/Rocket.Chat", + license = (openSource = mit), + categories = [communications, productivity, office, social, developerTools], + + author = ( + contactEmail = "team@rocket.chat", + pgpSignature = embed "pgp-signature", + upstreamAuthor = "Rocket.Chat", + ), + pgpKeyring = embed "pgp-keyring", + + description = (defaultText = embed "description.md"), + shortDescription = (defaultText = "Chat app"), + + screenshots = [ + (width = 1024, height = 696, png = embed "screenshot1.png"), + (width = 1024, height = 696, png = embed "screenshot2.png"), + (width = 1024, height = 696, png = embed "screenshot3.png"), + (width = 1024, height = 696, png = embed "screenshot4.png") + ], + + changeLog = (defaultText = embed "CHANGELOG.md"), + ), + + ), + + sourceMap = ( + # The following directories will be copied into your package. + searchPath = [ + ( sourcePath = "/home/vagrant/bundle" ), + ( sourcePath = "/opt/meteor-spk/meteor-spk.deps" ) + ] + ), + + alwaysInclude = [ "." ], + # This says that we always want to include all files from the source map. + # (An alternative is to automatically detect dependencies by watching what + # the app opens while running in dev mode. To see what that looks like, + # run `spk init` without the -A option.) + + bridgeConfig = ( + viewInfo = ( + eventTypes = [ + (name = "message", verbPhrase = (defaultText = "sent message")), + (name = "privateMessage", verbPhrase = (defaultText = "sent private message"), requiredPermission = (explicitList = void)), + ] + ), + saveIdentityCaps = true, + ), +); + +const myCommand :Spk.Manifest.Command = ( + # Here we define the command used to start up your server. + argv = ["/sandstorm-http-bridge", "8000", "--", "/opt/app/.sandstorm/launcher.sh"], + environ = [ + # Note that this defines the *entire* environment seen by your app. + (key = "PATH", value = "/usr/local/bin:/usr/bin:/bin"), + (key = "SANDSTORM", value = "1"), + (key = "HOME", value = "/var"), + (key = "Statistics_reporting", value = "false"), + (key = "Accounts_AllowUserAvatarChange", value = "false"), + (key = "Accounts_AllowUserProfileChange", value = "false"), + (key = "BABEL_CACHE_DIR", value = "/var/babel_cache") + ] +); diff --git a/README.md b/README.md index 6c47c5f0a1a3b..470bd961d1c01 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +## The WideChat repo is a fork of RocketChat for development of features to be merged upstream in coordination with the RocketChat team.... + [Rocket.Chat](https://rocket.chat) is an open-source fully customizable communications platform developed in JavaScript for organizations with high standards of data protection. diff --git a/app/analytics/client/loadScript.js b/app/analytics/client/loadScript.js index 4cd8ad84aefff..491f340e734be 100644 --- a/app/analytics/client/loadScript.js +++ b/app/analytics/client/loadScript.js @@ -2,6 +2,7 @@ import { Meteor } from 'meteor/meteor'; import { Template } from 'meteor/templating'; import { settings } from '../../settings'; +import { hex_sha1 } from '../../utils'; Template.body.onRendered(function() { this.autorun((c) => { @@ -77,7 +78,19 @@ Template.body.onRendered(function() { })(window,document,'script','https://www.google-analytics.com/analytics.js','ga'); ga('create', googleId, 'auto'); - ga('send', 'pageview'); + + // WIDECHAT - obfuscate username in GA + const pathArray = document.location.pathname.split('/'); + if (pathArray[1] === 'direct') { + const hashedUsername = hex_sha1(pathArray[2]); + const page = document.location.pathname.replace(pathArray[2], hashedUsername); + + ga('set', 'location', page); + ga('send', 'pageview', page); + + } else { + ga('send', 'pageview'); + } /* eslint-enable */ } } diff --git a/app/analytics/client/trackEvents.js b/app/analytics/client/trackEvents.js index 46364603cbdd7..99cfe4ed1b50f 100644 --- a/app/analytics/client/trackEvents.js +++ b/app/analytics/client/trackEvents.js @@ -5,6 +5,7 @@ import { Tracker } from 'meteor/tracker'; import { settings } from '../../settings'; import { callbacks } from '../../callbacks'; import { ChatRoom } from '../../models'; +import { hex_sha1 } from '../../utils'; function trackEvent(category, action, label) { if (window._paq) { @@ -26,7 +27,14 @@ if (!window._paq || window.ga) { window._paq.push(['trackPageView']); } if (window.ga) { - window.ga('send', 'pageview', route.path); + const pathArray = route.path.split('/'); + if (pathArray[1] === 'direct') { + const hashedUsername = hex_sha1(pathArray[2]); + const page = route.path.replace(pathArray[2], hashedUsername); + window.ga('send', 'pageview', page); + } else { + window.ga('send', 'pageview', route.path); + } } }]); diff --git a/app/analytics/server/index.js b/app/analytics/server/index.js index 97097791afdc4..5226ba2273b73 100644 --- a/app/analytics/server/index.js +++ b/app/analytics/server/index.js @@ -1 +1,2 @@ import './settings'; +import './trackEvents'; diff --git a/app/analytics/server/trackEvents.js b/app/analytics/server/trackEvents.js new file mode 100644 index 0000000000000..60413b9ef2722 --- /dev/null +++ b/app/analytics/server/trackEvents.js @@ -0,0 +1,34 @@ +import { Meteor } from 'meteor/meteor'; +import { HTTP } from 'meteor/http'; +import { callbacks } from '../../callbacks'; +import { settings } from '../../settings'; + +const gaEndpoint = 'https://www.google-analytics.com/collect?'; + +let googleId = ''; + +Meteor.startup(() => { + googleId = settings.get('GoogleAnalytics_enabled') && settings.get('GoogleAnalytics_ID'); +}); + +// Send Google Analytics +function trackEvent(category, action, label, cid) { + if (googleId) { + HTTP.call('POST', gaEndpoint, + { params: { + v: '1', + ds: 'server', + tid: googleId, + cid: cid, + t: 'event', + ec: category, + ea: action, + el: label, + } } + ); + } +} + +callbacks.add('customOauthRegisterNewUser', (uid) => { + trackEvent('User', 'Registered', 'CustomOauth', uid); +}, callbacks.priority.MEDIUM, 'analytics-login-state-change'); diff --git a/app/api/server/helpers/getUserInfo.js b/app/api/server/helpers/getUserInfo.js index 2d2daee1af344..49dc069a5e1e4 100644 --- a/app/api/server/helpers/getUserInfo.js +++ b/app/api/server/helpers/getUserInfo.js @@ -33,5 +33,30 @@ API.helperMethods.set('getUserInfo', function _getUserInfo(me) { }, }; + me.telephoneNumber = ''; + let phoneFieldName = ''; + + settings.get('Contacts_Phone_Custom_Field_Name', function(name, fieldName) { + phoneFieldName = fieldName; + }); + + let phoneFieldArray = []; + if (phoneFieldName) { + phoneFieldArray = phoneFieldName.split(','); + } + + if (phoneFieldArray.length > 0) { + let dict = me; + for (let i = 0; i < phoneFieldArray.length - 1; i++) { + if (phoneFieldArray[i] in dict) { + dict = dict[phoneFieldArray[i]]; + } + } + const phone = dict[phoneFieldArray[phoneFieldArray.length - 1]]; + if (phone) { + me.telephoneNumber = phone; + } + } + return me; }); diff --git a/app/api/server/v1/chat.js b/app/api/server/v1/chat.js index 047c84e77a68c..077f3187f6de0 100644 --- a/app/api/server/v1/chat.js +++ b/app/api/server/v1/chat.js @@ -128,7 +128,23 @@ API.v1.addRoute('chat.pinMessage', { authRequired: true }, { API.v1.addRoute('chat.postMessage', { authRequired: true }, { post() { - const messageReturn = processWebhookMessage(this.bodyParams, this.user)[0]; + // WIDECHAT only + let messageReturn; + if (this.user.username === settings.get('Notification_Service_User_Username')) { + const { hostname_id } = this.requestParams(); + if (!hostname_id) { + throw new Meteor.Error('error-hostname-id-not-provided', 'Body param "hostname_id" is required'); + } + const hostuser = Users.findOneById(hostname_id); + messageReturn = processWebhookMessage(this.bodyParams, hostuser, undefined, true)[0]; + } else { + messageReturn = processWebhookMessage(this.bodyParams, this.user, undefined, true)[0]; + } + // + + // RC code + // const messageReturn = processWebhookMessage(this.bodyParams, this.user)[0]; + if (!messageReturn) { return API.v1.failure('unknown-error'); diff --git a/app/api/server/v1/misc.js b/app/api/server/v1/misc.js index 01376658e4eb3..936df8c9ed899 100644 --- a/app/api/server/v1/misc.js +++ b/app/api/server/v1/misc.js @@ -222,6 +222,21 @@ API.v1.addRoute('directory', { authRequired: true }, { }, }); +API.v1.addRoute('manifest', { authRequired: false }, { + get() { + const manifestFile = require('../../../../public/manifest.json'); + const gcm_sender_id = settings.get('Gcm_sender_id'); + const manifest = { + ...manifestFile, + gcm_sender_id, + }; + return { + headers: { 'Content-Type': 'application/json;charset=utf-8' }, + body: manifest, + }; + }, +}); + API.v1.addRoute('stdout.queue', { authRequired: true }, { get() { if (!hasPermission(this.userId, 'view-logs')) { diff --git a/app/api/server/v1/settings.js b/app/api/server/v1/settings.js index c7a7ca33c97f8..4899b32cc370d 100644 --- a/app/api/server/v1/settings.js +++ b/app/api/server/v1/settings.js @@ -88,6 +88,21 @@ API.v1.addRoute('settings.addCustomOAuth', { authRequired: true, twoFactorRequir }, }); +API.v1.addRoute('settings.addCustomOAuthWithSettings', { authRequired: true }, { + post() { + if (!this.requestParams().name || !this.requestParams().name.trim()) { + throw new Meteor.Error('error-name-param-not-provided', 'The parameter "name" is required'); + } + + Meteor.runAsUser(this.userId, () => { + Meteor.call('addOAuthServiceWithSettings', this.requestParams().name, this.requestParams()); + }); + + + return API.v1.success(); + }, +}); + API.v1.addRoute('settings', { authRequired: true }, { get() { const { offset, count } = this.getPaginationItems(); diff --git a/app/apps/server/bridges/api.ts b/app/apps/server/bridges/api.ts index 277fdbd3d7b00..afa9b4649cc75 100644 --- a/app/apps/server/bridges/api.ts +++ b/app/apps/server/bridges/api.ts @@ -111,8 +111,12 @@ export class AppApisBridge extends ApiBridge { res.set(headers); res.status(status); res.send(content); + if (status !== 200) { + this.orch.errorLog(`The endpoint /${ endpoint.path } of the App ${ appId } failed. \n${ JSON.stringify(request) }\nStatus Code: ${ status } Response: ${ JSON.stringify(content) }`); + } }) .catch((reason) => { + this.orch.errorLog(reason); // Should we handle this as an error? res.status(500).send(reason.message); }); diff --git a/app/apps/server/bridges/listeners.js b/app/apps/server/bridges/listeners.js index 8e0bed83f0110..aeae13c12ec66 100644 --- a/app/apps/server/bridges/listeners.js +++ b/app/apps/server/bridges/listeners.js @@ -31,6 +31,8 @@ export class AppListenerBridge { case AppInterface.IPreRoomUserLeave: case AppInterface.IPostRoomUserLeave: return 'roomEvent'; + case AppInterface.IRoomUserTyping: + return 'typingEvent'; /** * @deprecated please prefer the AppInterface.IPostLivechatRoomClosed event */ @@ -98,6 +100,10 @@ export class AppListenerBridge { return this.orch.getConverters().get('rooms').convertAppRoom(result); } + async typingEvent(inte, data) { + return this.orch.getManager().getListenerManager().executeListener(inte, data); + } + async livechatEvent(inte, data) { switch (inte) { case AppInterface.IPostLivechatAgentAssigned: diff --git a/app/apps/server/bridges/scheduler.ts b/app/apps/server/bridges/scheduler.ts index 0676178c84de6..856d7859eb714 100644 --- a/app/apps/server/bridges/scheduler.ts +++ b/app/apps/server/bridges/scheduler.ts @@ -184,6 +184,24 @@ export class AppSchedulerBridge extends SchedulerBridge { } } + /** + * Cancels running jobs given its data query + * + * @param {object} data + * + * @returns Promise + */ + protected async cancelJobByDataQuery(data: object, appId: string): Promise { + this.orch.debugLog(`Canceling all jobs of App ${ appId } matching ${ JSON.stringify(data) }`); + await this.startScheduler(); + const matcher = new RegExp(`_${ appId }$`); + try { + await this.scheduler.cancel({ name: { $regex: matcher }, data }); + } catch (e) { + console.error(e); + } + } + private async startScheduler(): Promise { if (!this.isConnected) { await this.scheduler.start(); diff --git a/app/apps/server/index.js b/app/apps/server/index.js index ad3096af31588..5413852dd3208 100644 --- a/app/apps/server/index.js +++ b/app/apps/server/index.js @@ -1,3 +1,4 @@ import './cron'; export { Apps, AppEvents } from './orchestrator'; +export { LivechatNotifications } from './communication'; diff --git a/app/apps/server/orchestrator.js b/app/apps/server/orchestrator.js index 50f67ae497c58..ea3f3c07671c9 100644 --- a/app/apps/server/orchestrator.js +++ b/app/apps/server/orchestrator.js @@ -119,6 +119,12 @@ export class AppServerOrchestrator { } } + errorLog(...args) { + if (this.isDebugging()) { + this.getRocketChatLogger().error(...args); + } + } + getMarketplaceUrl() { return this._marketplaceUrl; } diff --git a/app/assets/server/assets.js b/app/assets/server/assets.js index cd9c0f0bc6b63..3555bb1cf8a59 100644 --- a/app/assets/server/assets.js +++ b/app/assets/server/assets.js @@ -183,6 +183,18 @@ const assets = { height: undefined, }, }, + livechat_guest_default_avatar: { + label: 'Livechat_guest_default_avatar', + defaultUrl: 'avatar/guest', + group: 'Omnichannel', + section: 'Livechat', + constraints: { + type: 'image', + extensions: ['svg', 'png', 'jpg', 'jpeg'], + width: undefined, + height: undefined, + }, + }, }; export const RocketChatAssets = new class { @@ -332,7 +344,8 @@ function addAssetToSetting(asset, value) { defaultUrl: value.defaultUrl, }, { type: 'asset', - group: 'Assets', + group: value.group || 'Assets', + section: value.section, fileConstraints: value.constraints, i18nLabel: value.label, asset, diff --git a/app/contacts/server/index.js b/app/contacts/server/index.js new file mode 100644 index 0000000000000..ef48b75bece0c --- /dev/null +++ b/app/contacts/server/index.js @@ -0,0 +1,181 @@ +/* globals SyncedCron */ + +import './startup'; + +import { Meteor } from 'meteor/meteor'; +import { HTTP } from 'meteor/http'; +import _ from 'underscore'; + +import { getUserPreference } from '../../utils'; +import { getAvatarURL } from '../../utils/lib/getAvatarURL'; +import { settings } from '../../settings'; + +const service = require('./service.js'); + +const provider = new service.Provider(); + +function refreshContactsHashMap() { + let phoneFieldName = ''; + settings.get('Contacts_Phone_Custom_Field_Name', function(name, fieldName) { + phoneFieldName = fieldName; + }); + + let emailFieldName = ''; + settings.get('Contacts_Email_Custom_Field_Name', function(name, fieldName) { + emailFieldName = fieldName; + }); + + let useDefaultEmails = false; + settings.get('Contacts_Use_Default_Emails', function(name, fieldName) { + useDefaultEmails = fieldName; + }); + + const contacts = []; + const cursor = Meteor.users.find({ active: true }); + + let phoneFieldArray = []; + if (phoneFieldName) { + phoneFieldArray = phoneFieldName.split(','); + } + + let emailFieldArray = []; + if (emailFieldName) { + emailFieldArray = emailFieldName.split(','); + } + + let dict; + + const phonePattern = /^\+?[1-9]\d{1,14}$/; + const rfcMailPattern = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + cursor.forEach((user) => { + const discoverability = getUserPreference(user, 'discoverability'); + if (discoverability !== 'none') { + if (phoneFieldArray.length > 0) { + dict = user; + for (let i = 0; i < phoneFieldArray.length - 1; i++) { + if (phoneFieldArray[i] in dict) { + dict = dict[phoneFieldArray[i]]; + } + } + let phone = dict[phoneFieldArray[phoneFieldArray.length - 1]]; + if (phone && _.isString(phone)) { + phone = phone.replace(/[^0-9+]|_/g, ''); + if (phonePattern.test(phone)) { + contacts.push({ d: phone, u: user.username, _id: user._id }); + } + } + } + + if (emailFieldArray.length > 0) { + dict = user; + for (let i = 0; i < emailFieldArray.length - 1; i++) { + if (emailFieldArray[i] in dict) { + dict = dict[emailFieldArray[i]]; + } + } + const email = dict[emailFieldArray[emailFieldArray.length - 1]]; + if (email && _.isString(email)) { + if (rfcMailPattern.test(email)) { + contacts.push({ d: email, u: user.username, _id: user._id }); + } + } + } + + if (useDefaultEmails && 'emails' in user) { + user.emails.forEach((email) => { + if (email.verified) { + contacts.push({ d: email.address, u: user.username, _id: user._id }); + } + }); + } + } + }); + provider.setHashedMap(provider.generateHashedMap(contacts)); +} + +Meteor.methods({ + queryContacts(weakHashes) { + if (!Meteor.userId()) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'queryContactse', + }); + } + return provider.queryContacts(weakHashes); + }, + getInviteLink() { + const user = Meteor.user(); + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'getInviteLink', + }); + } + + let link = ''; + try { + if (!settings.get('Contacts_Dynamic_Link_APIKey')) { + throw new Meteor.Error('error-invalid-config', 'Contacts_Dynamic_Link_APIKey not configured', { + method: 'getInviteLink', + }); + } + + if (!settings.get('Contacts_Dynamic_Link_DomainURIPrefix')) { + throw new Meteor.Error('error-invalid-config', 'Contacts_Dynamic_Link_DomainURIPrefix not configured', { + method: 'getInviteLink', + }); + } + + if (!settings.get('Contacts_Dynamic_Link_AndroidPackageName')) { + throw new Meteor.Error('error-invalid-config', 'Contacts_Dynamic_Link_AndroidPackageName not configured', { + method: 'getInviteLink', + }); + } + + const server = settings.get('Site_Url'); + + this.unblock(); + try { + const result = HTTP.call('POST', `https://firebasedynamiclinks.googleapis.com/v1/shortLinks?key=${ settings.get('Contacts_Dynamic_Link_APIKey') }`, { + data: { + dynamicLinkInfo: { + domainUriPrefix: settings.get('Contacts_Dynamic_Link_DomainURIPrefix'), + link: `${ server }direct/${ user.username }`, + androidInfo: { + androidPackageName: settings.get('Contacts_Dynamic_Link_AndroidPackageName'), + }, + socialMetaTagInfo: { + socialTitle: user.username, + socialDescription: `Chat with ${ user.username } on ${ server }`, + socialImageLink: `${ server.slice(0, -1) }${ getAvatarURL(user.username) }`, + }, + }, + }, + }); + link = result.data.shortLink; + } catch (e) { + throw new Meteor.Error('dynamic-link-request-failed', 'API request to generate dynamic link failed', { + method: 'getInviteLink', + }); + } + } catch (e) { + link = settings.get('Site_Url'); + } + return link; + }, +}); + +const jobName = 'Refresh_Contacts_Hashes'; + +Meteor.startup(() => { + Meteor.defer(() => { + refreshContactsHashMap(); + + settings.get('Contacts_Background_Sync_Interval', function(name, processingFrequency) { + SyncedCron.remove(jobName); + SyncedCron.add({ + name: jobName, + schedule: (parser) => parser.cron(`*/${ processingFrequency } * * * *`), + job: refreshContactsHashMap, + }); + }); + }); +}); diff --git a/app/contacts/server/service.js b/app/contacts/server/service.js new file mode 100644 index 0000000000000..c878f6ab807f1 --- /dev/null +++ b/app/contacts/server/service.js @@ -0,0 +1,81 @@ +const crypto = require('crypto'); + +class ContactsProvider { + constructor() { + this.contactsWeakHashMap = {}; + } + + addContact(contact, username, _id) { + const weakHash = this.getWeakHash(contact); + const strongHash = this.getStrongHash(contact); + + if (weakHash in this.contactsWeakHashMap) { + if (this.contactsWeakHashMap[weakHash].indexOf(strongHash) === -1) { + this.contactsWeakHashMap[weakHash].push({ + h: strongHash, + u: username, + _id, + }); + } + } else { + this.contactsWeakHashMap[weakHash] = [{ h: strongHash, u: username, _id }]; + } + } + + generateHashedMap(contacts) { + const contactsWeakHashMap = {}; + contacts.forEach((contact) => { + const weakHash = this.getWeakHash(contact.d); + const strongHash = this.getStrongHash(contact.d); + if (weakHash in contactsWeakHashMap) { + if (contactsWeakHashMap[weakHash].indexOf(strongHash) === -1) { + contactsWeakHashMap[weakHash].push({ h: strongHash, u: contact.u, _id: contact._id }); + } + } else { + contactsWeakHashMap[weakHash] = [{ h: strongHash, u: contact.u, _id: contact._id }]; + } + }); + return contactsWeakHashMap; + } + + setHashedMap(contactsWeakHashMap) { + this.contactsWeakHashMap = contactsWeakHashMap; + } + + getStrongHash(contact) { + return crypto.createHash('sha1').update(contact).digest('hex'); + } + + getWeakHash(contact) { + return crypto.createHash('sha1').update(contact).digest('hex').substr(3, 6); + } + + queryContacts(contactWeakHashList) { + let result = []; + contactWeakHashList.forEach((weakHash) => { + if (weakHash in this.contactsWeakHashMap) { + result = result.concat(this.contactsWeakHashMap[weakHash]); + } + }); + return result; + } + + removeContact(contact, username, _id) { + const weakHash = this.getWeakHash(contact); + const strongHash = this.getStrongHash(contact); + + if (weakHash in this.contactsWeakHashMap && this.contactsWeakHashMap[weakHash].indexOf(strongHash) >= 0) { + this.contactsWeakHashMap[weakHash].splice(this.contactsWeakHashMap[weakHash].indexOf({ h: strongHash, u: username, _id }), 1); + + if (!this.contactsWeakHashMap[weakHash].length) { delete this.contactsWeakHashMap[weakHash]; } + } + } + + reset() { + this.contactsWeakHashMap = {}; + } +} + +module.exports = { + Provider: ContactsProvider, +}; diff --git a/app/contacts/server/startup.js b/app/contacts/server/startup.js new file mode 100644 index 0000000000000..826cbe36468f7 --- /dev/null +++ b/app/contacts/server/startup.js @@ -0,0 +1,42 @@ +import { settings } from '../../settings'; + +settings.addGroup('Contacts', function() { + this.add('Contacts_Phone_Custom_Field_Name', '', { + type: 'string', + public: true, + i18nDescription: 'Contacts_Phone_Custom_Field_Name_Description', + }); + + this.add('Contacts_Use_Default_Emails', true, { + type: 'boolean', + public: true, + i18nDescription: 'Contacts_Use_Default_Emails_Description', + }); + + this.add('Contacts_Email_Custom_Field_Name', '', { + type: 'string', + public: true, + i18nDescription: 'Contacts_Email_Custom_Field_Name_Description', + }); + + this.add('Contacts_Background_Sync_Interval', 10, { + type: 'int', + public: true, + i18nDescription: 'Contacts_Background_Sync_Interval_Description', + }); + this.add('Contacts_Dynamic_Link_APIKey', '', { + type: 'string', + public: true, + i18nDescription: 'Contacts_Dynamic_Link_APIKey', + }); + this.add('Contacts_Dynamic_Link_DomainURIPrefix', '', { + type: 'string', + public: true, + i18nDescription: 'Contacts_Dynamic_Link_DomainURIPrefix', + }); + this.add('Contacts_Dynamic_Link_AndroidPackageName', '', { + type: 'string', + public: true, + i18nDescription: 'Contacts_Dynamic_Link_AndroidPackageName', + }); +}); diff --git a/app/custom-oauth/client/custom_oauth_client.js b/app/custom-oauth/client/custom_oauth_client.js index b01b829a76c34..4fc0368df48c0 100644 --- a/app/custom-oauth/client/custom_oauth_client.js +++ b/app/custom-oauth/client/custom_oauth_client.js @@ -8,6 +8,7 @@ import { OAuth } from 'meteor/oauth'; import './swapSessionStorage'; import { isURL } from '../../utils/lib/isURL'; +import { callbacks } from '../../callbacks'; // Request custom OAuth credentials for the user // @param options {optional} @@ -67,6 +68,7 @@ export class CustomOAuth { const credentialRequestCompleteCallback = Accounts.oauth.credentialRequestCompleteHandler(callback); this.requestCredential(options, credentialRequestCompleteCallback); + callbacks.run('onUserLogin'); }; } diff --git a/app/custom-oauth/server/custom_oauth_server.js b/app/custom-oauth/server/custom_oauth_server.js index bd698fd90c2b8..1bc181ed5e4ef 100644 --- a/app/custom-oauth/server/custom_oauth_server.js +++ b/app/custom-oauth/server/custom_oauth_server.js @@ -6,6 +6,7 @@ import { HTTP } from 'meteor/http'; import { ServiceConfiguration } from 'meteor/service-configuration'; import _ from 'underscore'; +import { callbacks } from '../../callbacks'; import { normalizers, fromTemplate, renameInvalidProperties } from './transform_helpers'; import { mapRolesFromSSO, mapSSOGroupsToChannels, updateRolesFromSSO } from './oauth_helpers'; import { Logger } from '../../logger'; @@ -13,6 +14,8 @@ import { Users } from '../../models'; import { isURL } from '../../utils/lib/isURL'; import { registerAccessTokenService } from '../../lib/server/oauth/oauth'; +const crypto = require('crypto'); + const logger = new Logger('CustomOAuth'); const Services = {}; @@ -344,6 +347,9 @@ export class CustomOAuth { } if (!user) { + // send GA event that a new user has registered + const cid = crypto.createHash('sha1').update(serviceData.username).digest('hex'); + callbacks.run('customOauthRegisterNewUser', cid); return; } diff --git a/app/emoji-custom/client/lib/emojiCustom.js b/app/emoji-custom/client/lib/emojiCustom.js index f1e8b440cfdc5..8b7ce1f530281 100644 --- a/app/emoji-custom/client/lib/emojiCustom.js +++ b/app/emoji-custom/client/lib/emojiCustom.js @@ -164,7 +164,7 @@ emoji.packages.emojiCustom = { renderPicker: customRender, }; -Meteor.startup(() => +Meteor.defer(() => CachedCollectionManager.onLogin(async () => { try { const { emojis: { update: emojis } } = await APIClient.v1.get('emoji-custom.list'); diff --git a/app/file-upload/client/lib/fileUploadHandler.js b/app/file-upload/client/lib/fileUploadHandler.js index 01fae75be6225..4027577c34fff 100644 --- a/app/file-upload/client/lib/fileUploadHandler.js +++ b/app/file-upload/client/lib/fileUploadHandler.js @@ -18,7 +18,6 @@ new UploadFS.Store({ export const fileUploadHandler = (directive, meta, file) => { const store = UploadFS.getStore(directive); - if (store) { return new FileUploadBase(store, meta, file); } diff --git a/app/file-upload/lib/FileUploadBase.js b/app/file-upload/lib/FileUploadBase.js index c162688d3f698..71328448567ac 100644 --- a/app/file-upload/lib/FileUploadBase.js +++ b/app/file-upload/lib/FileUploadBase.js @@ -59,7 +59,7 @@ export class FileUploadBase { return this.meta.name; } - start(callback) { + start(callback, offlineUpload) { this.handler = new UploadFS.Uploader({ store: this.store, data: this.file, @@ -77,6 +77,10 @@ export class FileUploadBase { this.onProgress(progress); }; + if (offlineUpload) { + offlineUpload(this.file, this.meta); + } + return this.handler.start(); } diff --git a/app/file-upload/server/methods/sendFileMessage.js b/app/file-upload/server/methods/sendFileMessage.js index a21c3c5a09d4e..593ebecd07dac 100644 --- a/app/file-upload/server/methods/sendFileMessage.js +++ b/app/file-upload/server/methods/sendFileMessage.js @@ -23,6 +23,7 @@ Meteor.methods({ } check(msgData, { + id: Match.Optional(String), avatar: Match.Optional(String), emoji: Match.Optional(String), alias: Match.Optional(String), @@ -68,8 +69,15 @@ Meteor.methods({ attachment.video_size = file.size; } + let id; + if (msgData.id) { + id = msgData.id; + } else { + id = Random.id(); + } + let msg = Object.assign({ - _id: Random.id(), + _id: id, rid: roomId, ts: new Date(), msg: '', diff --git a/app/lib/client/methods/sendMessage.js b/app/lib/client/methods/sendMessage.js index d765eefe27c7c..4434d515af6bd 100644 --- a/app/lib/client/methods/sendMessage.js +++ b/app/lib/client/methods/sendMessage.js @@ -1,25 +1,24 @@ import { Meteor } from 'meteor/meteor'; -import { TimeSync } from 'meteor/mizzao:timesync'; import s from 'underscore.string'; import toastr from 'toastr'; -import { ChatMessage } from '../../../models'; +import { ChatMessage, ChatRoom, ChatSubscription } from '../../../models'; import { settings } from '../../../settings'; import { callbacks } from '../../../callbacks'; import { promises } from '../../../promises/client'; import { t } from '../../../utils/client'; Meteor.methods({ - sendMessage(message) { - if (!Meteor.userId() || s.trim(message.msg) === '') { + sendMessage(message, offlineTrigerred = false) { + if (!Meteor.userId() || s.trim(message.msg) === '' || offlineTrigerred) { return false; } - const messageAlreadyExists = message._id && ChatMessage.findOne({ _id: message._id }); - if (messageAlreadyExists) { + const messageAlreadySent = message._id && ChatMessage.findOne({ _id: message._id, temp: { $exists: false } }); + if (messageAlreadySent) { return toastr.error(t('Message_Already_Sent')); } const user = Meteor.user(); - message.ts = isNaN(TimeSync.serverOffset()) ? new Date() : new Date(Date.now() + TimeSync.serverOffset()); + message.ts = new Date(); message.u = { _id: Meteor.userId(), username: user.username, @@ -28,12 +27,15 @@ Meteor.methods({ message.u.name = user.name; } message.temp = true; + message.tempActions = { send: true }; if (settings.get('Message_Read_Receipt_Enabled')) { message.unread = true; } message = callbacks.run('beforeSaveMessage', message); promises.run('onClientMessageReceived', message).then(function(message) { ChatMessage.insert(message); + ChatRoom.setLastMessage(message.rid, message); + ChatSubscription.setLastMessage(message.rid, message); return callbacks.run('afterSaveMessage', message); }); }, diff --git a/app/lib/server/functions/insertMessage.js b/app/lib/server/functions/insertMessage.js index 8117da5b548d7..9e96d7399c133 100644 --- a/app/lib/server/functions/insertMessage.js +++ b/app/lib/server/functions/insertMessage.js @@ -88,6 +88,7 @@ export const insertMessage = function(user, message, rid, upsert = false) { alias: String, emoji: String, avatar: String, + pushm: Boolean, attachments: [Match.Any], })); diff --git a/app/lib/server/functions/notifications/mobile.js b/app/lib/server/functions/notifications/mobile.js index 929c56dedb0b2..ed29bf2c9d461 100644 --- a/app/lib/server/functions/notifications/mobile.js +++ b/app/lib/server/functions/notifications/mobile.js @@ -2,11 +2,12 @@ import { Meteor } from 'meteor/meteor'; import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; import { settings } from '../../../../settings'; -import { Subscriptions } from '../../../../models'; +import { Subscriptions, PushNotificationSubscriptions } from '../../../../models'; import { roomTypes } from '../../../../utils'; const CATEGORY_MESSAGE = 'MESSAGE'; const CATEGORY_MESSAGE_NOREPLY = 'MESSAGE_NOREPLY'; +const webpush = require('web-push'); let SubscriptionRaw; Meteor.startup(() => { @@ -69,10 +70,104 @@ export async function getPushData({ room, message, userId, senderUsername, sende username, message: messageText, badge: await getBadgeCount(userId), + userId, category: enableNotificationReplyButton(room, receiver.username) ? CATEGORY_MESSAGE : CATEGORY_MESSAGE_NOREPLY, }; } +export function getNotificationPayload({ + userId, + user, + message, + room, + duration, + notificationMessage, +}) { + const { title, text } = roomTypes.getConfig(room.t).getNotificationDetails(room, user, notificationMessage); + + return { + title, + text, + duration, + payload: { + _id: message._id, + rid: message.rid, + tmid: message.tmid, + sender: message.u, + type: room.t, + name: room.name, + message: { + msg: message.msg, + t: message.t, + }, + }, + isPushMessage: message.pushm, + userId, + }; +} + +export function sendWebPush(notification, platform) { + if (settings.get('Push_enable') !== true) { + return; + } + + if (notification.isPushMessage) { + return; + } + + const gcmKey = settings.get('Push_gcm_api_key'); + const vapidPublic = settings.get('Vapid_public_key'); + const vapidPrivate = settings.get('Vapid_private_key'); + const vapidSubject = settings.get('Vapid_subject'); + + if (!gcmKey || !vapidPublic || !vapidPrivate || !vapidSubject) { + return; + } + + webpush.setGCMAPIKey(gcmKey); + webpush.setVapidDetails( + vapidSubject, + vapidPublic, + vapidPrivate, + ); + + const { userId, payload: { rid, type } } = notification; + const pushSubscriptions = PushNotificationSubscriptions.findByUserId(userId); + const options = { + TTL: 3600, + }; + + let redirectURL; + if (type === 'd') { + redirectURL = '/direct/'; + } else if (type === 'p') { + redirectURL = '/group/'; + } else if (type === 'c') { + redirectURL = '/channel/'; + } + redirectURL += rid; + notification.redirectURL = redirectURL; + + if (platform === 'mobile') { + notification.platform = platform; + notification.vibrate = [100, 50, 100]; + notification.icon = '/images/icons/icon-96x96.png'; + } + + const payload = notification; + const stringifiedPayload = JSON.stringify(payload); + + pushSubscriptions.forEach((pushSubscription) => { + pushSubscription.platform === platform + && webpush.sendNotification(pushSubscription, stringifiedPayload, options) + .catch((error) => { + if (error.statusCode === 410) { + PushNotificationSubscriptions.removeById(pushSubscription._id); + } + }); + }); +} + export function shouldNotifyMobile({ disableAllMessageNotifications, mobilePushNotifications, diff --git a/app/lib/server/functions/processWebhookMessage.js b/app/lib/server/functions/processWebhookMessage.js index 63eeeb16252bc..d96256df548f1 100644 --- a/app/lib/server/functions/processWebhookMessage.js +++ b/app/lib/server/functions/processWebhookMessage.js @@ -99,6 +99,13 @@ export const processWebhookMessage = function(messageObj, user, defaultValues = }, error); } + if (messageObj.pushm && messageObj.pushm === 'true') { + message.pushm = true; + message.pushm_post_processed = false; + message.pushm_scope = messageObj.pushm_scope; + message.pushm_origin = messageObj.pushm_origin; + } + const messageReturn = sendMessage(user, message, room); sentData.push({ channel, message: messageReturn }); } diff --git a/app/lib/server/functions/sendMessage.js b/app/lib/server/functions/sendMessage.js index a8b38a0b6f8fe..bac0d2c081032 100644 --- a/app/lib/server/functions/sendMessage.js +++ b/app/lib/server/functions/sendMessage.js @@ -145,7 +145,7 @@ const validateMessage = (message, room, user) => { })); if (message.alias || message.avatar) { - const isLiveChatGuest = !message.avatar && user.token && user.token === room.v?.token; + const isLiveChatGuest = user.token && user.token === room.v?.token; if (!isLiveChatGuest && !hasPermission(user._id, 'message-impersonate', room._id)) { throw new Error('Not enough permission'); @@ -225,34 +225,36 @@ export const sendMessage = function(user, message, room, upsert = false) { } catch (e) { console.log(e); // errors logged while the parser is at experimental stage } - if (message) { - if (message._id && upsert) { - const { _id } = message; - delete message._id; - Messages.upsert({ - _id, - 'u._id': message.u._id, - }, message); - message._id = _id; - } else { - const messageAlreadyExists = message._id && Messages.findOneById(message._id, { fields: { _id: 1 } }); - if (messageAlreadyExists) { - return; + if (!settings.get('Livechat_kill_switch') || room.lastMessage.msg !== settings.get('Livechat_kill_switch_message')) { + if (message) { + if (message._id && upsert) { + const { _id } = message; + delete message._id; + Messages.upsert({ + _id, + 'u._id': message.u._id, + }, message); + message._id = _id; + } else { + const messageAlreadyExists = message._id && Messages.findOneById(message._id, { fields: { _id: 1 } }); + if (messageAlreadyExists) { + return; + } + message._id = Messages.insert(message); } - message._id = Messages.insert(message); - } - if (Apps && Apps.isLoaded()) { - // This returns a promise, but it won't mutate anything about the message - // so, we don't really care if it is successful or fails - Apps.getBridges().getListenerBridge().messageEvent('IPostMessageSent', message); - } + if (Apps && Apps.isLoaded()) { + // This returns a promise, but it won't mutate anything about the message + // so, we don't really care if it is successful or fails + Apps.getBridges().getListenerBridge().messageEvent('IPostMessageSent', message); + } - /* - Defer other updates as their return is not interesting to the user - */ - // Execute all callbacks - callbacks.runAsync('afterSaveMessage', message, room, user._id); - return message; + /* + Defer other updates as their return is not interesting to the user + */ + // Execute all callbacks + callbacks.runAsync('afterSaveMessage', message, room, user._id); + return message; + } } }; diff --git a/app/lib/server/index.js b/app/lib/server/index.js index aa0f7c468bfc6..d38039ff54a66 100644 --- a/app/lib/server/index.js +++ b/app/lib/server/index.js @@ -19,6 +19,8 @@ import './oauth/google'; import './oauth/proxy'; import './oauth/twitter'; import './methods/addOAuthService'; +import './methods/addOAuthServiceWithSettings'; +import './methods/addUserToPushSubscription'; import './methods/addUsersToRoom'; import './methods/addUserToRoom'; import './methods/archiveRoom'; @@ -51,11 +53,15 @@ import './methods/joinRoom'; import './methods/leaveRoom'; import './methods/refreshOAuthService'; import './methods/removeOAuthService'; +import './methods/removeUserFromPushSubscription'; import './methods/restartServer'; import './methods/robotMethods'; +import './methods/savePostProcessedMessage'; +import './methods/savePushNotificationSubscription'; import './methods/saveSetting'; import './methods/saveSettings'; import './methods/sendInvitationEmail'; +import './methods/sendInvitationSMS'; import './methods/sendMessage'; import './methods/sendSMTPTestEmail'; import './methods/setAdminStatus'; diff --git a/app/lib/server/lib/sendNotificationsOnMessage.js b/app/lib/server/lib/sendNotificationsOnMessage.js index a5bb1d688b1cf..28a156282b1a7 100644 --- a/app/lib/server/lib/sendNotificationsOnMessage.js +++ b/app/lib/server/lib/sendNotificationsOnMessage.js @@ -8,7 +8,7 @@ import { Subscriptions, Users } from '../../../models/server'; import { roomTypes } from '../../../utils'; import { callJoinRoom, messageContainsHighlight, parseMessageTextPerUser, replaceMentionedUsernamesWithFullNames } from '../functions/notifications'; import { getEmailData, shouldNotifyEmail } from '../functions/notifications/email'; -import { getPushData, shouldNotifyMobile } from '../functions/notifications/mobile'; +import { sendWebPush, getPushData, shouldNotifyMobile, getNotificationPayload } from '../functions/notifications/mobile'; import { notifyDesktopUser, shouldNotifyDesktop } from '../functions/notifications/desktop'; import { notifyAudioUser, shouldNotifyAudio } from '../functions/notifications/audio'; import { Notification } from '../../../notification-queue/server/NotificationQueue'; @@ -118,6 +118,13 @@ export const sendNotification = async ({ message, room, }); + sendWebPush(getNotificationPayload({ + notificationMessage, + userId: subscription.u._id, + user: sender, + message, + room, + }), 'desktop'); } const queueItems = []; @@ -144,6 +151,13 @@ export const sendNotification = async ({ receiver, }), }); + sendWebPush(getNotificationPayload({ + notificationMessage, + userId: subscription.u._id, + user: sender, + message, + room, + }), 'mobile'); } if (receiver.emails && shouldNotifyEmail({ diff --git a/app/lib/server/methods/addOAuthServiceWithSettings.js b/app/lib/server/methods/addOAuthServiceWithSettings.js new file mode 100644 index 0000000000000..8a062129172ec --- /dev/null +++ b/app/lib/server/methods/addOAuthServiceWithSettings.js @@ -0,0 +1,21 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; + +import { hasPermission } from '../../../authorization'; +import { addOAuthService } from '../functions/addOAuthService'; + +Meteor.methods({ + addOAuthServiceWithSettings(name, values) { + check(name, String); + + if (!Meteor.userId()) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'addOAuthServiceWithSettings' }); + } + + if (hasPermission(Meteor.userId(), 'add-oauth-service') !== true) { + throw new Meteor.Error('error-action-not-allowed', 'Adding OAuth Services is not allowed', { method: 'addOAuthServiceWithSettings', action: 'Adding_OAuth_Services' }); + } + + addOAuthService(name, values); + }, +}); diff --git a/app/lib/server/methods/addUserToPushSubscription.js b/app/lib/server/methods/addUserToPushSubscription.js new file mode 100644 index 0000000000000..7f2216b3fbc38 --- /dev/null +++ b/app/lib/server/methods/addUserToPushSubscription.js @@ -0,0 +1,22 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; + +import { PushNotificationSubscriptions } from '../../../models'; + +Meteor.methods({ + addUserToPushSubscription(endpoint) { + check(endpoint, String); + + let user = Meteor.user(); + if (user) { + user = { + _id: user._id, + username: user.username, + }; + } + + PushNotificationSubscriptions.updateUserIdWithSubscriptionEndpoint(endpoint, user); + + return endpoint; + }, +}); diff --git a/app/lib/server/methods/deleteMessage.js b/app/lib/server/methods/deleteMessage.js index 086b9caee7f91..69d227256cc3d 100644 --- a/app/lib/server/methods/deleteMessage.js +++ b/app/lib/server/methods/deleteMessage.js @@ -28,7 +28,13 @@ Meteor.methods({ }, }); - if (!originalMessage || !canDeleteMessage(uid, originalMessage)) { + // return if message does not exist, instead of error, for offline messages + // which are deleted before they are send (e.g. file uploads) + if (!originalMessage) { + return; + } + + if (!canDeleteMessage(uid, originalMessage)) { throw new Meteor.Error('error-action-not-allowed', 'Not allowed', { method: 'deleteMessage', action: 'Delete_message', diff --git a/app/lib/server/methods/removeUserFromPushSubscription.js b/app/lib/server/methods/removeUserFromPushSubscription.js new file mode 100644 index 0000000000000..1d282d6adde66 --- /dev/null +++ b/app/lib/server/methods/removeUserFromPushSubscription.js @@ -0,0 +1,14 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; + +import { PushNotificationSubscriptions } from '../../../models'; + +Meteor.methods({ + removeUserFromPushSubscription(endpoint) { + check(endpoint, String); + + PushNotificationSubscriptions.updateUserIdWithSubscriptionEndpoint(endpoint); + + return endpoint; + }, +}); diff --git a/app/lib/server/methods/savePostProcessedMessage.js b/app/lib/server/methods/savePostProcessedMessage.js new file mode 100644 index 0000000000000..6906b481a9733 --- /dev/null +++ b/app/lib/server/methods/savePostProcessedMessage.js @@ -0,0 +1,15 @@ +import { Meteor } from 'meteor/meteor'; + +import { Messages } from '../../../models'; + +Meteor.methods({ + savePostProcessedMessage(_id, message) { + const originalMessage = Messages.findOneById(_id); + + if (!originalMessage || !originalMessage._id) { + return; + } + + return Messages.updatePostProcessedPushMessageById(_id, message); + }, +}); diff --git a/app/lib/server/methods/savePushNotificationSubscription.js b/app/lib/server/methods/savePushNotificationSubscription.js new file mode 100644 index 0000000000000..7dcec5f611228 --- /dev/null +++ b/app/lib/server/methods/savePushNotificationSubscription.js @@ -0,0 +1,23 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; + +import { PushNotificationSubscriptions } from '../../../models'; + +Meteor.methods({ + savePushNotificationSubscription(subscription, platform) { + subscription = JSON.parse(subscription); + + check(subscription.endpoint, String); + + let user = Meteor.user(); + if (user) { + user = { + _id: user._id, + username: user.username, + }; + } + PushNotificationSubscriptions.createWithUserAndSubscription(user, subscription, platform); + + return subscription; + }, +}); diff --git a/app/lib/server/methods/sendInvitationEmail.js b/app/lib/server/methods/sendInvitationEmail.js index 84a3a40db8edf..b558971c214d2 100644 --- a/app/lib/server/methods/sendInvitationEmail.js +++ b/app/lib/server/methods/sendInvitationEmail.js @@ -4,6 +4,7 @@ import { check } from 'meteor/check'; import * as Mailer from '../../../mailer'; import { hasPermission } from '../../../authorization'; import { settings } from '../../../settings'; +import { getAvatarURL } from '../../../utils/lib/getAvatarURL'; let html = ''; Meteor.startup(() => { @@ -13,7 +14,7 @@ Meteor.startup(() => { }); Meteor.methods({ - sendInvitationEmail(emails) { + sendInvitationEmail(emails, language, realname) { check(emails, [String]); if (!Meteor.userId()) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { @@ -33,8 +34,14 @@ Meteor.methods({ }); } - const subject = settings.get('Invitation_Subject'); + let inviter; + if (!realname) { + inviter = Meteor.user().username; + } else { + inviter = realname; + } + const subject = settings.get('Invitation_Subject'); return validEmails.filter((email) => { try { return Mailer.send({ @@ -44,7 +51,11 @@ Meteor.methods({ html, data: { email, + Invite_Link: Meteor.runAsUser(Meteor.userId(), () => Meteor.call('getInviteLink')), + Username: inviter, + Avatar_Link: `${ settings.get('Site_Url').slice(0, -1) }${ getAvatarURL(Meteor.user().username) }`, }, + lng: language, }); } catch ({ message }) { throw new Meteor.Error('error-email-send-failed', `Error trying to send email: ${ message }`, { diff --git a/app/lib/server/methods/sendInvitationSMS.js b/app/lib/server/methods/sendInvitationSMS.js new file mode 100644 index 0000000000000..753e81cfdb008 --- /dev/null +++ b/app/lib/server/methods/sendInvitationSMS.js @@ -0,0 +1,82 @@ +import { Meteor } from 'meteor/meteor'; +import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; +import { check } from 'meteor/check'; +import _ from 'underscore'; + +import { hasPermission } from '../../../authorization'; +import { settings } from '../../../settings'; +import { SMS } from '../../../sms'; +import { placeholders } from '../../../utils'; + +const supportedLanguages = ['en', 'es', 'pt', 'pt-BR']; + +Meteor.methods({ + sendInvitationSMS(phones, language, realname) { + const twilioService = SMS.getService('twilio'); + if (!SMS.enabled || !twilioService) { + throw new Meteor.Error('error-twilio-not-active', 'Twilio service not active', { + method: 'sendInvitationSMS', + }); + } + + const messageFrom = settings.get('Invitation_SMS_Twilio_From'); + if (!twilioService.accountSid || ! twilioService.authToken || !messageFrom) { + throw new Meteor.Error('error-twilio-not-configured', 'Twilio service not configured', { + method: 'sendInvitationSMS', + }); + } + + check(phones, [String]); + if (!Meteor.userId()) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'sendInvitationSMS', + }); + } + + // to be replaced by a seperate permission specific to SMS later + if (!hasPermission(Meteor.userId(), 'bulk-register-user')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'sendInvitationSMS', + }); + } + const phonePattern = /^\+?[1-9]\d{1,14}$/; + const validPhones = _.compact(_.map(phones, function(phone) { + if (phonePattern.test(phone)) { + return phone; + } + })); + const user = Meteor.user(); + + let inviter; + if (!realname) { + inviter = user.username; + } else { + inviter = realname; + } + + let body; + if (settings.get('Invitation_SMS_Customized')) { + body = settings.get('Invitation_SMS_Customized_Body'); + } else { + let lng = user.language || settings.get('language') || 'en'; + if (supportedLanguages.indexOf(language) > -1) { + lng = language; + } + body = TAPi18n.__('Invitation_SMS_Default_Body', { + lng, + }); + } + body = placeholders.replace(body, { name: inviter }); + validPhones.forEach((phone) => { + try { + twilioService.send(messageFrom, phone, body); + } catch ({ message }) { + throw new Meteor.Error('error-sms-send-failed', `Error trying to send SMS: ${ message }`, { + method: 'sendInvitationSMS', + message, + }); + } + }); + return validPhones; + }, +}); diff --git a/app/lib/server/startup/email.js b/app/lib/server/startup/email.js index 40cac50e9c8bf..1d713feacf2ab 100644 --- a/app/lib/server/startup/email.js +++ b/app/lib/server/startup/email.js @@ -417,7 +417,7 @@ settings.addGroup('Email', function() { type: 'string', i18nLabel: 'Subject', }); - this.add('Invitation_Email', '

{Welcome_to Site_Name}

{Visit_Site_Url_and_try_the_best_open_source_chat_solution_available_today}

{Join_Chat}', { + this.add('Invitation_Email', '{Invitation_Email_Default}', { type: 'code', code: 'text/html', multiline: true, diff --git a/app/lib/server/startup/settings.js b/app/lib/server/startup/settings.js index 18720214f4ed5..217dd9b3eaf82 100644 --- a/app/lib/server/startup/settings.js +++ b/app/lib/server/startup/settings.js @@ -529,6 +529,27 @@ settings.addGroup('Accounts', function() { i18nLabel: 'Enable_message_parser_early_adoption', alert: 'Enable_message_parser_early_adoption_alert', }); + + this.add('Accounts_Default_User_Preferences_discoverability', 'all', { + type: 'select', + values: [ + { + key: 'all', + i18nLabel: 'Everyone', + }, + { + key: 'contacts', + i18nLabel: 'My Contacts', + }, + { + key: 'none', + i18nLabel: 'Nobody', + }, + ], + + public: true, + i18nLabel: 'Discoverability', + }); }); this.section('Avatar', function() { @@ -1021,6 +1042,14 @@ settings.addGroup('General', function() { public: true, i18nDescription: 'Notifications_Max_Room_Members_Description', }); + this.add('Notifications_Always_Notify_Mobile', false, { + type: 'boolean', + public: true, + i18nDescription: 'Notifications_Always_Notify_Mobile_Description', + }); + this.add('Notification_Service_User_Username', 'viasat.notification.service', { + type: 'string', + }); }); this.section('REST API', function() { return this.add('API_User_Limit', 500, { @@ -1101,6 +1130,11 @@ settings.addGroup('Message', function() { type: 'boolean', public: true, }); + this.add('Message_AllowPrefetch_PrefetchRoomLimit', 20, { + type: 'int', + public: true, + i18nDescription: 'Message_AllowPrefetch_PrefetchRoomLimit', + }); this.add('Message_AllowEditing_BlockEditInMinutes', 0, { type: 'int', public: true, @@ -1386,11 +1420,27 @@ settings.addGroup('Push', function() { enableQuery: [], secret: true, }); + this.add('Vapid_public_key', '', { + type: 'string', + public: true, + }); + this.add('Vapid_private_key', '', { + type: 'string', + secret: true, + }); + this.add('Vapid_subject', 'https://www.viasat.com', { + type: 'string', + public: false, + }); this.add('Push_gcm_api_key', '', { type: 'string', enableQuery: [], secret: true, }); + this.add('Gcm_sender_id', '', { + type: 'string', + public: true, + }); return this.add('Push_gcm_project_number', '', { type: 'string', public: true, @@ -1486,6 +1536,58 @@ settings.addGroup('Layout', function() { type: 'boolean', public: true, }); + this.add('UI_DisplayLocalization', true, { + type: 'boolean', + public: true, + }); + this.add('UI_DisplayPrivacy', true, { + type: 'boolean', + public: true, + }); + this.add('UI_DisplayUserPresence', true, { + type: 'boolean', + public: true, + }); + this.add('UI_DisplayNotifications', true, { + type: 'boolean', + public: true, + }); + this.add('UI_DisplayMessages', true, { + type: 'boolean', + public: true, + }); + this.add('UI_DisplaySidebar', true, { + type: 'boolean', + public: true, + }); + this.add('UI_DisplayHighlights', true, { + type: 'boolean', + public: true, + }); + this.add('UI_DisplaySound', true, { + type: 'boolean', + public: true, + }); + this.add('UI_DisplayMyData', true, { + type: 'boolean', + public: true, + }); + this.add('UI_DisplayDirectory', true, { + type: 'boolean', + public: true, + }); + this.add('UI_Display_Security', true, { + type: 'boolean', + public: true, + }); + this.add('UI_Display_Integrations', true, { + type: 'boolean', + public: true, + }); + this.add('UI_Display_Personal_Access_Tokens', true, { + type: 'boolean', + public: true, + }); this.add('UI_Group_Channels_By_Type', true, { type: 'boolean', public: false, diff --git a/app/livechat/client/collections/LivechatFilters.js b/app/livechat/client/collections/LivechatFilters.js new file mode 100644 index 0000000000000..ac2f9619507fa --- /dev/null +++ b/app/livechat/client/collections/LivechatFilters.js @@ -0,0 +1,3 @@ +import { Mongo } from 'meteor/mongo'; + +export const LivechatFilter = new Mongo.Collection('rocketchat_livechat_filter'); diff --git a/app/livechat/imports/server/rest/filters.js b/app/livechat/imports/server/rest/filters.js new file mode 100644 index 0000000000000..0f6ee82e37d42 --- /dev/null +++ b/app/livechat/imports/server/rest/filters.js @@ -0,0 +1,63 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; + +import { API } from '../../../../api/server'; +import { findFilters, findFilterById } from '../../../server/api/lib/filters'; + +API.v1.addRoute('livechat/filter', { authRequired: true }, { + post() { + try { + check(this.bodyParams, { + _id: String, + name: String, + description: String, + enabled: Boolean, + regex: String, + slug: String, + }); + + API.v1.success( + Meteor.runAsUser(this.userId, + () => Meteor.call('livechat:saveFilter', this.bodyParams), + ), + ); + } catch (e) { + return API.v1.failure(e); + } + }, +}); + +API.v1.addRoute('livechat/filters', { authRequired: true }, { + get() { + const { offset, count } = this.getPaginationItems(); + const { sort } = this.parseJsonQuery(); + + const filters = Promise.await(findFilters({ + userId: this.userId, + pagination: { + offset, + count, + sort, + }, + })); + + return API.v1.success(filters); + }, +}); + +API.v1.addRoute('livechat/filters/:_id', { authRequired: true }, { + get() { + check(this.urlParams, { + _id: String, + }); + + const filter = Promise.await(findFilterById({ + userId: this.userId, + filterId: this.urlParams._id, + })); + + return API.v1.success({ + filter, + }); + }, +}); diff --git a/app/livechat/imports/server/rest/triggers.js b/app/livechat/imports/server/rest/triggers.js index de3d0b57f27bd..b766a7455ca95 100644 --- a/app/livechat/imports/server/rest/triggers.js +++ b/app/livechat/imports/server/rest/triggers.js @@ -1,8 +1,34 @@ +import { Meteor } from 'meteor/meteor'; import { check } from 'meteor/check'; import { API } from '../../../../api/server'; import { findTriggers, findTriggerById } from '../../../server/api/lib/triggers'; +API.v1.addRoute('livechat/trigger', { authRequired: true }, { + post() { + try { + check(this.bodyParams, { + _id: String, + name: String, + description: String, + enabled: Boolean, + runOnce: Boolean, + registeredOnly: Boolean, + conditions: Array, + actions: Array, + }); + + API.v1.success( + Meteor.runAsUser(this.userId, + () => Meteor.call('livechat:saveTrigger', this.bodyParams), + ), + ); + } catch (e) { + return API.v1.failure(e); + } + }, +}); + API.v1.addRoute('livechat/triggers', { authRequired: true }, { get() { const { offset, count } = this.getPaginationItems(); diff --git a/app/livechat/server/api.js b/app/livechat/server/api.js index 6a13dddc86bf8..5a137b0c14d82 100644 --- a/app/livechat/server/api.js +++ b/app/livechat/server/api.js @@ -7,6 +7,7 @@ import '../imports/server/rest/upload.js'; import '../imports/server/rest/inquiries.js'; import '../imports/server/rest/rooms.js'; import '../imports/server/rest/appearance.js'; +import '../imports/server/rest/filters.js'; import '../imports/server/rest/triggers.js'; import '../imports/server/rest/integrations.js'; import '../imports/server/rest/messages.js'; diff --git a/app/livechat/server/api/lib/filters.js b/app/livechat/server/api/lib/filters.js new file mode 100644 index 0000000000000..e6cd76b06e8e5 --- /dev/null +++ b/app/livechat/server/api/lib/filters.js @@ -0,0 +1,33 @@ +import { hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission'; +import { LivechatFilter } from '../../../../models/server/raw'; + +export async function findFilters({ userId, pagination: { offset, count, sort } }) { + if (!await hasPermissionAsync(userId, 'view-livechat-manager')) { + throw new Error('error-not-authorized'); + } + + const cursor = await LivechatFilter.find({}, { + sort: sort || { name: 1 }, + skip: offset, + limit: count, + }); + + const total = await cursor.count(); + + const filters = await cursor.toArray(); + + return { + filters, + count: filters.length, + offset, + total, + }; +} + +export async function findFilterById({ userId, filterId }) { + if (!await hasPermissionAsync(userId, 'view-livechat-manager')) { + throw new Error('error-not-authorized'); + } + + return LivechatFilter.findOneById(filterId); +} diff --git a/app/livechat/server/api/lib/livechat.js b/app/livechat/server/api/lib/livechat.js index a7a29598250f3..701b5f7f3d259 100644 --- a/app/livechat/server/api/lib/livechat.js +++ b/app/livechat/server/api/lib/livechat.js @@ -1,17 +1,25 @@ +import URL from 'url'; + import { Meteor } from 'meteor/meteor'; import { Random } from 'meteor/random'; +import _ from 'underscore'; -import { LivechatRooms, LivechatVisitors, LivechatDepartment, LivechatTrigger, EmojiCustom } from '../../../../models/server'; +import { LivechatRooms, LivechatVisitors, LivechatDepartment, LivechatTrigger, LivechatFilter, EmojiCustom } from '../../../../models/server'; import { Livechat } from '../../lib/Livechat'; import { callbacks } from '../../../../callbacks/server'; import { normalizeAgent } from '../../lib/Helper'; +import { Apps, AppEvents } from '../../../../apps/server'; export function online(department) { return Livechat.online(department); } +export function findFilters() { + return LivechatFilter.findEnabled().fetch().map((filter) => _.pick(filter, '_id', 'regex', 'slug')); +} + export function findTriggers() { - return LivechatTrigger.findEnabled().fetch().map(({ _id, actions, conditions, runOnce }) => ({ _id, actions, conditions, runOnce })); + return LivechatTrigger.findEnabled().fetch().map(({ _id, actions, conditions, runOnce, registeredOnly }) => ({ _id, actions, conditions, runOnce, registeredOnly })); } export function findDepartments() { @@ -58,10 +66,18 @@ export function findOpenRoom(token, departmentId) { }, }; + let room; const rooms = departmentId ? LivechatRooms.findOpenByVisitorTokenAndDepartmentId(token, departmentId, options).fetch() : LivechatRooms.findOpenByVisitorToken(token, options).fetch(); if (rooms && rooms.length > 0) { - return rooms[0]; + room = rooms[0]; } + + if (room) { + Livechat.addTypingListener(room._id, (username, typing, data) => { + Apps.triggerEvent(AppEvents.IRoomUserTyping, { roomId: room._id, username, typing, data }); + }); + } + return room; } export function getRoom({ guest, rid, roomInfo, agent, extraParams }) { @@ -75,6 +91,10 @@ export function getRoom({ guest, rid, roomInfo, agent, extraParams }) { ts: new Date(), }; + Livechat.addTypingListener(rid, (username, typing, data) => { + Apps.triggerEvent(AppEvents.IRoomUserTyping, { roomId: rid, username, typing, data }); + }); + return Livechat.getRoom(guest, message, roomInfo, agent, extraParams); } @@ -86,30 +106,71 @@ export function normalizeHttpHeaderData(headers = {}) { const httpHeaders = Object.assign({}, headers); return { httpHeaders }; } -export function settings() { + +export function settings(url) { const initSettings = Livechat.getInitSettings(); const triggers = findTriggers(); + const filters = findFilters(); const departments = findDepartments(); const sound = `${ Meteor.absoluteUrl() }sounds/chime.mp3`; const emojis = EmojiCustom.find().fetch(); + + const shouldShowRegistrationForm = () => { + if (!url) { + return initSettings.Livechat_registration_form; + } + + let skipOnDomainList = initSettings.Livechat_skip_registration_form_DomainsList; + skipOnDomainList = (!_.isEmpty(skipOnDomainList.trim()) && _.map(skipOnDomainList.split(','), function(domain) { + return domain.trim(); + })) || []; + + const urlObject = URL.parse(url); + const { hostname: urlHost, pathname: urlPath } = urlObject; + + const matchedDomain = skipOnDomainList.find((domain) => { + if (!domain.match(/^[a-zA-Z]+:\/\//)) { + domain = `http://${ domain }`; + } + const domainUrlObject = URL.parse(domain); + const { hostname: domainHost, pathname: domainPath } = domainUrlObject; + + if (domainPath !== '/') { + return domainHost.includes(urlHost) && (domainPath === urlPath); + } + return domainHost.includes(urlHost); + }); + + return initSettings.Livechat_registration_form && !matchedDomain; + }; + return { enabled: initSettings.Livechat_enabled, settings: { - registrationForm: initSettings.Livechat_registration_form, + registrationForm: shouldShowRegistrationForm(), + startSessionOnNewChat: initSettings.Livechat_start_session_on_new_chat, allowSwitchingDepartments: initSettings.Livechat_allow_switching_departments, nameFieldRegistrationForm: initSettings.Livechat_name_field_registration_form, emailFieldRegistrationForm: initSettings.Livechat_email_field_registration_form, + guestDefaultAvatar: initSettings.Assets_livechat_guest_default_avatar, displayOfflineForm: initSettings.Livechat_display_offline_form, videoCall: initSettings.Livechat_videocall_enabled === true && initSettings.Jitsi_Enabled === true, fileUpload: initSettings.Livechat_fileupload_enabled && initSettings.FileUpload_Enabled, language: initSettings.Language, transcript: initSettings.Livechat_enable_transcript, + printTranscript: initSettings.Livechat_enable_print_transcript, historyMonitorType: initSettings.Livechat_history_monitor_type, forceAcceptDataProcessingConsent: initSettings.Livechat_force_accept_data_processing_consent, showConnecting: initSettings.Livechat_Show_Connecting, agentHiddenInfo: initSettings.Livechat_show_agent_info === false, + hideSysMessages: initSettings.Livechat_hide_sys_messages, limitTextLength: initSettings.Livechat_enable_message_character_limit - && (initSettings.Livechat_message_character_limit || initSettings.Message_MaxAllowedSize), + && (initSettings.Livechat_message_character_limit || initSettings.Message_MaxAllowedSize), + + livechat_kill_switch: initSettings.Livechat_kill_switch, + livechat_kill_switch_message: initSettings.Livechat_kill_switch_message, + livechat_friendly_chat: initSettings.Livechat_friendly_chat, + livechat_enable_avatar: initSettings.Livechat_enable_avatar, }, theme: { title: initSettings.Livechat_title, @@ -136,6 +197,7 @@ export function settings() { values: ['1', '2', '3', '4', '5'], }, triggers, + filters, departments, resources: { sound, diff --git a/app/livechat/server/api/v1/config.js b/app/livechat/server/api/v1/config.js index e43509d4ba3ca..245e109800f0b 100644 --- a/app/livechat/server/api/v1/config.js +++ b/app/livechat/server/api/v1/config.js @@ -10,6 +10,7 @@ API.v1.addRoute('livechat/config', { check(this.queryParams, { token: Match.Maybe(String), department: Match.Maybe(String), + url: Match.Maybe(String), }); const enabled = Livechat.enabled(); @@ -17,7 +18,8 @@ API.v1.addRoute('livechat/config', { return API.v1.success({ config: { enabled: false } }); } - const config = settings(); + const { url } = this.queryParams; + const config = settings(url); const { token, department } = this.queryParams; const status = Livechat.online(department); diff --git a/app/livechat/server/api/v1/customField.js b/app/livechat/server/api/v1/customField.js index 641ad960474ee..3f676a3ac5a53 100644 --- a/app/livechat/server/api/v1/customField.js +++ b/app/livechat/server/api/v1/customField.js @@ -34,6 +34,31 @@ API.v1.addRoute('livechat/custom.field', { }, }); +API.v1.addRoute('livechat/custom-field', { authRequired: true }, { + post() { + try { + check(this.bodyParams, { + field: String, + label: String, + scope: String, + visibility: Boolean, + regexp: String, + }); + + const { field, visibility } = this.bodyParams; + const { customField } = Promise.await(findCustomFieldById({ userId: this.userId, customFieldId: field })); + + API.v1.success( + Meteor.runAsUser(this.userId, + () => Meteor.call('livechat:saveCustomField', customField ? field : undefined, { ...this.bodyParams, visibility: visibility ? 'visible' : 'hidden' }), + ), + ); + } catch (e) { + return API.v1.failure(e); + } + }, +}); + API.v1.addRoute('livechat/custom.fields', { post() { check(this.bodyParams, { diff --git a/app/livechat/server/api/v1/message.js b/app/livechat/server/api/v1/message.js index df8ac26a07f7a..fc980bdc84ae2 100644 --- a/app/livechat/server/api/v1/message.js +++ b/app/livechat/server/api/v1/message.js @@ -19,13 +19,14 @@ API.v1.addRoute('livechat/message', { token: String, rid: String, msg: String, + avatar: Match.Optional(String), agent: Match.Maybe({ agentId: String, username: String, }), }); - const { token, rid, agent, msg } = this.bodyParams; + const { token, rid, agent, msg, avatar } = this.bodyParams; const guest = findGuest(token); if (!guest) { @@ -53,6 +54,7 @@ API.v1.addRoute('livechat/message', { _id, rid, msg, + avatar, token, }, agent, diff --git a/app/livechat/server/api/v1/transcript.js b/app/livechat/server/api/v1/transcript.js index f8f3c923d25e0..070cad6eafb97 100644 --- a/app/livechat/server/api/v1/transcript.js +++ b/app/livechat/server/api/v1/transcript.js @@ -1,8 +1,10 @@ +import { Meteor } from 'meteor/meteor'; import { check } from 'meteor/check'; import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; import { API } from '../../../../api/server'; import { Livechat } from '../../lib/Livechat'; +import { hasPermission } from '../../../../authorization'; API.v1.addRoute('livechat/transcript', { post() { @@ -24,3 +26,30 @@ API.v1.addRoute('livechat/transcript', { } }, }); + +API.v1.addRoute('livechat/gettranscript', { authRequired: true }, { + post() { + try { + check(this.bodyParams, { + token: String, + rid: String, + }); + + const { token, rid } = this.bodyParams; + + if (!hasPermission(this.userId, 'send-omnichannel-chat-transcript')) { + throw new Meteor.Error('not-authorized', 'Not Authorized'); + } + + const response = Livechat.getTranscript({ token, rid }); + if (!response) { + return API.v1.failure({ message: TAPi18n.__('Error_sending_livechat_transcript') }); + } + + return API.v1.success(response); + } catch (e) { + Livechat.logger.error(e); + return API.v1.failure(e); + } + }, +}); diff --git a/app/livechat/server/config.js b/app/livechat/server/config.js index 45f59a8b26a90..6234bf1a86a6e 100644 --- a/app/livechat/server/config.js +++ b/app/livechat/server/config.js @@ -1,6 +1,7 @@ import { Meteor } from 'meteor/meteor'; import { settings } from '../../settings'; +import { MessageTypesValues } from '../../lib/lib/MessageTypes'; Meteor.startup(function() { settings.addGroup('Omnichannel'); @@ -11,6 +12,22 @@ Meteor.startup(function() { public: true, }); + settings.add('Livechat_friendly_chat', false, { + type: 'boolean', + group: 'Omnichannel', + section: 'Livechat', + public: true, + i18nDescription: 'Livechat_friendly_chat_description', + }); + + settings.add('Livechat_enable_avatar', true, { + type: 'boolean', + group: 'Omnichannel', + section: 'Livechat', + public: true, + i18nDescription: 'Livechat_enable_avatar_description', + }); + settings.add('Livechat_title', 'Rocket.Chat', { type: 'string', group: 'Omnichannel', @@ -18,6 +35,21 @@ Meteor.startup(function() { public: true, }); + settings.add('Livechat_kill_switch', false, { + type: 'boolean', + group: 'Omnichannel', + section: 'Livechat', + public: true, + i18nDescription: 'Livechat_kill_switch_description', + }); + + settings.add('Livechat_kill_switch_message', 'Livechat is Currently Offline', { + type: 'string', + group: 'Omnichannel', + section: 'Livechat', + public: true, + }); + settings.add('Livechat_title_color', '#C1272D', { type: 'color', editor: 'color', @@ -27,6 +59,19 @@ Meteor.startup(function() { public: true, }); + settings.add('Livechat_hide_sys_messages', [], { + type: 'multiSelect', + group: 'Omnichannel', + section: 'Livechat', + values: [ + ...MessageTypesValues, + { key: 'livechat-started', i18nLabel: 'Message_HideType_livechat_started' }, + { key: 'livechat-close', i18nLabel: 'Message_HideType_livechat_close' }, + { key: 'livechat_transfer_history', i18nLabel: 'Message_HideType_livechat_transfer_history' }, + ], + public: true, + }); + settings.add('Livechat_enable_message_character_limit', false, { type: 'boolean', group: 'Omnichannel', @@ -161,6 +206,24 @@ Meteor.startup(function() { i18nLabel: 'Show_preregistration_form', }); + settings.add('Livechat_start_session_on_new_chat', false, { + type: 'boolean', + group: 'Omnichannel', + section: 'Livechat', + public: true, + i18nLabel: 'Livechat_Start_session_on_new_chat', + }); + + settings.add('Livechat_skip_registration_form_DomainsList', '', { + type: 'string', + group: 'Omnichannel', + section: 'Livechat', + enableQuery: { _id: 'Livechat_registration_form', value: true }, + public: true, + i18nLabel: 'Livechat_SkipRegistrationFormDomainsList', + i18nDescription: 'Domains_on_which_skip_livechat_registration_form', + }); + settings.add('Livechat_name_field_registration_form', true, { type: 'boolean', group: 'Omnichannel', @@ -362,6 +425,13 @@ Meteor.startup(function() { enableQuery: { _id: 'Livechat_enable_transcript', value: true }, }); + settings.add('Livechat_enable_print_transcript', true, { + type: 'boolean', + group: 'Omnichannel', + public: true, + i18nLabel: 'Transcript_Print_Enabled', + }); + settings.add('Livechat_registration_form_message', '', { type: 'string', group: 'Omnichannel', @@ -451,6 +521,14 @@ Meteor.startup(function() { i18nDescription: 'Assign_new_conversations_to_bot_agent_description', }); + settings.add('Livechat_assign_new_conversation_to_department', '', { + type: 'string', + group: 'Omnichannel', + section: 'Routing', + i18nLabel: 'Assign_new_conversations_to_department', + i18nDescription: 'Assign_new_conversations_to_department_description', + }); + settings.add('Livechat_guest_pool_max_number_incoming_livechats_displayed', 0, { type: 'int', group: 'Omnichannel', diff --git a/app/livechat/server/hooks/leadCapture.js b/app/livechat/server/hooks/leadCapture.js index 7dd2092246b67..c2aa193be0ab4 100644 --- a/app/livechat/server/hooks/leadCapture.js +++ b/app/livechat/server/hooks/leadCapture.js @@ -1,6 +1,6 @@ import { callbacks } from '../../../callbacks'; -import { settings } from '../../../settings'; import { LivechatVisitors } from '../../../models'; +import { settings } from '../../../settings'; function validateMessage(message, room) { // skips this callback if the message was edited @@ -45,3 +45,14 @@ callbacks.add('afterSaveMessage', function(message, room) { return message; }, callbacks.priority.LOW, 'leadCapture'); + +callbacks.add('beforeSaveMessage', function(message, room) { + if (settings.get('Livechat_kill_switch')) { + if (room && room.lastMessage.msg !== settings.get('Livechat_kill_switch_message')) { + message.msg = settings.get('Livechat_kill_switch_message'); + message.avatar = ''; + message.u._id = room.servedBy._id; + message.u.username = room.servedBy.username; + } + } +}, callbacks.priority.LOW, 'leadCapture'); diff --git a/app/livechat/server/index.js b/app/livechat/server/index.js index 9d2c6fdf3b355..79e0bf81ef039 100644 --- a/app/livechat/server/index.js +++ b/app/livechat/server/index.js @@ -43,6 +43,7 @@ import './methods/removeAgent'; import './methods/removeAllClosedRooms'; import './methods/removeCustomField'; import './methods/removeDepartment'; +import './methods/removeFilter'; import './methods/removeManager'; import './methods/removeTrigger'; import './methods/removeRoom'; @@ -51,6 +52,7 @@ import './methods/saveAppearance'; import './methods/saveCustomField'; import './methods/saveDepartment'; import './methods/saveDepartmentAgents'; +import './methods/saveFilter'; import './methods/saveInfo'; import './methods/saveIntegration'; import './methods/saveSurveyFeedback'; diff --git a/app/livechat/server/lib/Livechat.js b/app/livechat/server/lib/Livechat.js index 04f08efacd4c3..8d9d688b71d6f 100644 --- a/app/livechat/server/lib/Livechat.js +++ b/app/livechat/server/lib/Livechat.js @@ -40,6 +40,9 @@ import { Apps, AppEvents } from '../../../apps/server'; import { businessHourManager } from '../business-hour'; import notifications from '../../../notifications/server/lib/Notifications'; import { validateEmailDomain } from '../../../lib/server'; +import { Notifications } from '../../../notifications'; + +const rooms = {}; export const Livechat = { Analytics, @@ -51,7 +54,6 @@ export const Livechat = { }, }), - findGuest(token) { return LivechatVisitors.getVisitorByToken(token, { fields: { @@ -64,6 +66,21 @@ export const Livechat = { }); }, + addTypingListener(rid, callback) { + if (rooms[rid]) { + return; + } + rooms[rid] = callback; + return Notifications.onRoom(rid, 'typing', rooms[rid]); + }, + + removeTypingListener(rid) { + if (rooms[rid]) { + Notifications.unRoom(rid, 'typing', rooms[rid]); + delete rooms[rid]; + } + }, + online(department) { if (settings.get('Livechat_accept_chats_with_no_agents')) { return true; @@ -125,6 +142,15 @@ export const Livechat = { getRequiredDepartment(onlineRequired = true) { const departments = LivechatDepartment.findEnabledWithAgents(); + const deparmentName = settings.get('Livechat_assign_new_conversation_to_department'); + + if (deparmentName) { + const department = departments.fetch().find((dept) => dept.name === deparmentName); + if (department) { + return department; + } + } + return departments.fetch().find((dept) => { if (!dept.showOnRegistration) { return false; @@ -350,6 +376,14 @@ export const Livechat = { const params = callbacks.run('livechat.beforeCloseRoom', { room, options }); const { extraData } = params; + const guest = LivechatVisitors.findOneById(room.v._id); + const updateUser = {}; + + // remove department from guest when closing chat. + if (guest.department) { + Object.assign(updateUser, { $unset: { department: 1 } }); + LivechatVisitors.updateById(guest._id, updateUser); + } const now = new Date(); const { _id: rid, servedBy, transcriptRequest } = room; @@ -402,6 +436,7 @@ export const Livechat = { */ Apps.getBridges().getListenerBridge().livechatEvent(AppEvents.ILivechatRoomClosedHandler, room); Apps.getBridges().getListenerBridge().livechatEvent(AppEvents.IPostLivechatRoomClosed, room); + Livechat.removeTypingListener(rid); }); callbacks.runAsync('livechat.closeRoom', room); @@ -418,6 +453,7 @@ export const Livechat = { Messages.removeByRoomId(rid); Subscriptions.removeByRoomId(rid); LivechatInquiry.removeByRoomId(rid); + Livechat.removeTypingListener(rid); return LivechatRooms.removeById(rid); }, @@ -454,13 +490,18 @@ export const Livechat = { const rcSettings = {}; Settings.findNotHiddenPublic([ + 'Livechat_friendly_chat', + 'Livechat_enable_avatar', 'Livechat_title', 'Livechat_title_color', + 'Livechat_kill_switch', + 'Livechat_kill_switch_message', 'Livechat_enable_message_character_limit', 'Livechat_message_character_limit', 'Message_MaxAllowedSize', 'Livechat_enabled', 'Livechat_registration_form', + 'Livechat_start_session_on_new_chat', 'Livechat_allow_switching_departments', 'Livechat_offline_title', 'Livechat_offline_title_color', @@ -473,16 +514,20 @@ export const Livechat = { 'Language', 'Livechat_enable_transcript', 'Livechat_transcript_message', + 'Livechat_enable_print_transcript', 'Livechat_fileupload_enabled', 'FileUpload_Enabled', 'Livechat_conversation_finished_message', 'Livechat_conversation_finished_text', 'Livechat_name_field_registration_form', 'Livechat_email_field_registration_form', + 'Assets_livechat_guest_default_avatar', 'Livechat_registration_form_message', 'Livechat_force_accept_data_processing_consent', 'Livechat_data_processing_consent_text', 'Livechat_show_agent_info', + 'Livechat_skip_registration_form_DomainsList', + 'Livechat_hide_sys_messages', ]).forEach((setting) => { rcSettings[setting._id] = setting.value; }); @@ -1038,6 +1083,37 @@ export const Livechat = { return true; }, + getTranscript({ token, rid, user }) { + check(rid, String); + + const room = LivechatRooms.findOneById(rid); + + const visitor = LivechatVisitors.getVisitorByToken(token, { fields: { _id: 1, token: 1, language: 1, username: 1, name: 1 } }); + + // allow to only user to send transcripts from their own chats + if (!room || room.t !== 'l' || !room.v || room.v.token !== token) { + Livechat.logger.error('getTranscript: Room/Token not valid or Visitor token not allowed to access the room.', { rid, token }); + throw new Meteor.Error('error-invalid-room', 'Invalid room'); + } + + const ignoredMessageTypes = ['livechat_navigation_history', 'livechat_transcript_history', 'command', 'livechat-close', 'livechat_video_call']; + const messages = Messages.findVisibleByRoomIdNotContainingTypes(rid, ignoredMessageTypes, { sort: { ts: 1 } }); + + const response = []; + messages.forEach((message) => { + response.push({ sender: message.u._id === visitor._id ? 'customer' : 'agent', message: message.msg, timestamp: message.ts }); + }); + + let type = 'user'; + if (!user) { + user = Users.findOneById('rocket.cat', { fields: { _id: 1, username: 1, name: 1 } }); + type = 'visitor'; + } + + Messages.createTranscriptHistoryWithRoomIdMessageAndUser(room._id, '', user, { requestData: { type, visitor, user } }); + return response; + }, + requestTranscript({ rid, email, subject, user }) { check(rid, String); check(email, String); diff --git a/app/livechat/server/lib/routing/AutoSelection.js b/app/livechat/server/lib/routing/AutoSelection.js index 7850dd36990be..abf10d38ef0d5 100644 --- a/app/livechat/server/lib/routing/AutoSelection.js +++ b/app/livechat/server/lib/routing/AutoSelection.js @@ -1,6 +1,7 @@ import { RoutingManager } from '../RoutingManager'; import { LivechatDepartmentAgents, Users } from '../../../../models/server'; import { callbacks } from '../../../../callbacks'; +import { settings } from '../../../../settings/server'; /* Auto Selection Queuing method: * @@ -21,7 +22,10 @@ class AutoSelection { } getNextAgent(department, ignoreAgentId) { - const extraQuery = callbacks.run('livechat.applySimultaneousChatRestrictions', { ...department ? { departmentId: department } : {} }); + const extraQuery = callbacks.run('livechat.applySimultaneousChatRestrictions', undefined, { ...department ? { departmentId: department } : {} }); + if (settings.get('Livechat_kill_switch')) { + return null; + } if (department) { return LivechatDepartmentAgents.getNextAgentForDepartment(department, ignoreAgentId, extraQuery); } diff --git a/app/livechat/server/lib/routing/External.js b/app/livechat/server/lib/routing/External.js index 4b104a9d2df07..351c5571cc7d7 100644 --- a/app/livechat/server/lib/routing/External.js +++ b/app/livechat/server/lib/routing/External.js @@ -19,6 +19,9 @@ class ExternalQueue { } getNextAgent(department, ignoreAgentId) { + if (settings.get('Livechat_kill_switch')) { + return null; + } for (let i = 0; i < 10; i++) { try { let queryString = department ? `?departmentId=${ department }` : ''; diff --git a/app/livechat/server/methods/getInitialData.js b/app/livechat/server/methods/getInitialData.js index 9b7a2f22a7055..a00cb996acd85 100644 --- a/app/livechat/server/methods/getInitialData.js +++ b/app/livechat/server/methods/getInitialData.js @@ -1,7 +1,7 @@ import { Meteor } from 'meteor/meteor'; import _ from 'underscore'; -import { LivechatRooms, Users, LivechatDepartment, LivechatTrigger, LivechatVisitors } from '../../../models'; +import { LivechatRooms, Users, LivechatDepartment, LivechatTrigger, LivechatFilter, LivechatVisitors } from '../../../models'; import { Livechat } from '../lib/Livechat'; Meteor.methods({ @@ -11,8 +11,10 @@ Meteor.methods({ title: null, color: null, registrationForm: null, + startSessionOnNewChat: null, room: null, visitor: null, + filters: [], triggers: [], departments: [], allowSwitchingDepartments: null, @@ -28,8 +30,10 @@ Meteor.methods({ conversationFinishedText: null, nameFieldRegistrationForm: null, emailFieldRegistrationForm: null, + guestDefaultAvatar: null, registrationFormMessage: null, showConnecting: false, + hideSysMessages: [], }; const options = { @@ -68,6 +72,7 @@ Meteor.methods({ info.color = initSettings.Livechat_title_color; info.enabled = initSettings.Livechat_enabled; info.registrationForm = initSettings.Livechat_registration_form; + info.startSessionOnNewChat = initSettings.Livechat_start_session_on_new_chat; info.offlineTitle = initSettings.Livechat_offline_title; info.offlineColor = initSettings.Livechat_offline_title_color; info.offlineMessage = initSettings.Livechat_offline_message; @@ -79,17 +84,28 @@ Meteor.methods({ info.fileUpload = initSettings.Livechat_fileupload_enabled && initSettings.FileUpload_Enabled; info.transcript = initSettings.Livechat_enable_transcript; info.transcriptMessage = initSettings.Livechat_transcript_message; + info.printTranscript = initSettings.Livechat_enable_print_transcript; info.conversationFinishedMessage = initSettings.Livechat_conversation_finished_message; info.conversationFinishedText = initSettings.Livechat_conversation_finished_text; info.nameFieldRegistrationForm = initSettings.Livechat_name_field_registration_form; info.emailFieldRegistrationForm = initSettings.Livechat_email_field_registration_form; + info.guestDefaultAvatar = initSettings.Assets_livechat_guest_default_avatar; info.registrationFormMessage = initSettings.Livechat_registration_form_message; info.showConnecting = initSettings.Livechat_Show_Connecting; + info.hideSysMessages = initSettings.Livechat_hide_sys_messages; + info.livechat_kill_switch = initSettings.Livechat_kill_switch; + info.livechat_kill_switch_message = initSettings.Livechat_kill_switch_message; + info.livechat_friendly_chat = initSettings.Livechat_friendly_chat; + info.livechat_enable_avatar = initSettings.Livechat_enable_avatar; info.agentData = room && room[0] && room[0].servedBy && Users.getAgentInfo(room[0].servedBy._id); + LivechatFilter.findEnabled().forEach((filter) => { + info.filters.push(_.pick(filter, '_id', 'regex', 'slug')); + }); + LivechatTrigger.findEnabled().forEach((trigger) => { - info.triggers.push(_.pick(trigger, '_id', 'actions', 'conditions', 'runOnce')); + info.triggers.push(_.pick(trigger, '_id', 'actions', 'conditions', 'runOnce', 'registeredOnly')); }); LivechatDepartment.findEnabledWithAgents().forEach((department) => { diff --git a/app/livechat/server/methods/removeFilter.js b/app/livechat/server/methods/removeFilter.js new file mode 100644 index 0000000000000..825af1090bda8 --- /dev/null +++ b/app/livechat/server/methods/removeFilter.js @@ -0,0 +1,17 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; + +import { hasPermission } from '../../../authorization'; +import { LivechatFilter } from '../../../models'; + +Meteor.methods({ + 'livechat:removeFilter'(filterId) { + if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'view-livechat-manager')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'livechat:removeFilter' }); + } + + check(filterId, String); + + return LivechatFilter.removeById(filterId); + }, +}); diff --git a/app/livechat/server/methods/saveAppearance.js b/app/livechat/server/methods/saveAppearance.js index 44a3749d81b43..4c6bfd530be36 100644 --- a/app/livechat/server/methods/saveAppearance.js +++ b/app/livechat/server/methods/saveAppearance.js @@ -29,6 +29,7 @@ Meteor.methods({ 'Livechat_name_field_registration_form', 'Livechat_email_field_registration_form', 'Livechat_registration_form_message', + 'Livechat_hide_sys_messages', ]; const valid = settings.every((setting) => validSettings.indexOf(setting._id) !== -1); diff --git a/app/livechat/server/methods/saveFilter.js b/app/livechat/server/methods/saveFilter.js new file mode 100644 index 0000000000000..826c576b9d944 --- /dev/null +++ b/app/livechat/server/methods/saveFilter.js @@ -0,0 +1,24 @@ +import { Meteor } from 'meteor/meteor'; +import { Match, check } from 'meteor/check'; + +import { hasPermission } from '../../../authorization'; +import { LivechatFilter } from '../../../models'; + +Meteor.methods({ + 'livechat:saveFilter'(filter) { + if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'view-livechat-manager')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'livechat:saveFilter' }); + } + + check(filter, { + _id: Match.Maybe(String), + name: String, + description: String, + enabled: Boolean, + regex: String, + slug: String, + }); + + return LivechatFilter.insertOrUpsert(filter); + }, +}); diff --git a/app/livechat/server/methods/saveTrigger.js b/app/livechat/server/methods/saveTrigger.js index 6be81479badc5..aa6de0aec07e6 100644 --- a/app/livechat/server/methods/saveTrigger.js +++ b/app/livechat/server/methods/saveTrigger.js @@ -16,13 +16,11 @@ Meteor.methods({ description: String, enabled: Boolean, runOnce: Boolean, + registeredOnly: Boolean, conditions: Array, actions: Array, }); - if (trigger._id) { - return LivechatTrigger.updateById(trigger._id, trigger); - } - return LivechatTrigger.insert(trigger); + return LivechatTrigger.insertOrUpsert(trigger); }, }); diff --git a/app/livechat/server/sendMessageBySMS.js b/app/livechat/server/sendMessageBySMS.js index 6094d1dc62a33..c089a6b841273 100644 --- a/app/livechat/server/sendMessageBySMS.js +++ b/app/livechat/server/sendMessageBySMS.js @@ -36,6 +36,11 @@ callbacks.add('afterSaveMessage', function(message, room) { extraData = Object.assign({}, { rid, userId, fileUpload }); } + if (message.customFields && message.customFields.mediaCardURL) { + const mediaUrl = message.customFields.mediaCardURL; + extraData = Object.assign({}, { mediaUrl }); + } + if (message.location) { const { location } = message; extraData = Object.assign({}, extraData, { location }); @@ -53,6 +58,7 @@ callbacks.add('afterSaveMessage', function(message, room) { return message; } + SMSService.send(room.sms.from, visitor.phone[0].phoneNumber, message.msg, extraData); return message; diff --git a/app/logger/server/server.js b/app/logger/server/server.js index bce22758c551f..d9b6d0a8e84fa 100644 --- a/app/logger/server/server.js +++ b/app/logger/server/server.js @@ -269,6 +269,7 @@ export class Logger { } _log(options, ...args) { + // require('log-timestamp'); if (LoggerManager.enabled === false) { LoggerManager.addToQueue(this, [options, ...args]); return; diff --git a/app/mailer/server/api.js b/app/mailer/server/api.js index 1405841f1b45f..f7be389eda9aa 100644 --- a/app/mailer/server/api.js +++ b/app/mailer/server/api.js @@ -60,13 +60,7 @@ export const replaceEscaped = (str, data = {}) => replace(str, { }, {}), }); -export const wrap = (html, data = {}) => { - if (settings.get('email_plain_text_only')) { - return replace(html, data); - } - - return replaceEscaped(body.replace('{{body}}', html), data); -}; +export const wrap = (html, data = {}) => replaceEscaped(body.replace('{{body}}', html), data); export const inlinecss = (html) => juice.inlineContent(html, Settings.get('email_style')); export const getTemplate = (template, fn, escape = true) => { let html = ''; @@ -119,10 +113,6 @@ export const sendNoWrap = ({ to, from, replyTo, subject, html, text, headers }) text = stripHtml(html).result; } - if (settings.get('email_plain_text_only')) { - html = undefined; - } - Meteor.defer(() => Email.send({ to, from, replyTo, subject, html, text, headers })); }; diff --git a/app/mentions-flextab/client/actionButton.js b/app/mentions-flextab/client/actionButton.js index d6b86681f5897..921720e2a5f23 100644 --- a/app/mentions-flextab/client/actionButton.js +++ b/app/mentions-flextab/client/actionButton.js @@ -17,7 +17,7 @@ Meteor.startup(function() { e.stopPropagation(); const { msg: message } = messageArgs(this); if (window.matchMedia('(max-width: 500px)').matches) { - Template.instance().tabBar.close(); + Template.currentData().instance.tabBar.close(); } if (message.tmid) { return FlowRouter.go(FlowRouter.getRouteName(), { diff --git a/app/message-pin/client/actionButton.js b/app/message-pin/client/actionButton.js index 07c0fde808d38..9650db05f311f 100644 --- a/app/message-pin/client/actionButton.js +++ b/app/message-pin/client/actionButton.js @@ -74,7 +74,7 @@ Meteor.startup(function() { action() { const { msg: message } = messageArgs(this); if (window.matchMedia('(max-width: 500px)').matches) { - Template.instance().tabBar.close(); + Template.currentData().instance.tabBar.close(); } if (message.tmid) { return FlowRouter.go(FlowRouter.getRouteName(), { @@ -89,8 +89,8 @@ Meteor.startup(function() { } return RoomHistoryManager.getSurroundingMessages(message, 50); }, - condition({ subscription }) { - return !!subscription; + condition({ msg, subscription }) { + return msg.pinned && !!subscription; }, order: 100, group: ['message', 'menu'], diff --git a/app/message-star/client/actionButton.js b/app/message-star/client/actionButton.js index 092e3e3c8dc25..5c194b2103f60 100644 --- a/app/message-star/client/actionButton.js +++ b/app/message-star/client/actionButton.js @@ -74,7 +74,7 @@ Meteor.startup(function() { action() { const { msg: message } = messageArgs(this); if (window.matchMedia('(max-width: 500px)').matches) { - Template.instance().tabBar.close(); + Template.currentData().instance.tabBar.close(); } if (message.tmid) { return FlowRouter.go(FlowRouter.getRouteName(), { diff --git a/app/models/client/index.js b/app/models/client/index.js index fbcbee481f5c4..0a82feba69691 100644 --- a/app/models/client/index.js +++ b/app/models/client/index.js @@ -14,7 +14,7 @@ import { CachedChatSubscription } from './models/CachedChatSubscription'; import { CachedUserList } from './models/CachedUserList'; import { ChatRoom } from './models/ChatRoom'; import { ChatSubscription } from './models/ChatSubscription'; -import { ChatMessage } from './models/ChatMessage'; +import { ChatMessage, CachedChatMessage } from './models/ChatMessage'; import { RoomRoles } from './models/RoomRoles'; import { UserAndRoom } from './models/UserAndRoom'; import { UserRoles } from './models/UserRoles'; @@ -48,6 +48,7 @@ export { AuthzCachedCollection, ChatPermissions, ChatMessage, + CachedChatMessage, ChatSubscription, Rooms, CustomSounds, diff --git a/app/models/client/models/ChatMessage.js b/app/models/client/models/ChatMessage.js index 7a3192a535e1c..f891f25f198bc 100644 --- a/app/models/client/models/ChatMessage.js +++ b/app/models/client/models/ChatMessage.js @@ -1,13 +1,35 @@ -import { Mongo } from 'meteor/mongo'; +import { Meteor } from 'meteor/meteor'; +import s from 'underscore.string'; +import { Tracker } from 'meteor/tracker'; -export const ChatMessage = new Mongo.Collection(null); +import { CachedCollection } from '../../../ui-cached-collection'; +import { CachedChatSubscription } from './CachedChatSubscription'; +import { ChatSubscription } from './ChatSubscription'; +import { getConfig } from '../../../ui-utils/client/config'; +import { cleanMessagesAtStartup, triggerOfflineMsgs } from '../../../utils'; +import { renderMessageBody } from '../../../../client/lib/renderMessageBody'; +import { promises } from '../../../promises/client'; +import { callbacks } from '../../../callbacks'; +import { settings } from '../../../settings'; -ChatMessage.setReactions = function(messageId, reactions) { - return this.update({ _id: messageId }, { $set: { reactions } }); +export const CachedChatMessage = new CachedCollection({ name: 'chatMessage' }); + +export const ChatMessage = CachedChatMessage.collection; + +let timeout; + +ChatMessage.find().observe({ + added: CachedChatMessage.save, + changed: CachedChatMessage.save, + removed: CachedChatMessage.save, +}); + +ChatMessage.setReactions = function(messageId, reactions, tempActions) { + return this.update({ _id: messageId }, { $set: { temp: true, tempActions, reactions } }); }; -ChatMessage.unsetReactions = function(messageId) { - return this.update({ _id: messageId }, { $unset: { reactions: 1 } }); +ChatMessage.unsetReactions = function(messageId, tempActions) { + return this.update({ _id: messageId }, { $unset: { reactions: 1 }, $set: { temp: true, tempActions } }); }; ChatMessage.findOneByRoomIdAndMessageId = function(rid, messageId, options) { @@ -18,3 +40,113 @@ ChatMessage.findOneByRoomIdAndMessageId = function(rid, messageId, options) { return this.findOne(query, options); }; + +ChatMessage.setProgress = function(messageId, upload) { + return this.update({ _id: messageId }, { $set: { uploads: upload } }); +}; + +const normalizeThreadMessage = (message) => { + if (message.msg) { + return renderMessageBody(message).replace(//g, ' '); + } + + if (message.attachments) { + const attachment = message.attachments.find((attachment) => attachment.title || attachment.description); + + if (attachment && attachment.description) { + return s.escapeHTML(attachment.description); + } + + if (attachment && attachment.title) { + return s.escapeHTML(attachment.title); + } + } +}; + +const upsertMessage = async ({ msg, subscription, uid = Tracker.nonreactive(() => Meteor.userId()) }, collection = ChatMessage) => { + const userId = msg.u && msg.u._id; + + if (subscription && subscription.ignored && subscription.ignored.indexOf(userId) > -1) { + msg.ignored = true; + } + + if (msg.t === 'e2e' && !msg.file) { + msg.e2e = 'pending'; + } + msg = await promises.run('onClientMessageReceived', msg) || msg; + + const { _id, ...messageToUpsert } = msg; + + if (msg.tcount) { + collection.direct.update({ tmid: _id }, { + $set: { + following: msg.replies && msg.replies.indexOf(uid) > -1, + threadMsg: normalizeThreadMessage(messageToUpsert), + repliesCount: msg.tcount, + }, + }, { multi: true }); + } + + return collection.direct.upsert({ _id }, messageToUpsert); +}; + +function upsertMessageBulk({ msgs, subscription }, collection = ChatMessage) { + const uid = Tracker.nonreactive(() => Meteor.userId()); + const { queries } = ChatMessage; + collection.queries = []; + msgs.forEach((msg, index) => { + if (index === msgs.length - 1) { + ChatMessage.queries = queries; + } + upsertMessage({ msg, subscription, uid }, collection); + }); +} + +const messagePreFetch = () => { + let messagesFetched = false; + if (Meteor.status().status !== 'connected') { + clearTimeout(timeout); + timeout = setTimeout(cleanMessagesAtStartup, 3000); + } + Tracker.autorun(() => { + if (!messagesFetched && CachedChatSubscription.ready.get() && settings.cachedCollection.ready.get()) { + const status = Meteor.status(); + if (status.status !== 'connected') { + return; + } + clearTimeout(timeout); + triggerOfflineMsgs(); + messagesFetched = true; + const roomLimit = settings.get('Message_AllowPrefetch_PrefetchRoomLimit'); + const subscriptions = ChatSubscription.find( + { + open: true, + }, + { + fields: { + rid: 1, + ls: 1, + }, + limit: roomLimit, + }, + ).fetch(); + const limit = parseInt(getConfig('roomListLimit')) || 50; + subscriptions.forEach((subscription) => { + const ts = undefined; + const { rid, ls } = subscription; + Meteor.call('loadHistory', rid, ts, limit, ls, (err, result) => { + if (err) { + return; + } + const { messages = [] } = result; + upsertMessageBulk({ + msgs: messages.filter((msg) => msg.t !== 'command'), + subscription, + }); + }); + }); + } + }); +}; + +callbacks.add('afterMainReady', messagePreFetch, callbacks.priority.LOW, 'messagePreFetch'); diff --git a/app/models/client/models/ChatRoom.js b/app/models/client/models/ChatRoom.js index e55c3e94f9308..4ea124d742fef 100644 --- a/app/models/client/models/ChatRoom.js +++ b/app/models/client/models/ChatRoom.js @@ -9,3 +9,9 @@ ChatRoom.setReactionsInLastMessage = function(roomId, lastMessage) { ChatRoom.unsetReactionsInLastMessage = function(roomId) { return this.update({ _id: roomId }, { $unset: { lastMessage: { reactions: 1 } } }); }; + +ChatRoom.setLastMessage = function(roomId, lastMessage) { + const update = this.update({ _id: roomId }, { $set: { lastMessage } }); + CachedChatRoom.save(); + return update; +}; diff --git a/app/models/client/models/ChatSubscription.js b/app/models/client/models/ChatSubscription.js index 964f622db4958..e80c1c5bde4b5 100644 --- a/app/models/client/models/ChatSubscription.js +++ b/app/models/client/models/ChatSubscription.js @@ -1,3 +1,9 @@ import { CachedChatSubscription } from './CachedChatSubscription'; export const ChatSubscription = CachedChatSubscription.collection; + +ChatSubscription.setLastMessage = function(roomId, lastMessage) { + const update = this.update({ rid: roomId }, { $set: { lastMessage } }); + CachedChatSubscription.save(); + return update; +}; diff --git a/app/models/client/models/Roles.js b/app/models/client/models/Roles.js index ed4ed9fc71e61..11fc35c9cc0fe 100644 --- a/app/models/client/models/Roles.js +++ b/app/models/client/models/Roles.js @@ -1,9 +1,11 @@ -import { Mongo } from 'meteor/mongo'; import { ReactiveVar } from 'meteor/reactive-var'; +import { CachedCollection } from '../../../ui-cached-collection'; import * as Models from '..'; -const Roles = new Mongo.Collection(null); +const CachedRoles = new CachedCollection({ name: 'roles' }); + +const Roles = CachedRoles.collection; Object.assign(Roles, { findUsersInRole(name, scope, options) { diff --git a/app/models/client/models/Users.js b/app/models/client/models/Users.js index 51e71fedf9e01..f0d53bbba4c47 100644 --- a/app/models/client/models/Users.js +++ b/app/models/client/models/Users.js @@ -1,6 +1,8 @@ import { Meteor } from 'meteor/meteor'; import { Mongo } from 'meteor/mongo'; +import { CachedCollection } from '../../../ui-cached-collection'; + export const Users = { findOneById(userId, options = {}) { const query = { @@ -29,3 +31,14 @@ export const Users = { // overwrite Meteor.users collection so records on it don't get erased whenever the client reconnects to websocket Meteor.users = new Mongo.Collection(null); Meteor.user = () => Meteor.users.findOne({ _id: Meteor.userId() }); + +// logged user data will come to this collection +const CachedOwnUser = new CachedCollection({ name: 'ownUser' }); +const OwnUser = CachedOwnUser.collection; + +// register an observer to logged user's collection and populate "original" Meteor.users with it +OwnUser.find().observe({ + added: (record) => Meteor.users.upsert({ _id: record._id }, record), + changed: (record) => Meteor.users.update({ _id: record._id }, record), + removed: (_id) => Meteor.users.remove({ _id }), +}); diff --git a/app/models/server/index.js b/app/models/server/index.js index efcd3790302ff..a74764b0ae40d 100644 --- a/app/models/server/index.js +++ b/app/models/server/index.js @@ -32,6 +32,7 @@ import LivechatDepartmentAgents from './models/LivechatDepartmentAgents'; import LivechatPageVisited from './models/LivechatPageVisited'; import LivechatRooms from './models/LivechatRooms'; import LivechatTrigger from './models/LivechatTrigger'; +import LivechatFilter from './models/LivechatFilter'; import LivechatVisitors from './models/LivechatVisitors'; import LivechatAgentActivity from './models/LivechatAgentActivity'; import LivechatInquiry from './models/LivechatInquiry'; @@ -40,6 +41,7 @@ import LivechatExternalMessage from './models/LivechatExternalMessages'; import OmnichannelQueue from './models/OmnichannelQueue'; import Analytics from './models/Analytics'; import EmailInbox from './models/EmailInbox'; +import PushNotificationSubscriptions from './models/PushNotificationSubscriptions'; export { AppsLogsModel } from './models/apps-logs-model'; export { AppsPersistenceModel } from './models/apps-persistence-model'; @@ -84,8 +86,10 @@ export { LivechatPageVisited, LivechatRooms, LivechatTrigger, + LivechatFilter, LivechatVisitors, LivechatAgentActivity, + PushNotificationSubscriptions, ReadReceipts, LivechatExternalMessage, LivechatInquiry, diff --git a/app/models/server/models/LivechatFilter.js b/app/models/server/models/LivechatFilter.js new file mode 100644 index 0000000000000..6cfddec455538 --- /dev/null +++ b/app/models/server/models/LivechatFilter.js @@ -0,0 +1,32 @@ +import { Base } from './_Base'; + +/** + * Livechat Filter model + */ +export class LivechatFilter extends Base { + constructor() { + super('livechat_filter'); + } + + updateById(_id, data) { + return this.update({ _id }, { $set: data }); + } + + removeAll() { + return this.remove({}); + } + + findById(_id) { + return this.find({ _id }); + } + + removeById(_id) { + return this.remove({ _id }); + } + + findEnabled() { + return this.find({ enabled: true }); + } +} + +export default new LivechatFilter(); diff --git a/app/models/server/models/Messages.js b/app/models/server/models/Messages.js index a840ef76d7290..946337a47bc6f 100644 --- a/app/models/server/models/Messages.js +++ b/app/models/server/models/Messages.js @@ -32,6 +32,10 @@ export class Messages extends Base { this.tryEnsureIndex({ tcount: 1, tlm: 1 }, { sparse: true }); // livechat this.tryEnsureIndex({ 'navigation.token': 1 }, { sparse: true }); + + // push-message + this.tryEnsureIndex({ pushm: 1 }, { sparse: true }); + this.tryEnsureIndex({ pushm_encrypted: 1 }, { sparse: true }); } setReactions(messageId, reactions) { @@ -726,6 +730,20 @@ export class Messages extends Base { return this.update(query, update, { multi: true }); } + updatePostProcessedPushMessageById(_id, post_processed_message) { + const query = { _id }; + + const update = { + $set: { + post_processed_message, + pushm_post_processed: true, + msg: '', + }, + }; + + return this.update(query, update); + } + // INSERT createWithTypeRoomIdMessageAndUser(type, roomId, message, user, extraData) { const record = { diff --git a/app/models/server/models/PushNotificationSubscriptions.js b/app/models/server/models/PushNotificationSubscriptions.js new file mode 100644 index 0000000000000..205afe7ebc35e --- /dev/null +++ b/app/models/server/models/PushNotificationSubscriptions.js @@ -0,0 +1,59 @@ +import { Base } from './_Base'; + +export class PushNotificationSubscriptions extends Base { + constructor(...args) { + super(...args); + + this.tryEnsureIndex({ u: 1 }); + } + + createWithUserAndSubscription(user, subscription, platform) { + const pushNotificationSubscription = { + ...subscription, + }; + if (user && user._id != null) { + pushNotificationSubscription.u = { ...user }; + } + pushNotificationSubscription.platform = platform; + const result = this.insert(pushNotificationSubscription); + return result; + } + + findByUserId(userId) { + const query = { + 'u._id': userId, + }; + return this.find(query); + } + + findSubscriptionsWithNoUser() { + const query = { + user: { + $exists: false, + }, + }; + return this.find(query); + } + + updateUserIdWithSubscriptionEndpoint(endpoint, user = null) { + const query = { + endpoint, + }; + + const update = {}; + + if (user !== null) { + update.$set = { u: user }; + } else { + update.$unset = { u: 1 }; + } + + return this.update(query, update); + } + + removeById(_id) { + return this.remove(_id); + } +} + +export default new PushNotificationSubscriptions('pushNotificationSubscriptions'); diff --git a/app/models/server/models/Users.js b/app/models/server/models/Users.js index fbe0bcfc0b3b3..4d54fa3251e89 100644 --- a/app/models/server/models/Users.js +++ b/app/models/server/models/Users.js @@ -219,7 +219,7 @@ export class Users extends Base { // if department is provided, remove the agents that are not from the selected department const departmentFilter = departmentId ? [{ $lookup: { - from: 'rocketchat_livechat_department_agent', + from: 'rocketchat_livechat_department_agents', let: { departmentId: '$departmentId', agentId: '$agentId' }, pipeline: [{ $match: { $expr: { $eq: ['$$agentId', '$_id'] } }, @@ -888,6 +888,25 @@ export class Users extends Base { ...extraQuery, ], }; + if (options.filterByDiscoverability) { + const defaultDiscoverability = settings.get('Accounts_Default_User_Preferences_discoverability'); + if (defaultDiscoverability === 'all') { + query.$and.push({ + $or: [ + { + 'settings.preferences': { $exists: false }, + }, + { + 'settings.preferences.discoverability': 'all', + }, + ], + }); + } else { + query.$and.push({ + 'settings.preferences.discoverability': 'all', + }); + } + } // do not use cache return this._db.find(query, options); diff --git a/app/models/server/raw/LivechatFilter.js b/app/models/server/raw/LivechatFilter.js new file mode 100644 index 0000000000000..662fe7542f766 --- /dev/null +++ b/app/models/server/raw/LivechatFilter.js @@ -0,0 +1,5 @@ +import { BaseRaw } from './BaseRaw'; + +export class LivechatFilterRaw extends BaseRaw { + +} diff --git a/app/models/server/raw/index.ts b/app/models/server/raw/index.ts index 87ecbad097d1b..17661b6cddce5 100644 --- a/app/models/server/raw/index.ts +++ b/app/models/server/raw/index.ts @@ -16,6 +16,8 @@ import LivechatCustomFieldModel from '../models/LivechatCustomField'; import { LivechatCustomFieldRaw } from './LivechatCustomField'; import LivechatTriggerModel from '../models/LivechatTrigger'; import { LivechatTriggerRaw } from './LivechatTrigger'; +import LivechatFilterModel from '../models/LivechatFilter'; +import { LivechatFilterRaw } from './LivechatFilter'; import LivechatDepartmentModel from '../models/LivechatDepartment'; import { LivechatDepartmentRaw } from './LivechatDepartment'; import LivechatDepartmentAgentsModel from '../models/LivechatDepartmentAgents'; @@ -79,6 +81,7 @@ export const Users = new UsersRaw(UsersModel.model.rawCollection(), trashCollect export const Rooms = new RoomsRaw(RoomsModel.model.rawCollection(), trashCollection); export const LivechatCustomField = new LivechatCustomFieldRaw(LivechatCustomFieldModel.model.rawCollection(), trashCollection); export const LivechatTrigger = new LivechatTriggerRaw(LivechatTriggerModel.model.rawCollection(), trashCollection); +export const LivechatFilter = new LivechatFilterRaw(LivechatFilterModel.model.rawCollection()); export const LivechatDepartment = new LivechatDepartmentRaw(LivechatDepartmentModel.model.rawCollection(), trashCollection); export const LivechatDepartmentAgents = new LivechatDepartmentAgentsRaw(LivechatDepartmentAgentsModel.model.rawCollection(), trashCollection); export const LivechatRooms = new LivechatRoomsRaw(LivechatRoomsModel.model.rawCollection(), trashCollection); diff --git a/app/reactions/client/methods/setReaction.js b/app/reactions/client/methods/setReaction.js index 14ec5010f7f65..9b29afd158506 100644 --- a/app/reactions/client/methods/setReaction.js +++ b/app/reactions/client/methods/setReaction.js @@ -7,15 +7,31 @@ import { emoji } from '../../../emoji'; import { roomTypes } from '../../../utils/client'; Meteor.methods({ - setReaction(reaction, messageId) { + setReaction(reaction, messageId, shouldReact, offlineTrigerred = false) { if (!Meteor.userId()) { throw new Meteor.Error(203, 'User_logged_out'); } + if (offlineTrigerred) { + return; + } + const user = Meteor.user(); const message = Messages.findOne({ _id: messageId }); const room = Rooms.findOne({ _id: message.rid }); + const tempActions = message.tempActions || {}; + + if (tempActions.delete) { + return false; + } + + if (tempActions.react) { + tempActions.reactions.push(reaction); + } else if (!tempActions.send) { + tempActions.react = true; + tempActions.reactions = [reaction]; + } if (message.private) { return false; @@ -42,10 +58,10 @@ Meteor.methods({ if (_.isEmpty(message.reactions)) { delete message.reactions; - Messages.unsetReactions(messageId); + Messages.unsetReactions(messageId, tempActions); callbacks.run('unsetReaction', messageId, reaction); } else { - Messages.setReactions(messageId, message.reactions); + Messages.setReactions(messageId, message.reactions, tempActions); callbacks.run('setReaction', messageId, reaction); } } else { @@ -59,7 +75,7 @@ Meteor.methods({ } message.reactions[reaction].usernames.push(user.username); - Messages.setReactions(messageId, message.reactions); + Messages.setReactions(messageId, message.reactions, tempActions); callbacks.run('setReaction', messageId, reaction); } }, diff --git a/app/search/client/provider/result.js b/app/search/client/provider/result.js index ff2cc972bf174..7df29b89906fb 100644 --- a/app/search/client/provider/result.js +++ b/app/search/client/provider/result.js @@ -21,6 +21,13 @@ Meteor.startup(function() { context: ['search'], action() { const { msg: message } = messageArgs(this); + if (Session.get('openSearchPage')) { + Session.set('openSearchPage', false); + window.setTimeout(() => { + RoomHistoryManager.getSurroundingMessages(message, 50); + }, 400); + return; + } if (message.tmid) { return FlowRouter.go(FlowRouter.getRouteName(), { tab: 'thread', @@ -40,7 +47,7 @@ Meteor.startup(function() { // RocketChat.MessageAction.hideDropDown(); if (window.matchMedia('(max-width: 500px)').matches) { - Template.instance().tabBar.close(); + Template.currentData().instance.tabBar.close(); } window.setTimeout(() => { diff --git a/app/search/client/search/search.html b/app/search/client/search/search.html index dbb6c5a7f179d..5b64a45962192 100644 --- a/app/search/client/search/search.html +++ b/app/search/client/search/search.html @@ -6,12 +6,21 @@ {{/if}} {{else}} + {{#if isMobile}} +
+ +
+ {{/if}}
- {{#if provider.description}} -
-

{{{_ provider.description}}}

-
- {{/if}} + {{#unless isMobile}} + {{#if provider.description}} +
+

{{{_ provider.description}}}

+
+ {{/if}} + {{/unless}}
diff --git a/app/search/client/search/search.js b/app/search/client/search/search.js index a71cc3270ec19..49750e7a9992d 100644 --- a/app/search/client/search/search.js +++ b/app/search/client/search/search.js @@ -7,6 +7,8 @@ import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; import toastr from 'toastr'; import _ from 'underscore'; +import { isMobile } from '../../../utils/client'; + Template.RocketSearch.onCreated(function() { this.provider = new ReactiveVar(); this.isActive = new ReactiveVar(false); @@ -78,6 +80,9 @@ Template.RocketSearch.onCreated(function() { }); Template.RocketSearch.events = { + 'click .js-close-search'() { + Session.set('openSearchPage', !Session.get('openSearchPage')); + }, 'keydown #message-search'(evt, t) { if (evt.keyCode === 13) { if (t.suggestionActive.get() !== undefined) { @@ -144,6 +149,9 @@ Template.RocketSearch.events = { }; Template.RocketSearch.helpers({ + isMobile() { + return isMobile(); + }, error() { return Template.instance().error.get(); }, diff --git a/app/search/client/style/style.css b/app/search/client/style/style.css index 87dadc8c355f6..98b6bac816691 100644 --- a/app/search/client/style/style.css +++ b/app/search/client/style/style.css @@ -12,6 +12,13 @@ flex: 1; } +.back-button { + position: relative; + top: 36px; + + padding-left: 24px; +} + .rocket-default-search-results { overflow: auto; overflow-x: hidden; diff --git a/app/settings/client/index.js b/app/settings/client/index.js new file mode 100644 index 0000000000000..481b58fd8a8a2 --- /dev/null +++ b/app/settings/client/index.js @@ -0,0 +1,5 @@ +import { settings } from './lib/settings'; + +export { + settings, +}; diff --git a/app/settings/server/index.js b/app/settings/server/index.js new file mode 100644 index 0000000000000..3adfad5409b3b --- /dev/null +++ b/app/settings/server/index.js @@ -0,0 +1,6 @@ +import { settings, SettingsEvents } from './functions/settings'; + +export { + settings, + SettingsEvents, +}; diff --git a/app/sms/server/services/twilio.js b/app/sms/server/services/twilio.js index bf52dcd5491f8..bc4854c035d4d 100644 --- a/app/sms/server/services/twilio.js +++ b/app/sms/server/services/twilio.js @@ -79,6 +79,7 @@ class Twilio { let mediaUrl; const defaultLanguage = settings.get('Language') || 'en'; + if (extraData && extraData.fileUpload) { const { rid, userId, fileUpload: { size, type, publicFilePath } } = extraData; const user = userId ? Meteor.users.findOne(userId) : null; @@ -103,6 +104,13 @@ class Twilio { mediaUrl = [publicFilePath]; } + if (extraData && extraData.mediaUrl) { + if (mediaUrl) { + mediaUrl.push(extraData.mediaUrl); + } else { + mediaUrl = extraData.mediaUrl; + } + } let persistentAction; if (extraData && extraData.location) { diff --git a/app/sms/server/settings.js b/app/sms/server/settings.js index 4a5ffae29bf99..4af8a4d283a5e 100644 --- a/app/sms/server/settings.js +++ b/app/sms/server/settings.js @@ -166,5 +166,31 @@ Meteor.startup(function() { i18nDescription: 'Mobex_sms_gateway_from_numbers_list_desc', }); }); + + this.section('Invitation', function() { + this.add('Invitation_SMS_Twilio_From', '', { + type: 'string', + i18nLabel: 'Invitation_SMS_Twilio_From', + }); + this.add('Invitation_SMS_Customized', false, { + type: 'boolean', + i18nLabel: 'Custom_SMS', + }); + return this.add('Invitation_SMS_Customized_Body', '', { + type: 'code', + code: 'text', + multiline: true, + i18nLabel: 'Body', + i18nDescription: 'Invitation_SMS_Customized_Body', + enableQuery: { + _id: 'Invitation_SMS_Customized', + value: true, + }, + i18nDefaultQuery: { + _id: 'Invitation_SMS_Default_Body', + value: false, + }, + }); + }); }); }); diff --git a/app/theme/client/imports/components/contextual-bar.css b/app/theme/client/imports/components/contextual-bar.css index e27c96fefae2d..eb1d31edbb711 100644 --- a/app/theme/client/imports/components/contextual-bar.css +++ b/app/theme/client/imports/components/contextual-bar.css @@ -187,13 +187,15 @@ @media (width <= 500px) { .contextual-bar { - position: fixed; - z-index: 999; - top: 0; + &.contextual-bar { + position: fixed; + z-index: 999; + top: 0; - width: 100%; + width: 100%; - animation: dropup-show 0.3s cubic-bezier(0.45, 0.05, 0.55, 0.95); + animation: dropup-show 0.3s cubic-bezier(0.45, 0.05, 0.55, 0.95); + } } } diff --git a/app/theme/client/imports/components/header.css b/app/theme/client/imports/components/header.css index ebf39c94879a8..00f42098ede85 100644 --- a/app/theme/client/imports/components/header.css +++ b/app/theme/client/imports/components/header.css @@ -20,7 +20,7 @@ &__block { display: flex; - margin: 0 -0.5rem; + margin: 0 0.5rem; padding: 0 0.5rem; @@ -167,12 +167,27 @@ @media (width <= 500px) { .rc-header { &__block { - margin: 0 0.25rem; + margin: 0 0.2em; + + padding: 0 0.1em; } - &__block-action { - order: 2; + &__section { + display: flex; + + margin: 0 0.5rem; + + padding: 0 0.5rem; + + align-items: center; + } + + &__first-icon { + width: 2.25em; + padding: 0; + } + &__block-action { & + & { border-left: 1px var(--color-gray) solid; @@ -181,6 +196,16 @@ border-left: 0; } } + + .rc-room-actions { + &__action { + .rc-room-actions__button { + .tab-button-icon { + height: 1.2em; + } + } + } + } } &__data { @@ -193,15 +218,42 @@ display: flex; margin: 0; + padding: 0; } } + + .rc-channel { + &--room { + background: var(--veranda-color-primary); + } + + &__wrap { + color: var(--color-white); + background: var(--veranda-color-primary); + } + + &__name { + color: var(--color-white); + + font-size: 1.3rem; + + & > .rc-header__icon { + display: none; + } + } + + .burger i { + background-color: var(--color-white); + } + } } .burger { position: relative; cursor: pointer; + transition: transform 0.2s ease-out 0.1s; will-change: transform; diff --git a/app/theme/client/imports/components/main-content.css b/app/theme/client/imports/components/main-content.css index 04f7cb05d9706..4e9238db7f483 100644 --- a/app/theme/client/imports/components/main-content.css +++ b/app/theme/client/imports/components/main-content.css @@ -2,7 +2,7 @@ position: relative; - z-index: 0; + z-index: 2; display: flex; flex-direction: column; @@ -10,7 +10,8 @@ width: 1vw; - height: 100%; + transition: transform 0.3s; + transform: translate3d(100%, 0, 0); } .messages-container .room-icon { diff --git a/app/theme/client/imports/components/pushMessage.css b/app/theme/client/imports/components/pushMessage.css new file mode 100644 index 0000000000000..04db4fdd67b20 --- /dev/null +++ b/app/theme/client/imports/components/pushMessage.css @@ -0,0 +1,148 @@ +.push-message-container { + overflow: hidden; + + min-height: 40px; + margin-top: 4px; + padding: 8px; + + border: 1px solid; + border-color: grey; + border-radius: 10px; + + line-height: 20px; +} + +.push-message { + display: flex; + + &-content-container { + margin: 0 8px; + } + + &-icon-container { + float: left; + + width: 36px; + height: 36px; + + margin: 4px 8px 0 0; + } + + &-icon { + width: 100%; + height: 100%; + + background-repeat: no-repeat; + background-position: center; + background-size: cover; + } + + &-header { + display: flex; + justify-content: space-between; + } + + &-title { + overflow: hidden; + + white-space: nowrap; + text-overflow: ellipsis; + + font-size: 15px; + font-weight: bold; + } + + &-timestamp-container { + display: flex; + } + + &-timestamp { + overflow: hidden; + + white-space: nowrap; + text-overflow: ellipsis; + + font-size: 13px; + flex-shrink: 0; + } + + &-body { + overflow: hidden; + + text-overflow: ellipsis; + + font-size: 14px; + } + + &-actions-container { + display: flex; + overflow: hidden; + } +} + +.button-expand { + transition: transform 0.2s; + transform: rotate(180deg); +} + +.button-collapse { + transition: transform 0.2s; + transform: rotate(0deg); +} + +.body-collapsed { + display: block; + + max-height: 20px; + + white-space: nowrap; +} + +.push-message-action-button { + display: flex; + overflow: hidden; + + min-height: 40px; + + margin: 4px; + padding: 2px 12px; + + border: 1px solid; + border-color: grey; + border-radius: 10px; + align-items: center; + + &:hover { + background-color: #dddddd; + } + + &-icon-container { + float: left; + flex: none; + + width: 16px; + height: 16px; + + margin-right: 8px; + } + + &-icon { + width: 100%; + height: 100%; + + background-repeat: no-repeat; + background-position: center; + background-size: cover; + } + + &-title { + overflow: hidden; + + white-space: nowrap; + + text-overflow: ellipsis; + + font-size: 15px; + font-weight: bold; + } +} diff --git a/app/theme/client/imports/components/read-receipts.css b/app/theme/client/imports/components/read-receipts.css index da73d930416bd..c88dc7ba97cdc 100644 --- a/app/theme/client/imports/components/read-receipts.css +++ b/app/theme/client/imports/components/read-receipts.css @@ -9,6 +9,11 @@ height: 0.8em; } +/* Overwriting the color-component-color class defined in colors.less */ +.color-component-color { + color: grey; +} + .message:hover .read-receipt, .message.active .read-receipt { display: none; diff --git a/app/theme/client/imports/components/share.css b/app/theme/client/imports/components/share.css new file mode 100644 index 0000000000000..8b1ac94d679ff --- /dev/null +++ b/app/theme/client/imports/components/share.css @@ -0,0 +1,56 @@ +.share-header-container { + display: flex; + flex-wrap: wrap; +} + +.share-icon { + display: inline-block; + + box-sizing: border-box; + + width: 25%; + min-width: 46px; + + padding: 16px 0; + + cursor: pointer; + + text-align: center; + + border: none; + + background-color: transparent; + + box-shadow: inset 0 0 20px rgba(0, 0, 0, 0); + + font-size: 12px; + font-weight: 400; + + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + &:hover { + box-shadow: inset 0 0 20px rgba(0, 0, 0, 0.125); + } +} + +.share-svg-icon { + display: block; + + width: 42px; + height: 36px; + margin: auto; + + &__header { + width: 24px; + height: 24px; + } +} + +.share-icon-title { + display: block; + + padding-top: 10px; + + font-size: 14px; +} diff --git a/app/theme/client/imports/components/sidebar/rooms-list.css b/app/theme/client/imports/components/sidebar/rooms-list.css index e952e991a5199..65532010758f0 100644 --- a/app/theme/client/imports/components/sidebar/rooms-list.css +++ b/app/theme/client/imports/components/sidebar/rooms-list.css @@ -59,6 +59,20 @@ } } +@media (width <= 500px) { + .rooms-list { + &__type { + padding: 0 var(--sidebar-small-default-padding) 1rem var(--sidebar-small-default-padding); + + font-size: var(--rooms-list-title-text-size-mobile); + } + + &__empty-room { + font-size: var(--rooms-list-empty-text-size-mobile); + } + } +} + @media (width <= 400px) { padding: 0 calc(var(--sidebar-small-default-padding) - 4px); diff --git a/app/theme/client/imports/components/sidebar/sidebar.css b/app/theme/client/imports/components/sidebar/sidebar.css index 1b8f4e312f032..1ff3e4b2ef7cd 100644 --- a/app/theme/client/imports/components/sidebar/sidebar.css +++ b/app/theme/client/imports/components/sidebar/sidebar.css @@ -2,13 +2,14 @@ position: relative; - z-index: 2; + z-index: 0; display: flex; flex-direction: column; flex: 0 0 var(--sidebar-width); - width: var(--sidebar-width); + width: 100%; + max-width: var(--sidebar-width); height: 100%; @@ -88,7 +89,7 @@ position: absolute; user-select: none; - transform: translate3d(-100%, 0, 0); + transform: translate3d(0, 0, 0); -webkit-tap-highlight-color: rgba(0, 0, 0, 0); touch-action: pan-y; -webkit-user-drag: none; @@ -100,19 +101,20 @@ } } -@media (width <= 400px) { +@media (width <= 500px) { .sidebar { flex: 0 0 var(--sidebar-small-width); - width: var(--sidebar-small-width); - max-width: var(--sidebar-small-width); + width: 100%; + + max-width: none; &__footer { display: none; } &:not(&--light) { - transform: translate3d(-100%, 0, 0); + transform: translate3d(0, 0, 0); } } diff --git a/app/theme/client/imports/components/table.css b/app/theme/client/imports/components/table.css index 16da7f6cf3e5e..c817bccc2150b 100644 --- a/app/theme/client/imports/components/table.css +++ b/app/theme/client/imports/components/table.css @@ -65,7 +65,7 @@ padding: 0.25rem 0; - white-space: nowrap; + white-space: pre-wrap; text-overflow: ellipsis; font-size: 0.9rem; diff --git a/app/theme/client/imports/forms/button.css b/app/theme/client/imports/forms/button.css index 463ed17346cad..1165ddfd50842 100644 --- a/app/theme/client/imports/forms/button.css +++ b/app/theme/client/imports/forms/button.css @@ -110,6 +110,11 @@ border: 0; border-color: var(--button-cancel-color); background-color: var(--button-cancel-color); + + .rc-button-icon { + display: none; + visibility: hidden; + } } &--cancel.rc-button--outline { @@ -216,4 +221,18 @@ .rc-button--full { width: 100%; } + + .rc-button--cancel { + padding: 0 1rem; + + .rc-button-icon { + display: block; + visibility: visible; + } + + .rc-button-text { + display: none; + visibility: hidden; + } + } } diff --git a/app/theme/client/imports/general/base.css b/app/theme/client/imports/general/base.css index 1cf8605b6b6a0..318501c0840ab 100644 --- a/app/theme/client/imports/general/base.css +++ b/app/theme/client/imports/general/base.css @@ -38,6 +38,8 @@ body { @media (width <= 500px) { body { position: fixed; + + font-size: 1rem; } } diff --git a/app/theme/client/imports/general/base_old.css b/app/theme/client/imports/general/base_old.css index cd8294c202cfc..7a8068481a6fc 100644 --- a/app/theme/client/imports/general/base_old.css +++ b/app/theme/client/imports/general/base_old.css @@ -308,7 +308,7 @@ & > div { - width: 60%; + width: 55%; min-height: 2.5rem; & label { @@ -2225,6 +2225,7 @@ & .body { display: inline; + font-size: 12px; font-style: italic; & em { diff --git a/app/theme/client/imports/general/forms.css b/app/theme/client/imports/general/forms.css index 9516f91a2072f..ba8168c24dc42 100644 --- a/app/theme/client/imports/general/forms.css +++ b/app/theme/client/imports/general/forms.css @@ -198,15 +198,13 @@ } &__content { - display: flex; overflow-y: auto; flex-direction: column; - flex: 1; - width: 100%; - padding: 25px 0; + margin: 0 -1.25rem; + padding: 1.25rem 1.25rem 0; } &--apps .preferences-page__header { diff --git a/app/theme/client/imports/general/reset.css b/app/theme/client/imports/general/reset.css index b6ed2ac1ba86f..b652ff39824ab 100644 --- a/app/theme/client/imports/general/reset.css +++ b/app/theme/client/imports/general/reset.css @@ -100,6 +100,92 @@ video { } } +@media (max-width: 400px) { + html, + body, + div, + span, + applet, + object, + iframe, + h1, + h2, + h3, + h4, + h5, + h6, + p, + blockquote, + pre, + a, + abbr, + acronym, + address, + big, + cite, + code, + del, + dfn, + em, + img, + ins, + kbd, + q, + s, + samp, + small, + strike, + strong, + sub, + sup, + tt, + var, + b, + u, + i, + center, + dl, + dt, + dd, + ol, + ul, + li, + fieldset, + form, + label, + legend, + table, + caption, + tbody, + tfoot, + thead, + tr, + th, + td, + article, + aside, + canvas, + details, + embed, + figure, + figcaption, + footer, + header, + hgroup, + menu, + nav, + output, + ruby, + section, + summary, + time, + mark, + audio, + video { + display: auto; + } +} + /* HTML5 display-role reset for older browsers */ article, diff --git a/app/theme/client/imports/general/variables.css b/app/theme/client/imports/general/variables.css index 635786e6a0ccf..b497845d3e495 100644 --- a/app/theme/client/imports/general/variables.css +++ b/app/theme/client/imports/general/variables.css @@ -65,6 +65,7 @@ --rc-color-announcement-background: #d1ebfe; --rc-color-announcement-text-hover: #01336b; --rc-color-announcement-background-hover: #76b7fc; + --veranda-color-primary: #008085; /* #endregion */ @@ -236,6 +237,7 @@ * Sidebar Account */ --sidebar-account-thumb-size: 23px; + --sidebar-account-thumb-size-mobile: 1.75em; --sidebar-small-account-thumb-size: 40px; --sidebar-account-status-bullet-size: 10px; --sidebar-small-account-status-bullet-size: 8px; @@ -269,6 +271,7 @@ --sidebar-item-user-status-size: 6px; --sidebar-item-user-status-radius: 50%; --sidebar-item-text-size: 0.875rem; + --sidebar-item-text-size-mobile: 1rem; /* * Modal - Create Channel @@ -289,8 +292,10 @@ */ --rooms-list-title-color: var(--rc-color-primary-light); --rooms-list-title-text-size: 0.75rem; + --rooms-list-title-text-size-mobile: 1rem; --rooms-list-empty-text-color: var(--color-gray); --rooms-list-empty-text-size: 0.75rem; + --rooms-list-empty-text-size-mobile: 1rem; --rooms-list-padding: var(--sidebar-default-padding); --rooms-list-small-padding: var(--sidebar-small-default-padding); diff --git a/app/theme/client/main.css b/app/theme/client/main.css index 1795e14bbef84..01bf0f22680be 100644 --- a/app/theme/client/main.css +++ b/app/theme/client/main.css @@ -42,6 +42,8 @@ @import 'imports/components/emojiPicker.css'; @import 'imports/components/table.css'; @import 'imports/components/tabs.css'; +@import 'imports/components/share.css'; +@import 'imports/components/pushMessage.css'; /* Modal */ @import 'imports/components/modal/create-channel.css'; diff --git a/app/ui-login/client/login/form.js b/app/ui-login/client/login/form.js index 198d6c49d3557..c59d051366e88 100644 --- a/app/ui-login/client/login/form.js +++ b/app/ui-login/client/login/form.js @@ -126,6 +126,7 @@ Template.loginForm.events({ } if (error && error.error === 'error-user-is-not-activated') { return instance.state.set('wait-activation'); } + callbacks.run('onUserLogin'); Session.set('forceLogin', false); }); }); @@ -157,6 +158,7 @@ Template.loginForm.events({ return toastr.error(t('User_not_found_or_incorrect_password')); } } + callbacks.run('onUserLogin'); Session.set('forceLogin', false); }); } diff --git a/app/ui-login/client/login/services.js b/app/ui-login/client/login/services.js index 2b8a04f1cc6c6..6863e5d1f06f4 100644 --- a/app/ui-login/client/login/services.js +++ b/app/ui-login/client/login/services.js @@ -5,6 +5,7 @@ import { ServiceConfiguration } from 'meteor/service-configuration'; import toastr from 'toastr'; import { CustomOAuth } from '../../../custom-oauth'; +import { callbacks } from '../../../callbacks'; Meteor.startup(function() { return ServiceConfiguration.configurations.find({ @@ -88,6 +89,8 @@ Template.loginServices.events({ } else { toastr.error(error.message); } + } else { + callbacks.run('onUserLogin'); } }); }, diff --git a/app/ui-master/client/main.js b/app/ui-master/client/main.js index fd855764af5f0..2cf6317b5e331 100644 --- a/app/ui-master/client/main.js +++ b/app/ui-master/client/main.js @@ -11,7 +11,7 @@ import { CachedChatSubscription, Roles, Users } from '../../models'; import { CachedCollectionManager } from '../../ui-cached-collection'; import { tooltip } from '../../ui/client/components/tooltip'; import { callbacks } from '../../callbacks/client'; -import { isSyncReady } from '../../../client/lib/userData'; +// import { isSyncReady } from '../../../client/lib/userData'; import { fireGlobalEvent } from '../../ui-utils/client'; import './main.html'; @@ -41,9 +41,12 @@ Template.main.helpers({ return iframeEnabled && iframeLogin.reactiveIframeUrl.get(); }, subsReady: () => { + const userReady = Meteor.user(); const subscriptionsReady = CachedChatSubscription.ready.get(); + const settingsReady = settings.cachedCollection.ready.get(); - const ready = !Meteor.userId() || (isSyncReady.get() && subscriptionsReady && settingsReady); + + const ready = (userReady && subscriptionsReady && settingsReady) || !Meteor.userId(); CachedCollectionManager.syncEnabled = ready; mainReady.set(ready); diff --git a/app/ui-master/server/index.js b/app/ui-master/server/index.js index ec4dc5fd17004..766a7c8a6dae2 100644 --- a/app/ui-master/server/index.js +++ b/app/ui-master/server/index.js @@ -49,7 +49,14 @@ Meteor.startup(() => { } }); - settings.get('theme-color-sidebar-background', (key, value) => { + // settings.get('theme-color-sidebar-background', (key, value) => { + // const escapedValue = escapeHTML(value); + // injectIntoHead(key, `` + // + ``); + // }); + + // WIDE CHAT + settings.get('theme-color-rc-color-primary', (key, value) => { const escapedValue = escapeHTML(value); injectIntoHead(key, `` + ``); diff --git a/app/ui-message/client/index.js b/app/ui-message/client/index.js index ab52f2f87da5c..ae5ee7ade836d 100644 --- a/app/ui-message/client/index.js +++ b/app/ui-message/client/index.js @@ -6,3 +6,5 @@ import './popup/messagePopupChannel'; import './popup/messagePopupConfig'; import './popup/messagePopupEmoji'; import './popup/messagePopupSlashCommandPreview'; +import './pushMessage'; +import './pushMessageAction'; diff --git a/app/ui-message/client/message.html b/app/ui-message/client/message.html index 5e3d05079d970..9bb8a6aec82bb 100644 --- a/app/ui-message/client/message.html +++ b/app/ui-message/client/message.html @@ -126,6 +126,14 @@ {{> reactAttachments attachments=msg.attachments file=msg.file }} {{/if}} + {{#if isPushMessage}} + {{> pushMessage msg=msg}} + {{/if}} + + {{#if msg.drid}} + {{> DiscussionMetric count=msg.dcount drid=msg.drid lm=msg.dlm openDiscussion=actions.openDiscussion }} + {{/if}} + {{#with readReceipt}}
diff --git a/app/ui-message/client/message.js b/app/ui-message/client/message.js index ecf101d212105..7196358286996 100644 --- a/app/ui-message/client/message.js +++ b/app/ui-message/client/message.js @@ -446,6 +446,10 @@ Template.message.helpers({ const { msg: { threadMsg } } = this; return threadMsg; }, + isPushMessage() { + const { msg } = this; + return msg.pushm; + }, showStar() { const { msg } = this; return msg.starred && msg.starred.length > 0 && msg.starred.find((star) => star._id === Meteor.userId()) && !(msg.actionContext === 'starred' || this.context === 'starred'); diff --git a/app/ui-message/client/messageBox/messageBox.js b/app/ui-message/client/messageBox/messageBox.js index 7fcf12f371a90..7bf08b4ac3060 100644 --- a/app/ui-message/client/messageBox/messageBox.js +++ b/app/ui-message/client/messageBox/messageBox.js @@ -82,6 +82,7 @@ Template.messageBox.onCreated(function() { autogrow.update(); }; + // let isSending = false; this.send = (event) => { const { input } = this; diff --git a/app/ui-message/client/messageBox/messageBoxActions.js b/app/ui-message/client/messageBox/messageBoxActions.js index e75406b062d78..f43ced7cb6881 100644 --- a/app/ui-message/client/messageBox/messageBoxActions.js +++ b/app/ui-message/client/messageBox/messageBoxActions.js @@ -26,7 +26,7 @@ messageBox.actions.add('Create_new', 'Video_message', { messageBox.actions.add('Add_files_from', 'Computer', { id: 'file-upload', - icon: 'computer', + icon: 'clip', condition: () => settings.get('FileUpload_Enabled'), action({ rid, tmid, event, messageBox }) { event.preventDefault(); diff --git a/app/ui-message/client/popup/messagePopup.js b/app/ui-message/client/popup/messagePopup.js index 3b9ed5eac4c98..6a064ad1077bd 100644 --- a/app/ui-message/client/popup/messagePopup.js +++ b/app/ui-message/client/popup/messagePopup.js @@ -6,6 +6,7 @@ import { Meteor } from 'meteor/meteor'; import { ReactiveVar } from 'meteor/reactive-var'; import { Template } from 'meteor/templating'; +import { isMobile } from '../../../utils/client'; import './messagePopup.html'; const keys = { @@ -18,6 +19,10 @@ const keys = { ARROW_DOWN: 40, }; +let touchMoved = false; +let lastTouchX = null; +let lastTouchY = null; + function getCursorPosition(input) { if (input == null) { return; @@ -78,6 +83,9 @@ Template.messagePopup.onCreated(function() { return _id; }); template.up = () => { + if (isMobile()) { + return; + } const current = template.find('.popup-item.selected'); const previous = $(current).prev('.popup-item')[0] || template.find('.popup-item:last-child'); if (previous != null) { @@ -88,6 +96,9 @@ Template.messagePopup.onCreated(function() { } }; template.down = () => { + if (isMobile()) { + return; + } const current = template.find('.popup-item.selected'); const next = $(current).next('.popup-item')[0] || template.find('.popup-item'); if (next && next.classList.contains('popup-item')) { @@ -98,7 +109,7 @@ Template.messagePopup.onCreated(function() { } }; template.verifySelection = () => { - if (!template.open.curValue) { + if (!template.open.curValue || isMobile()) { return; } const current = template.find('.popup-item.selected'); @@ -235,7 +246,7 @@ Template.messagePopup.onRendered(function() { if (this.data.getInput != null) { this.input = typeof this.data.getInput === 'function' && this.data.getInput(); } else if (this.data.input) { - this.input = this.parentTemplate().find(this.data.input); + this.input = this.parentTemplate(this.data.parent).find(this.data.input); } if (this.input == null) { console.error('Input not found for popup'); @@ -271,7 +282,7 @@ Template.messagePopup.onDestroyed(function() { Template.messagePopup.events({ 'mouseenter .popup-item'(e) { - if (e.currentTarget.className.indexOf('selected') > -1) { + if (e.currentTarget.className.indexOf('selected') > -1 || isMobile()) { return; } const template = Template.instance(); @@ -282,12 +293,43 @@ Template.messagePopup.events({ e.currentTarget.className += ' selected sidebar-item__popup-active'; return template.value.set(this._id); }, - 'mousedown .popup-item, touchstart .popup-item'() { + 'mousedown .popup-item'() { const template = Template.instance(); template.clickingItem = true; }, - 'mouseup .popup-item, touchend .popup-item'(e) { - e.stopPropagation(); + 'touchstart .popup-item'(e) { + const { touches } = e.originalEvent; + if (touches && touches.length) { + lastTouchX = touches[0].pageX; + lastTouchY = touches[0].pageY; + } + touchMoved = false; + }, + 'touchmove .popup-item'(e) { + const { touches } = e.originalEvent; + if (touches && touches.length) { + const deltaX = Math.abs(lastTouchX - touches[0].pageX); + const deltaY = Math.abs(lastTouchY - touches[0].pageY); + if (deltaX > 5 || deltaY > 5) { + touchMoved = true; + } + } + }, + 'touchend .popup-item'(e) { + const template = Template.instance(); + if (!touchMoved) { + template.value.set(this._id); + template.enterValue(); + template.open.set(false); + e.preventDefault(); + e.stopPropagation(); + } + }, + 'mouseup .popup-item'(e) { + // To prevent refreshing of page in Mobile client. + if (isMobile()) { + return; + } const template = Template.instance(); const wasMenuIconClicked = e.target.classList.contains('sidebar-item__menu-icon'); template.clickingItem = false; diff --git a/app/ui-message/client/pushMessage.html b/app/ui-message/client/pushMessage.html new file mode 100644 index 0000000000000..b4de733672a4f --- /dev/null +++ b/app/ui-message/client/pushMessage.html @@ -0,0 +1,46 @@ + diff --git a/app/ui-message/client/pushMessage.js b/app/ui-message/client/pushMessage.js new file mode 100644 index 0000000000000..3d8b6c39665a1 --- /dev/null +++ b/app/ui-message/client/pushMessage.js @@ -0,0 +1,46 @@ +import { Meteor } from 'meteor/meteor'; +import { Template } from 'meteor/templating'; + +import { timeAgo } from '../../lib/client/lib/formatDate'; +import './pushMessage.html'; + +Template.pushMessage.helpers({ + data() { + const { _id, pushm_post_processed, pushm_scope, pushm_origin, msg, post_processed_message } = this.msg; + + if (!pushm_post_processed) { + navigator.serviceWorker.ready.then((serviceWorkerRegistration) => { + console.log('Pushing message to service worker for post processing'); + const promise = serviceWorkerRegistration.monitorNotification(pushm_origin); + serviceWorkerRegistration.pushManager.dispatchMessage(pushm_scope, msg); + promise.then((post_processed_message) => { + const newMsg = {}; + console.log(post_processed_message); + newMsg.title = post_processed_message.title; + newMsg.body = post_processed_message.body; + newMsg.icon = post_processed_message.icon; + newMsg.actions = post_processed_message.actions; + newMsg.timestamp = post_processed_message.timestamp; + Meteor.call('savePostProcessedMessage', _id, newMsg); + }); + }); + } else { + return post_processed_message; + } + }, + timeAgo(date) { + return timeAgo(date); + }, +}); + +Template.pushMessage.events({ + 'click .button-collapse': (e) => { + $(e.delegateTarget).find('.button-down').removeClass('button-collapse').addClass('button-expand'); + $(e.delegateTarget).find('.push-message-body').removeClass('body-collapsed'); + }, + + 'click .button-expand': (e) => { + $(e.delegateTarget).find('.button-down').removeClass('button-expand').addClass('button-collapse'); + $(e.delegateTarget).find('.push-message-body').addClass('body-collapsed'); + }, +}); diff --git a/app/ui-message/client/pushMessageAction.html b/app/ui-message/client/pushMessageAction.html new file mode 100644 index 0000000000000..ad4a49a5fec19 --- /dev/null +++ b/app/ui-message/client/pushMessageAction.html @@ -0,0 +1,17 @@ + diff --git a/app/ui-message/client/pushMessageAction.js b/app/ui-message/client/pushMessageAction.js new file mode 100644 index 0000000000000..13b90d17a7a7c --- /dev/null +++ b/app/ui-message/client/pushMessageAction.js @@ -0,0 +1,14 @@ +import { Template } from 'meteor/templating'; + +import './pushMessageAction.html'; + +Template.pushMessage.helpers({ +}); + +Template.pushMessage.events({ + 'click .push-message-action-button'(event) { + alert(this.action); + event.stopPropagation(); + event.preventDefault(); + }, +}); diff --git a/app/ui-share/README.md b/app/ui-share/README.md new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/app/ui-share/client/index.js b/app/ui-share/client/index.js new file mode 100644 index 0000000000000..c9281cc5bff87 --- /dev/null +++ b/app/ui-share/client/index.js @@ -0,0 +1,2 @@ +import './share.html'; +import './share'; diff --git a/app/ui-share/client/share.html b/app/ui-share/client/share.html new file mode 100644 index 0000000000000..482e7fc12c8f9 --- /dev/null +++ b/app/ui-share/client/share.html @@ -0,0 +1,45 @@ + diff --git a/app/ui-share/client/share.js b/app/ui-share/client/share.js new file mode 100644 index 0000000000000..9a0b358277e20 --- /dev/null +++ b/app/ui-share/client/share.js @@ -0,0 +1,71 @@ +import { Template } from 'meteor/templating'; + +import { getShareData } from '../../utils'; + +function getShareString() { + const data = getShareData(); + return `${ data.title } \n${ data.url } \n${ data.text }`; +} + +function fallbackCopyTextToClipboard(text) { + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; // avoid scrolling to bottom + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + try { + document.execCommand('copy'); + } catch (err) { + console.error('Unable to copy', err); + } + + document.body.removeChild(textArea); +} + +Template.share.helpers({ + +}); + +Template.share.events({ + 'click [data-type="copy"]'() { + if (!navigator.clipboard) { + fallbackCopyTextToClipboard(getShareString()); + return; + } + navigator.clipboard.writeText(getShareString()); + }, + 'click [data-type="print"]'() { + self.print(); + }, + 'click [data-type="email"]'() { + const { title } = getShareData(); + window.open(`mailto:?subject=${ title }&body=${ getShareString() }`); + }, + 'click [data-type="sms"]'() { + location.href = `sms:?&body=${ getShareString() }`; + }, + + + 'click [data-type="facebook"]'() { + const { url } = getShareData(); + window.open(`https://www.facebook.com/sharer/sharer.php?u=${ encodeURIComponent(url) }`); + }, + 'click [data-type="whatsapp"]'() { + window.open(`https://api.whatsapp.com/send?text=${ encodeURIComponent(getShareString()) }`); + }, + 'click [data-type="twitter"]'() { + const { url } = getShareData(); + window.open(`http://twitter.com/share?text=${ getShareString() }&url=${ url }`); + }, + 'click [data-type="linkedin"]'() { + const { title, url } = getShareData(); + window.open(`https://www.linkedin.com/shareArticle?mini=true&url=${ url }&title=${ title }&summary=${ getShareString() }&source=LinkedIn`); + }, + 'click [data-type="telegram"]'() { + const { url } = getShareData(); + window.open(`https://telegram.me/share/msg?url=${ url }&text=${ getShareString() }`); + }, + +}); diff --git a/app/ui-share/index.js b/app/ui-share/index.js new file mode 100644 index 0000000000000..40a7340d38877 --- /dev/null +++ b/app/ui-share/index.js @@ -0,0 +1 @@ +export * from './client/index'; diff --git a/app/ui-sidenav/client/roomList.js b/app/ui-sidenav/client/roomList.js index eaa21ad59d231..2823c2500906f 100644 --- a/app/ui-sidenav/client/roomList.js +++ b/app/ui-sidenav/client/roomList.js @@ -325,7 +325,7 @@ const mergeRoomSub = (room) => { Subscriptions.update({ rid: room._id, - lm: { $lt: room.lm }, + lm: { $lt: (room.lastMessage && room.lastMessage.ts) || room.lm }, }, { $set: { lm: room.lm, diff --git a/app/ui-sidenav/client/sideNav.js b/app/ui-sidenav/client/sideNav.js index 50b75cc8adb8c..fddca9968693f 100644 --- a/app/ui-sidenav/client/sideNav.js +++ b/app/ui-sidenav/client/sideNav.js @@ -6,7 +6,7 @@ import { Template } from 'meteor/templating'; import { SideNav, menu } from '../../ui-utils'; import { settings } from '../../settings'; -import { roomTypes, getUserPreference } from '../../utils'; +import { roomTypes, getUserPreference, isMobile } from '../../utils'; import { Users } from '../../models'; Template.sideNav.helpers({ @@ -35,8 +35,11 @@ Template.sideNav.helpers({ }, sidebarViewMode() { + if (isMobile()) { + return 'extended'; + } const viewMode = getUserPreference(Meteor.userId(), 'sidebarViewMode'); - return viewMode || 'condensed'; + return viewMode || 'extended'; }, sidebarHideAvatar() { @@ -100,10 +103,25 @@ const redirectToDefaultChannelIfNeeded = () => { }); }; +const openMainContentIfNeeded = () => { + const currentRouteState = FlowRouter.current(); + const defaults = ['/', '/home', '/account']; + + if (defaults.includes(currentRouteState.path)) { + menu.open(); + } else { + menu.close(); + } +}; + Template.sideNav.onRendered(function() { SideNav.init(); menu.init(); redirectToDefaultChannelIfNeeded(); + Tracker.autorun(function() { + FlowRouter.watchPathChange(); + openMainContentIfNeeded(); + }); return Meteor.defer(() => menu.updateUnreadBars()); }); diff --git a/app/ui-utils/client/index.js b/app/ui-utils/client/index.js index ff68ad0c9549f..30102dee7ea4c 100644 --- a/app/ui-utils/client/index.js +++ b/app/ui-utils/client/index.js @@ -6,6 +6,7 @@ export { call } from './lib/callMethod'; export { erase, hide, leave } from './lib/ChannelActions'; export { MessageAction } from './lib/MessageAction'; export { messageBox } from './lib/messageBox'; +export { offlineAction } from './lib/offlineAction'; export { popover } from './lib/popover'; export { readMessage } from './lib/readMessages'; export { RoomManager } from './lib/RoomManager'; diff --git a/app/ui-utils/client/lib/RoomHistoryManager.js b/app/ui-utils/client/lib/RoomHistoryManager.js index 5e6a169b6d8a1..f5bdd066edde6 100644 --- a/app/ui-utils/client/lib/RoomHistoryManager.js +++ b/app/ui-utils/client/lib/RoomHistoryManager.js @@ -12,8 +12,8 @@ import { RoomManager } from './RoomManager'; import { readMessage } from './readMessages'; import { renderMessageBody } from '../../../../client/lib/renderMessageBody'; import { getConfig } from '../config'; -import { ChatMessage, ChatSubscription, ChatRoom } from '../../../models'; import { call } from './callMethod'; +import { ChatMessage, ChatSubscription, ChatRoom } from '../../../models'; import { filterMarkdown } from '../../../markdown/lib/markdown'; import { getUserPreference } from '../../../utils/client'; @@ -320,7 +320,6 @@ export const RoomHistoryManager = new class extends Emitter { const subscription = ChatSubscription.findOne({ rid: message.rid }); if (subscription) { - // const { ls } = subscription; typeName = subscription.t + subscription.name; } else { const curRoomDoc = ChatRoom.findOne({ _id: message.rid }); @@ -391,7 +390,20 @@ export const RoomHistoryManager = new class extends Emitter { } clear(rid) { - ChatMessage.remove({ rid }); + const query = { rid }; + const options = { + sort: { + ts: -1, + }, + limit: 50, + }; + const retain = ChatMessage.find(query, options).fetch(); + ChatMessage.remove({ + rid, + ts: { + $lt: retain[retain.length - 1].ts, + }, + }); if (this.histories[rid]) { this.histories[rid].hasMore.set(true); this.histories[rid].isLoading.set(false); diff --git a/app/ui-utils/client/lib/RoomManager.js b/app/ui-utils/client/lib/RoomManager.js index ed01f1766fd5c..e0e41475ffed2 100644 --- a/app/ui-utils/client/lib/RoomManager.js +++ b/app/ui-utils/client/lib/RoomManager.js @@ -350,7 +350,7 @@ Meteor.startup(() => { Tracker.autorun(function() { if (Meteor.userId()) { return Notifications.onUser('message', function(msg) { - msg.u = msg.u || { username: 'rocket.cat' }; + msg.u = msg.u || { username: 'viasat' }; msg.private = true; return ChatMessage.upsert({ _id: msg._id }, msg); diff --git a/app/ui-utils/client/lib/SideNav.js b/app/ui-utils/client/lib/SideNav.js index fe0b5bab0c102..bc524bcae8c1d 100644 --- a/app/ui-utils/client/lib/SideNav.js +++ b/app/ui-utils/client/lib/SideNav.js @@ -5,6 +5,7 @@ import { AccountBox } from './AccountBox'; import { roomTypes } from '../../../utils/client/lib/roomTypes'; import { Subscriptions } from '../../../models'; import { RoomManager } from '../../../../client/lib/RoomManager'; +import { isMobile } from '../../../utils/client/lib/isMobile'; export const SideNav = new class { constructor() { @@ -50,7 +51,11 @@ export const SideNav = new class { if (!routesNamesForRooms.includes(FlowRouter.current().route.name)) { const subscription = Subscriptions.findOne({ rid: RoomManager.lastRid }); if (subscription) { - roomTypes.openRouteLink(subscription.t, subscription, FlowRouter.current().queryParams); + if (isMobile()) { + FlowRouter.go('home'); + } else { + roomTypes.openRouteLink(subscription.t, subscription, FlowRouter.current().queryParams); + } } else { FlowRouter.go('home'); } diff --git a/app/ui-utils/client/lib/menu.js b/app/ui-utils/client/lib/menu.js index 918c9f19e2d5b..35031d1fb42bf 100644 --- a/app/ui-utils/client/lib/menu.js +++ b/app/ui-utils/client/lib/menu.js @@ -1,3 +1,4 @@ +import { FlowRouter } from 'meteor/kadira:flow-router'; import { Session } from 'meteor/session'; import { Meteor } from 'meteor/meteor'; import _ from 'underscore'; @@ -6,7 +7,6 @@ import { Emitter } from '@rocket.chat/emitter'; import { isRtl } from '../../../utils'; const sideNavW = 280; -const map = (x, in_min, in_max, out_min, out_max) => (x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min; export const menu = new class extends Emitter { constructor() { @@ -104,23 +104,20 @@ export const menu = new class extends Emitter { this.diff = 0; } } - // if (map((this.diff / sideNavW), 0, 1, -.1, .8) > 0) { this.sidebar.css('box-shadow', '0 0 15px 1px rgba(0,0,0,.3)'); - // this.sidebarWrap.css('z-index', '9998'); this.translate(this.diff); - // } } } - translate(diff, width = sideNavW) { + translate(diff) { if (diff === undefined) { diff = this.isRtl ? -1 * sideNavW : sideNavW; } - this.sidebarWrap.css('width', '100%'); + this.wrapper.css('overflow', 'hidden'); - this.sidebarWrap.css('background-color', '#000'); - this.sidebarWrap.css('opacity', map(Math.abs(diff) / width, 0, 1, -0.1, 0.8).toFixed(2)); - this.isRtl ? this.sidebar.css('transform', `translate3d(${ (sideNavW + diff).toFixed(3) }px, 0 , 0)`) : this.sidebar.css('transform', `translate3d(${ (diff - sideNavW).toFixed(3) }px, 0 , 0)`); + + // WIDECHAT translate main content + this.isRtl ? this.mainContent.css('transform', `translate3d(${ diff.toFixed(3) }px, 0 , 0)`) : this.mainContent.css('transform', `translate3d(${ diff.toFixed(3) }px, 0 , 0)`); } touchend() { @@ -154,6 +151,8 @@ export const menu = new class extends Emitter { this.sidebar = this.menu; this.sidebarWrap = $('.sidebar-wrap'); this.wrapper = $('.messages-box > .wrapper'); + this.mainContent = $('.main-content'); + const ignore = (fn) => (event) => document.body.clientWidth <= 780 && fn(event); document.body.addEventListener('touchstart', ignore((e) => this.touchstart(e))); @@ -163,21 +162,21 @@ export const menu = new class extends Emitter { e.target === this.sidebarWrap[0] && this.isOpen() && this.emit('clickOut', e); })); this.on('close', () => { - this.sidebarWrap.css('width', ''); - // this.sidebarWrap.css('z-index', ''); - this.sidebarWrap.css('background-color', ''); - this.sidebar.css('transform', ''); - this.sidebar.css('box-shadow', ''); - this.sidebar.css('transition', ''); - this.sidebarWrap.css('transition', ''); - this.wrapper && this.wrapper.css('overflow', ''); + // WIDECHAT open main content + this.mainContent.css('transform', 'translate3d( 0, 0 , 0)'); }); this.on('open', ignore(() => { - this.sidebar.css('box-shadow', '0 0 15px 1px rgba(0,0,0,.3)'); - // this.sidebarWrap.css('z-index', '9998'); - this.translate(); + // WIDECHAT close main content + this.mainContent.css('transform', 'translate3d( 100%, 0 , 0)'); + if (!FlowRouter.current().path.startsWith('/admin') + && !FlowRouter.current().path.startsWith('/account') + && !FlowRouter.current().path.startsWith('/omnichannel')) { + FlowRouter.withReplaceState(function() { + FlowRouter.go('/home'); + }); + Session.set('openSearchPage', false); + } })); - this.mainContent = $('.main-content'); this.list = $('.rooms-list'); this._open = false; diff --git a/app/ui-utils/client/lib/offlineAction.js b/app/ui-utils/client/lib/offlineAction.js new file mode 100644 index 0000000000000..a217050deaac3 --- /dev/null +++ b/app/ui-utils/client/lib/offlineAction.js @@ -0,0 +1,11 @@ +import { Meteor } from 'meteor/meteor'; +import toastr from 'toastr'; + +import { t } from '../../../utils'; + +export const offlineAction = (action) => { + if (Meteor.status().status === 'connected') { + return false; + } + return toastr.info(t('Check_your_internet_connection', { action })); +}; diff --git a/app/ui-utils/client/lib/openRoom.js b/app/ui-utils/client/lib/openRoom.js index 30a8e7f20cb60..7a82d97b70abd 100644 --- a/app/ui-utils/client/lib/openRoom.js +++ b/app/ui-utils/client/lib/openRoom.js @@ -63,7 +63,6 @@ export const openRoom = async function(type, name, render = true) { render && appLayout.render('main', { center: 'room' }); - c.stop(); if (window.currentTracker) { diff --git a/app/ui-utils/client/lib/popover.js b/app/ui-utils/client/lib/popover.js index 014212e10a7fa..5721d61aca965 100644 --- a/app/ui-utils/client/lib/popover.js +++ b/app/ui-utils/client/lib/popover.js @@ -1,8 +1,10 @@ import './popover.html'; import { Blaze } from 'meteor/blaze'; +import { FlowRouter } from 'meteor/kadira:flow-router'; import { Template } from 'meteor/templating'; import _ from 'underscore'; +import { share, isShareAvailable } from '../../../utils'; import { messageBox } from './messageBox'; import { MessageAction } from './MessageAction'; import { isRtl } from '../../../utils/client'; @@ -28,6 +30,7 @@ export const popover = { if (activeElement) { $(activeElement).removeClass('active'); } + this.renderedPopover = null; }, }; @@ -138,6 +141,17 @@ Template.popover.onRendered(function() { this.firstNode.style.visibility = 'visible'; }); +// WIDE CHAT +Template.popover.onCreated(function() { + this.route = FlowRouter.current().path; + this.autorun(() => { + FlowRouter.watchPathChange(); + if (FlowRouter.current().path !== this.route) { + popover.close(); + } + }); +}); + Template.popover.onDestroyed(function() { if (this.data.onDestroyed) { this.data.onDestroyed(); @@ -148,7 +162,7 @@ Template.popover.onDestroyed(function() { Template.popover.events({ 'click .js-action'(e, instance) { !this.action || this.action.call(this, e, instance.data.data); - popover.close(); + !this.hasPopup && popover.close(); }, 'click .js-close'() { popover.close(); @@ -177,6 +191,23 @@ Template.popover.events({ return false; } }, + 'click [data-type="share-action"]'(e) { + if (isShareAvailable()) { + share(); + } else { + popover.close(); + const options = []; + const config = { + template: 'share', + currentTarget: e.target, + data: { + options, + }, + offsetVertical: e.target.clientHeight + 10, + }; + popover.open(config); + } + }, }); Template.popover.helpers({ diff --git a/app/ui/client/components/header/header.html b/app/ui/client/components/header/header.html index 14fb89cd3939f..31350fce6786b 100644 --- a/app/ui/client/components/header/header.html +++ b/app/ui/client/components/header/header.html @@ -8,7 +8,7 @@ {{#if rawSectionName}} {{rawSectionName}} {{else}} - {{_ sectionName}} + {{_ sectionName}} {{/if}} {{#if Template.contentBlock}} diff --git a/app/ui/client/index.js b/app/ui/client/index.js index e32c0ec70a783..93d84c46e7b36 100644 --- a/app/ui/client/index.js +++ b/app/ui/client/index.js @@ -50,6 +50,7 @@ export { fileUpload } from './lib/fileUpload'; export { MsgTyping } from './lib/msgTyping'; export { KonchatNotification } from './lib/notification'; export { Login, Button } from './lib/rocket'; +export { sendOfflineFileMessage } from './lib/sendOfflineFileMessage'; export { AudioRecorder } from './lib/recorderjs/audioRecorder'; export { VideoRecorder } from './lib/recorderjs/videoRecorder'; export { chatMessages } from './views/app/room'; diff --git a/app/ui/client/lib/fileUpload.js b/app/ui/client/lib/fileUpload.js index b8ad23505353a..0ffd21794ee06 100644 --- a/app/ui/client/lib/fileUpload.js +++ b/app/ui/client/lib/fileUpload.js @@ -1,13 +1,14 @@ import { Tracker } from 'meteor/tracker'; +import { Random } from 'meteor/random'; import { Session } from 'meteor/session'; import s from 'underscore.string'; import { Handlebars } from 'meteor/ui'; -import { Random } from 'meteor/random'; import { settings } from '../../../settings/client'; -import { t, fileUploadIsValidContentType, APIClient } from '../../../utils'; +import { ChatMessage } from '../../../models/client'; +import { t, fileUploadIsValidContentType, SWCache, APIClient } from '../../../utils'; import { modal, prependReplies } from '../../../ui-utils'; - +import { sendOfflineFileMessage } from './sendOfflineFileMessage'; const readAsDataURL = (file, callback) => { const reader = new FileReader(); @@ -214,7 +215,6 @@ export const fileUpload = async (files, input, { rid, tmid }) => { const replies = $(input).data('reply') || []; const mention = $(input).data('mention-user') || false; - let msg = ''; if (!mention || !threadsEnabled) { @@ -225,6 +225,9 @@ export const fileUpload = async (files, input, { rid, tmid }) => { tmid = replies[0]._id; } + const msgData = { id: Random.id(), msg, tmid }; + let offlineFile = null; + const uploadNextFile = () => { const file = files.pop(); if (!file) { @@ -266,16 +269,74 @@ export const fileUpload = async (files, input, { rid, tmid }) => { return; } + const record = { + name: document.getElementById('file-name').value || file.name || file.file.name, + size: file.file.size, + type: file.file.type, + rid, + description: document.getElementById('file-description').value, + }; + const fileName = document.getElementById('file-name').value || file.name || file.file.name; - uploadFileWithMessage(rid, tmid, { - description: document.getElementById('file-description').value || undefined, - fileName, - msg: msg || undefined, - file, + const data = new FormData(); + record.description && data.append('description', record.description); + data.append('id', msgData.id); + msg && data.append('msg', msg); + tmid && data.append('tmid', tmid); + data.append('file', file.file, fileName); + + const upload = { + id: Random.id(), + name: fileName, + percentage: 0, + }; + file.file._id = upload.id; + uploadNextFile(); + + sendOfflineFileMessage(rid, msgData, file.file, record, (file) => { + offlineFile = file; }); - uploadNextFile(); + const { xhr, promise } = APIClient.upload(`v1/rooms.upload/${ rid }`, {}, data, { + progress(progress) { + if (progress === 100) { + return; + } + + const uploads = upload; + uploads.percentage = Math.round(progress) || 0; + ChatMessage.setProgress(msgData.id, uploads); + }, + error(error) { + const uploads = upload; + uploads.error = (error.xhr && error.xhr.responseJSON && error.xhr.responseJSON.error) || error.message; + uploads.percentage = 0; + ChatMessage.setProgress(msg._id, uploads); + }, + }); + + Tracker.autorun((computation) => { + const isCanceling = Session.get(`uploading-cancel-${ upload.id }`); + if (!isCanceling) { + return; + } + computation.stop(); + Session.delete(`uploading-cancel-${ upload.id }`); + + xhr.abort(); + }); + + try { + const res = await promise; + + if (typeof res === 'object' && res.success && offlineFile) { SWCache.removeFromCache(offlineFile); } + } catch (error) { + const uploads = upload; + uploads.error = (error.xhr && error.xhr.responseJSON && error.xhr.responseJSON.error) || error.message; + uploads.percentage = 0; + ChatMessage.setProgress(msgData.id, uploads); + } })); }; diff --git a/app/ui/client/lib/iframeCommands.js b/app/ui/client/lib/iframeCommands.js index 5ad9003470b26..e9e2a64c513a0 100644 --- a/app/ui/client/lib/iframeCommands.js +++ b/app/ui/client/lib/iframeCommands.js @@ -48,6 +48,7 @@ const commands = { const customLoginWith = Meteor[`loginWith${ s.capitalize(customOauth.service, true) }`]; const customRedirectUri = data.redirectUrl || siteUrl; customLoginWith.call(Meteor, { redirectUrl: customRedirectUri }, customOAuthCallback); + callbacks.run('onUserLogin'); } } }, diff --git a/app/ui/client/lib/notification.js b/app/ui/client/lib/notification.js index ee8b8918d9001..51f3708359837 100644 --- a/app/ui/client/lib/notification.js +++ b/app/ui/client/lib/notification.js @@ -168,5 +168,11 @@ Meteor.startup(() => { } else { CustomSounds.pause(newRoomNotification); } + + if ('serviceWorker' in navigator) { + navigator.serviceWorker.onmessage = (event) => { + KonchatNotification.showDesktop(event.data); + }; + } }); }); diff --git a/app/ui/client/lib/sendOfflineFileMessage.js b/app/ui/client/lib/sendOfflineFileMessage.js new file mode 100644 index 0000000000000..7aba4fb8b65ed --- /dev/null +++ b/app/ui/client/lib/sendOfflineFileMessage.js @@ -0,0 +1,104 @@ +import { Meteor } from 'meteor/meteor'; +import { Random } from 'meteor/random'; +import toastr from 'toastr'; + +import { ChatMessage } from '../../../models'; +import { settings } from '../../../settings'; +import { callbacks } from '../../../callbacks'; +import { promises } from '../../../promises/client'; +import { t, SWCache } from '../../../utils/client'; + +const getUrl = ({ _id, name }) => `/file-upload/${ _id }/${ name }`; + +const getOfflineMessage = (roomId, msgData, file, meta) => { + const id = file._id || Random.id(); + const name = file.name || meta.name; + const type = file.type || meta.type; + const fileUrl = getUrl({ _id: id, name }); + const size = file.size || meta.size; + const attachment = { + title: name, + type: 'file', + temp: true, + description: file.description || meta.description, + title_link: fileUrl, + title_link_download: true, + }; + + if (/^image\/.+/.test(type)) { + attachment.image_url = fileUrl; + attachment.image_type = type; + attachment.image_size = size; + if (file.identify && file.identify.size) { + attachment.image_dimensions = file.identify.size; + } + } else if (/^audio\/.+/.test(file.type)) { + attachment.audio_url = fileUrl; + attachment.audio_type = type; + attachment.audio_size = size; + } else if (/^video\/.+/.test(file.type)) { + attachment.video_url = fileUrl; + attachment.video_type = type; + attachment.video_size = size; + } + + return Object.assign({ + _id: msgData.id, + rid: roomId, + ts: new Date(), + msg: '', + file: { + _id: id, + name, + type, + }, + uploads: { + id, + name, + percentage: 0, + }, + meta, + groupable: false, + attachments: [attachment], + }, msgData); +}; + +export const sendOfflineFileMessage = (roomId, msgData, file, meta, callback) => { + if (!Meteor.userId()) { + return false; + } + let message = getOfflineMessage(roomId, msgData, file, meta); + const messageAlreadyExists = message._id && ChatMessage.findOne({ _id: message._id }); + + if (messageAlreadyExists) { + return toastr.error(t('Message_Already_Sent')); + } + + const user = Meteor.user(); + message.ts = new Date(); + message.u = { + _id: Meteor.userId(), + username: user.username, + }; + + if (settings.get('UI_Use_Real_Name')) { + message.u.name = user.name; + } + + message.temp = true; + message.tempActions = { send: true }; + if (settings.get('Message_Read_Receipt_Enabled')) { + message.unread = true; + } + + SWCache.uploadToCache(message, file, (error) => { + if (error) { return; } + + callback(message.file); + message = callbacks.run('beforeSaveMessage', message); + promises.run('onClientMessageReceived', message).then(function(message) { + ChatMessage.insert(message); + return callbacks.run('afterSaveMessage', message); + }); + }); +}; diff --git a/app/ui/client/views/app/burger.html b/app/ui/client/views/app/burger.html index 825dcd4f18c25..f05021230d850 100644 --- a/app/ui/client/views/app/burger.html +++ b/app/ui/client/views/app/burger.html @@ -1,5 +1,5 @@ + \ No newline at end of file diff --git a/app/ui/client/views/app/room.js b/app/ui/client/views/app/room.js index 7914043a6c5fc..9d3bc3ca3b57b 100644 --- a/app/ui/client/views/app/room.js +++ b/app/ui/client/views/app/room.js @@ -48,13 +48,13 @@ export const openProfileTab = (e, tabBar, username) => { tabBar.openUserInfo(username); }; -const wipeFailedUploads = () => { - const uploads = Session.get('uploading'); +// const wipeFailedUploads = () => { +// const uploads = Session.get('uploading'); - if (uploads) { - Session.set('uploading', uploads.filter((upload) => !upload.error)); - } -}; +// if (uploads) { +// Session.set('uploading', uploads.filter((upload) => !upload.error)); +// } +// }; function roomHasGlobalPurge(room) { if (!settings.get('RetentionPolicy_Enabled')) { @@ -157,7 +157,8 @@ function addToInput(text) { $(input).change().trigger('input'); } -callbacks.add('enter-room', wipeFailedUploads); +// WIDE CHAT +// callbacks.add('enter-room', wipeFailedUploads); export const dropzoneHelpers = { dragAndDrop() { @@ -186,6 +187,7 @@ Template.roomOld.helpers({ }, subscribed() { const { state } = Template.instance(); + console.log(state); return state.get('subscribed'); }, messagesHistory() { @@ -215,7 +217,6 @@ Template.roomOld.helpers({ ts: 1, }, }; - return ChatMessage.find(query, options); }, @@ -235,10 +236,6 @@ Template.roomOld.helpers({ return `chat-window-${ this._id }`; }, - uploading() { - return Session.get('uploading'); - }, - roomLeader() { const roles = RoomRoles.findOne({ rid: this._id, roles: 'leader', 'u._id': { $ne: Meteor.userId() } }); if (roles) { diff --git a/app/utils/client/index.js b/app/utils/client/index.js index 6df40269d05e2..becb742e64b16 100644 --- a/app/utils/client/index.js +++ b/app/utils/client/index.js @@ -18,4 +18,9 @@ export { placeholders } from '../lib/placeholders'; export { templateVarHandler } from '../lib/templateVarHandler'; export { APIClient, mountArrayQueryParameters } from './lib/RestApiClient'; export { canDeleteMessage } from './lib/canDeleteMessage'; +export { SWCache } from './lib/swCache'; +export { cleanMessagesAtStartup, triggerOfflineMsgs } from './lib/offlineMessages'; export { secondsToHHMMSS } from '../lib/timeConverter'; +export { isMobile } from './lib/isMobile'; +export { hex_sha1 } from './lib/sha1'; +export { share, isShareAvailable, getShareData } from './lib/share'; diff --git a/app/utils/client/lib/isMobile.js b/app/utils/client/lib/isMobile.js new file mode 100644 index 0000000000000..94bb8cb74b00c --- /dev/null +++ b/app/utils/client/lib/isMobile.js @@ -0,0 +1,6 @@ +export const isMobile = () => { + if (window.matchMedia('(max-width: 500px)').matches) { + return true; + } + return false; +}; diff --git a/app/utils/client/lib/offlineMessages.js b/app/utils/client/lib/offlineMessages.js new file mode 100644 index 0000000000000..675f8718fa74b --- /dev/null +++ b/app/utils/client/lib/offlineMessages.js @@ -0,0 +1,157 @@ +import { Tracker } from 'meteor/tracker'; +import { Session } from 'meteor/session'; +import { sortBy } from 'underscore'; +import localforage from 'localforage'; + +import { call } from '../../../ui-utils/client'; +import { getConfig } from '../../../ui-utils/client/config'; +import { ChatMessage, CachedChatMessage } from '../../../models/client'; +import { SWCache, APIClient } from '..'; + +const action = { + clean: (msg) => { + const { temp, tempActions, ...originalMsg } = msg; + return originalMsg; + }, + + send: (msg) => { + msg.ts = new Date(); + if (msg.file && msg.meta) { + action.sendFile(msg); + return; + } + + call('sendMessage', msg, true); + }, + + sendFile: async (msg) => { + const file = await SWCache.getFileFromCache(msg.file); + const upload = { + id: msg.file._id, + name: msg.file.name, + percentage: 0, + }; + + if (!file) { return; } + + const data = new FormData(); + msg.meta.description && data.append('description', msg.meta.description); + data.append('id', msg._id); + msg.msg && data.append('msg', msg.msg); + msg.tmid && data.append('tmid', msg.tmid); + data.append('file', file, msg.file.name); + const { xhr, promise } = APIClient.upload(`v1/rooms.upload/${ msg.rid }`, {}, data, { + progress(progress) { + if (progress === 100) { + return; + } + const uploads = upload; + uploads.percentage = Math.round(progress) || 0; + ChatMessage.setProgress(msg._id, uploads); + }, + error(error) { + const uploads = upload; + uploads.error = (error.xhr && error.xhr.responseJSON && error.xhr.responseJSON.error) || error.message; + uploads.percentage = 0; + ChatMessage.setProgress(msg._id, uploads); + }, + }); + + Tracker.autorun((computation) => { + const isCanceling = Session.get(`uploading-cancel-${ upload.id }`); + + if (!isCanceling) { + return; + } + computation.stop(); + Session.delete(`uploading-cancel-${ upload.id }`); + + xhr.abort(); + }); + + try { + const res = await promise; + + if (typeof res === 'object' && res.success) { SWCache.removeFromCache(msg.file); } + } catch (error) { + const uploads = upload; + uploads.error = (error.xhr && error.xhr.responseJSON && error.xhr.responseJSON.error) || error.message; + uploads.percentage = 0; + ChatMessage.setProgress(msg._id, uploads); + } + }, + + update: (msg) => { + msg.editedAt = new Date(); + call('updateMessage', msg, true); + }, + + react: ({ _id }, reaction) => { + call('setReaction', reaction, _id, undefined, true); + }, + + delete: ({ _id }) => call('deleteMessage', { _id }, true), +}; + +function trigger(msg) { + const tempActions = msg.tempActions || {}; + msg = action.clean(msg); + + if (tempActions.send) { + action.send(msg); + return; + } + + if (tempActions.delete) { + action.delete(msg); + return; + } + + if (tempActions.update) { + action.update(msg); + } + + if (tempActions.react && tempActions.reactions) { + tempActions.reactions.forEach((reaction) => { + action.react(msg, reaction); + }); + } +} + +export const triggerOfflineMsgs = () => { + localforage.getItem('chatMessage').then((value) => { + if (value && value.records) { + const tempMsgs = value.records.filter((msg) => msg.temp); + tempMsgs.forEach((msg) => trigger(msg)); + } + }); +}; + +const retainMessages = (rid, messages) => { + const roomMsgs = messages.filter((msg) => rid === msg.rid); + const limit = parseInt(getConfig('roomListLimit')) || 50; + const retain = sortBy(roomMsgs.filter((msg) => !msg.temp), 'ts').reverse().slice(0, limit); + retain.push(...roomMsgs.filter((msg) => msg.temp)); + return retain; +}; + +function clearOldMessages({ records: messages, ...value }) { + const rids = [...new Set(messages.map((msg) => msg.rid))]; + const retain = []; + rids.forEach((rid) => { + retain.push(...retainMessages(rid, messages)); + }); + value.records = retain; + value.updatedAt = new Date(); + localforage.setItem('chatMessage', value).then(() => { + CachedChatMessage.loadFromCache(); + }); +} + +export const cleanMessagesAtStartup = () => { + localforage.getItem('chatMessage').then((value) => { + if (value && value.records) { + clearOldMessages(value); + } + }); +}; diff --git a/app/utils/client/lib/sha1.js b/app/utils/client/lib/sha1.js new file mode 100644 index 0000000000000..8c86f9c81949f --- /dev/null +++ b/app/utils/client/lib/sha1.js @@ -0,0 +1,330 @@ +/* + * A JavaScript implementation of the Secure Hash Algorithm, SHA-1, as defined + * in FIPS 180-1 + * Version 2.2 Copyright Paul Johnston 2000 - 2009. + * Other contributors: Greg Holt, Andrew Kepert, Ydnar, Lostinet + * Distributed under the BSD License + * See http://pajhome.org.uk/crypt/md5 for details. + */ + +/* + * Configurable variables. You may need to tweak these to be compatible with + * the server-side, but the defaults work in most cases. + */ +var hexcase = 0; /* hex output format. 0 - lowercase; 1 - uppercase */ +var b64pad = ""; /* base-64 pad character. "=" for strict RFC compliance */ + +/* + * These are the functions you'll usually want to call + * They take string arguments and return either hex or base-64 encoded strings + */ +export const hex_sha1 = function(s) { return rstr2hex(rstr_sha1(str2rstr_utf8(s))); } +function b64_sha1(s) { return rstr2b64(rstr_sha1(str2rstr_utf8(s))); } +function any_sha1(s, e) { return rstr2any(rstr_sha1(str2rstr_utf8(s)), e); } +function hex_hmac_sha1(k, d) + { return rstr2hex(rstr_hmac_sha1(str2rstr_utf8(k), str2rstr_utf8(d))); } +function b64_hmac_sha1(k, d) + { return rstr2b64(rstr_hmac_sha1(str2rstr_utf8(k), str2rstr_utf8(d))); } +function any_hmac_sha1(k, d, e) + { return rstr2any(rstr_hmac_sha1(str2rstr_utf8(k), str2rstr_utf8(d)), e); } + +/* + * Perform a simple self-test to see if the VM is working + */ +function sha1_vm_test() +{ + return hex_sha1("abc").toLowerCase() == "a9993e364706816aba3e25717850c26c9cd0d89d"; +} + +/* + * Calculate the SHA1 of a raw string + */ +function rstr_sha1(s) +{ + return binb2rstr(binb_sha1(rstr2binb(s), s.length * 8)); +} + +/* + * Calculate the HMAC-SHA1 of a key and some data (raw strings) + */ +function rstr_hmac_sha1(key, data) +{ + var bkey = rstr2binb(key); + if(bkey.length > 16) bkey = binb_sha1(bkey, key.length * 8); + + var ipad = Array(16), opad = Array(16); + for(var i = 0; i < 16; i++) + { + ipad[i] = bkey[i] ^ 0x36363636; + opad[i] = bkey[i] ^ 0x5C5C5C5C; + } + + var hash = binb_sha1(ipad.concat(rstr2binb(data)), 512 + data.length * 8); + return binb2rstr(binb_sha1(opad.concat(hash), 512 + 160)); +} + +/* + * Convert a raw string to a hex string + */ +function rstr2hex(input) +{ + try { hexcase } catch(e) { hexcase=0; } + var hex_tab = hexcase ? "0123456789ABCDEF" : "0123456789abcdef"; + var output = ""; + var x; + for(var i = 0; i < input.length; i++) + { + x = input.charCodeAt(i); + output += hex_tab.charAt((x >>> 4) & 0x0F) + + hex_tab.charAt( x & 0x0F); + } + return output; +} + +/* + * Convert a raw string to a base-64 string + */ +function rstr2b64(input) +{ + try { b64pad } catch(e) { b64pad=''; } + var tab = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + var output = ""; + var len = input.length; + for(var i = 0; i < len; i += 3) + { + var triplet = (input.charCodeAt(i) << 16) + | (i + 1 < len ? input.charCodeAt(i+1) << 8 : 0) + | (i + 2 < len ? input.charCodeAt(i+2) : 0); + for(var j = 0; j < 4; j++) + { + if(i * 8 + j * 6 > input.length * 8) output += b64pad; + else output += tab.charAt((triplet >>> 6*(3-j)) & 0x3F); + } + } + return output; +} + +/* + * Convert a raw string to an arbitrary string encoding + */ +function rstr2any(input, encoding) +{ + var divisor = encoding.length; + var remainders = Array(); + var i, q, x, quotient; + + /* Convert to an array of 16-bit big-endian values, forming the dividend */ + var dividend = Array(Math.ceil(input.length / 2)); + for(i = 0; i < dividend.length; i++) + { + dividend[i] = (input.charCodeAt(i * 2) << 8) | input.charCodeAt(i * 2 + 1); + } + + /* + * Repeatedly perform a long division. The binary array forms the dividend, + * the length of the encoding is the divisor. Once computed, the quotient + * forms the dividend for the next step. We stop when the dividend is zero. + * All remainders are stored for later use. + */ + while(dividend.length > 0) + { + quotient = Array(); + x = 0; + for(i = 0; i < dividend.length; i++) + { + x = (x << 16) + dividend[i]; + q = Math.floor(x / divisor); + x -= q * divisor; + if(quotient.length > 0 || q > 0) + quotient[quotient.length] = q; + } + remainders[remainders.length] = x; + dividend = quotient; + } + + /* Convert the remainders to the output string */ + var output = ""; + for(i = remainders.length - 1; i >= 0; i--) + output += encoding.charAt(remainders[i]); + + /* Append leading zero equivalents */ + var full_length = Math.ceil(input.length * 8 / + (Math.log(encoding.length) / Math.log(2))) + for(i = output.length; i < full_length; i++) + output = encoding[0] + output; + + return output; +} + +/* + * Encode a string as utf-8. + * For efficiency, this assumes the input is valid utf-16. + */ +function str2rstr_utf8(input) +{ + var output = ""; + var i = -1; + var x, y; + + while(++i < input.length) + { + /* Decode utf-16 surrogate pairs */ + x = input.charCodeAt(i); + y = i + 1 < input.length ? input.charCodeAt(i + 1) : 0; + if(0xD800 <= x && x <= 0xDBFF && 0xDC00 <= y && y <= 0xDFFF) + { + x = 0x10000 + ((x & 0x03FF) << 10) + (y & 0x03FF); + i++; + } + + /* Encode output as utf-8 */ + if(x <= 0x7F) + output += String.fromCharCode(x); + else if(x <= 0x7FF) + output += String.fromCharCode(0xC0 | ((x >>> 6 ) & 0x1F), + 0x80 | ( x & 0x3F)); + else if(x <= 0xFFFF) + output += String.fromCharCode(0xE0 | ((x >>> 12) & 0x0F), + 0x80 | ((x >>> 6 ) & 0x3F), + 0x80 | ( x & 0x3F)); + else if(x <= 0x1FFFFF) + output += String.fromCharCode(0xF0 | ((x >>> 18) & 0x07), + 0x80 | ((x >>> 12) & 0x3F), + 0x80 | ((x >>> 6 ) & 0x3F), + 0x80 | ( x & 0x3F)); + } + return output; +} + +/* + * Encode a string as utf-16 + */ +function str2rstr_utf16le(input) +{ + var output = ""; + for(var i = 0; i < input.length; i++) + output += String.fromCharCode( input.charCodeAt(i) & 0xFF, + (input.charCodeAt(i) >>> 8) & 0xFF); + return output; +} + +function str2rstr_utf16be(input) +{ + var output = ""; + for(var i = 0; i < input.length; i++) + output += String.fromCharCode((input.charCodeAt(i) >>> 8) & 0xFF, + input.charCodeAt(i) & 0xFF); + return output; +} + +/* + * Convert a raw string to an array of big-endian words + * Characters >255 have their high-byte silently ignored. + */ +function rstr2binb(input) +{ + var output = Array(input.length >> 2); + for(var i = 0; i < output.length; i++) + output[i] = 0; + for(var i = 0; i < input.length * 8; i += 8) + output[i>>5] |= (input.charCodeAt(i / 8) & 0xFF) << (24 - i % 32); + return output; +} + +/* + * Convert an array of big-endian words to a string + */ +function binb2rstr(input) +{ + var output = ""; + for(var i = 0; i < input.length * 32; i += 8) + output += String.fromCharCode((input[i>>5] >>> (24 - i % 32)) & 0xFF); + return output; +} + +/* + * Calculate the SHA-1 of an array of big-endian words, and a bit length + */ +function binb_sha1(x, len) +{ + /* append padding */ + x[len >> 5] |= 0x80 << (24 - len % 32); + x[((len + 64 >> 9) << 4) + 15] = len; + + var w = Array(80); + var a = 1732584193; + var b = -271733879; + var c = -1732584194; + var d = 271733878; + var e = -1009589776; + + for(var i = 0; i < x.length; i += 16) + { + var olda = a; + var oldb = b; + var oldc = c; + var oldd = d; + var olde = e; + + for(var j = 0; j < 80; j++) + { + if(j < 16) w[j] = x[i + j]; + else w[j] = bit_rol(w[j-3] ^ w[j-8] ^ w[j-14] ^ w[j-16], 1); + var t = safe_add(safe_add(bit_rol(a, 5), sha1_ft(j, b, c, d)), + safe_add(safe_add(e, w[j]), sha1_kt(j))); + e = d; + d = c; + c = bit_rol(b, 30); + b = a; + a = t; + } + + a = safe_add(a, olda); + b = safe_add(b, oldb); + c = safe_add(c, oldc); + d = safe_add(d, oldd); + e = safe_add(e, olde); + } + return Array(a, b, c, d, e); + +} + +/* + * Perform the appropriate triplet combination function for the current + * iteration + */ +function sha1_ft(t, b, c, d) +{ + if(t < 20) return (b & c) | ((~b) & d); + if(t < 40) return b ^ c ^ d; + if(t < 60) return (b & c) | (b & d) | (c & d); + return b ^ c ^ d; +} + +/* + * Determine the appropriate additive constant for the current iteration + */ +function sha1_kt(t) +{ + return (t < 20) ? 1518500249 : (t < 40) ? 1859775393 : + (t < 60) ? -1894007588 : -899497514; +} + +/* + * Add integers, wrapping at 2^32. This uses 16-bit operations internally + * to work around bugs in some JS interpreters. + */ +function safe_add(x, y) +{ + var lsw = (x & 0xFFFF) + (y & 0xFFFF); + var msw = (x >> 16) + (y >> 16) + (lsw >> 16); + return (msw << 16) | (lsw & 0xFFFF); +} + +/* + * Bitwise rotate a 32-bit number to the left. + */ +function bit_rol(num, cnt) +{ + return (num << cnt) | (num >>> (32 - cnt)); +} diff --git a/app/utils/client/lib/share.js b/app/utils/client/lib/share.js new file mode 100644 index 0000000000000..b8059290a67c6 --- /dev/null +++ b/app/utils/client/lib/share.js @@ -0,0 +1,55 @@ +import { Meteor } from 'meteor/meteor'; + +// TODO: Remove logs + +export const isShareAvailable = () => { + if (navigator.share) { return true; } + return false; +}; + +export const getShareData = () => { + const data = {}; + + data.url = document.location.href || 'https://viasatconnect.com'; + const path = new URL(data.url).pathname; + const roomName = path.substring(path.lastIndexOf('/') + 1); + + data.title = 'Viasat Connect'; + data.text = 'Viasat Connect is a new application that makes it easy for you to chat with friends and family. Open this link to connect.'; + + if (path.startsWith('/channel')) { + data.title = `Join #${ roomName } on Viasat Connect`; + data.text = `You are invited to channel #${ roomName } on Viasat Connect. ${ data.text }`; + } else if (path.startsWith('/group')) { + data.title = `Join #${ roomName } on Viasat Connect`; + data.text = `You are invited to private group 🔒${ roomName } on Viasat Connect. ${ data.text }`; + } else if (path.startsWith('/direct')) { + data.title = `Chat with @${ roomName } on Viasat Connect`; + } else { + const user = Meteor.user(); + + data.title = 'Viasat Connect'; + data.text = 'Viasat Connect is a new application that makes it easy for me to chat with friends and family. Open this link and connect with me.'; + data.url = new URL(document.location.href).origin; + + if (data.url && user) { + data.url = `${ data.url }/direct/${ user.username }`; + } + } + + return data; +}; + +export const share = () => { + const data = getShareData(); + + console.log(`data: ${ JSON.stringify(data) }`); + + if (navigator.share) { + navigator.share(data) + .then(() => console.log('Successfully shared')) + .catch((error) => console.log('Error while sharing', error)); + } else { + console.log('Share feature not available'); + } +}; diff --git a/app/utils/client/lib/swCache.js b/app/utils/client/lib/swCache.js new file mode 100644 index 0000000000000..93912dde9ad7e --- /dev/null +++ b/app/utils/client/lib/swCache.js @@ -0,0 +1,34 @@ +const version = 'viasat-0.1'; +const getFileUrl = ({ _id, name }) => `/file-upload/${ _id }/${ name }`; + +export const SWCache = { + uploadToCache: (message, file, callback) => { + caches.open(version).then((cache) => { + file._id = file._id || message.file._id; + file.name = file.name || message.file.name; + const res = new Response(file, { + status: 200, + statusText: 'No connection to the server', + headers: new Headers({ 'Content-Type': file.type }), + }); + cache.put(getFileUrl(file), res).then(() => { + callback(); + }); + }).catch((err) => { + callback(err); + }); + }, + + removeFromCache: (file) => { + caches.open(version).then((cache) => { + cache.delete(getFileUrl(file)); + }).catch((err) => { + console.log(err); + }); + }, + + getFileFromCache: ({ _id, name, type }) => fetch(getFileUrl({ _id, name })) + .then((r) => r.blob()) + .then((blobFile) => new File([blobFile], name, { type })), + +}; diff --git a/app/utils/lib/placeholders.js b/app/utils/lib/placeholders.js index ffb93e7312aeb..46165960ec2fa 100644 --- a/app/utils/lib/placeholders.js +++ b/app/utils/lib/placeholders.js @@ -1,6 +1,8 @@ +import { Meteor } from 'meteor/meteor'; import s from 'underscore.string'; import { settings } from '../../settings'; +import { getAvatarURL } from './getAvatarURL'; export const placeholders = { replace: (str, data) => { @@ -11,6 +13,19 @@ export const placeholders = { str = str.replace(/\[Site_Name\]/g, settings.get('Site_Name') || ''); str = str.replace(/\[Site_URL\]/g, settings.get('Site_Url') || ''); + if (str.includes('[Invite_Link]')) { + const invite_link = Meteor.runAsUser(Meteor.userId(), () => Meteor.call('getInviteLink')); + str = str.replace(/\[Invite_Link\]/g, invite_link); + } + + if (str.includes('[Username]')) { + str = str.replace(/\[Username\]/g, Meteor.user().username); + } + + if (str.includes('[Avatar_Link]')) { + str = str.replace(/\[Avatar_Link\]/g, `${ settings.get('Site_Url').slice(0, -1) }${ getAvatarURL(Meteor.user().username) }`); + } + if (data) { str = str.replace(/\[name\]/g, data.name || ''); str = str.replace(/\[fname\]/g, s.strLeft(data.name, ' ') || ''); diff --git a/client/components/AutoCompleteDepartment.js b/client/components/AutoCompleteDepartment.js index c7a4b83f7dc34..e4ba6fa768edb 100644 --- a/client/components/AutoCompleteDepartment.js +++ b/client/components/AutoCompleteDepartment.js @@ -44,7 +44,7 @@ const AutoCompleteDepartment = (props) => { }); const department = sortedByName.find( - (dep) => dep._id === (typeof value === 'string' ? value : value.value), + (dep) => dep._id === (typeof value === 'string' ? value : value?.value), )?.value; return ( diff --git a/client/components/SortList/SortList.js b/client/components/SortList/SortList.js index 3aaca9bd88eef..39d5d12059859 100644 --- a/client/components/SortList/SortList.js +++ b/client/components/SortList/SortList.js @@ -1,6 +1,7 @@ import { Divider } from '@rocket.chat/fuselage'; import React from 'react'; +import { isMobile } from '../../../app/utils/client'; import GroupingList from './GroupingList'; import SortModeList from './SortModeList'; import ViewModeList from './ViewModeList'; @@ -9,8 +10,8 @@ function SortList() { return ( <>
- - + {!isMobile() && } + {!isMobile() && } diff --git a/client/head.html b/client/head.html index e92d2c10606a6..78941e3d982c0 100644 --- a/client/head.html +++ b/client/head.html @@ -13,7 +13,11 @@ - + + + + + diff --git a/client/hooks/useSession.js b/client/hooks/useSession.js new file mode 100644 index 0000000000000..731d222651053 --- /dev/null +++ b/client/hooks/useSession.js @@ -0,0 +1,5 @@ +import { Session } from 'meteor/session'; + +import { useReactiveValue } from './useReactiveValue'; + +export const useSession = (variableName) => useReactiveValue(() => Session.get(variableName)); diff --git a/client/hooks/useUserId.js b/client/hooks/useUserId.js new file mode 100644 index 0000000000000..600efa45f5117 --- /dev/null +++ b/client/hooks/useUserId.js @@ -0,0 +1,5 @@ +import { Meteor } from 'meteor/meteor'; + +import { useReactiveValue } from './useReactiveValue'; + +export const useUserId = () => useReactiveValue(() => Meteor.userId()); diff --git a/client/hooks/useUserPreference.js b/client/hooks/useUserPreference.js new file mode 100644 index 0000000000000..b3ef7412ed452 --- /dev/null +++ b/client/hooks/useUserPreference.js @@ -0,0 +1,8 @@ +import { getUserPreference } from '../../app/utils/client'; +import { useReactiveValue } from './useReactiveValue'; +import { useUserId } from './useUserId'; + +export const useUserPreference = (key, defaultValue = undefined) => { + const userId = useUserId(); + return useReactiveValue(() => getUserPreference(userId, key, defaultValue), [userId]); +}; diff --git a/client/importPackages.ts b/client/importPackages.ts index a6c7c487b9874..3c560634be9aa 100644 --- a/client/importPackages.ts +++ b/client/importPackages.ts @@ -30,7 +30,7 @@ import '../app/lib/client'; import '../app/livestream/client'; import '../app/logger/client'; import '../app/token-login/client'; -import '../app/markdown/client'; +// import '../app/markdown/client'; import '../app/mentions-flextab/client'; import '../app/message-attachments/client'; import '../app/message-mark-as-unread/client'; @@ -63,6 +63,7 @@ import '../app/ui-login/client'; import '../app/ui-master/client'; import '../app/ui-message/client'; import '../app/ui-sidenav/client'; +import '../app/ui-share/client'; import '../app/ui-vrecord/client'; import '../app/videobridge/client'; import '../app/webdav/client'; diff --git a/client/importServiceWorker.js b/client/importServiceWorker.js new file mode 100644 index 0000000000000..7009191e0fdf9 --- /dev/null +++ b/client/importServiceWorker.js @@ -0,0 +1,110 @@ +import { Meteor } from 'meteor/meteor'; +import { Tracker } from 'meteor/tracker'; + +import { settings } from '../app/settings/client'; +import { modal } from '../app/ui-utils/client'; +import { handleError, t } from '../app/utils/client'; + +function urlBase64ToUint8Array(base64String) { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + + const rawData = atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} + +function isMobile() { + const toMatch = [ + /Android/i, + /webOS/i, + /iPhone/i, + /iPad/i, + /iPod/i, + /BlackBerry/i, + /Windows Phone/i, + ]; + + return toMatch.some((toMatchItem) => navigator.userAgent.match(toMatchItem)); +} + +function subscribeUser() { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.ready.then(async (reg) => { + try { + const vapidKey = await settings.get('Vapid_public_key'); + const subscription = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapidKey), + }); + + const platform = isMobile() ? 'mobile' : 'desktop'; + Meteor.call('savePushNotificationSubscription', JSON.stringify(subscription), platform); + } catch (e) { + handleError(e); + } + }); + } +} + +Meteor.startup(() => { + Tracker.autorun((computation) => { + const settingsReady = settings.cachedCollection.ready.get(); + if ('serviceWorker' in navigator) { + navigator.serviceWorker + .register('sw.js', { + scope: './', + }) + .then((reg) => { + if (reg.installing) { + const sw = reg.installing || reg.waiting; + sw.onstatechange = function () { + if (sw.state === 'installed') { + // SW installed. Reload page. + window.location.reload(); + } + }; + console.log(`Service worker has been registered for scope: ${reg.scope}`); + } else { + if (settings.get('Push_enable') !== true) { + return; + } + + reg.pushManager.getSubscription().then((sub) => { + if (sub === null) { + console.log('Not subscribed to push service!'); + if (settingsReady) { + modal.open( + { + title: t('Important'), + type: 'info', + text: t('Please subscribe to push notifications to continue'), + showCancelButton: true, + confirmButtonText: t('Subscribe'), + cancelButtonText: t('Cancel'), + closeOnConfirm: true, + }, + () => { + Notification.requestPermission().then((permission) => { + if (permission === 'granted') { + subscribeUser(); + } + }); + }, + ); + computation.stop(); + } + } else { + console.log('Subscribed to push service'); + computation.stop(); + } + }); + } + }); + } + }); +}); diff --git a/client/main.ts b/client/main.ts index e08f775af7dee..a22900124f2c7 100644 --- a/client/main.ts +++ b/client/main.ts @@ -1,6 +1,8 @@ import '../ee/client/ecdh'; import './polyfills'; +import './importServiceWorker'; + import './lib/meteorCallWrapper'; import './importPackages'; diff --git a/client/methods/deleteMessage.js b/client/methods/deleteMessage.js index dad84129af205..6c1b0554bcc8e 100644 --- a/client/methods/deleteMessage.js +++ b/client/methods/deleteMessage.js @@ -1,11 +1,12 @@ import { Meteor } from 'meteor/meteor'; +import { Session } from 'meteor/session'; import { ChatMessage } from '../../app/models/client'; -import { canDeleteMessage } from '../../app/utils/client'; +import { canDeleteMessage, SWCache } from '../../app/utils/client'; Meteor.methods({ - deleteMessage(msg) { - if (!Meteor.userId()) { + deleteMessage(msg, offlineTriggered = false) { + if (!Meteor.userId() || offlineTriggered) { return false; } @@ -13,6 +14,7 @@ Meteor.methods({ const message = ChatMessage.findOne({ _id: msg._id }); if ( + !message || !canDeleteMessage({ rid: message.rid, ts: message.ts, @@ -22,9 +24,25 @@ Meteor.methods({ return false; } - ChatMessage.remove({ - '_id': message._id, - 'u._id': Meteor.userId(), - }); + if (message.temp && message.tempActions.send) { + ChatMessage.remove({ + '_id': message._id, + 'u._id': Meteor.userId(), + }); + if (message.file) { + SWCache.removeFromCache(message.file); + Session.set(`uploading-cancel-${message.file._id}`, true); + } + } else { + const messageObject = { temp: true, msg: 'Message deleted', tempActions: { delete: true } }; + + ChatMessage.update( + { + '_id': message._id, + 'u._id': Meteor.userId(), + }, + { $set: messageObject, $unset: { reactions: 1, file: 1, attachments: 1 } }, + ); + } }, }); diff --git a/client/methods/updateMessage.js b/client/methods/updateMessage.js index 70346b7295bf7..153e3ef1cd9e9 100644 --- a/client/methods/updateMessage.js +++ b/client/methods/updateMessage.js @@ -12,8 +12,8 @@ import { settings } from '../../app/settings/client'; import { t } from '../../app/utils/client'; Meteor.methods({ - updateMessage(message) { - if (!Meteor.userId()) { + updateMessage(message, offlineTriggered = false) { + if (!Meteor.userId() || offlineTriggered) { return false; } @@ -63,10 +63,19 @@ Meteor.methods({ }; message = callbacks.run('beforeSaveMessage', message); + + const tempActions = originalMessage.tempActions || {}; + + if (!tempActions.send) { + tempActions.update = true; + } + const messageObject = { editedAt: message.editedAt, editedBy: message.editedBy, msg: message.msg, + temp: true, + tempActions, }; if (originalMessage.attachments) { diff --git a/client/providers/UserProvider.tsx b/client/providers/UserProvider.tsx index 45a91b6adf97a..c37c97d338c35 100644 --- a/client/providers/UserProvider.tsx +++ b/client/providers/UserProvider.tsx @@ -26,6 +26,7 @@ const loginWithPassword = (user: string | object, password: string): Promise { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.ready.then((reg) => { + reg.pushManager.getSubscription().then((sub) => { + Meteor.call('removeUserFromPushSubscription', sub.endpoint); + }); + }); + } +}; + +const addUserIdOnLogin = async () => { + const user = await Meteor.user(); + if ('serviceWorker' in navigator) { + navigator.serviceWorker.ready.then((reg) => { + reg.pushManager.getSubscription().then((sub) => { + Meteor.call('addUserToPushSubscription', sub.endpoint, user); + }); + }); + } +}; + +callbacks.add( + 'afterLogoutCleanUp', + removeUserIdOnLogout, + callbacks.priority.MEDIUM, + 'remove-user-from-push-subscription', +); +callbacks.add( + 'onUserLogin', + addUserIdOnLogin, + callbacks.priority.MEDIUM, + 'add-user-to-push-subscription', +); diff --git a/client/startup/routes.ts b/client/startup/routes.ts index cd29aaeb8ab10..b13d41dae0a82 100644 --- a/client/startup/routes.ts +++ b/client/startup/routes.ts @@ -5,7 +5,7 @@ import { Tracker } from 'meteor/tracker'; import { lazy } from 'react'; import toastr from 'toastr'; -import { KonchatNotification } from '../../app/ui/client'; +// import { KonchatNotification } from '../../app/ui/client'; import { handleError } from '../../app/utils/client'; import { IUser } from '../../definition/IUser'; import { appLayout } from '../lib/appLayout'; @@ -54,7 +54,8 @@ FlowRouter.route('/home', { name: 'home', action(_params, queryParams) { - KonchatNotification.getDesktopPermission(); + // WIDECHAT + // KonchatNotification.getDesktopPermission(); if (queryParams?.saml_idp_credentialToken !== undefined) { const token = queryParams.saml_idp_credentialToken; FlowRouter.setQueryParams({ diff --git a/client/startup/startup.ts b/client/startup/startup.ts index 2cc59de05437e..515b9a15fa0fe 100644 --- a/client/startup/startup.ts +++ b/client/startup/startup.ts @@ -42,9 +42,6 @@ Meteor.startup(() => { if (!uid) { return; } - if (!Meteor.status().connected) { - return; - } const user = await synchronizeUserData(uid); diff --git a/client/views/account/AccountProfileForm.js b/client/views/account/AccountProfileForm.js index 86d49a775fa39..4b085b26dcf47 100644 --- a/client/views/account/AccountProfileForm.js +++ b/client/views/account/AccountProfileForm.js @@ -151,9 +151,8 @@ function AccountProfileForm({ values, handlers, user, settings, onSaveStateChang : t('Max_length_is', STATUS_TEXT_MAX_LENGTH), [statusText, t], ); - const { - emails: [{ verified = false } = { verified: false }], - } = user; + + const verified = (user.emails && user.emails.length && user.emails[0].verified) || false; const canSave = !![ !!passwordError, diff --git a/client/views/admin/apps/AppDetailsPageContent.tsx b/client/views/admin/apps/AppDetailsPageContent.tsx index 1fd8d884d26dc..efa3bd2b89a2d 100644 --- a/client/views/admin/apps/AppDetailsPageContent.tsx +++ b/client/views/admin/apps/AppDetailsPageContent.tsx @@ -4,6 +4,7 @@ import React, { FC } from 'react'; import ExternalLink from '../../../components/ExternalLink'; import AppAvatar from '../../../components/avatar/AppAvatar'; +import { useToastMessageDispatch } from '../../../contexts/ToastMessagesContext'; import { TranslationKey, useTranslation } from '../../../contexts/TranslationContext'; import AppMenu from './AppMenu'; import AppStatus from './AppStatus'; @@ -16,10 +17,11 @@ type AppDetailsPageContentProps = { const AppDetailsPageContent: FC = ({ data }) => { const t = useTranslation(); - + const dispatchToastMessage = useToastMessageDispatch(); const { iconFileData = '', name, + commitHash, author: { name: authorName, homepage, support }, description, categories = [], @@ -32,6 +34,11 @@ const AppDetailsPageContent: FC = ({ data }) => { bundledIn, } = data; + const copyHash = (): void => { + navigator.clipboard.writeText(commitHash); + dispatchToastMessage({ type: 'success', message: t('Commit_hash_copy_toast') }); + }; + return ( <> @@ -48,6 +55,16 @@ const AppDetailsPageContent: FC = ({ data }) => { {t('By_author', { author: authorName })} |{t('Version_version', { version })} + {commitHash && ( + + | {commitHash.substring(0, 7)} + + )} { return; } + const localApp = await Apps.getApp(appId); const app = apps.find((app) => app.id === appId) ?? { - ...(await Apps.getApp(appId)), + ...localApp, installed: true, marketplace: false, }; + app.commitHash = localApp.commitHash; const [bundledIn, settings, apis] = await Promise.all([ app.marketplace === false ? [] : getBundledIn(app.id, app.version), diff --git a/client/views/admin/apps/types.ts b/client/views/admin/apps/types.ts index 5f49372dbd39d..64dff15c155ca 100644 --- a/client/views/admin/apps/types.ts +++ b/client/views/admin/apps/types.ts @@ -4,6 +4,7 @@ export type App = { id: string; iconFileData: string; name: string; + commitHash: string; author: { name: string; homepage: string; diff --git a/client/views/admin/users/UserForm.js b/client/views/admin/users/UserForm.js index 8729d6de9b81e..634f3c4dd0511 100644 --- a/client/views/admin/users/UserForm.js +++ b/client/views/admin/users/UserForm.js @@ -199,7 +199,7 @@ export default function UserForm({ { > {t('Name')} , + {t('Id')}, { qa-user-id={_id} > {fname} + {_id} {department ? department.name : ''} {servedBy && servedBy.username} {moment(ts).format('L LTS')} diff --git a/client/views/omnichannel/directory/chats/contextualBar/DepartmentField.js b/client/views/omnichannel/directory/chats/contextualBar/DepartmentField.js index 28aaa7ccc84f9..bf3644f939ef9 100644 --- a/client/views/omnichannel/directory/chats/contextualBar/DepartmentField.js +++ b/client/views/omnichannel/directory/chats/contextualBar/DepartmentField.js @@ -14,11 +14,11 @@ const DepartmentField = ({ departmentId }) => { if (state === AsyncStatePhase.LOADING) { return ; } - const { department: { name } = {} } = data || { department: {} }; + const { department } = data || { department: {} }; return ( - {name || t('Department_not_found')} + {department?.name || t('Department_not_found')} ); }; diff --git a/client/views/omnichannel/filters/EditFilterPage.js b/client/views/omnichannel/filters/EditFilterPage.js new file mode 100644 index 0000000000000..fadf3c670298e --- /dev/null +++ b/client/views/omnichannel/filters/EditFilterPage.js @@ -0,0 +1,64 @@ +import { Margins, FieldGroup, Box, Button } from '@rocket.chat/fuselage'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import React from 'react'; + +import { useRoute } from '../../../contexts/RouterContext'; +import { useMethod } from '../../../contexts/ServerContext'; +import { useToastMessageDispatch } from '../../../contexts/ToastMessagesContext'; +import { useTranslation } from '../../../contexts/TranslationContext'; +import { useForm } from '../../../hooks/useForm'; +import FiltersForm from './FiltersForm'; + +const getInitialValues = ({ name, description, enabled, regex, slug }) => ({ + name: name ?? '', + description: description ?? '', + enabled: !!enabled, + regex: regex ?? '', + slug: slug ?? '', +}); + +const EditFilterPage = ({ data, onSave }) => { + const dispatchToastMessage = useToastMessageDispatch(); + const t = useTranslation(); + + const router = useRoute('omnichannel-filters'); + + const save = useMethod('livechat:saveFilter'); + + const { values, handlers, hasUnsavedChanges } = useForm(getInitialValues(data)); + + const handleSave = useMutableCallback(async () => { + try { + await save({ + _id: data._id, + ...values, + }); + dispatchToastMessage({ type: 'success', message: t('Saved') }); + onSave(); + router.push({}); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }); + + const { name } = values; + + const canSave = name && hasUnsavedChanges; + + return ( + <> + + + + + + + + + + ); +}; + +export default EditFilterPage; diff --git a/client/views/omnichannel/filters/EditFilterPageContainer.js b/client/views/omnichannel/filters/EditFilterPageContainer.js new file mode 100644 index 0000000000000..bd8e5a26d08bc --- /dev/null +++ b/client/views/omnichannel/filters/EditFilterPageContainer.js @@ -0,0 +1,25 @@ +import { Callout } from '@rocket.chat/fuselage'; +import React from 'react'; + +import PageSkeleton from '../../../components/PageSkeleton'; +import { useTranslation } from '../../../contexts/TranslationContext'; +import { AsyncStatePhase } from '../../../hooks/useAsyncState'; +import { useEndpointData } from '../../../hooks/useEndpointData'; +import EditFilterPage from './EditFilterPage'; + +const EditFilterPageContainer = ({ id, onSave }) => { + const t = useTranslation(); + const { value: data, phase: state } = useEndpointData(`livechat/filters/${id}`); + + if (state === AsyncStatePhase.LOADING) { + return ; + } + + if (state === AsyncStatePhase.REJECTED || !data?.filter) { + return {t('Error')}: error; + } + + return ; +}; + +export default EditFilterPageContainer; diff --git a/client/views/omnichannel/filters/FiltersForm.stories.js b/client/views/omnichannel/filters/FiltersForm.stories.js new file mode 100644 index 0000000000000..6bbb524e2f454 --- /dev/null +++ b/client/views/omnichannel/filters/FiltersForm.stories.js @@ -0,0 +1,27 @@ +import { FieldGroup, Box } from '@rocket.chat/fuselage'; +import React from 'react'; + +import { useForm } from '../../../hooks/useForm'; +import FiltersForm from './FiltersForm'; + +export default { + title: 'omnichannel/FiltersForm', + component: FiltersForm, +}; + +export const Default = () => { + const { values, handlers } = useForm({ + name: '', + description: '', + enabled: true, + regex: '', + slug: '', + }); + return ( + + + ; + + + ); +}; diff --git a/client/views/omnichannel/filters/FiltersForm.tsx b/client/views/omnichannel/filters/FiltersForm.tsx new file mode 100644 index 0000000000000..3abc7a7d0df64 --- /dev/null +++ b/client/views/omnichannel/filters/FiltersForm.tsx @@ -0,0 +1,72 @@ +import { Box, Field, TextInput, ToggleSwitch } from '@rocket.chat/fuselage'; +import React, { ComponentProps, FC, FormEvent } from 'react'; + +import { useTranslation } from '../../../contexts/TranslationContext'; + +type FiltersFormProps = { + values: { + name: string; + description: string; + enabled: boolean; + regex: string; + slug: string; + }; + handlers: { + handleName: (event: FormEvent) => void; + handleDescription: (event: FormEvent) => void; + handleEnabled: (event: FormEvent) => void; + handleRegex: (event: FormEvent) => void; + handleSlug: (event: FormEvent) => void; + }; + className?: ComponentProps['className']; +}; + +const FiltersForm: FC = ({ values, handlers, className }) => { + const t = useTranslation(); + const { name, description, enabled, regex, slug } = values; + + const { handleName, handleDescription, handleEnabled, handleRegex, handleSlug } = handlers; + + return ( + <> + + + {t('Enabled')} + + + + + + + {t('Name')} + + + + + + {t('Description')} + + + + + + {t('Regex')} + + + + + + {t('Slug')} + + + + + + ); +}; + +export default FiltersForm; diff --git a/client/views/omnichannel/filters/FiltersPage.js b/client/views/omnichannel/filters/FiltersPage.js new file mode 100644 index 0000000000000..0c47bb8666850 --- /dev/null +++ b/client/views/omnichannel/filters/FiltersPage.js @@ -0,0 +1,67 @@ +import { Button, Icon } from '@rocket.chat/fuselage'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import React, { useRef } from 'react'; + +import NotAuthorizedPage from '../../../components/NotAuthorizedPage'; +import Page from '../../../components/Page'; +import VerticalBar from '../../../components/VerticalBar'; +import { usePermission } from '../../../contexts/AuthorizationContext'; +import { useRoute, useRouteParameter } from '../../../contexts/RouterContext'; +import { useTranslation } from '../../../contexts/TranslationContext'; +import EditFilterPageContainer from './EditFilterPageContainer'; +import FiltersTableContainer from './FiltersTableContainer'; +import NewFilterPage from './NewFilterPage'; + +const MonitorsPage = () => { + const t = useTranslation(); + + const canViewTriggers = usePermission('view-livechat-triggers'); + + const router = useRoute('omnichannel-filters'); + + const reload = useRef(() => {}); + + const context = useRouteParameter('context'); + const id = useRouteParameter('id'); + + const handleAdd = useMutableCallback(() => { + router.push({ context: 'new' }); + }); + + const handleCloseVerticalBar = useMutableCallback(() => { + router.push({}); + }); + + if (!canViewTriggers) { + return ; + } + + return ( + + + + + + + + + + {context && ( + + + {t('Filter')} + + + + {context === 'edit' && } + {context === 'new' && } + + + )} + + ); +}; + +export default MonitorsPage; diff --git a/client/views/omnichannel/filters/FiltersRow.js b/client/views/omnichannel/filters/FiltersRow.js new file mode 100644 index 0000000000000..5b2d56dfcb73c --- /dev/null +++ b/client/views/omnichannel/filters/FiltersRow.js @@ -0,0 +1,83 @@ +import { Table, Icon, Button } from '@rocket.chat/fuselage'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import React, { memo } from 'react'; + +import GenericModal from '../../../components/GenericModal'; +import { useSetModal } from '../../../contexts/ModalContext'; +import { useRoute } from '../../../contexts/RouterContext'; +import { useMethod } from '../../../contexts/ServerContext'; +import { useToastMessageDispatch } from '../../../contexts/ToastMessagesContext'; +import { useTranslation } from '../../../contexts/TranslationContext'; + +const FiltersRow = memo(function FiltersRow(props) { + const { _id, name, description, enabled, onDelete } = props; + + const dispatchToastMessage = useToastMessageDispatch(); + const t = useTranslation(); + + const setModal = useSetModal(); + + const bhRoute = useRoute('omnichannel-filters'); + + const deleteFilter = useMethod('livechat:removeFilter'); + + const handleClick = useMutableCallback(() => { + bhRoute.push({ + context: 'edit', + id: _id, + }); + }); + + const handleKeyDown = useMutableCallback((e) => { + if (!['Enter', 'Space'].includes(e.nativeEvent.code)) { + return; + } + + handleClick(); + }); + + const handleDelete = useMutableCallback((e) => { + e.stopPropagation(); + const onDeleteFilter = async () => { + try { + await deleteFilter(_id); + dispatchToastMessage({ type: 'success', message: t('Filter_removed') }); + onDelete(); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + setModal(); + }; + + setModal( + setModal()} + confirmText={t('Delete')} + />, + ); + }); + + return ( + + {name} + {description} + {enabled ? t('Yes') : t('No')} + + + + + ); +}); + +export default FiltersRow; diff --git a/client/views/omnichannel/filters/FiltersTable.js b/client/views/omnichannel/filters/FiltersTable.js new file mode 100644 index 0000000000000..5d140d2052425 --- /dev/null +++ b/client/views/omnichannel/filters/FiltersTable.js @@ -0,0 +1,36 @@ +import React from 'react'; + +import GenericTable from '../../../components/GenericTable'; +import { useTranslation } from '../../../contexts/TranslationContext'; +import { useResizeInlineBreakpoint } from '../../../hooks/useResizeInlineBreakpoint'; +import FiltersRow from './FiltersRow'; + +export function FiltersTable({ filters, totalFilters, params, onChangeParams, onDelete }) { + const t = useTranslation(); + + const [ref, onMediumBreakpoint] = useResizeInlineBreakpoint([600], 200); + + return ( + + {t('Name')} + {t('Description')} + {t('Enabled')} + {t('Remove')} + + } + results={filters} + total={totalFilters} + params={params} + setParams={onChangeParams} + > + {(props) => ( + + )} + + ); +} + +export default FiltersTable; diff --git a/client/views/omnichannel/filters/FiltersTableContainer.js b/client/views/omnichannel/filters/FiltersTableContainer.js new file mode 100644 index 0000000000000..4b036dde976f4 --- /dev/null +++ b/client/views/omnichannel/filters/FiltersTableContainer.js @@ -0,0 +1,41 @@ +import { Callout } from '@rocket.chat/fuselage'; +import React, { useState, useMemo } from 'react'; + +import { useTranslation } from '../../../contexts/TranslationContext'; +import { AsyncStatePhase } from '../../../hooks/useAsyncState'; +import { useEndpointData } from '../../../hooks/useEndpointData'; +import FiltersTable from './FiltersTable'; + +const FiltersTableContainer = ({ reloadRef }) => { + const t = useTranslation(); + const [params, setParams] = useState(() => ({ current: 0, itemsPerPage: 25 })); + + const { current, itemsPerPage } = params; + + const { + value: data, + phase: state, + reload, + } = useEndpointData( + 'livechat/filters', + useMemo(() => ({ offset: current, count: itemsPerPage }), [current, itemsPerPage]), + ); + + reloadRef.current = reload; + + if (state === AsyncStatePhase.REJECTED) { + return {t('Error')}: error; + } + + return ( + + ); +}; + +export default FiltersTableContainer; diff --git a/client/views/omnichannel/filters/NewFilterPage.js b/client/views/omnichannel/filters/NewFilterPage.js new file mode 100644 index 0000000000000..b174ca015ebf2 --- /dev/null +++ b/client/views/omnichannel/filters/NewFilterPage.js @@ -0,0 +1,53 @@ +import { Button, FieldGroup, ButtonGroup } from '@rocket.chat/fuselage'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import React from 'react'; + +import { useRoute } from '../../../contexts/RouterContext'; +import { useMethod } from '../../../contexts/ServerContext'; +import { useToastMessageDispatch } from '../../../contexts/ToastMessagesContext'; +import { useTranslation } from '../../../contexts/TranslationContext'; +import { useForm } from '../../../hooks/useForm'; +import FiltersForm from './FiltersForm'; + +const NewFilterPage = ({ onSave }) => { + const dispatchToastMessage = useToastMessageDispatch(); + const t = useTranslation(); + + const router = useRoute('omnichannel-filters'); + + const save = useMethod('livechat:saveFilter'); + + const { values, handlers } = useForm({ + name: '', + description: '', + enabled: true, + regex: '', + slug: '', + }); + + const handleSave = useMutableCallback(async () => { + try { + await save(values); + dispatchToastMessage({ type: 'success', message: t('Saved') }); + onSave(); + router.push({}); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }); + + return ( + <> + + + + + + + + ); +}; + +export default NewFilterPage; diff --git a/client/views/omnichannel/installation/Installation.js b/client/views/omnichannel/installation/Installation.js index 186737caa76b2..72821f8ea4c47 100644 --- a/client/views/omnichannel/installation/Installation.js +++ b/client/views/omnichannel/installation/Installation.js @@ -16,7 +16,7 @@ const Installation = () => { const installString = `