diff --git a/.docker-mongo/Dockerfile b/.docker-mongo/Dockerfile index ea00916d2961..567b09c7730f 100644 --- a/.docker-mongo/Dockerfile +++ b/.docker-mongo/Dockerfile @@ -1,24 +1,40 @@ -FROM rocketchat/base:12.16.1 +FROM node:12.16.1-buster-slim LABEL maintainer="buildmaster@rocket.chat" +# Install MongoDB and dependencies RUN set -x \ - && apt-get update \ - && apt-get install -y wget \ - && wget -qO - https://www.mongodb.org/static/pgp/server-4.0.asc | apt-key add - \ - && echo "deb http://repo.mongodb.org/apt/debian stretch/mongodb-org/4.0 main" | tee /etc/apt/sources.list.d/mongodb-org-4.0.list \ - && apt-get update \ - && apt-get install -y pwgen mongodb-org \ - && apt-get clean my room + && apt-get update \ + && apt-get install -y wget gnupg dirmngr pwgen \ + && wget -qO - https://www.mongodb.org/static/pgp/server-4.2.asc | apt-key add - \ + && echo "deb http://repo.mongodb.org/apt/debian buster/mongodb-org/4.2 main" | tee /etc/apt/sources.list.d/mongodb-org-4.2.list \ + && apt-get update \ + && apt-get install -y mongodb-org fontconfig \ + && apt-get clean my room \ + && groupadd -g 65533 -r rocketchat \ + && useradd -u 65533 -r -g rocketchat rocketchat \ + && mkdir -p /app/uploads \ + && chown rocketchat:rocketchat /app/uploads ADD . /app ADD entrypoint.sh /app/bundle/ -RUN set -x \ - && cd /app/bundle/programs/server \ - && npm install \ - && npm cache clear --force \ - && chown -R rocketchat:rocketchat /app +RUN aptMark="$(apt-mark showmanual)" \ + && apt-get install -y --no-install-recommends g++ make python ca-certificates \ + && cd /app/bundle/programs/server \ + && npm install \ + && apt-mark auto '.*' > /dev/null \ + && apt-mark manual $aptMark > /dev/null \ + && find /usr/local -type f -executable -exec ldd '{}' ';' \ + | awk '/=>/ { print $(NF-1) }' \ + | sort -u \ + | xargs -r dpkg-query --search \ + | cut -d: -f1 \ + | sort -u \ + | xargs -r apt-mark manual \ + && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \ + && npm cache clear --force \ + && chown -R rocketchat:rocketchat /app VOLUME /app/uploads diff --git a/.docker-mongo/entrypoint.sh b/.docker-mongo/entrypoint.sh index 15d0c0f76e65..0568da462bf3 100644 --- a/.docker-mongo/entrypoint.sh +++ b/.docker-mongo/entrypoint.sh @@ -39,7 +39,7 @@ echo """ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝╚═╝ ╚═╝╚══════╝ ╚═╝╚═╝ ╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚══════╝ ╚═══╝ ╚═╝╚══════╝ ╚══╝╚══╝ """ -mongod --smallfiles --storageEngine=mmapv1 --fork --replSet rs0 --config /etc/mongod.conf +mongod --fork --replSet rs0 --config /etc/mongod.conf until mongo --eval "db" &> /dev/null; do echo "MongoDB still not ready, sleeping" diff --git a/.docker/Dockerfile b/.docker/Dockerfile index 350965acf7e7..4594afe41539 100644 --- a/.docker/Dockerfile +++ b/.docker/Dockerfile @@ -1,14 +1,33 @@ -FROM rocketchat/base:12.16.1 - -ADD . /app +FROM node:12.16.1-buster-slim LABEL maintainer="buildmaster@rocket.chat" -RUN set -x \ - && cd /app/bundle/programs/server \ - && npm install \ - && npm cache clear --force \ - && chown -R rocketchat:rocketchat /app +# dependencies +RUN groupadd -g 65533 -r rocketchat \ + && useradd -u 65533 -r -g rocketchat rocketchat \ + && mkdir -p /app/uploads \ + && chown rocketchat:rocketchat /app/uploads \ + && apt-get update \ + && apt-get install -y --no-install-recommends fontconfig + +ADD . /app + +RUN aptMark="$(apt-mark showmanual)" \ + && apt-get install -y --no-install-recommends g++ make python ca-certificates \ + && cd /app/bundle/programs/server \ + && npm install \ + && apt-mark auto '.*' > /dev/null \ + && apt-mark manual $aptMark > /dev/null \ + && find /usr/local -type f -executable -exec ldd '{}' ';' \ + | awk '/=>/ { print $(NF-1) }' \ + | sort -u \ + | xargs -r dpkg-query --search \ + | cut -d: -f1 \ + | sort -u \ + | xargs -r apt-mark manual \ + && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \ + && npm cache clear --force \ + && chown -R rocketchat:rocketchat /app USER rocketchat diff --git a/.eslintrc b/.eslintrc index efef5b7d68d8..de12661f255b 100644 --- a/.eslintrc +++ b/.eslintrc @@ -11,7 +11,8 @@ "jscolor": false }, "plugins": [ - "react" + "react", + "react-hooks" ], "rules": { "jsx-quotes": [ @@ -24,7 +25,9 @@ "react/jsx-fragments": [ "error", "syntax" - ] + ], + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn" }, "settings": { "import/resolver": { diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index d6d6abd54ed1..973977269c89 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -18,7 +18,7 @@ __Note:__ If there's a feature you'd like, there's a bug you'd like to fix, or y ## Setup -Your development workstation needs to have at least 8GB or RAM to be able to build the Rocket.Chat's source code. +Your development workstation needs to have at least 8GB RAM or more to be able to build the Rocket.Chat's source code. Rocket.Chat runs on top of [Meteor](https://www.meteor.com/). To run it on development mode you need to [install Meteor](https://www.meteor.com/install) and clone/download the Rocket.Chat's code, then just open the code folder and run: ```shell @@ -241,7 +241,7 @@ All those PRs will be grouped under the `Minor changes` section which is collaps - Prefer inform the fields you want, and only the necessary ones, when querying data from database over query the full documents - Limit the number of returned records to a reasonable value -- Check if the query is using indexes, it it's not create new indexes +- Check if the query is using indexes, if it's not create new indexes - Prefer queues over long executions - Create new metrics to mesure things whenever possible - Cache data and returns whenever possible diff --git a/.github/ISSUE_TEMPLATE/custom.md b/.github/ISSUE_TEMPLATE/custom.md deleted file mode 100644 index 99bb9a00975f..000000000000 --- a/.github/ISSUE_TEMPLATE/custom.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -name: Custom issue template -about: Describe this issue template's purpose here. - ---- - - diff --git a/.github/issue-close.yml b/.github/issue-close.yml new file mode 100644 index 000000000000..4f3e39624275 --- /dev/null +++ b/.github/issue-close.yml @@ -0,0 +1,17 @@ +# Config for Close Issue app: https://probot.github.io/apps/close-issue/ + +# Comment that will be sent if an issue is judged to be closed +comment: "This issue was closed because it does not use any of our issue templates. Please make sure to use one of the suggested templates." +issueConfigs: +# There can be several configs for different kind of issues. +- content: +# Bug report + - "Steps to reproduce" + - "Expected behavior" + - "Actual behavior" + - "Version of Rocket.Chat Server" +- content: +# Release issue + - "Before Release - Preparation" +# The issue is considered to be valid if it includes all keywords from any of these two configs. +# Or it will be closed by the app. diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index eb42184dd8f6..9d9d5a21fbdc 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -19,6 +19,7 @@ jobs: runs-on: ubuntu-latest steps: + - name: Github Info run: | echo "GITHUB_ACTION: $GITHUB_ACTION" @@ -34,7 +35,15 @@ jobs: with: node-version: "12.16.1" - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 + + - name: Free disk space + run: | + sudo swapoff -a + sudo rm -f /swapfile + sudo apt clean + docker rmi $(docker image ls -aq) + df -h - name: check package-lock run: | @@ -199,7 +208,7 @@ jobs: docker exec mongo mongo --eval 'rs.initiate({_id:"rs0", members: [{"_id":1, "host":"localhost:27017"}]})' docker exec mongo mongo --eval 'rs.status()' - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 - name: Cache cypress id: cache-cypress @@ -245,10 +254,10 @@ jobs: build-image-pr: runs-on: ubuntu-latest - if: github.event_name == 'pull_request' + if: github.event.pull_request.head.repo.full_name == github.repository steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 - name: Free disk space run: | @@ -356,7 +365,7 @@ jobs: needs: test steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 - name: Restore build uses: actions/download-artifact@v1 @@ -409,7 +418,7 @@ jobs: IMAGE: "rocketchat/rocket.chat" steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 - name: Restore build uses: actions/download-artifact@v1 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 000000000000..607b9c8b9953 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,38 @@ +name: Stale Questions + +on: + schedule: + - cron: "0 */6 * * *" + +jobs: + stale: + runs-on: ubuntu-latest + + steps: + - uses: actions/stale@v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + days-before-stale: 10 + days-before-close: 4 + only-labels: 'stat: need more info' + stale-issue-message: >- + This issue has been marked as stale because there has been + no further activity in the last 10 days. If the issue remains + stale for the next 4 days (a total of two weeks with no activity), + then it will be assumed that the question has been resolved and + the issue will be automatically closed. + stale-issue-label: 'stat: no response' + operations-per-run: 40 + + - uses: actions/stale@v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + days-before-stale: 60 + days-before-close: 7 + exempt-issue-labels: 'Epic,Feature: Planned,sla,sponsored,stat: waiting PR merge,Triaged,subj: security' + stale-issue-message: >- + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Thank you + for your contributions. + stale-issue-label: 'stat: stale' + operations-per-run: 40 diff --git a/.storybook/hooks.js b/.storybook/hooks.js index d5e2dcd1c06d..4f9fc7ef9673 100644 --- a/.storybook/hooks.js +++ b/.storybook/hooks.js @@ -9,7 +9,7 @@ export const useAutoToggle = (initialValue = false, ms = 1000) => { return () => { clearInterval(timer); }; - }, []); + }, [ms]); return value; }; diff --git a/app/api/server/api.js b/app/api/server/api.js index 5a834be36553..954ffd7e6216 100644 --- a/app/api/server/api.js +++ b/app/api/server/api.js @@ -616,7 +616,7 @@ const defaultOptionsEndpoint = function _defaultOptionsEndpoint() { this.response.writeHead(200, { 'Access-Control-Allow-Origin': settings.get('API_CORS_Origin'), 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, HEAD, PATCH', - 'Access-Control-Allow-Headers': 'Origin, X-Requested-With, Content-Type, Accept, X-User-Id, X-Auth-Token, x-visitor-token', + 'Access-Control-Allow-Headers': 'Origin, X-Requested-With, Content-Type, Accept, X-User-Id, X-Auth-Token, x-visitor-token, Authorization', }); } else { this.response.writeHead(405); diff --git a/app/api/server/lib/users.js b/app/api/server/lib/users.js index 82194832ceb8..4d7226a608d1 100644 --- a/app/api/server/lib/users.js +++ b/app/api/server/lib/users.js @@ -14,6 +14,7 @@ export async function findUsersToAutocomplete({ uid, selector }) { name: 1, username: 1, status: 1, + avatarETag: 1, }, sort: { username: 1, diff --git a/app/api/server/settings.js b/app/api/server/settings.js index 9f775d2b73ca..7a33ff4dce9d 100644 --- a/app/api/server/settings.js +++ b/app/api/server/settings.js @@ -12,10 +12,9 @@ settings.addGroup('General', function() { this.add('API_Enable_CORS', false, { type: 'boolean', public: false }); this.add('API_CORS_Origin', '*', { type: 'string', public: false, enableQuery: { _id: 'API_Enable_CORS', value: true } }); - this.add('API_Use_REST_For_DDP_Calls', false, { + this.add('API_Use_REST_For_DDP_Calls', true, { type: 'boolean', public: true, - alert: 'API_Use_REST_For_DDP_Calls_Alert', }); }); }); diff --git a/app/api/server/v1/chat.js b/app/api/server/v1/chat.js index 69685a3f6f97..666725f3bb32 100644 --- a/app/api/server/v1/chat.js +++ b/app/api/server/v1/chat.js @@ -5,6 +5,8 @@ import { Messages } from '../../../models'; import { canAccessRoom, hasPermission } from '../../../authorization'; import { normalizeMessagesForUser } from '../../../utils/server/lib/normalizeMessagesForUser'; import { processWebhookMessage } from '../../../lib/server'; +import { executeSendMessage } from '../../../lib/server/methods/sendMessage'; +import { executeSetReaction } from '../../../reactions/server/setReaction'; import { API } from '../api'; import Rooms from '../../../models/server/models/Rooms'; import Users from '../../../models/server/models/Users'; @@ -172,8 +174,7 @@ API.v1.addRoute('chat.sendMessage', { authRequired: true }, { throw new Meteor.Error('error-invalid-params', 'The "message" parameter must be provided.'); } - const sent = Meteor.runAsUser(this.userId, () => Meteor.call('sendMessage', this.bodyParams.message)); - + const sent = executeSendMessage(this.userId, this.bodyParams.message); const [message] = normalizeMessagesForUser([sent], this.userId); return API.v1.success({ @@ -294,7 +295,7 @@ API.v1.addRoute('chat.react', { authRequired: true }, { throw new Meteor.Error('error-emoji-param-not-provided', 'The required "emoji" param is missing.'); } - Meteor.runAsUser(this.userId, () => Meteor.call('setReaction', emoji, msg._id, this.bodyParams.shouldReact)); + Meteor.runAsUser(this.userId, () => Promise.await(executeSetReaction(emoji, msg._id, this.bodyParams.shouldReact))); return API.v1.success(); }, diff --git a/app/api/server/v1/custom-user-status.js b/app/api/server/v1/custom-user-status.js index 14764be6248f..162ab1e0fc51 100644 --- a/app/api/server/v1/custom-user-status.js +++ b/app/api/server/v1/custom-user-status.js @@ -1,3 +1,7 @@ +import { Meteor } from 'meteor/meteor'; +import { Match, check } from 'meteor/check'; + +import { CustomUserStatus } from '../../../models'; import { API } from '../api'; import { findCustomUserStatus } from '../lib/custom-user-status'; @@ -16,3 +20,69 @@ API.v1.addRoute('custom-user-status.list', { authRequired: true }, { }))); }, }); + +API.v1.addRoute('custom-user-status.create', { authRequired: true }, { + post() { + check(this.bodyParams, { + name: String, + statusType: Match.Maybe(String), + }); + + const userStatusData = { + name: this.bodyParams.name, + statusType: this.bodyParams.statusType, + }; + + Meteor.runAsUser(this.userId, () => { + Meteor.call('insertOrUpdateUserStatus', userStatusData); + }); + + return API.v1.success({ + customUserStatus: CustomUserStatus.findOneByName(userStatusData.name), + }); + }, +}); + +API.v1.addRoute('custom-user-status.delete', { authRequired: true }, { + post() { + const { customUserStatusId } = this.bodyParams; + if (!customUserStatusId) { + return API.v1.failure('The "customUserStatusId" params is required!'); + } + + Meteor.runAsUser(this.userId, () => Meteor.call('deleteCustomUserStatus', customUserStatusId)); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('custom-user-status.update', { authRequired: true }, { + post() { + check(this.bodyParams, { + _id: String, + name: String, + statusType: Match.Maybe(String), + }); + + const userStatusData = { + _id: this.bodyParams._id, + name: this.bodyParams.name, + statusType: this.bodyParams.statusType, + }; + + const customUserStatus = CustomUserStatus.findOneById(userStatusData._id); + + // Ensure the message exists + if (!customUserStatus) { + return API.v1.failure(`No custom user status found with the id of "${ userStatusData._id }".`); + } + + Meteor.runAsUser(this.userId, () => { + Meteor.call('insertOrUpdateUserStatus', userStatusData); + }); + + return API.v1.success({ + customUserStatus: CustomUserStatus.findOneById(userStatusData._id), + }); + }, +}); diff --git a/app/api/server/v1/integrations.js b/app/api/server/v1/integrations.js index 8c71129a41e1..480c3e8743eb 100644 --- a/app/api/server/v1/integrations.js +++ b/app/api/server/v1/integrations.js @@ -190,3 +190,61 @@ API.v1.addRoute('integrations.get', { authRequired: true }, { }); }, }); + +API.v1.addRoute('integrations.update', { authRequired: true }, { + put() { + check(this.bodyParams, Match.ObjectIncluding({ + type: String, + name: String, + enabled: Boolean, + username: String, + urls: Match.Maybe([String]), + channel: String, + event: Match.Maybe(String), + triggerWords: Match.Maybe([String]), + alias: Match.Maybe(String), + avatar: Match.Maybe(String), + emoji: Match.Maybe(String), + token: Match.Maybe(String), + scriptEnabled: Boolean, + script: Match.Maybe(String), + targetChannel: Match.Maybe(String), + integrationId: Match.Maybe(String), + target_url: Match.Maybe(String), + })); + + let integration; + switch (this.bodyParams.type) { + case 'webhook-outgoing': + if (this.bodyParams.target_url) { + integration = Integrations.findOne({ urls: this.bodyParams.target_url }); + } else if (this.bodyParams.integrationId) { + integration = Integrations.findOne({ _id: this.bodyParams.integrationId }); + } + + if (!integration) { + return API.v1.failure('No integration found.'); + } + + Meteor.call('updateOutgoingIntegration', integration._id, this.bodyParams); + + return API.v1.success({ + integration: Integrations.findOne({ _id: integration._id }), + }); + case 'webhook-incoming': + integration = Integrations.findOne({ _id: this.bodyParams.integrationId }); + + if (!integration) { + return API.v1.failure('No integration found.'); + } + + Meteor.call('updateIncomingIntegration', integration._id, this.bodyParams); + + return API.v1.success({ + integration: Integrations.findOne({ _id: integration._id }), + }); + default: + return API.v1.failure('Invalid integration type.'); + } + }, +}); diff --git a/app/api/server/v1/invites.js b/app/api/server/v1/invites.js index 513f99f7808e..9409458e3093 100644 --- a/app/api/server/v1/invites.js +++ b/app/api/server/v1/invites.js @@ -35,6 +35,7 @@ API.v1.addRoute('removeInvite/:_id', { authRequired: true }, { API.v1.addRoute('useInviteToken', { authRequired: true }, { post() { const { token } = this.bodyParams; + // eslint-disable-next-line react-hooks/rules-of-hooks const result = useInviteToken(this.userId, token); return API.v1.success(result); diff --git a/app/api/server/v1/users.js b/app/api/server/v1/users.js index c252b743e849..4eebd8bc7bbe 100644 --- a/app/api/server/v1/users.js +++ b/app/api/server/v1/users.js @@ -716,6 +716,7 @@ API.v1.addRoute('users.presence', { authRequired: true }, { status: 1, utcOffset: 1, statusText: 1, + avatarETag: 1, }, }; diff --git a/app/api/server/v1/video-conference.js b/app/api/server/v1/video-conference.js index 51d0b8830499..d261da4b8665 100644 --- a/app/api/server/v1/video-conference.js +++ b/app/api/server/v1/video-conference.js @@ -1,6 +1,6 @@ import { Meteor } from 'meteor/meteor'; -import { Rooms } from '../../../models'; +import { Rooms } from '../../../models/server'; import { API } from '../api'; API.v1.addRoute('video-conference/jitsi.update-timeout', { authRequired: true }, { @@ -10,13 +10,13 @@ API.v1.addRoute('video-conference/jitsi.update-timeout', { authRequired: true }, return API.v1.failure('The "roomId" parameter is required!'); } - const room = Rooms.findOneById(roomId); + const room = Rooms.findOneById(roomId, { fields: { _id: 1 } }); if (!room) { return API.v1.failure('Room does not exist!'); } - Meteor.runAsUser(this.userId, () => Meteor.call('jitsi:updateTimeout', roomId)); + const jitsiTimeout = Meteor.runAsUser(this.userId, () => Meteor.call('jitsi:updateTimeout', roomId)); - return API.v1.success({ jitsiTimeout: Rooms.findOneById(roomId).jitsiTimeout }); + return API.v1.success({ jitsiTimeout }); }, }); diff --git a/app/apps/client/RealAppsEngineUIHost.js b/app/apps/client/RealAppsEngineUIHost.js index e21a458baff1..29802587f9b7 100644 --- a/app/apps/client/RealAppsEngineUIHost.js +++ b/app/apps/client/RealAppsEngineUIHost.js @@ -11,7 +11,7 @@ export class RealAppsEngineUIHost extends AppsEngineUIHost { constructor() { super(); - this._baseURL = baseURI; + this._baseURL = baseURI.replace(/\/$/, ''); } getUserAvatarUrl(username) { diff --git a/app/apps/server/communication/uikit.js b/app/apps/server/communication/uikit.js index 0bad3e7c251f..2bb8beeebc1f 100644 --- a/app/apps/server/communication/uikit.js +++ b/app/apps/server/communication/uikit.js @@ -1,8 +1,11 @@ import express from 'express'; +import rateLimit from 'express-rate-limit'; +import { Meteor } from 'meteor/meteor'; import { WebApp } from 'meteor/webapp'; import { UIKitIncomingInteractionType } from '@rocket.chat/apps-engine/definition/uikit'; import { Users } from '../../../models/server'; +import { settings } from '../../../settings/server'; const apiServer = express(); @@ -13,11 +16,22 @@ WebApp.connectHandlers.use(apiServer); // eslint-disable-next-line new-cap const router = express.Router(); -const unauthorized = (res) => - res.status(401).send({ - status: 'error', - message: 'You must be logged in to do this.', +const unauthorized = (res) => res.status(401).send({ + status: 'error', + message: 'You must be logged in to do this.', +}); + +Meteor.startup(() => { + // use specific rate limit of 600 (which is 60 times the default limits) requests per minute (around 10/second) + const apiLimiter = rateLimit({ + windowMs: settings.get('API_Enable_Rate_Limiter_Limit_Time_Default'), + max: settings.get('API_Enable_Rate_Limiter_Limit_Calls_Default') * 60, + skip: () => + settings.get('API_Enable_Rate_Limiter') !== true + || (process.env.NODE_ENV === 'development' && settings.get('API_Enable_Rate_Limiter_Dev') !== true), }); + router.use(apiLimiter); +}); router.use((req, res, next) => { const { diff --git a/app/authorization/server/functions/canDeleteMessage.js b/app/authorization/server/functions/canDeleteMessage.js index aeb1e06ef126..3f3d436e0786 100644 --- a/app/authorization/server/functions/canDeleteMessage.js +++ b/app/authorization/server/functions/canDeleteMessage.js @@ -1,5 +1,6 @@ import { hasPermissionAsync } from './hasPermission'; import { getValue } from '../../../settings/server/raw'; +import { Rooms } from '../../../models'; const elapsedTime = (ts) => { const dif = Date.now() - ts; @@ -30,12 +31,20 @@ export const canDeleteMessageAsync = async (uid, { u, rid, ts }) => { } const blockDeleteInMinutes = await getValue('Message_AllowDeleting_BlockDeleteInMinutes'); - if (!blockDeleteInMinutes) { - return true; + if (blockDeleteInMinutes) { + const timeElapsedForMessage = elapsedTime(ts); + return timeElapsedForMessage <= blockDeleteInMinutes; + } + + const room = await Rooms.findOneById(rid, { fields: { ro: 1, unmuted: 1 } }); + if (room.ro === true && !await hasPermissionAsync(uid, 'post-readonly', rid)) { + // Unless the user was manually unmuted + if (!(room.unmuted || []).includes(u.username)) { + throw new Error('You can\'t delete messages because the room is readonly.'); + } } - const timeElapsedForMessage = elapsedTime(ts); - return timeElapsedForMessage <= blockDeleteInMinutes; + return true; }; export const canDeleteMessage = (uid, { u, rid, ts }) => Promise.await(canDeleteMessageAsync(uid, { u, rid, ts })); diff --git a/app/authorization/server/functions/canSendMessage.js b/app/authorization/server/functions/canSendMessage.js index 10f1a33c7281..18ecfb777381 100644 --- a/app/authorization/server/functions/canSendMessage.js +++ b/app/authorization/server/functions/canSendMessage.js @@ -10,21 +10,19 @@ const subscriptionOptions = { }, }; -export const canSendMessageAsync = async (rid, { uid, username, type }, extraData) => { - const room = await Rooms.findOneById(rid); - +export const validateRoomMessagePermissionsAsync = async (room, { uid, username, type }, extraData) => { if (type !== 'app' && !await canAccessRoomAsync(room, { _id: uid, username }, extraData)) { throw new Error('error-not-allowed'); } if (roomTypes.getConfig(room.t).allowMemberAction(room, RoomMemberActions.BLOCK)) { - const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, uid, subscriptionOptions); + const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, uid, subscriptionOptions); if (subscription && (subscription.blocked || subscription.blocker)) { throw new Error('room_is_blocked'); } } - if (room.ro === true && !await hasPermissionAsync(uid, 'post-readonly', rid)) { + if (room.ro === true && !await hasPermissionAsync(uid, 'post-readonly', room._id)) { // Unless the user was manually unmuted if (!(room.unmuted || []).includes(username)) { throw new Error('You can\'t send messages because the room is readonly.'); @@ -34,8 +32,13 @@ export const canSendMessageAsync = async (rid, { uid, username, type }, extraDat if ((room.muted || []).includes(username)) { throw new Error('You_have_been_muted'); } +}; +export const canSendMessageAsync = async (rid, { uid, username, type }, extraData) => { + const room = await Rooms.findOneById(rid); + await validateRoomMessagePermissionsAsync(room, { uid, username, type }, extraData); return room; }; export const canSendMessage = (rid, { uid, username, type }, extraData) => Promise.await(canSendMessageAsync(rid, { uid, username, type }, extraData)); +export const validateRoomMessagePermissions = (room, { uid, username, type }, extraData) => Promise.await(validateRoomMessagePermissionsAsync(room, { uid, username, type }, extraData)); diff --git a/app/authorization/server/index.js b/app/authorization/server/index.js index 485038ae3e23..c4ceb42c9a3d 100644 --- a/app/authorization/server/index.js +++ b/app/authorization/server/index.js @@ -4,7 +4,7 @@ import { canAccessRoom, roomAccessValidators, } from './functions/canAccessRoom'; -import { canSendMessage } from './functions/canSendMessage'; +import { canSendMessage, validateRoomMessagePermissions } from './functions/canSendMessage'; import { getRoles } from './functions/getRoles'; import { getUsersInRole } from './functions/getUsersInRole'; import { @@ -31,6 +31,7 @@ export { subscriptionHasRole, removeUserFromRoles, canSendMessage, + validateRoomMessagePermissions, addRoomAccessValidator, roomAccessValidators, addUserRoles, diff --git a/app/bigbluebutton/server/bigbluebutton-api.js b/app/bigbluebutton/server/bigbluebutton-api.js index 2ba280bad7b7..b90424914736 100644 --- a/app/bigbluebutton/server/bigbluebutton-api.js +++ b/app/bigbluebutton/server/bigbluebutton-api.js @@ -1,7 +1,7 @@ /* eslint-disable */ import crypto from 'crypto'; -var BigBlueButtonApi, filterCustomParameters, include, noChecksumMethods, root, +var BigBlueButtonApi, filterCustomParameters, include, noChecksumMethods, __indexOf = [].indexOf || function (item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }; BigBlueButtonApi = (function () { diff --git a/app/chatpal-search/client/style.css b/app/chatpal-search/client/style.css index 049f80d78950..890e3c6891fb 100644 --- a/app/chatpal-search/client/style.css +++ b/app/chatpal-search/client/style.css @@ -41,7 +41,7 @@ .chatpal-search-typefilter li { display: flex; - flex: 0 0 50%; + flex: 0 0 33%; height: 35px; padding-top: 10px; diff --git a/app/chatpal-search/client/template/result.html b/app/chatpal-search/client/template/result.html index ce27ec2a1405..c0c410f3e934 100644 --- a/app/chatpal-search/client/template/result.html +++ b/app/chatpal-search/client/template/result.html @@ -2,6 +2,7 @@
@@ -59,7 +60,7 @@

{{_ "Chatpal_No_Results"}}

{{/if}} {{/if}} - {{#if $eq resultType "Messages"}} + {{#if resultMessagesOnly}}

{{#if resultNumFound}} @@ -110,7 +111,7 @@

+ style="background-image:url({{getAvatarUrl username }});">
{{username}} {{time}} diff --git a/app/chatpal-search/client/template/result.js b/app/chatpal-search/client/template/result.js index 2ef230fccc8a..4268444de28c 100644 --- a/app/chatpal-search/client/template/result.js +++ b/app/chatpal-search/client/template/result.js @@ -6,8 +6,8 @@ import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; import { DateFormat } from '../../../lib'; import { roomTypes, getURL } from '../../../utils'; import { Subscriptions } from '../../../models'; +import { getUserAvatarURL as getAvatarUrl } from '../../../utils/lib/getUserAvatarURL'; -const getAvatarUrl = (username) => getURL(`/avatar/${ username }?_dc=undefined`); const getDMUrl = (username) => getURL(`/direct/${ username }`); Template.ChatpalSearchResultTemplate.onCreated(function() { @@ -78,6 +78,10 @@ Template.ChatpalSearchResultTemplate.helpers({ } } }, + resultMessagesOnly() { + return Template.instance().resultType.get() === 'Messages' + || Template.instance().resultType.get() === 'Room'; + }, resultPaging() { const result = Template.instance().data.result.get(); const pageSize = Template.instance().data.settings.PageSize; @@ -134,7 +138,8 @@ Template.ChatpalSearchSingleRoom.helpers({ Template.ChatpalSearchSingleUser.helpers({ cleanUsername() { - return this.user_username.replace(/<\/?em>/ig, ''); + const username = this.user_username || this.username; // varies whether users or messages of users are displayed + return username.replace(/<\/?em>/ig, ''); }, getAvatarUrl, getDMUrl, diff --git a/app/chatpal-search/server/provider/provider.js b/app/chatpal-search/server/provider/provider.js index 6ce02e17b8f6..701710eb148b 100644 --- a/app/chatpal-search/server/provider/provider.js +++ b/app/chatpal-search/server/provider/provider.js @@ -81,8 +81,9 @@ class ChatpalProvider extends SearchProvider { }); this._settings.add('DefaultResultType', 'select', 'All', { values: [ - { key: 'All', i18nLabel: 'All' }, - { key: 'Messages', i18nLabel: 'Messages' }, + { key: 'All', i18nLabel: 'Chatpal_All_Results' }, + { key: 'Room', i18nLabel: 'Chatpal_Current_Room_Only' }, + { key: 'Messages', i18nLabel: 'Chatpal_Messages_Only' }, ], i18nLabel: 'Chatpal_Default_Result_Type', i18nDescription: 'Chatpal_Default_Result_Type_Description', @@ -313,7 +314,7 @@ class ChatpalProvider extends SearchProvider { this.index.query( text, this._settings.get('Main_Language'), - this._getAcl(context), + payload.resultType === 'Room' ? [context.rid] : this._getAcl(context), type, payload.start || 0, payload.rows || this._settings.get('PageSize'), diff --git a/app/custom-oauth/client/custom_oauth_client.js b/app/custom-oauth/client/custom_oauth_client.js index f1561c84e5d1..40e802be8f6e 100644 --- a/app/custom-oauth/client/custom_oauth_client.js +++ b/app/custom-oauth/client/custom_oauth_client.js @@ -93,8 +93,8 @@ export class CustomOAuth { }${ separator }client_id=${ config.clientId }&redirect_uri=${ encodeURIComponent(OAuth._redirectUri(this.name, config)) }&response_type=code` - + `&state=${ OAuth._stateParam(loginStyle, credentialToken, options.redirectUrl) - }&scope=${ this.scope }`; + + `&state=${ encodeURIComponent(OAuth._stateParam(loginStyle, credentialToken, options.redirectUrl)) + }&scope=${ encodeURIComponent(this.scope) }`; OAuth.launchLogin({ loginService: this.name, diff --git a/app/emoji-emojione/lib/generateEmojiIndex.mjs b/app/emoji-emojione/lib/generateEmojiIndex.mjs index a503a652084b..f7f82101af9d 100644 --- a/app/emoji-emojione/lib/generateEmojiIndex.mjs +++ b/app/emoji-emojione/lib/generateEmojiIndex.mjs @@ -2,10 +2,9 @@ // node --experimental-modules generateEmojiIndex.mjs import fs from 'fs'; -import https from 'https'; import nsg from 'node-sprite-generator'; import _ from 'underscore'; -import gm from 'gm'; +import gm from 'gm'; // lgtm[js/unused-local-variable] const assetFolder = '../../../node_modules/emojione-assets'; const emojiJsonFile = `${ assetFolder }/emoji.json`; diff --git a/app/file-upload/server/startup/settings.js b/app/file-upload/server/startup/settings.js index 84ff2da2d4e7..1d0c565e1292 100644 --- a/app/file-upload/server/startup/settings.js +++ b/app/file-upload/server/startup/settings.js @@ -12,13 +12,13 @@ settings.addGroup('FileUpload', function() { i18nDescription: 'FileUpload_MaxFileSizeDescription', }); - this.add('FileUpload_MediaTypeWhiteList', 'image/*,audio/*,video/*,application/zip,application/x-rar-compressed,application/pdf,text/plain,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.oasis.opendocument.text,application/vnd.oasis.opendocument.spreadsheet,application/vnd.oasis.opendocument.presentation', { + this.add('FileUpload_MediaTypeWhiteList', '', { type: 'string', public: true, i18nDescription: 'FileUpload_MediaTypeWhiteListDescription', }); - this.add('FileUpload_MediaTypeBlackList', '', { + this.add('FileUpload_MediaTypeBlackList', 'image/svg+xml', { type: 'string', public: true, i18nDescription: 'FileUpload_MediaTypeBlackListDescription', diff --git a/app/lazy-load/client/index.js b/app/lazy-load/client/index.js index 61a0ece9c735..7bb31b6e8977 100644 --- a/app/lazy-load/client/index.js +++ b/app/lazy-load/client/index.js @@ -9,14 +9,16 @@ const loadImage = (el) => { map.delete(el); if (!instance) { - return instance.loaded.set(true); + return; } + const img = new Image(); const src = el.getAttribute('data-src'); img.onload = () => { el.className = el.className.replace('lazy-img', ''); el.src = src; el.removeAttribute('data-src'); + instance.loaded.set(true); }; img.src = src; }; diff --git a/app/lazy-load/client/lazyloadImage.js b/app/lazy-load/client/lazyloadImage.js index c711f73ebb9d..9d2151498b02 100644 --- a/app/lazy-load/client/lazyloadImage.js +++ b/app/lazy-load/client/lazyloadImage.js @@ -4,7 +4,7 @@ import { Template } from 'meteor/templating'; import './lazyloadImage.html'; import { addImage } from '.'; -const emptyImageEncoded = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8+/u3PQAJJAM0dIyWdgAAAABJRU5ErkJggg=='; +const emptyImageEncoded = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8+/u3PQAJJAM0dIyWdgAAAABJRU5ErkJggg=='; const imgsrcs = new Set(); @@ -15,12 +15,17 @@ Template.lazyloadImage.helpers({ }, srcUrl() { + if (Template.instance().loaded.get()) { + return; + } return this.src; }, lazySrcUrl() { const { preview, placeholder, src } = this; - if (Template.instance().loaded.get() || (!preview && !placeholder) || imgsrcs.has(src)) { + const { loaded } = Template.instance(); + + if (loaded.get() || (!preview && !placeholder) || imgsrcs.has(src)) { return src; } diff --git a/app/ldap/server/sync.js b/app/ldap/server/sync.js index 5e290d9bb8e4..a2e6a24eb9a7 100644 --- a/app/ldap/server/sync.js +++ b/app/ldap/server/sync.js @@ -409,10 +409,10 @@ export function syncUserData(user, ldapUser, ldap) { }; Meteor.runAsUser(user._id, () => { - fileStore.insert(file, rs, () => { + fileStore.insert(file, rs, (err, result) => { Meteor.setTimeout(function() { - Users.setAvatarOrigin(user._id, 'ldap'); - Notifications.notifyLogged('updateAvatar', { username: user.username }); + Users.setAvatarData(user._id, 'ldap', result.etag); + Notifications.notifyLogged('updateAvatar', { username: user.username, etag: result.etag }); }, 500); }); }); diff --git a/app/lib/lib/MessageTypes.js b/app/lib/lib/MessageTypes.js index 3f6df6090416..35df78e15288 100644 --- a/app/lib/lib/MessageTypes.js +++ b/app/lib/lib/MessageTypes.js @@ -190,10 +190,10 @@ export const MessageTypesValues = [ key: 'rm', i18nLabel: 'Message_HideType_rm', }, { - key: 'subscription_role_added', + key: 'subscription-role-added', i18nLabel: 'Message_HideType_subscription_role_added', }, { - key: 'subscription_role_removed', + key: 'subscription-role-removed', i18nLabel: 'Message_HideType_subscription_role_removed', }, { key: 'room_archived', @@ -202,4 +202,8 @@ export const MessageTypesValues = [ key: 'room_unarchived', i18nLabel: 'Message_HideType_room_unarchived', }, + { + key: 'room_changed_privacy', + i18nLabel: 'Message_HideType_room_changed_privacy', + }, ]; diff --git a/app/lib/lib/roomTypes/direct.js b/app/lib/lib/roomTypes/direct.js index a8b7bc73d18f..e54022b5f2eb 100644 --- a/app/lib/lib/roomTypes/direct.js +++ b/app/lib/lib/roomTypes/direct.js @@ -190,6 +190,11 @@ export class DirectMessageRoomType extends RoomTypeConfig { return ''; } + // if coming from sidenav search + if (roomData.name && roomData.avatarETag) { + return getUserAvatarURL(roomData.name, roomData.avatarETag); + } + if (this.isGroupChat(roomData)) { return getAvatarURL({ username: roomData.uids.length + roomData.usernames.join() }); } @@ -197,7 +202,8 @@ export class DirectMessageRoomType extends RoomTypeConfig { const sub = subData || Subscriptions.findOne({ rid: roomData._id }, { fields: { name: 1 } }); if (sub && sub.name) { - return getUserAvatarURL(sub.name); + const user = Meteor.users.findOne({ username: sub.name }, { fields: { username: 1, avatarETag: 1 } }); + return getUserAvatarURL(user?.username || sub.name, user?.avatarETag); } if (roomData) { diff --git a/app/lib/lib/roomTypes/public.js b/app/lib/lib/roomTypes/public.js index 70b2dc3fd41a..e7b935805cec 100644 --- a/app/lib/lib/roomTypes/public.js +++ b/app/lib/lib/roomTypes/public.js @@ -4,7 +4,7 @@ import { openRoom } from '../../../ui-utils'; import { ChatRoom, ChatSubscription } from '../../../models'; import { settings } from '../../../settings'; import { hasAtLeastOnePermission } from '../../../authorization'; -import { getUserPreference, RoomTypeConfig, RoomTypeRouteConfig, RoomSettingsEnum, UiTextContext, RoomMemberActions } from '../../../utils'; +import { getUserPreference, RoomTypeConfig, RoomTypeRouteConfig, RoomSettingsEnum, UiTextContext, RoomMemberActions, roomTypes } from '../../../utils'; import { getAvatarURL } from '../../../utils/lib/getAvatarURL'; export class PublicRoomRoute extends RoomTypeRouteConfig { @@ -136,6 +136,17 @@ export class PublicRoomType extends RoomTypeConfig { getAvatarPath(roomData) { // TODO: change to always get avatar from _id when rooms have avatars + // if room is not a discussion, returns the avatar for its name + if (!roomData.prid) { + return getAvatarURL({ username: `@${ this.roomName(roomData) }` }); + } + + // if discussion's parent room is known, get his avatar + const proom = ChatRoom.findOne({ _id: roomData.prid }, { reactive: false }); + if (proom) { + return roomTypes.getConfig(proom.t).getAvatarPath(proom); + } + return getAvatarURL({ username: `@${ this.roomName(roomData) }` }); } diff --git a/app/lib/server/functions/getFullUserData.js b/app/lib/server/functions/getFullUserData.js index cb75e4fa37ed..571977614f5e 100644 --- a/app/lib/server/functions/getFullUserData.js +++ b/app/lib/server/functions/getFullUserData.js @@ -16,6 +16,7 @@ const defaultFields = { active: 1, reason: 1, statusText: 1, + avatarETag: 1, }; const fullFields = { diff --git a/app/lib/server/functions/processWebhookMessage.js b/app/lib/server/functions/processWebhookMessage.js index a667203b47a5..18bbd4e76aa5 100644 --- a/app/lib/server/functions/processWebhookMessage.js +++ b/app/lib/server/functions/processWebhookMessage.js @@ -4,6 +4,7 @@ import s from 'underscore.string'; import { getRoomByNameOrIdWithOptionToJoin } from './getRoomByNameOrIdWithOptionToJoin'; import { sendMessage } from './sendMessage'; +import { validateRoomMessagePermissions } from '../../../authorization/server/functions/canSendMessage'; import { Subscriptions } from '../../../models'; import { getDirectMessageByIdWithOptionToJoin, getDirectMessageByNameOrIdWithOptionToJoin } from './getDirectMessageByNameOrIdWithOptionToJoin'; @@ -60,6 +61,7 @@ export const processWebhookMessage = function(messageObj, user, defaultValues = parseUrls: messageObj.parseUrls !== undefined ? messageObj.parseUrls : !messageObj.attachments, bot: messageObj.bot, groupable: messageObj.groupable !== undefined ? messageObj.groupable : false, + tmid: messageObj.tmid !== undefined ? messageObj.tmid : '', }; if (!_.isEmpty(messageObj.icon_url) || !_.isEmpty(messageObj.avatar)) { @@ -82,6 +84,8 @@ export const processWebhookMessage = function(messageObj, user, defaultValues = } } + validateRoomMessagePermissions(room, { uid: user._id, ...user }); + const messageReturn = sendMessage(user, message, room); sentData.push({ channel, message: messageReturn }); } diff --git a/app/lib/server/functions/setUserAvatar.js b/app/lib/server/functions/setUserAvatar.js index 9cab632e239a..ae6c54fe1eff 100644 --- a/app/lib/server/functions/setUserAvatar.js +++ b/app/lib/server/functions/setUserAvatar.js @@ -11,7 +11,7 @@ export const setUserAvatar = function(user, dataURI, contentType, service) { let image; if (service === 'initials') { - return Users.setAvatarOrigin(user._id, service); + return Users.setAvatarData(user._id, service, null); } if (service === 'url') { let result = null; @@ -61,10 +61,10 @@ export const setUserAvatar = function(user, dataURI, contentType, service) { size: buffer.length, }; - fileStore.insert(file, buffer, () => { + fileStore.insert(file, buffer, (err, result) => { Meteor.setTimeout(function() { - Users.setAvatarOrigin(user._id, service); - Notifications.notifyLogged('updateAvatar', { username: user.username }); + Users.setAvatarData(user._id, service, result.etag); + Notifications.notifyLogged('updateAvatar', { username: user.username, etag: result.etag }); }, 500); }); }; diff --git a/app/lib/server/methods/sendMessage.js b/app/lib/server/methods/sendMessage.js index be4ef0670d9c..ae59502e151b 100644 --- a/app/lib/server/methods/sendMessage.js +++ b/app/lib/server/methods/sendMessage.js @@ -72,18 +72,21 @@ export function executeSendMessage(uid, message) { metrics.messagesSent.inc(); // TODO This line needs to be moved to it's proper place. See the comments on: https://github.com/RocketChat/Rocket.Chat/pull/5736 return sendMessage(user, message, room, false); } catch (error) { - if (error === 'error-not-allowed') { - throw new Meteor.Error('error-not-allowed'); - } - SystemLogger.error('Error sending message:', error); + const errorMessage = typeof error === 'string' ? error : error.error || error.message; Notifications.notifyUser(uid, 'message', { _id: Random.id(), rid: message.rid, ts: new Date(), - msg: TAPi18n.__(error, {}, user.language), + msg: TAPi18n.__(errorMessage, {}, user.language), }); + + if (typeof error === 'string') { + throw new Error(error); + } + + throw error; } } @@ -98,7 +101,15 @@ Meteor.methods({ }); } - return executeSendMessage(uid, message); + try { + return executeSendMessage(uid, message); + } catch (error) { + if ((error.error || error.message) === 'error-not-allowed') { + throw new Meteor.Error(error.error || error.message, error.reason, { + method: 'sendMessage', + }); + } + } }, }); // Limit a user, who does not have the "bot" role, to sending 5 msgs/second diff --git a/app/lib/server/methods/updateMessage.js b/app/lib/server/methods/updateMessage.js index 3b90cc484f7b..49d7720ec73c 100644 --- a/app/lib/server/methods/updateMessage.js +++ b/app/lib/server/methods/updateMessage.js @@ -4,7 +4,7 @@ import moment from 'moment'; import { Messages } from '../../../models'; import { settings } from '../../../settings'; -import { hasPermission } from '../../../authorization'; +import { hasPermission, canSendMessage } from '../../../authorization/server'; import { updateMessage } from '../functions'; Meteor.methods({ @@ -47,6 +47,9 @@ Meteor.methods({ } } + const user = Meteor.users.findOne(Meteor.userId()); + canSendMessage(message.rid, { uid: user._id, ...user }); + // It is possible to have an empty array as the attachments property, so ensure both things exist if (originalMessage.attachments && originalMessage.attachments.length > 0 && originalMessage.attachments[0].description !== undefined) { message.attachments = originalMessage.attachments; diff --git a/app/livechat/client/stylesheets/livechat.css b/app/livechat/client/stylesheets/livechat.css index f80d646c0b5c..00eb63ef619d 100644 --- a/app/livechat/client/stylesheets/livechat.css +++ b/app/livechat/client/stylesheets/livechat.css @@ -5,6 +5,7 @@ --primary-font-color: #444444; --secondary-font-color: #7f7f7f; --info-font-color: #aaaaaa; + --color-gray: #9ea2a8; } .flex-list { @@ -639,3 +640,53 @@ width: 100%; height: 100%; } + +.chat-history-item { + cursor: pointer; + + border-bottom: 2px solid #f2f3f5; +} + +.open { + padding-left: 4% !important; +} + +.contact-chat-history-messages-list .message-actions { + visibility: hidden; +} + +.chat-history-item-count-msg { + margin-top: 1%; + + color: grey; + + font-family: Inter; + font-size: 12px; + font-weight: bold; + font-style: normal; + line-height: 16px; +} + +.closing-message-body-wrapper { + margin-top: 3%; +} + +.closing-message-title { + color: var(--color-dark-light); + + font-family: Inter; + font-size: 12px; + font-weight: bold; + font-style: normal; + line-height: 16px; +} + +.closing-message-text { + color: var(--color-gray); + + font-family: Inter; + font-size: 12px; + font-weight: 500; + font-style: italic; + line-height: 16px; +} diff --git a/app/livechat/client/ui.js b/app/livechat/client/ui.js index 23b945147195..2572503a21d1 100644 --- a/app/livechat/client/ui.js +++ b/app/livechat/client/ui.js @@ -32,10 +32,10 @@ TabBar.addButton({ TabBar.addButton({ groups: ['live'], - id: 'visitor-history', - i18nTitle: 'Past_Chats', + id: 'conatct-chat-history', + i18nTitle: 'Contact_Chat_History', icon: 'clock', - template: 'visitorHistory', + template: 'contactChatHistory', order: 11, }); diff --git a/app/livechat/client/views/app/tabbar/contactChatHistory.html b/app/livechat/client/views/app/tabbar/contactChatHistory.html new file mode 100644 index 000000000000..d5305556fe42 --- /dev/null +++ b/app/livechat/client/views/app/tabbar/contactChatHistory.html @@ -0,0 +1,44 @@ + diff --git a/app/livechat/client/views/app/tabbar/contactChatHistory.js b/app/livechat/client/views/app/tabbar/contactChatHistory.js new file mode 100644 index 000000000000..87ff3b1cee5d --- /dev/null +++ b/app/livechat/client/views/app/tabbar/contactChatHistory.js @@ -0,0 +1,150 @@ +import { Tracker } from 'meteor/tracker'; +import { Template } from 'meteor/templating'; +import moment from 'moment'; +import { ReactiveVar } from 'meteor/reactive-var'; +import './contactChatHistory.html'; +import './contactChatHistoryItem.html'; +import _ from 'underscore'; + +import { t, APIClient } from '../../../../../utils/client'; + + +const HISTORY_LIMIT = 50; + +Template.contactChatHistory.helpers({ + isReady() { + return Template.instance().isReady.get(); + }, + hasChatHistory() { + return Template.instance().history.get().length > 0; + }, + history() { + return Template.instance().history.get(); + }, + isLoading() { + return Template.instance().isLoading.get(); + }, + isSearching() { + return Template.instance().searchTerm.get().length > 0; + }, + showChatHistoryMessages() { + return Template.instance().showChatHistoryMessages.get(); + }, + chatHistoryMessagesContext() { + return { + tabBar: Template.instance().tabBar, + clear: Template.instance().returnChatHistoryList, + ...Template.instance().chatHistoryMessagesContext.get(), + }; + }, + canSearch() { + return Template.instance().canSearch.get(); + }, +}); + +Template.contactChatHistory.onCreated(async function() { + const currentData = Template.currentData(); + this.offset = new ReactiveVar(0); + this.visitorId = new ReactiveVar(); + this.history = new ReactiveVar([]); + this.searchTerm = new ReactiveVar(''); + this.hasMore = new ReactiveVar(true); + this.isLoading = new ReactiveVar(true); + this.chatHistoryMessagesContext = new ReactiveVar(); + this.showChatHistoryMessages = new ReactiveVar(false); + this.limit = new ReactiveVar(HISTORY_LIMIT); + this.isReady = new ReactiveVar(false); + this.canSearch = new ReactiveVar(false); + this.tabBar = currentData.tabBar; + + this.returnChatHistoryList = () => { + this.showChatHistoryMessages.set(false); + this.chatHistoryMessagesContext.set(); + + this.tabBar.setData({ + label: 'Contact_Chat_History', + icon: 'clock', + }); + }; + + this.autorun(async () => { + if (!this.visitorId.get() || !currentData || !currentData.rid) { + return; + } + + const limit = this.limit.get(); + const offset = this.offset.get(); + const searchTerm = this.searchTerm.get(); + + let baseUrl = `livechat/visitors.searchChats/room/${ currentData.rid }/visitor/${ this.visitorId.get() }?count=${ limit }&offset=${ offset }&closedChatsOnly=true&servedChatsOnly=true`; + if (searchTerm) { + baseUrl += `&searchText=${ searchTerm }`; + } + + this.isLoading.set(true); + const { history, total } = await APIClient.v1.get(baseUrl); + this.history.set(offset === 0 ? history : this.history.get().concat(history)); + this.hasMore.set(total > this.history.get().length); + this.isLoading.set(false); + }); + + this.autorun(async () => { + const { room } = await APIClient.v1.get(`rooms.info?roomId=${ currentData.rid }`); + if (room?.v) { + this.visitorId.set(room.v._id); + } + }); +}); + +Template.contactChatHistory.onRendered(function() { + Tracker.autorun((computation) => { + if (this.isLoading.get()) { + return; + } + + const history = this.history.get(); + this.canSearch.set(history && history.length > 0); + this.isReady.set(true); + + computation.stop(); + }); +}); + + +Template.contactChatHistory.events({ + 'scroll .js-list': _.throttle(function(e, instance) { + if (e.target.scrollTop >= e.target.scrollHeight - e.target.clientHeight - 10 && instance.hasMore.get()) { + instance.offset.set(instance.offset.get() + instance.limit.get()); + } + }, 200), + 'click .chat-history-item'(event, instance) { + event.preventDefault(); + event.stopPropagation(); + + const { _id: rid, v: { name, username } = { }, closedAt } = this; + + const closedAtLabel = t('Closed_At'); + const closedDay = moment(closedAt).format('MMM D YYYY'); + + instance.chatHistoryMessagesContext.set({ + label: `${ name || username }, ${ closedAtLabel } ${ closedDay }`, + rid, + }); + + instance.showChatHistoryMessages.set(true); + }, + 'keyup #chat-search': _.debounce(function(e, instance) { + if (e.keyCode === 13) { + return e.preventDefault(); + } + + const { value } = e.target; + + if (e.keyCode === 40 || e.keyCode === 38) { + return e.preventDefault(); + } + + instance.offset.set(0); + instance.searchTerm.set(value); + }, 300), +}); diff --git a/app/livechat/client/views/app/tabbar/contactChatHistoryItem.html b/app/livechat/client/views/app/tabbar/contactChatHistoryItem.html new file mode 100644 index 000000000000..2a10c9890d98 --- /dev/null +++ b/app/livechat/client/views/app/tabbar/contactChatHistoryItem.html @@ -0,0 +1,26 @@ + diff --git a/app/livechat/client/views/app/tabbar/contactChatHistoryItem.js b/app/livechat/client/views/app/tabbar/contactChatHistoryItem.js new file mode 100644 index 000000000000..dc370050df63 --- /dev/null +++ b/app/livechat/client/views/app/tabbar/contactChatHistoryItem.js @@ -0,0 +1,28 @@ +import moment from 'moment'; +import './contactChatHistoryItem.html'; +import { Template } from 'meteor/templating'; +import { ReactiveVar } from 'meteor/reactive-var'; + +Template.contactChatHistoryItem.helpers({ + closedAt() { + const { closedAt } = Template.instance().room.get(); + return moment(closedAt).format('lll'); + }, + closingRoomMessage() { + const closingObj = Template.instance().closingRoomMessage.get(); + return closingObj.msg; + }, + i18nMessageCounter() { + const { msgs } = this; + return `${ msgs }`; + }, +}); + +Template.contactChatHistoryItem.onCreated(function() { + this.room = new ReactiveVar(); + + this.autorun(async () => { + const currentData = Template.currentData(); + this.room.set(currentData); + }); +}); diff --git a/app/livechat/client/views/app/tabbar/contactChatHistoryMessages.html b/app/livechat/client/views/app/tabbar/contactChatHistoryMessages.html new file mode 100644 index 000000000000..5a938d563168 --- /dev/null +++ b/app/livechat/client/views/app/tabbar/contactChatHistoryMessages.html @@ -0,0 +1,47 @@ + diff --git a/app/livechat/client/views/app/tabbar/contactChatHistoryMessages.js b/app/livechat/client/views/app/tabbar/contactChatHistoryMessages.js new file mode 100644 index 000000000000..4e09c19dd82e --- /dev/null +++ b/app/livechat/client/views/app/tabbar/contactChatHistoryMessages.js @@ -0,0 +1,103 @@ +import { Template } from 'meteor/templating'; +import './contactChatHistoryMessages.html'; +import { ReactiveVar } from 'meteor/reactive-var'; +import _ from 'underscore'; + +import { messageContext } from '../../../../../ui-utils/client/lib/messageContext'; +import { APIClient } from '../../../../../utils/client'; + +const MESSAGES_LIMIT = 10; + +Template.contactChatHistoryMessages.helpers({ + messages() { + return Template.instance().messages.get(); + }, + messageContext() { + const result = messageContext.call(this, { rid: Template.instance().rid }); + return { + ...result, + settings: { + ...result.settings, + showReplyButton: false, + showreply: false, + hideRoles: true, + }, + }; + }, + hasMore() { + return Template.instance().hasMore.get(); + }, + isLoading() { + return Template.instance().isLoading.get(); + }, + isSearching() { + return Template.instance().searchTerm.get().length > 0; + }, + empty() { + return Template.instance().messages.get().length === 0; + }, +}); + +Template.contactChatHistoryMessages.events({ + 'click .js-back'(e, instance) { + return instance.clear(); + }, + 'scroll .js-list': _.throttle(function(e, instance) { + if (e.target.scrollTop >= e.target.scrollHeight - e.target.clientHeight && instance.hasMore.get()) { + instance.offset.set(instance.offset.get() + instance.limit.get()); + } + }, 200), + 'keyup #message-search': _.debounce(function(e, instance) { + if (e.keyCode === 13) { + return e.preventDefault(); + } + + const { value } = e.target; + + if (e.keyCode === 40 || e.keyCode === 38) { + return e.preventDefault(); + } + + instance.offset.set(0); + instance.searchTerm.set(value); + }, 300), +}); + +Template.contactChatHistoryMessages.onCreated(function() { + const currentData = Template.currentData(); + this.rid = currentData.rid; + this.messages = new ReactiveVar([]); + this.hasMore = new ReactiveVar(true); + this.offset = new ReactiveVar(0); + this.searchTerm = new ReactiveVar(''); + this.isLoading = new ReactiveVar(true); + this.limit = new ReactiveVar(MESSAGES_LIMIT); + + this.loadMessages = async (url) => { + this.isLoading.set(true); + const offset = this.offset.get(); + + const { messages, total } = await APIClient.v1.get(url); + this.messages.set(offset === 0 ? messages : this.messages.get().concat(messages)); + this.hasMore.set(total > this.messages.get().length); + this.isLoading.set(false); + }; + + this.autorun(() => { + const limit = this.limit.get(); + const offset = this.offset.get(); + const searchTerm = this.searchTerm.get(); + + if (searchTerm !== '') { + return this.loadMessages(`chat.search/?roomId=${ this.rid }&searchText=${ searchTerm }&count=${ limit }&offset=${ offset }&sort={"ts": 1}`); + } + + this.loadMessages(`channels.messages/?roomId=${ this.rid }&count=${ limit }&offset=${ offset }&sort={"ts": 1}&query={"$or": [ {"t": {"$exists": false} }, {"t": "livechat-close"} ] }`); + }); + + this.autorun(() => { + if (currentData.clear != null) { + this.clear = currentData.clear; + } + }); +}); diff --git a/app/livechat/client/views/app/tabbar/visitorHistory.html b/app/livechat/client/views/app/tabbar/visitorHistory.html deleted file mode 100644 index 25b744f51cc4..000000000000 --- a/app/livechat/client/views/app/tabbar/visitorHistory.html +++ /dev/null @@ -1,22 +0,0 @@ - diff --git a/app/livechat/client/views/app/tabbar/visitorHistory.js b/app/livechat/client/views/app/tabbar/visitorHistory.js deleted file mode 100644 index 84d3c00b447c..000000000000 --- a/app/livechat/client/views/app/tabbar/visitorHistory.js +++ /dev/null @@ -1,70 +0,0 @@ -import { ReactiveVar } from 'meteor/reactive-var'; -import { Template } from 'meteor/templating'; -import moment from 'moment'; -import _ from 'underscore'; - -import './visitorHistory.html'; -import { APIClient } from '../../../../../utils/client'; - -const ITEMS_COUNT = 50; - -Template.visitorHistory.helpers({ - isLoading() { - return Template.instance().isLoading.get(); - }, - - previousChats() { - return Template.instance().history.get(); - }, - - title() { - let title = moment(this.ts).format('L LTS'); - - if (this.label) { - title += ` - ${ this.label }`; - } - - return title; - }, -}); - -Template.visitorHistory.onCreated(function() { - const currentData = Template.currentData(); - this.visitorId = new ReactiveVar(); - this.isLoading = new ReactiveVar(false); - this.history = new ReactiveVar([]); - this.offset = new ReactiveVar(0); - this.total = new ReactiveVar(0); - - this.autorun(async () => { - const { room } = await APIClient.v1.get(`rooms.info?roomId=${ currentData.rid }`); - if (room && room.v) { - this.visitorId.set(room.v._id); - } - }); - - this.autorun(async () => { - if (!this.visitorId.get() || !currentData || !currentData.rid) { - return; - } - - const offset = this.offset.get(); - this.isLoading.set(true); - const { history, total } = await APIClient.v1.get(`livechat/visitors.chatHistory/room/${ currentData.rid }/visitor/${ this.visitorId.get() }?count=${ ITEMS_COUNT }&offset=${ offset }`); - this.isLoading.set(false); - this.total.set(total); - this.history.set(this.history.get().concat(history)); - }); -}); - -Template.visitorHistory.events({ - 'scroll .visitor-scroll': _.throttle(function(e, instance) { - if (e.target.scrollTop >= (e.target.scrollHeight - e.target.clientHeight)) { - const history = instance.history.get(); - if (instance.total.get() <= history.length) { - return; - } - return instance.offset.set(instance.offset.get() + ITEMS_COUNT); - } - }, 200), -}); diff --git a/app/livechat/client/views/regular.js b/app/livechat/client/views/regular.js index e2a3f3b8b6fd..00ce6f4184b2 100644 --- a/app/livechat/client/views/regular.js +++ b/app/livechat/client/views/regular.js @@ -8,6 +8,8 @@ import './app/tabbar/agentInfo'; import './app/tabbar/visitorEdit'; import './app/tabbar/visitorEditCustomField'; import './app/tabbar/visitorForward'; -import './app/tabbar/visitorHistory'; +import './app/tabbar/contactChatHistory'; +import './app/tabbar/contactChatHistoryItem'; +import './app/tabbar/contactChatHistoryMessages'; import './app/tabbar/visitorInfo'; import './app/tabbar/visitorNavigation'; diff --git a/app/livechat/imports/server/rest/visitors.js b/app/livechat/imports/server/rest/visitors.js index cc38c3bc4008..423950ff3d02 100644 --- a/app/livechat/imports/server/rest/visitors.js +++ b/app/livechat/imports/server/rest/visitors.js @@ -2,7 +2,7 @@ import { check } from 'meteor/check'; import { API } from '../../../../api/server'; -import { findVisitorInfo, findVisitedPages, findChatHistory, findVisitorsToAutocomplete } from '../../../server/api/lib/visitors'; +import { findVisitorInfo, findVisitedPages, findChatHistory, searchChats, findVisitorsToAutocomplete } from '../../../server/api/lib/visitors'; API.v1.addRoute('livechat/visitors.info', { authRequired: true }, { get() { @@ -47,7 +47,6 @@ API.v1.addRoute('livechat/visitors.chatHistory/room/:roomId/visitor/:visitorId', }); const { offset, count } = this.getPaginationItems(); const { sort } = this.parseJsonQuery(); - const history = Promise.await(findChatHistory({ userId: this.userId, roomId: this.urlParams.roomId, @@ -63,6 +62,33 @@ API.v1.addRoute('livechat/visitors.chatHistory/room/:roomId/visitor/:visitorId', }, }); +API.v1.addRoute('livechat/visitors.searchChats/room/:roomId/visitor/:visitorId', { authRequired: true }, { + get() { + check(this.urlParams, { + visitorId: String, + roomId: String, + }); + const { roomId, visitorId } = this.urlParams; + const { searchText, closedChatsOnly, servedChatsOnly } = this.queryParams; + const { offset, count } = this.getPaginationItems(); + const { sort } = this.parseJsonQuery(); + const history = Promise.await(searchChats({ + userId: this.userId, + roomId, + visitorId, + searchText, + closedChatsOnly, + servedChatsOnly, + pagination: { + offset, + count, + sort, + }, + })); + return API.v1.success(history); + }, +}); + API.v1.addRoute('livechat/visitors.autocomplete', { authRequired: true }, { get() { const { selector } = this.queryParams; diff --git a/app/livechat/server/api/lib/visitors.js b/app/livechat/server/api/lib/visitors.js index d1c7477fdd56..39fca9d90c8a 100644 --- a/app/livechat/server/api/lib/visitors.js +++ b/app/livechat/server/api/lib/visitors.js @@ -72,6 +72,37 @@ export async function findChatHistory({ userId, roomId, visitorId, pagination: { total, }; } +export async function searchChats({ userId, roomId, visitorId, searchText, closedChatsOnly, servedChatsOnly: served, pagination: { offset, count, sort } }) { + if (!await hasPermissionAsync(userId, 'view-l-room')) { + throw new Error('error-not-authorized'); + } + const room = await LivechatRooms.findOneById(roomId); + if (!room) { + throw new Error('invalid-room'); + } + + if (!await canAccessRoomAsync(room, { _id: userId })) { + throw new Error('error-not-allowed'); + } + + const options = { + sort: sort || { ts: -1 }, + skip: offset, + limit: count, + }; + + const [total] = await LivechatRooms.findRoomsByVisitorIdAndMessageWithCriteria({ visitorId, open: !closedChatsOnly, served, searchText, onlyCount: true }).toArray(); + const cursor = await LivechatRooms.findRoomsByVisitorIdAndMessageWithCriteria({ visitorId, open: !closedChatsOnly, served, searchText, options }); + + const history = await cursor.toArray(); + + return { + history, + count: history.length, + offset, + total: (total && total.count) || 0, + }; +} export async function findVisitorsToAutocomplete({ userId, selector }) { if (!await hasPermissionAsync(userId, 'view-l-room')) { diff --git a/app/livechat/server/api/v1/message.js b/app/livechat/server/api/v1/message.js index 0811bc8324fb..68a83706fc18 100644 --- a/app/livechat/server/api/v1/message.js +++ b/app/livechat/server/api/v1/message.js @@ -204,8 +204,10 @@ API.v1.addRoute('livechat/messages.history/:rid', { rid: String, }); + const { offset } = this.getPaginationItems(); + const { searchText: text, token } = this.queryParams; const { rid } = this.urlParams; - const { token } = this.queryParams; + const { sort } = this.parseJsonQuery(); if (!token) { throw new Meteor.Error('error-token-param-not-provided', 'The required "token" query param is missing.'); @@ -236,7 +238,7 @@ API.v1.addRoute('livechat/messages.history/:rid', { limit = parseInt(this.queryParams.limit); } - const messages = loadMessageHistory({ userId: guest._id, rid, end, limit, ls }) + const messages = loadMessageHistory({ userId: guest._id, rid, end, limit, ls, sort, offset, text }) .messages .map(normalizeMessageFileUpload); return API.v1.success({ messages }); diff --git a/app/markdown/lib/parser/original/markdown.js b/app/markdown/lib/parser/original/markdown.js index 9b17a9e09899..a9c2fdb2fe29 100644 --- a/app/markdown/lib/parser/original/markdown.js +++ b/app/markdown/lib/parser/original/markdown.js @@ -4,7 +4,6 @@ */ import { Meteor } from 'meteor/meteor'; import { Random } from 'meteor/random'; -import s from 'underscore.string'; import { settings } from '../../../../settings'; @@ -81,7 +80,7 @@ const parseNotEscaped = function(msg, message) { return match; } const target = url.indexOf(Meteor.absoluteUrl()) === 0 ? '' : '_blank'; - return addAsToken(message, `
`); + return addAsToken(message, `
`); }); // Support [Text](http://link) @@ -92,10 +91,10 @@ const parseNotEscaped = function(msg, message) { const target = url.indexOf(Meteor.absoluteUrl()) === 0 ? '' : '_blank'; title = title.replace(/&/g, '&'); - let escapedUrl = s.escapeHTML(url); + let escapedUrl = url; escapedUrl = escapedUrl.replace(/&/g, '&'); - return addAsToken(message, `${ s.escapeHTML(title) }`); + return addAsToken(message, `${ title }`); }); // Support @@ -104,7 +103,7 @@ const parseNotEscaped = function(msg, message) { return match; } const target = url.indexOf(Meteor.absoluteUrl()) === 0 ? '' : '_blank'; - return addAsToken(message, `${ s.escapeHTML(title) }`); + return addAsToken(message, `${ title }`); }); return msg; diff --git a/app/markdown/tests/client.tests.js b/app/markdown/tests/client.tests.js index 41b4df0012bd..a8283386c16f 100644 --- a/app/markdown/tests/client.tests.js +++ b/app/markdown/tests/client.tests.js @@ -15,7 +15,7 @@ const italicWrapper = (text) => wrapper(`${ text }`, '_'); const strikeWrapper = (text) => wrapper(`${ text }`, '~'); const headerWrapper = (text, level) => `${ text }`; const quoteWrapper = (text) => `
>${ text }
`; -const linkWrapped = (link, title) => `${ s.escapeHTML(title) }`; +const linkWrapped = (link, title) => `${ title }`; const inlinecodeWrapper = (text) => wrapper(`${ text }`, '`'); const codeWrapper = (text, lang) => `
\`\`\`
${ text }
\`\`\`
`; diff --git a/app/models/server/models/CustomUserStatus.js b/app/models/server/models/CustomUserStatus.js index 9e0818dc6354..eb3a586da6ba 100644 --- a/app/models/server/models/CustomUserStatus.js +++ b/app/models/server/models/CustomUserStatus.js @@ -12,6 +12,11 @@ class CustomUserStatus extends Base { return this.findOne(_id, options); } + // find one by name + findOneByName(name, options) { + return this.findOne({ name }, options); + } + // find findByName(name, options) { const query = { diff --git a/app/models/server/models/LivechatRooms.js b/app/models/server/models/LivechatRooms.js index 06707539e47f..1ff1886caa03 100644 --- a/app/models/server/models/LivechatRooms.js +++ b/app/models/server/models/LivechatRooms.js @@ -19,6 +19,7 @@ export class LivechatRooms extends Base { this.tryEnsureIndex({ closedAt: 1 }, { sparse: true }); this.tryEnsureIndex({ servedBy: 1 }, { sparse: true }); this.tryEnsureIndex({ 'v.token': 1 }, { sparse: true }); + this.tryEnsureIndex({ 'v._id': 1 }, { sparse: true }); } findLivechat(filter = {}, offset = 0, limit = 20) { diff --git a/app/models/server/models/Messages.js b/app/models/server/models/Messages.js index 285161850efa..862207d1f971 100644 --- a/app/models/server/models/Messages.js +++ b/app/models/server/models/Messages.js @@ -251,7 +251,6 @@ export class Messages extends Base { _hidden: { $ne: true, }, - rid: roomId, }; diff --git a/app/models/server/models/Users.js b/app/models/server/models/Users.js index 09f01138fe3c..6400aac6be3f 100644 --- a/app/models/server/models/Users.js +++ b/app/models/server/models/Users.js @@ -1018,20 +1018,22 @@ export class Users extends Base { return this.update(_id, update); } - setAvatarOrigin(_id, origin) { + setAvatarData(_id, origin, etag) { const update = { $set: { avatarOrigin: origin, + avatarETag: etag, }, }; return this.update(_id, update); } - unsetAvatarOrigin(_id) { + unsetAvatarData(_id) { const update = { $unset: { avatarOrigin: 1, + avatarETag: 1, }, }; diff --git a/app/models/server/raw/LivechatRooms.js b/app/models/server/raw/LivechatRooms.js index 39fa94546222..0c07e12add2b 100644 --- a/app/models/server/raw/LivechatRooms.js +++ b/app/models/server/raw/LivechatRooms.js @@ -834,11 +834,69 @@ export class LivechatRoomsRaw extends BaseRaw { t: 'l', 'v._id': visitorId, }; - return this.find(query, options); } - findRoomsWithCriteria({ agents, roomName, departmentId, open, createdAt, closedAt, tags, customFields, options = {} }) { + findRoomsByVisitorIdAndMessageWithCriteria({ visitorId, searchText, open, served, onlyCount = false, options = {} }) { + const match = { + $match: { + 'v._id': visitorId, + ...open !== undefined && { open: { $exists: open } }, + ...served !== undefined && { servedBy: { $exists: served } }, + }, + }; + const lookup = { $lookup: { from: 'rocketchat_message', localField: '_id', foreignField: 'rid', as: 'messages' } }; + const matchMessages = searchText && { $match: { 'messages.msg': { $regex: `.*${ searchText }.*` } } }; + + const params = [match, lookup]; + + if (matchMessages) { + params.push(matchMessages); + } + + const project = { + $project: { + fname: 1, + ts: 1, + v: 1, + msgs: 1, + servedBy: 1, + closedAt: 1, + closedBy: 1, + closer: 1, + tags: 1, + closingMessage: { + $filter: { + input: '$messages', + as: 'messages', + cond: { $eq: ['$$messages.t', 'livechat-close'] }, + }, + }, + }, + }; + + const unwindClosingMsg = { $unwind: { path: '$closingMessage', preserveNullAndEmptyArrays: true } }; + const sort = { $sort: options.sort || { ts: -1 } }; + + params.push(project, unwindClosingMsg, sort); + + if (onlyCount) { + params.push({ $count: 'count' }); + return this.col.aggregate(params); + } + + if (options.skip) { + params.push({ $skip: options.skip }); + } + + if (options.limit) { + params.push({ $limit: options.limit }); + } + + return this.col.aggregate(params); + } + + findRoomsWithCriteria({ agents, roomName, departmentId, open, served, createdAt, closedAt, tags, customFields, visitorId, roomIds, options = {} }) { const query = { t: 'l', }; @@ -854,6 +912,12 @@ export class LivechatRoomsRaw extends BaseRaw { if (open !== undefined) { query.open = { $exists: open }; } + if (served !== undefined) { + query.servedBy = { $exists: served }; + } + if (visitorId && visitorId !== 'undefined') { + query['v._id'] = visitorId; + } if (createdAt) { query.ts = {}; if (createdAt.start) { @@ -878,6 +942,11 @@ export class LivechatRoomsRaw extends BaseRaw { if (customFields) { query.$and = Object.keys(customFields).map((key) => ({ [`livechatData.${ key }`]: new RegExp(customFields[key], 'i') })); } + + if (roomIds) { + query._id = { $in: roomIds }; + } + return this.find(query, { sort: options.sort || { name: 1 }, skip: options.offset, limit: options.count }); } diff --git a/app/oembed/server/providers.js b/app/oembed/server/providers.js index 8a2433834ee5..b7049d9c8930 100644 --- a/app/oembed/server/providers.js +++ b/app/oembed/server/providers.js @@ -68,6 +68,11 @@ providers.registerProvider({ endPoint: 'https://www.dailymotion.com/services/oembed?maxheight=200', }); +providers.registerProvider({ + urls: [new RegExp('https?://twitter\\.com/[^/]+/status/\\S+')], + endPoint: 'https://publish.twitter.com/oembed', +}); + export const oembed = {}; oembed.providers = providers; diff --git a/app/reactions/server/setReaction.js b/app/reactions/server/setReaction.js index 52adec6d8c29..6729baf00cee 100644 --- a/app/reactions/server/setReaction.js +++ b/app/reactions/server/setReaction.js @@ -33,13 +33,9 @@ async function setReaction(room, user, message, reaction, shouldReact) { } if (Array.isArray(room.muted) && room.muted.indexOf(user.username) !== -1) { - Notifications.notifyUser(Meteor.userId(), 'message', { - _id: Random.id(), + throw new Meteor.Error('error-not-allowed', TAPi18n.__('You_have_been_muted', {}, user.language), { rid: room._id, - ts: new Date(), - msg: TAPi18n.__('You_have_been_muted', {}, user.language), }); - return false; } const userAlreadyReacted = Boolean(message.reactions) && Boolean(message.reactions[reaction]) && message.reactions[reaction].usernames.indexOf(user.username) !== -1; @@ -88,26 +84,45 @@ async function setReaction(room, user, message, reaction, shouldReact) { msgStream.emit(message.rid, message); } -Meteor.methods({ - setReaction(reaction, messageId, shouldReact) { - const user = Meteor.user(); +export const executeSetReaction = async function(reaction, messageId, shouldReact) { + const user = Meteor.user(); - if (!user) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'setReaction' }); - } + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'setReaction' }); + } - const message = Messages.findOneById(messageId); + const message = Messages.findOneById(messageId); - if (!message) { - throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'setReaction' }); - } + if (!message) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'setReaction' }); + } - const room = Meteor.call('canAccessRoom', message.rid, Meteor.userId()); + const room = Meteor.call('canAccessRoom', message.rid, Meteor.userId()); - if (!room) { - throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'setReaction' }); - } + if (!room) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'setReaction' }); + } - setReaction(room, user, message, reaction, shouldReact); + return setReaction(room, user, message, reaction, shouldReact); +}; + +Meteor.methods({ + setReaction(reaction, messageId, shouldReact) { + try { + return Promise.await(executeSetReaction(reaction, messageId, shouldReact)); + } catch (e) { + if (e.error === 'error-not-allowed' && e.reason && e.details && e.details.rid) { + Notifications.notifyUser(Meteor.userId(), 'message', { + _id: Random.id(), + rid: e.details.rid, + ts: new Date(), + msg: e.reason, + }); + + return false; + } + + throw e; + } }, }); diff --git a/app/search/client/style/style.css b/app/search/client/style/style.css index cd7509548840..6fce863ca6c7 100644 --- a/app/search/client/style/style.css +++ b/app/search/client/style/style.css @@ -23,7 +23,7 @@ .rocket-search-result { display: flex; - overflow: hidden; + overflow-x: hidden; flex-direction: column; flex: 1 1 0; } diff --git a/app/settings/client/lib/settings.ts b/app/settings/client/lib/settings.ts index 712eadc6c37d..6a9597e9df48 100644 --- a/app/settings/client/lib/settings.ts +++ b/app/settings/client/lib/settings.ts @@ -1,20 +1,13 @@ import { Meteor } from 'meteor/meteor'; import { ReactiveDict } from 'meteor/reactive-dict'; -import { CachedCollection } from '../../../ui-cached-collection'; +import { PublicSettingsCachedCollection } from '../../../../client/lib/settings/PublicSettingsCachedCollection'; import { SettingsBase, SettingValue } from '../../lib/settings'; -const cachedCollection = new CachedCollection({ - name: 'public-settings', - eventType: 'onAll', - userRelated: false, - listenChangesForLoggedUsersOnly: true, -}); - class Settings extends SettingsBase { - cachedCollection = cachedCollection + cachedCollection = PublicSettingsCachedCollection.get() - collection = cachedCollection.collection; + collection = PublicSettingsCachedCollection.get().collection; dict = new ReactiveDict('settings'); diff --git a/app/theme/client/imports/general/base_old.css b/app/theme/client/imports/general/base_old.css index c5be5ae73bde..b43977f60374 100644 --- a/app/theme/client/imports/general/base_old.css +++ b/app/theme/client/imports/general/base_old.css @@ -3871,6 +3871,10 @@ height: 2rem; } +.flex-tab.discussions > .flex-tab__content { + padding: 0; +} + .rc-old .flex-tab { &__content { display: flex; @@ -3886,6 +3890,7 @@ } &__result { + overflow-x: hidden; overflow-y: auto; flex: 1 1 auto; } diff --git a/app/ui-account/client/accountProfile.js b/app/ui-account/client/accountProfile.js index 4fabb780af70..a9b300c5681d 100644 --- a/app/ui-account/client/accountProfile.js +++ b/app/ui-account/client/accountProfile.js @@ -360,7 +360,7 @@ const checkAvailability = _.debounce((username, { usernameAvaliable }) => { Meteor.call('checkUsernameAvailability', username, function(error, data) { usernameAvaliable.set(data); }); -}, 300); +}, 800); Template.accountProfile.events({ 'change [data-customfield="true"], input [data-customfield="true"]': _.debounce((e, i) => { diff --git a/app/ui-account/client/avatar/avatar.js b/app/ui-account/client/avatar/avatar.js index 6ea8531bde2a..2035df824ff3 100644 --- a/app/ui-account/client/avatar/avatar.js +++ b/app/ui-account/client/avatar/avatar.js @@ -1,18 +1,24 @@ import { Meteor } from 'meteor/meteor'; -import { Session } from 'meteor/session'; import { Template } from 'meteor/templating'; import { getUserAvatarURL } from '../../../utils/lib/getUserAvatarURL'; const getUsername = ({ userId, username }) => { + const query = {}; if (username) { - return username; + query.username = username; } if (userId) { - const user = Meteor.users.findOne(userId, { fields: { username: 1 } }); - return user && user.username; + query._id = userId; + } + + const user = Meteor.users.findOne(query, { fields: { username: 1, avatarETag: 1 } }); + if (!user) { + return {}; } + + return user; }; Template.avatar.helpers({ @@ -22,21 +28,23 @@ Template.avatar.helpers({ return url; } - let username = getUsername(this); - if (!username) { - return; + if (this.roomIcon && this.username) { + return getUserAvatarURL(`@${ this.username }`); } - Session.get(`avatar_random_${ username }`); - - if (this.roomIcon) { - username = `@${ username }`; + const { username, avatarETag } = getUsername(this); + if (!username) { + if (this.username) { + return getUserAvatarURL(this.username); + } + return; } - return getUserAvatarURL(username); + return getUserAvatarURL(username, avatarETag); }, alt() { - return getUsername(this); + const { username } = getUsername(this); + return username; }, }); diff --git a/app/ui-flextab/client/tabs/membersList.html b/app/ui-flextab/client/tabs/membersList.html index 69c75e11214e..9eead962def0 100644 --- a/app/ui-flextab/client/tabs/membersList.html +++ b/app/ui-flextab/client/tabs/membersList.html @@ -34,7 +34,7 @@
    {{#each users}}
  • - {{> avatar username=user.username}} + {{> avatar url=avatarUrl}}
    {{# userPresence uid=user._id}}
    {{/userPresence}} {{ignored}} {{displayName}} {{utcOffset}} diff --git a/app/ui-flextab/client/tabs/membersList.js b/app/ui-flextab/client/tabs/membersList.js index bd1797578ab8..54d8bf1a297e 100644 --- a/app/ui-flextab/client/tabs/membersList.js +++ b/app/ui-flextab/client/tabs/membersList.js @@ -4,12 +4,12 @@ import { Session } from 'meteor/session'; import { Template } from 'meteor/templating'; import { getActions } from './userActions'; -import { RoomManager, popover } from '../../../ui-utils'; -import { ChatRoom, Subscriptions } from '../../../models'; -import { settings } from '../../../settings'; -import { t, isRtl, handleError, roomTypes } from '../../../utils'; +import { RoomManager, popover } from '../../../ui-utils/client'; +import { ChatRoom, Subscriptions } from '../../../models/client'; +import { settings } from '../../../settings/client'; +import { t, isRtl, handleError, roomTypes, getUserAvatarURL } from '../../../utils/client'; import { WebRTC } from '../../../webrtc/client'; -import { hasPermission } from '../../../authorization'; +import { hasPermission } from '../../../authorization/client'; Template.membersList.helpers({ ignored() { @@ -129,6 +129,11 @@ Template.membersList.helpers({ loadingMore() { return Template.instance().loadingMore.get(); }, + + avatarUrl() { + const { user: { username, avatarETag } } = this; + return getUserAvatarURL(username, avatarETag); + }, }); Template.membersList.events({ diff --git a/app/ui-flextab/client/tabs/userInfo.html b/app/ui-flextab/client/tabs/userInfo.html index 69c85ecd4075..ca38703e9e31 100644 --- a/app/ui-flextab/client/tabs/userInfo.html +++ b/app/ui-flextab/client/tabs/userInfo.html @@ -105,7 +105,7 @@ {{/if}} {{#each customField}} {{/each}} diff --git a/app/ui-flextab/client/tabs/userInfo.js b/app/ui-flextab/client/tabs/userInfo.js index 53041723b572..6da899415e08 100644 --- a/app/ui-flextab/client/tabs/userInfo.js +++ b/app/ui-flextab/client/tabs/userInfo.js @@ -255,6 +255,10 @@ Template.userInfo.events({ 'click .js-close-info'(e, instance) { return instance.clear(); }, + 'click .js-close'(e, instance) { + return instance.clear(); + }, + 'click .js-back'(e, instance) { return instance.clear(); }, diff --git a/app/ui-message/client/message.html b/app/ui-message/client/message.html index 5269bcc5711e..2d2e08fa8320 100644 --- a/app/ui-message/client/message.html +++ b/app/ui-message/client/message.html @@ -60,6 +60,9 @@ {{/if}} + {{#if msg.pinned}} + + {{/if}} {{#if showStar}} {{/if}} diff --git a/app/ui-message/client/popup/messagePopupConfig.js b/app/ui-message/client/popup/messagePopupConfig.js index a99b3e108385..def4a4714ced 100644 --- a/app/ui-message/client/popup/messagePopupConfig.js +++ b/app/ui-message/client/popup/messagePopupConfig.js @@ -67,13 +67,14 @@ const fetchUsersFromServer = _.throttle(async (filterText, records, rid, cb) => users .slice(0, 5) - .forEach(({ username, name, status }) => { + .forEach(({ username, name, status, avatarETag }) => { if (records.length < 5) { records.push({ _id: username, username, name, status, + avatarETag, sort: 3, }); } diff --git a/app/ui-message/client/popup/messagePopupUser.html b/app/ui-message/client/popup/messagePopupUser.html index c90a04375d3d..c620f4553e50 100644 --- a/app/ui-message/client/popup/messagePopupUser.html +++ b/app/ui-message/client/popup/messagePopupUser.html @@ -1,7 +1,7 @@ diff --git a/app/ui-sidenav/client/toolbar.js b/app/ui-sidenav/client/toolbar.js index 3c59a62a7496..df0995056d9d 100644 --- a/app/ui-sidenav/client/toolbar.js +++ b/app/ui-sidenav/client/toolbar.js @@ -52,6 +52,7 @@ const getFromServer = (cb, type) => { t: 'd', name: user.username, fname: user.name, + avatarETag: user.avatarETag, }); resultsFromServer.push(...results.users.map(userMap)); diff --git a/app/ui-utils/client/lib/popover.js b/app/ui-utils/client/lib/popover.js index 300384d7e65a..2aaaadc799aa 100644 --- a/app/ui-utils/client/lib/popover.js +++ b/app/ui-utils/client/lib/popover.js @@ -54,6 +54,7 @@ Template.popover.onRendered(function() { }); const { offsetVertical = 0, offsetHorizontal = 0 } = this.data; const { activeElement } = this.data; + const originalWidth = window.innerWidth; const popoverContent = this.firstNode.children[0]; const position = _.throttle(() => { const direction = typeof this.data.direction === 'function' ? this.data.direction() : this.data.direction; @@ -73,9 +74,11 @@ Template.popover.onRendered(function() { const offsetWidth = offsetHorizontal * (horizontalDirection === 'left' ? 1 : -1); const offsetHeight = offsetVertical * (verticalDirection === 'bottom' ? 1 : -1); + const leftDiff = window.innerWidth - originalWidth; + if (position) { popoverContent.style.top = `${ position.top }px`; - popoverContent.style.left = `${ position.left }px`; + popoverContent.style.left = `${ position.left + leftDiff }px`; } else { const clientHeight = this.data.targetRect.height; const popoverWidth = popoverContent.offsetWidth; @@ -112,7 +115,7 @@ Template.popover.onRendered(function() { } popoverContent.style.top = `${ top }px`; - popoverContent.style.left = `${ left }px`; + popoverContent.style.left = `${ left + leftDiff }px`; } if (customCSSProperties) { @@ -131,7 +134,6 @@ Template.popover.onRendered(function() { if (activeElement) { $(activeElement).addClass('active'); } - popoverContent.style.opacity = 1; }, 50); $(window).on('resize', position); diff --git a/app/utils/lib/getAvatarURL.js b/app/utils/lib/getAvatarURL.js index 6794060a3cee..a6a50930e588 100644 --- a/app/utils/lib/getAvatarURL.js +++ b/app/utils/lib/getAvatarURL.js @@ -2,9 +2,9 @@ import { getURL } from './getURL'; export const getAvatarURL = ({ username, roomId, cache }) => { if (username) { - return getURL(`/avatar/${ encodeURIComponent(username) }${ cache ? `?_dc=${ cache }` : '' }`); + return getURL(`/avatar/${ encodeURIComponent(username) }${ cache ? `?etag=${ cache }` : '' }`); } if (roomId) { - return getURL(`/avatar/room/${ encodeURIComponent(roomId) }${ cache ? `?_dc=${ cache }` : '' }`); + return getURL(`/avatar/room/${ encodeURIComponent(roomId) }${ cache ? `?etag=${ cache }` : '' }`); } }; diff --git a/app/utils/lib/getUserAvatarURL.js b/app/utils/lib/getUserAvatarURL.js index 82f0905f571b..54a546ba9ec3 100644 --- a/app/utils/lib/getUserAvatarURL.js +++ b/app/utils/lib/getUserAvatarURL.js @@ -1,10 +1,7 @@ -import { Session } from 'meteor/session'; -import { Tracker } from 'meteor/tracker'; - import { getAvatarURL } from './getAvatarURL'; import { settings } from '../../settings'; -export const getUserAvatarURL = function(username) { +export const getUserAvatarURL = function(username, cache = '') { const externalSource = (settings.get('Accounts_AvatarExternalProviderUrl') || '').trim().replace(/\/$/, ''); if (externalSource !== '') { return externalSource.replace('{username}', username); @@ -12,8 +9,6 @@ export const getUserAvatarURL = function(username) { if (username == null) { return; } - const key = `avatar_random_${ username }`; - const cache = Tracker.nonreactive(() => Session && Session.get(key)); // there is no Session on server return getAvatarURL({ username, cache }); }; diff --git a/app/videobridge/client/views/videoFlexTab.js b/app/videobridge/client/views/videoFlexTab.js index ebc32f1bace2..803f4648a54c 100644 --- a/app/videobridge/client/views/videoFlexTab.js +++ b/app/videobridge/client/views/videoFlexTab.js @@ -5,8 +5,8 @@ import { Template } from 'meteor/templating'; import { TimeSync } from 'meteor/mizzao:timesync'; import { settings } from '../../../settings'; -import { modal, TabBar } from '../../../ui-utils'; -import { t } from '../../../utils'; +import { modal, TabBar, call } from '../../../ui-utils/client'; +import { t } from '../../../utils/client'; import { Users, Rooms } from '../../../models'; import * as CONSTANTS from '../../constants'; @@ -57,22 +57,33 @@ Template.videoFlexTab.onRendered(function() { this.stop = stop; - const start = () => { - const update = () => { - const { jitsiTimeout } = Rooms.findOne({ _id: rid }, { fields: { jitsiTimeout: 1 } }); + const update = async () => { + const { jitsiTimeout } = Rooms.findOne({ _id: rid }, { fields: { jitsiTimeout: 1 } }); - if (jitsiTimeout && (TimeSync.serverTime() - new Date(jitsiTimeout) + CONSTANTS.TIMEOUT < CONSTANTS.DEBOUNCE)) { + if (jitsiTimeout && (TimeSync.serverTime() - new Date(jitsiTimeout) + CONSTANTS.TIMEOUT < CONSTANTS.DEBOUNCE)) { + return; + } + if (Meteor.status().connected) { + return call('jitsi:updateTimeout', rid); + } + closePanel(); + return this.stop(); + }; + + const start = async () => { + try { + const jitsiTimeout = await update(); + if (!jitsiTimeout) { return; } - if (Meteor.status().connected) { - return Meteor.call('jitsi:updateTimeout', rid); - } + this.intervalHandler = setInterval(update, CONSTANTS.HEARTBEAT); + TabBar.updateButton('video', { class: 'red' }); + return jitsiTimeout; + } catch (error) { + console.error(error); closePanel(); - return this.stop(); - }; - update(); - this.intervalHandler = setInterval(update, CONSTANTS.HEARTBEAT); - TabBar.updateButton('video', { class: 'red' }); + throw error; + } }; modal.open({ @@ -111,54 +122,47 @@ Template.videoFlexTab.onRendered(function() { return stop(); } - let accessToken = null; - if (isEnabledTokenAuth) { - accessToken = await new Promise((resolve, reject) => { - Meteor.call('jitsi:generateAccessToken', rid, (error, result) => { - if (error) { - return reject(error); - } - resolve(result); - }); - }); - } + const accessToken = isEnabledTokenAuth && await call('jitsi:generateAccessToken', rid); jitsiRoomActive = jitsiRoom; if (settings.get('Jitsi_Open_New_Window')) { - Tracker.nonreactive(() => start()); - let queryString = ''; - if (accessToken) { - queryString = `?jwt=${ accessToken }`; - } - - const newWindow = window.open(`${ (noSsl ? 'http://' : 'https://') + domain }/${ jitsiRoom }${ queryString }`, jitsiRoom); - if (newWindow) { - const closeInterval = setInterval(() => { - if (newWindow.closed === false) { - return; - } - closePanel(); - stop(); - clearInterval(closeInterval); - }, 300); - return newWindow.focus(); - } + Tracker.nonreactive(async () => { + await start(); + + const queryString = accessToken && `?jwt=${ accessToken }`; + + const newWindow = window.open(`${ (noSsl ? 'http://' : 'https://') + domain }/${ jitsiRoom }${ queryString }`, jitsiRoom); + if (newWindow) { + const closeInterval = setInterval(() => { + if (newWindow.closed === false) { + return; + } + closePanel(); + stop(); + clearInterval(closeInterval); + }, 300); + return newWindow.focus(); + } + }); } if (typeof JitsiMeetExternalAPI !== 'undefined') { // Keep it from showing duplicates when re-evaluated on variable change. const name = Users.findOne(Meteor.userId(), { fields: { name: 1 } }); if (!$('[id^=jitsiConference]').length) { - this.api = new JitsiMeetExternalAPI(domain, jitsiRoom, width, height, this.$('.video-container').get(0), configOverwrite, interfaceConfigOverwrite, noSsl, accessToken); - - /* - * Hack to send after frame is loaded. - * postMessage converts to events in the jitsi meet iframe. - * For some reason those aren't working right. - */ - Meteor.setTimeout(() => this.api.executeCommand('displayName', [name]), 5000); - return Tracker.nonreactive(() => start()); + Tracker.nonreactive(async () => { + await start(); + + this.api = new JitsiMeetExternalAPI(domain, jitsiRoom, width, height, this.$('.video-container').get(0), configOverwrite, interfaceConfigOverwrite, noSsl, accessToken); + + /* + * Hack to send after frame is loaded. + * postMessage converts to events in the jitsi meet iframe. + * For some reason those aren't working right. + */ + Meteor.setTimeout(() => this.api.executeCommand('displayName', [name]), 5000); + }); } // Execute any commands that might be reactive. Like name changing. diff --git a/app/videobridge/server/methods/jitsiSetTimeout.js b/app/videobridge/server/methods/jitsiSetTimeout.js index af0351a6d4b2..2a18ee53811f 100644 --- a/app/videobridge/server/methods/jitsiSetTimeout.js +++ b/app/videobridge/server/methods/jitsiSetTimeout.js @@ -1,10 +1,12 @@ import { Meteor } from 'meteor/meteor'; import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; -import { Rooms, Messages } from '../../../models'; -import { callbacks } from '../../../callbacks'; +import { Rooms, Messages, Users } from '../../../models/server'; +import { callbacks } from '../../../callbacks/server'; +import { metrics } from '../../../metrics/server'; import * as CONSTANTS from '../../constants'; -import { canAccessRoom } from '../../../authorization/server'; +import { canSendMessage } from '../../../authorization/server'; +import { SystemLogger } from '../../../logger/server'; Meteor.methods({ 'jitsi:updateTimeout': (rid) => { @@ -12,34 +14,51 @@ Meteor.methods({ throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'jitsi:updateTimeout' }); } - const room = Rooms.findOneById(rid); + const uid = Meteor.userId(); - if (!canAccessRoom(room, Meteor.user())) { - throw new Meteor.Error('error-not-allowerd', 'not allowed', { method: 'jitsi:updateTimeout' }); - } + const user = Users.findOneById(uid, { + fields: { + username: 1, + type: 1, + }, + }); - const currentTime = new Date().getTime(); + try { + const room = canSendMessage(rid, { uid, username: user.username, type: user.type }); - const jitsiTimeout = room.jitsiTimeout && new Date(room.jitsiTimeout).getTime(); + const currentTime = new Date().getTime(); - if (!jitsiTimeout || currentTime > jitsiTimeout - CONSTANTS.TIMEOUT / 2) { - Rooms.setJitsiTimeout(rid, new Date(currentTime + CONSTANTS.TIMEOUT)); - } + const jitsiTimeout = room.jitsiTimeout && new Date(room.jitsiTimeout).getTime(); + + const nextTimeOut = new Date(currentTime + CONSTANTS.TIMEOUT); + + if (!jitsiTimeout || currentTime > jitsiTimeout - CONSTANTS.TIMEOUT / 2) { + Rooms.setJitsiTimeout(rid, nextTimeOut); + } + + if (!jitsiTimeout || currentTime > jitsiTimeout) { + metrics.messagesSent.inc(); // TODO This line needs to be moved to it's proper place. See the comments on: https://github.com/RocketChat/Rocket.Chat/pull/5736 + + const message = Messages.createWithTypeRoomIdMessageAndUser('jitsi_call_started', rid, '', Meteor.user(), { + actionLinks: [ + { icon: 'icon-videocam', label: TAPi18n.__('Click_to_join'), method_id: 'joinJitsiCall', params: '' }, + ], + }); + message.msg = TAPi18n.__('Started_a_video_call'); + message.mentions = [ + { + _id: 'here', + username: 'here', + }, + ]; + callbacks.run('afterSaveMessage', message, { ...room, jitsiTimeout: currentTime + CONSTANTS.TIMEOUT }); + } + + return jitsiTimeout || nextTimeOut; + } catch (error) { + SystemLogger.error('Error starting video call:', error); - if (!jitsiTimeout || currentTime > jitsiTimeout) { - const message = Messages.createWithTypeRoomIdMessageAndUser('jitsi_call_started', rid, '', Meteor.user(), { - actionLinks: [ - { icon: 'icon-videocam', label: TAPi18n.__('Click_to_join'), method_id: 'joinJitsiCall', params: '' }, - ], - }); - message.msg = TAPi18n.__('Started_a_video_call'); - message.mentions = [ - { - _id: 'here', - username: 'here', - }, - ]; - callbacks.run('afterSaveMessage', message, { ...room, jitsiTimeout: currentTime + CONSTANTS.TIMEOUT }); + throw new Meteor.Error('error-starting-video-call', error.message); } }, }); diff --git a/client/admin/AdministrationRouter.js b/client/admin/AdministrationRouter.js index 1abeee9688f0..381bfa8ceee6 100644 --- a/client/admin/AdministrationRouter.js +++ b/client/admin/AdministrationRouter.js @@ -1,17 +1,17 @@ import React, { lazy, useMemo, Suspense } from 'react'; +import SettingsProvider from '../providers/SettingsProvider'; import AdministrationLayout from './AdministrationLayout'; -import PrivilegedSettingsProvider from './PrivilegedSettingsProvider'; import PageSkeleton from './PageSkeleton'; function AdministrationRouter({ lazyRouteComponent, ...props }) { const LazyRouteComponent = useMemo(() => lazy(lazyRouteComponent), [lazyRouteComponent]); return - + }> - + ; } diff --git a/client/admin/PrivilegedSettingsProvider.js b/client/admin/PrivilegedSettingsProvider.js deleted file mode 100644 index 4cdfbeae0d7c..000000000000 --- a/client/admin/PrivilegedSettingsProvider.js +++ /dev/null @@ -1,217 +0,0 @@ -import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { Mongo } from 'meteor/mongo'; -import { Tracker } from 'meteor/tracker'; -import React, { useEffect, useMemo, useReducer, useRef, useState } from 'react'; - -import { PrivilegedSettingsContext } from '../contexts/PrivilegedSettingsContext'; -import { useAtLeastOnePermission } from '../contexts/AuthorizationContext'; -import { PrivateSettingsCachedCollection } from './PrivateSettingsCachedCollection'; - -const compareStrings = (a = '', b = '') => { - if (a === b || (!a && !b)) { - return 0; - } - - return a > b ? 1 : -1; -}; - -const compareSettings = (a, b) => - compareStrings(a.section, b.section) - || compareStrings(a.sorter, b.sorter) - || compareStrings(a.i18nLabel, b.i18nLabel); - -const settingsReducer = (states, { type, payload }) => { - const { - settings, - persistedSettings, - } = states; - - switch (type) { - case 'add': { - return { - settings: [...settings, ...payload].sort(compareSettings), - persistedSettings: [...persistedSettings, ...payload].sort(compareSettings), - }; - } - - case 'change': { - const mapping = (setting) => (setting._id !== payload._id ? setting : payload); - - return { - settings: settings.map(mapping), - persistedSettings: settings.map(mapping), - }; - } - - case 'remove': { - const mapping = (setting) => setting._id !== payload; - - return { - settings: settings.filter(mapping), - persistedSettings: persistedSettings.filter(mapping), - }; - } - - case 'hydrate': { - const map = {}; - payload.forEach((setting) => { - map[setting._id] = setting; - }); - - const mapping = (setting) => (map[setting._id] ? { ...setting, ...map[setting._id] } : setting); - - return { - settings: settings.map(mapping), - persistedSettings, - }; - } - } - - return states; -}; - -function AuthorizedPrivilegedSettingsProvider({ cachedCollection, children }) { - const [isLoading, setLoading] = useState(true); - - const subscribersRef = useRef(); - if (!subscribersRef.current) { - subscribersRef.current = new Set(); - } - - const stateRef = useRef({ settings: [], persistedSettings: [] }); - - const [state, dispatch] = useReducer(settingsReducer, { settings: [], persistedSettings: [] }); - stateRef.current = state; - - subscribersRef.current.forEach((subscriber) => { - subscriber(state); - }); - - const collectionsRef = useRef({}); - - useEffect(() => { - const stopLoading = () => { - setLoading(false); - }; - - if (!Tracker.nonreactive(() => cachedCollection.ready.get())) { - cachedCollection.init().then(stopLoading, stopLoading); - } else { - stopLoading(); - } - - const { collection: persistedSettingsCollection } = cachedCollection; - const settingsCollection = new Mongo.Collection(null); - - collectionsRef.current = { - persistedSettingsCollection, - settingsCollection, - }; - }, [collectionsRef]); - - useEffect(() => { - if (isLoading) { - return; - } - - const { current: { persistedSettingsCollection, settingsCollection } } = collectionsRef; - - const query = persistedSettingsCollection.find(); - - const syncCollectionsHandle = query.observe({ - added: (data) => settingsCollection.insert(data), - changed: (data) => settingsCollection.update(data._id, data), - removed: ({ _id }) => settingsCollection.remove(_id), - }); - - const addedQueue = []; - let addedActionTimer; - - const syncStateHandle = query.observe({ - added: (data) => { - addedQueue.push(data); - clearTimeout(addedActionTimer); - addedActionTimer = setTimeout(() => { - dispatch({ type: 'add', payload: addedQueue }); - }, 300); - }, - changed: (data) => { - dispatch({ type: 'change', payload: data }); - }, - removed: ({ _id }) => { - dispatch({ type: 'remove', payload: _id }); - }, - }); - - return () => { - syncCollectionsHandle && syncCollectionsHandle.stop(); - syncStateHandle && syncStateHandle.stop(); - clearTimeout(addedActionTimer); - }; - }, [isLoading, collectionsRef]); - - const updateTimersRef = useRef({}); - - const updateAtCollection = useMutableCallback(({ _id, ...data }) => { - const { current: { settingsCollection } } = collectionsRef; - const { current: updateTimers } = updateTimersRef; - clearTimeout(updateTimers[_id]); - updateTimers[_id] = setTimeout(() => { - settingsCollection.update(_id, { $set: data }); - }, 70); - }); - - const hydrate = useMutableCallback((changes) => { - changes.forEach(updateAtCollection); - dispatch({ type: 'hydrate', payload: changes }); - }); - - const isDisabled = useMutableCallback(({ blocked, enableQuery }) => { - if (blocked) { - return true; - } - - if (!enableQuery) { - return false; - } - - const { current: { settingsCollection } } = collectionsRef; - - const queries = [].concat(typeof enableQuery === 'string' ? JSON.parse(enableQuery) : enableQuery); - return !queries.every((query) => !!settingsCollection.findOne(query)); - }); - - const contextValue = useMemo(() => ({ - authorized: true, - loading: isLoading, - subscribers: subscribersRef.current, - stateRef, - hydrate, - isDisabled, - }), [ - isLoading, - hydrate, - isDisabled, - ]); - - return ; -} - -function PrivilegedSettingsProvider({ children }) { - const hasPermission = useAtLeastOnePermission([ - 'view-privileged-setting', - 'edit-privileged-setting', - 'manage-selected-settings', - ]); - - if (!hasPermission) { - return children; - } - - return ; -} - -export default PrivilegedSettingsProvider; diff --git a/client/admin/cloud/CloudPage.js b/client/admin/cloud/CloudPage.js index e4e285da741a..3223014073b8 100644 --- a/client/admin/cloud/CloudPage.js +++ b/client/admin/cloud/CloudPage.js @@ -58,7 +58,7 @@ function CloudPage() { }; acceptOAuthAuthorization(); - }, [errorCode, code, state]); + }, [errorCode, code, state, page, dispatchToastMessage, t, cloudRoute, finishOAuthAuthorization]); const [registerStatus, setRegisterStatus] = useSafely(useState()); const [modal, setModal] = useState(null); @@ -92,7 +92,7 @@ function CloudPage() { }; acceptWorkspaceToken(); - }, [token]); + }, [connectWorkspace, dispatchToastMessage, fetchRegisterStatus, t, token]); const handleManualWorkspaceRegistrationButtonClick = () => { const handleModalClose = () => { diff --git a/client/admin/cloud/ManualWorkspaceRegistrationModal.js b/client/admin/cloud/ManualWorkspaceRegistrationModal.js index ffd3a68bc4b0..b424942a1e72 100644 --- a/client/admin/cloud/ManualWorkspaceRegistrationModal.js +++ b/client/admin/cloud/ManualWorkspaceRegistrationModal.js @@ -24,7 +24,7 @@ function CopyStep({ onNextButtonClick }) { }; loadWorkspaceRegisterData(); - }, []); + }, [getWorkspaceRegisterData]); const copyRef = useRef(); @@ -37,7 +37,7 @@ function CopyStep({ onNextButtonClick }) { return () => { clipboard.destroy(); }; - }, []); + }, [dispatchToastMessage, t]); return <> diff --git a/client/admin/cloud/WorkspaceLoginSection.js b/client/admin/cloud/WorkspaceLoginSection.js index 7e2bad6cb0dd..bc98cc544755 100644 --- a/client/admin/cloud/WorkspaceLoginSection.js +++ b/client/admin/cloud/WorkspaceLoginSection.js @@ -82,7 +82,7 @@ function WorkspaceLoginSection({ }; checkLoginState(); - }, []); + }, [checkUserLoggedIn, dispatchToastMessage, setLoading, setLoggedIn]); return diff --git a/client/admin/customEmoji/AddCustomEmoji.js b/client/admin/customEmoji/AddCustomEmoji.js index f429710763d7..67b9997553eb 100644 --- a/client/admin/customEmoji/AddCustomEmoji.js +++ b/client/admin/customEmoji/AddCustomEmoji.js @@ -32,7 +32,7 @@ export function AddCustomEmoji({ close, onChange, ...props }) { onChange(); close(); } - }, [name, aliases, emojiFile]); + }, [emojiFile, name, aliases, saveAction, onChange, close]); const clickUpload = useFileInput(setEmojiPreview, 'emoji'); diff --git a/client/admin/customEmoji/CustomEmoji.js b/client/admin/customEmoji/CustomEmoji.js index 84c7d5867ddf..3effa9b5cc2d 100644 --- a/client/admin/customEmoji/CustomEmoji.js +++ b/client/admin/customEmoji/CustomEmoji.js @@ -11,7 +11,7 @@ const FilterByText = ({ setFilter, ...props }) => { useEffect(() => { setFilter({ text }); - }, [text]); + }, [setFilter, text]); return e.preventDefault(), [])} display='flex' flexDirection='column' {...props}> } onChange={handleChange} value={text} /> ; @@ -30,7 +30,7 @@ export function CustomEmoji({ const header = useMemo(() => [ {t('Name')}, {t('Aliases')}, - ], [sort]); + ], [onHeaderClick, sort, t]); const renderRow = (emojis) => { const { _id, name, aliases } = emojis; diff --git a/client/admin/customEmoji/CustomEmojiRoute.js b/client/admin/customEmoji/CustomEmojiRoute.js index fa80981c77c1..24a51f54a0fa 100644 --- a/client/admin/customEmoji/CustomEmojiRoute.js +++ b/client/admin/customEmoji/CustomEmojiRoute.js @@ -15,12 +15,13 @@ import VerticalBar from '../../components/basic/VerticalBar'; const sortDir = (sortDir) => (sortDir === 'asc' ? 1 : -1); -export const useQuery = (params, sort, cache) => useMemo(() => ({ - query: JSON.stringify({ name: { $regex: params.text || '', $options: 'i' } }), - sort: JSON.stringify({ [sort[0]]: sortDir(sort[1]) }), - ...params.itemsPerPage && { count: params.itemsPerPage }, - ...params.current && { offset: params.current }, -}), [JSON.stringify(params), JSON.stringify(sort), cache]); +export const useQuery = ({ text, itemsPerPage, current }, [column, direction], cache) => useMemo(() => ({ + query: JSON.stringify({ name: { $regex: text || '', $options: 'i' } }), + sort: JSON.stringify({ [column]: sortDir(direction) }), + ...itemsPerPage && { count: itemsPerPage }, + ...current && { offset: current }, +// TODO: remove cache. Is necessary for data invalidation +}), [text, itemsPerPage, current, column, direction, cache]); export default function CustomEmojiRoute({ props }) { const t = useTranslation(); diff --git a/client/admin/customEmoji/EditCustomEmoji.js b/client/admin/customEmoji/EditCustomEmoji.js index e56af8ce2db7..5e37344463cb 100644 --- a/client/admin/customEmoji/EditCustomEmoji.js +++ b/client/admin/customEmoji/EditCustomEmoji.js @@ -52,6 +52,7 @@ export function EditCustomEmojiWithData({ _id, cache, onChange, ...props }) { const t = useTranslation(); const query = useMemo(() => ({ query: JSON.stringify({ _id }), + // TODO: remove cache. Is necessary for data invalidation }), [_id, cache]); const { data = { emojis: {} }, state, error } = useEndpointDataExperimental('emoji-custom.list', query); @@ -101,7 +102,7 @@ export function EditCustomEmoji({ close, onChange, data, ...props }) { setNewEmojiPreview(URL.createObjectURL(file)); }, [setEmojiFile]); - const hasUnsavedChanges = useMemo(() => previousName !== name || aliases !== previousAliases.join(', ') || !!emojiFile, [name, aliases, emojiFile]); + const hasUnsavedChanges = useMemo(() => previousName !== name || aliases !== previousAliases.join(', ') || !!emojiFile, [previousName, name, aliases, previousAliases, emojiFile]); const saveAction = useEndpointUpload('emoji-custom.update', {}, t('Custom_Emoji_Updated_Successfully')); @@ -115,7 +116,7 @@ export function EditCustomEmoji({ close, onChange, data, ...props }) { if (result.success) { onChange(); } - }, [name, _id, aliases, emojiFile]); + }, [emojiFile, _id, name, aliases, saveAction, onChange]); const deleteAction = useEndpointAction('POST', 'emoji-custom.delete', useMemo(() => ({ emojiId: _id }), [_id])); @@ -124,11 +125,11 @@ export function EditCustomEmoji({ close, onChange, data, ...props }) { if (result.success) { setModal(() => { setModal(undefined); close(); onChange(); }}/>); } - }, [_id]); + }, [close, deleteAction, onChange]); const openConfirmDelete = useCallback(() => setModal(() => setModal(undefined)}/>), [onDeleteConfirm, setModal]); - const handleAliasesChange = useCallback((e) => setAliases(e.currentTarget.value, [])); + const handleAliasesChange = useCallback((e) => setAliases(e.currentTarget.value), [setAliases]); const clickUpload = useFileInput(setEmojiPreview, 'emoji'); diff --git a/client/admin/customSounds/AddCustomSound.js b/client/admin/customSounds/AddCustomSound.js index 15be04aceb6e..7ced1e33aa33 100644 --- a/client/admin/customSounds/AddCustomSound.js +++ b/client/admin/customSounds/AddCustomSound.js @@ -19,15 +19,15 @@ export function AddCustomSound({ goToNew, close, onChange, ...props }) { const insertOrUpdateSound = useMethod('insertOrUpdateSound'); - const handleChangeFile = (soundFile) => { + const handleChangeFile = useCallback((soundFile) => { setSound(soundFile); - }; + }, []); const clickUpload = useFileInput(handleChangeFile, 'audio/mp3'); - const saveAction = async (name, soundFile) => { + const saveAction = useCallback(async (name, soundFile) => { const soundData = createSoundData(soundFile, name); - const validation = validate(soundData, sound); + const validation = validate(soundData, soundFile); if (validation.length === 0) { let soundId; try { @@ -56,7 +56,7 @@ export function AddCustomSound({ goToNew, close, onChange, ...props }) { return soundId; } validation.forEach((error) => { throw new Error({ type: 'error', message: t('error-the-field-is-required', { field: t(error) }) }); }); - }; + }, [dispatchToastMessage, insertOrUpdateSound, t, uploadCustomSound]); const handleSave = useCallback(async () => { try { @@ -70,7 +70,7 @@ export function AddCustomSound({ goToNew, close, onChange, ...props }) { } catch (error) { dispatchToastMessage({ type: 'error', message: error }); } - }, [name, sound]); + }, [dispatchToastMessage, goToNew, name, onChange, saveAction, sound, t]); return diff --git a/client/admin/customSounds/AdminSoundsRoute.js b/client/admin/customSounds/AdminSoundsRoute.js index 9a5ad6e8516a..0497f6a6bd2a 100644 --- a/client/admin/customSounds/AdminSoundsRoute.js +++ b/client/admin/customSounds/AdminSoundsRoute.js @@ -17,12 +17,13 @@ import NotAuthorizedPage from '../NotAuthorizedPage'; const sortDir = (sortDir) => (sortDir === 'asc' ? 1 : -1); -export const useQuery = (params, sort, cache) => useMemo(() => ({ - query: JSON.stringify({ name: { $regex: params.text || '', $options: 'i' } }), - sort: JSON.stringify({ [sort[0]]: sortDir(sort[1]) }), - ...params.itemsPerPage && { count: params.itemsPerPage }, - ...params.current && { offset: params.current }, -}), [JSON.stringify(params), JSON.stringify(sort), cache]); +export const useQuery = ({ text, itemsPerPage, current }, [column, direction], cache) => useMemo(() => ({ + query: JSON.stringify({ name: { $regex: text || '', $options: 'i' } }), + sort: JSON.stringify({ [column]: sortDir(direction) }), + ...itemsPerPage && { count: itemsPerPage }, + ...current && { offset: current }, + // TODO: remove cache. Is necessary for data invalidation +}), [text, itemsPerPage, current, column, direction, cache]); export default function CustomSoundsRoute({ props }) { const t = useTranslation(); @@ -50,12 +51,12 @@ export default function CustomSoundsRoute({ props }) { const context = useRouteParameter('context'); const id = useRouteParameter('id'); - const onClick = (_id) => () => { + const onClick = useCallback((_id) => () => { router.push({ context: 'edit', id: _id, }); - }; + }, [router]); const onHeaderClick = (id) => { const [sortBy, sortDirection] = sort; @@ -71,18 +72,18 @@ export default function CustomSoundsRoute({ props }) { router.push({ context }); }, [router]); - const close = () => { + const close = useCallback(() => { router.push({}); - }; - - if (!canManageCustomSounds) { - return ; - } + }, [router]); const onChange = useCallback(() => { setCache(new Date()); }, []); + if (!canManageCustomSounds) { + return ; + } + return diff --git a/client/admin/customSounds/EditCustomSound.js b/client/admin/customSounds/EditCustomSound.js index d7938a96a589..0f1bbb2b1437 100644 --- a/client/admin/customSounds/EditCustomSound.js +++ b/client/admin/customSounds/EditCustomSound.js @@ -56,7 +56,6 @@ export function EditCustomSound({ _id, cache, ...props }) { const { data, state, error } = useEndpointDataExperimental('custom-sounds.list', query); - if (state === ENDPOINT_STATES.LOADING) { return @@ -100,13 +99,13 @@ function EditSound({ close, onChange, data, ...props }) { const uploadCustomSound = useMethod('uploadCustomSound'); const insertOrUpdateSound = useMethod('insertOrUpdateSound'); - const handleChangeFile = (soundFile) => { + const handleChangeFile = useCallback((soundFile) => { setSound(soundFile); - }; + }, []); - const hasUnsavedChanges = useMemo(() => previousName !== name || previousSound !== sound, [name, sound]); + const hasUnsavedChanges = useMemo(() => previousName !== name || previousSound !== sound, [name, previousName, previousSound, sound]); - const saveAction = async (sound) => { + const saveAction = useCallback(async (sound) => { const soundData = createSoundData(sound, name, { previousName, previousSound, _id }); const validation = validate(soundData, sound); if (validation.length === 0) { @@ -137,12 +136,12 @@ function EditSound({ close, onChange, data, ...props }) { } validation.forEach((error) => dispatchToastMessage({ type: 'error', message: t('error-the-field-is-required', { field: t(error) }) })); - }; + }, [_id, dispatchToastMessage, insertOrUpdateSound, name, previousName, previousSound, t, uploadCustomSound]); const handleSave = useCallback(async () => { saveAction(sound); onChange(); - }, [name, _id, sound]); + }, [saveAction, sound, onChange]); const onDeleteConfirm = useCallback(async () => { try { @@ -152,14 +151,12 @@ function EditSound({ close, onChange, data, ...props }) { dispatchToastMessage({ type: 'error', message: error }); onChange(); } - }, [_id]); + }, [_id, close, deleteCustomSound, dispatchToastMessage, onChange]); const openConfirmDelete = () => setModal(() => setModal(undefined)}/>); - const clickUpload = useFileInput(handleChangeFile, 'audio/mp3'); - return <> diff --git a/client/admin/customSounds/EditSound.js b/client/admin/customSounds/EditSound.js deleted file mode 100644 index 0496a617fb0e..000000000000 --- a/client/admin/customSounds/EditSound.js +++ /dev/null @@ -1,202 +0,0 @@ -import React, { useCallback, useState, useMemo, useEffect } from 'react'; -import { Box, Button, ButtonGroup, Margins, TextInput, Field, Icon, Skeleton, Throbber, InputBox } from '@rocket.chat/fuselage'; - -import { useTranslation } from '../../contexts/TranslationContext'; -import { useMethod } from '../../contexts/ServerContext'; -import { useToastMessageDispatch } from '../../contexts/ToastMessagesContext'; -import { Modal } from '../../components/basic/Modal'; -import { useFileInput } from '../../hooks/useFileInput'; -import { useEndpointDataExperimental, ENDPOINT_STATES } from '../../hooks/useEndpointDataExperimental'; -import { validate, createSoundData } from './lib'; - -const DeleteWarningModal = ({ onDelete, onCancel, ...props }) => { - const t = useTranslation(); - return - - - {t('Are_you_sure')} - - - - {t('Custom_Sound_Status_Delete_Warning')} - - - - - - - - ; -}; - -const SuccessModal = ({ onClose, ...props }) => { - const t = useTranslation(); - return - - - {t('Deleted')} - - - - {t('Custom_Sound_Has_Been_Deleted')} - - - - - - - ; -}; - -export function EditSound({ _id, cache, ...props }) { - const t = useTranslation(); - const query = useMemo(() => ({ - query: JSON.stringify({ _id }), - }), [_id]); - - const { data, state, error } = useEndpointDataExperimental('custom-sounds.list', query); - - - if (state === ENDPOINT_STATES.LOADING) { - return - - - - - - - - - - - - ; - } - - if (error || !data || data.sounds.length < 1) { - return {t('Custom_User_Status_Error_Invalid_User_Status')}; - } - - return ; -} - -export function EditCustomSound({ close, onChange, data, ...props }) { - const t = useTranslation(); - const dispatchToastMessage = useToastMessageDispatch(); - - const { _id, name: previousName } = data || {}; - const previousSound = data || {}; - - const [name, setName] = useState(''); - const [sound, setSound] = useState(); - const [modal, setModal] = useState(); - - useEffect(() => { - setName(previousName || ''); - setSound(previousSound || ''); - }, [previousName, previousSound, _id]); - - const deleteCustomSound = useMethod('deleteCustomSound'); - const uploadCustomSound = useMethod('uploadCustomSound'); - const insertOrUpdateSound = useMethod('insertOrUpdateSound'); - - const handleChangeFile = (soundFile) => { - setSound(soundFile); - }; - - const hasUnsavedChanges = useMemo(() => previousName !== name || previousSound !== sound, [name, sound]); - - const saveAction = async (sound) => { - const soundData = createSoundData(sound, name, { previousName, previousSound, _id }); - const validation = validate(soundData, sound); - if (validation.length === 0) { - let soundId; - try { - soundId = await insertOrUpdateSound(soundData); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } - - soundData._id = soundId; - soundData.random = Math.round(Math.random() * 1000); - - if (sound && sound !== previousSound) { - dispatchToastMessage({ type: 'success', message: t('Uploading_file') }); - - const reader = new FileReader(); - reader.readAsBinaryString(sound); - reader.onloadend = () => { - try { - uploadCustomSound(reader.result, sound.type, soundData); - return dispatchToastMessage({ type: 'success', message: t('File_uploaded') }); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } - }; - } - } - - validation.forEach((error) => dispatchToastMessage({ type: 'error', message: t('error-the-field-is-required', { field: t(error) }) })); - }; - - const handleSave = useCallback(async () => { - saveAction(sound); - onChange(); - }, [name, _id, sound]); - - const onDeleteConfirm = useCallback(async () => { - try { - await deleteCustomSound(_id); - setModal(() => { setModal(undefined); close(); onChange(); }}/>); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - onChange(); - } - }, [_id]); - - const openConfirmDelete = () => setModal(() => setModal(undefined)}/>); - - - const clickUpload = useFileInput(handleChangeFile, 'audio/mp3'); - - - return <> - - - - {t('Name')} - - setName(e.currentTarget.value)} placeholder={t('Name')} /> - - - - - {t('Sound_File_mp3')} - - - - {(sound && sound.name) || 'none'} - - - - - - - - - - - - - - - - - - - - - - { modal } - ; -} diff --git a/client/admin/customSounds/NewSound.js b/client/admin/customSounds/NewSound.js deleted file mode 100644 index b77671f1b07a..000000000000 --- a/client/admin/customSounds/NewSound.js +++ /dev/null @@ -1,101 +0,0 @@ -import React, { useState, useCallback } from 'react'; -import { Field, TextInput, Box, Icon, Margins, Button, ButtonGroup } from '@rocket.chat/fuselage'; - -import { useToastMessageDispatch } from '../../contexts/ToastMessagesContext'; -import { useTranslation } from '../../contexts/TranslationContext'; -import { useMethod } from '../../contexts/ServerContext'; -import { useFileInput } from '../../hooks/useFileInput'; -import { validate, createSoundData } from './lib'; - -export function NewSound({ goToNew, close, onChange, ...props }) { - const t = useTranslation(); - const dispatchToastMessage = useToastMessageDispatch(); - - const [name, setName] = useState(''); - const [sound, setSound] = useState(); - - const uploadCustomSound = useMethod('uploadCustomSound'); - - const insertOrUpdateSound = useMethod('insertOrUpdateSound'); - - const handleChangeFile = (soundFile) => { - setSound(soundFile); - }; - - const clickUpload = useFileInput(handleChangeFile, 'audio/mp3'); - - const saveAction = async (name, soundFile) => { - const soundData = createSoundData(soundFile, name); - const validation = validate(soundData, sound); - if (validation.length === 0) { - let soundId; - try { - soundId = await insertOrUpdateSound(soundData); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } - - soundData._id = soundId; - soundData.random = Math.round(Math.random() * 1000); - - if (soundId) { - dispatchToastMessage({ type: 'success', message: t('Uploading_file') }); - - const reader = new FileReader(); - reader.readAsBinaryString(soundFile); - reader.onloadend = () => { - try { - uploadCustomSound(reader.result, soundFile.type, soundData); - dispatchToastMessage({ type: 'success', message: t('File_uploaded') }); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } - }; - } - return soundId; - } - validation.forEach((error) => { throw new Error({ type: 'error', message: t('error-the-field-is-required', { field: t(error) }) }); }); - }; - - const handleSave = useCallback(async () => { - try { - const result = await saveAction( - name, - sound, - ); - dispatchToastMessage({ type: 'success', message: t('Custom_Sound_Updated_Successfully') }); - goToNew(result)(); - onChange(); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } - }, [name, sound]); - - return - - - {t('Name')} - - setName(e.currentTarget.value)} placeholder={t('Name')} /> - - - - {t('Sound_File_mp3')} - - - - {(sound && sound.name) || 'none'} - - - - - - - - - - - - - ; -} diff --git a/client/admin/customUserStatus/AddCustomUserStatus.js b/client/admin/customUserStatus/AddCustomUserStatus.js index 1597e90ef00c..573d50d4c82d 100644 --- a/client/admin/customUserStatus/AddCustomUserStatus.js +++ b/client/admin/customUserStatus/AddCustomUserStatus.js @@ -26,7 +26,7 @@ export function AddCustomUserStatus({ goToNew, close, onChange, ...props }) { } catch (error) { dispatchToastMessage({ type: 'error', message: error }); } - }, [name, statusType]); + }, [dispatchToastMessage, goToNew, name, onChange, saveStatus, statusType, t]); const presenceOptions = [ ['online', t('Online')], diff --git a/client/admin/customUserStatus/CustomUserStatus.js b/client/admin/customUserStatus/CustomUserStatus.js index bd63cc6d5aaa..743a880490d7 100644 --- a/client/admin/customUserStatus/CustomUserStatus.js +++ b/client/admin/customUserStatus/CustomUserStatus.js @@ -13,7 +13,7 @@ const FilterByText = ({ setFilter, ...props }) => { useEffect(() => { setFilter({ text }); - }, [text]); + }, [setFilter, text]); return e.preventDefault(), [])} display='flex' flexDirection='column' {...props}> } onChange={handleChange} value={text} /> ; @@ -32,7 +32,7 @@ export function CustomUserStatus({ const header = useMemo(() => [ {t('Name')}, {t('Presence')}, - ].filter(Boolean), [sort]); + ].filter(Boolean), [onHeaderClick, sort, t]); const renderRow = (status) => { const { _id, name, statusType } = status; diff --git a/client/admin/customUserStatus/CustomUserStatusRoute.js b/client/admin/customUserStatus/CustomUserStatusRoute.js index c5e5aef163ec..1862d3336b51 100644 --- a/client/admin/customUserStatus/CustomUserStatusRoute.js +++ b/client/admin/customUserStatus/CustomUserStatusRoute.js @@ -15,12 +15,13 @@ import NotAuthorizedPage from '../NotAuthorizedPage'; const sortDir = (sortDir) => (sortDir === 'asc' ? 1 : -1); -export const useQuery = (params, sort, cache) => useMemo(() => ({ - query: JSON.stringify({ name: { $regex: params.text || '', $options: 'i' } }), - sort: JSON.stringify({ [sort[0]]: sortDir(sort[1]) }), - ...params.itemsPerPage && { count: params.itemsPerPage }, - ...params.current && { offset: params.current }, -}), [JSON.stringify(params), JSON.stringify(sort), cache]); +export const useQuery = ({ text, itemsPerPage, current }, [column, direction], cache) => useMemo(() => ({ + query: JSON.stringify({ name: { $regex: text || '', $options: 'i' } }), + sort: JSON.stringify({ [column]: sortDir(direction) }), + ...itemsPerPage && { count: itemsPerPage }, + ...current && { offset: current }, + // TODO: remove cache. Is necessary for data invalidation +}), [text, itemsPerPage, current, column, direction, cache]); export default function CustomUserStatusRoute({ props }) { const t = useTranslation(); @@ -68,18 +69,18 @@ export default function CustomUserStatusRoute({ props }) { router.push({ context }); }, [router]); - const close = () => { + const close = useCallback(() => { router.push({}); - }; - - if (!canManageUserStatus) { - return ; - } + }, [router]); const onChange = useCallback(() => { setCache(new Date()); }, []); + if (!canManageUserStatus) { + return ; + } + return diff --git a/client/admin/customUserStatus/EditCustomUserStatus.js b/client/admin/customUserStatus/EditCustomUserStatus.js index 7a7853d5b1aa..6b9fda2aa6cc 100644 --- a/client/admin/customUserStatus/EditCustomUserStatus.js +++ b/client/admin/customUserStatus/EditCustomUserStatus.js @@ -51,7 +51,8 @@ export function EditCustomUserStatusWithData({ _id, cache, ...props }) { const t = useTranslation(); const query = useMemo(() => ({ query: JSON.stringify({ _id }), - }), [_id]); + // TODO: remove cache. Is necessary for data invalidation + }), [_id, cache]); const { data, state, error } = useEndpointDataExperimental('custom-user-status.list', query); @@ -96,7 +97,7 @@ export function EditCustomUserStatus({ close, onChange, data, ...props }) { const saveStatus = useMethod('insertOrUpdateUserStatus'); const deleteStatus = useMethod('deleteCustomUserStatus'); - const hasUnsavedChanges = useMemo(() => previousName !== name || previousStatusType !== statusType, [name, statusType]); + const hasUnsavedChanges = useMemo(() => previousName !== name || previousStatusType !== statusType, [name, previousName, previousStatusType, statusType]); const handleSave = useCallback(async () => { try { await saveStatus({ @@ -111,7 +112,7 @@ export function EditCustomUserStatus({ close, onChange, data, ...props }) { } catch (error) { dispatchToastMessage({ type: 'error', message: error }); } - }, [name, statusType, _id]); + }, [saveStatus, _id, previousName, previousStatusType, name, statusType, dispatchToastMessage, t, onChange]); const onDeleteConfirm = useCallback(async () => { try { @@ -121,7 +122,7 @@ export function EditCustomUserStatus({ close, onChange, data, ...props }) { dispatchToastMessage({ type: 'error', message: error }); onChange(); } - }, [_id]); + }, [_id, close, deleteStatus, dispatchToastMessage, onChange]); const openConfirmDelete = () => setModal(() => setModal(undefined)}/>); diff --git a/client/admin/import/ImportHistoryPage.js b/client/admin/import/ImportHistoryPage.js index 4474d2b7cc1f..8edf7ba5f00d 100644 --- a/client/admin/import/ImportHistoryPage.js +++ b/client/admin/import/ImportHistoryPage.js @@ -47,7 +47,7 @@ function ImportHistoryPage() { }; loadData(); - }, []); + }, [dispatchToastMessage, getCurrentImportOperation, getLatestImportOperations, setCurrentOperation, setLatestOperations, setLoading, t]); const hasAnySuccessfulSlackImport = useMemo(() => latestOperations?.some(({ importerKey, status }) => importerKey === 'slack' && status === ProgressStep.DONE), [latestOperations]); diff --git a/client/admin/import/ImportProgressPage.js b/client/admin/import/ImportProgressPage.js index c06c034c46c9..ed5cbd7774fe 100644 --- a/client/admin/import/ImportProgressPage.js +++ b/client/admin/import/ImportProgressPage.js @@ -53,7 +53,7 @@ function ImportProgressPage() { }; loadCurrentOperation(); - }, []); + }, [getCurrentImportOperation, handleError, importHistoryRoute, prepareImportRoute, setCompleted, setImporterKey, setTotal, t]); useEffect(() => { if (!importerKey) { @@ -110,7 +110,7 @@ function ImportProgressPage() { return () => { streamer.removeListener('progress', handleProgressUpdated); }; - }, [importerKey]); + }, [dispatchToastMessage, getImportProgress, handleError, importHistoryRoute, importerKey, prepareImportRoute, setCompleted, setStep, setTotal, t]); const progressRate = useMemo(() => { if (total === 0) { @@ -118,7 +118,7 @@ function ImportProgressPage() { } return completed / total * 100; - }); + }, [completed, total]); return diff --git a/client/admin/import/NewImportPage.js b/client/admin/import/NewImportPage.js index 637201a159b0..4e4e7d77eff5 100644 --- a/client/admin/import/NewImportPage.js +++ b/client/admin/import/NewImportPage.js @@ -49,7 +49,7 @@ function NewImportPage() { if (importerKey && !importer) { newImportRoute.replace(); } - }, [importerKey, !importer]); + }, [importer, importerKey, newImportRoute]); const formatMemorySize = useFormatMemorySize(); diff --git a/client/admin/import/PrepareImportPage.js b/client/admin/import/PrepareImportPage.js index 687e78fde25e..298bb4027824 100644 --- a/client/admin/import/PrepareImportPage.js +++ b/client/admin/import/PrepareImportPage.js @@ -50,8 +50,8 @@ function PrepareUsers({ usersCount, users, setUsers }) { const t = useTranslation(); const [current, setCurrent] = useState(0); const [itemsPerPage, setItemsPerPage] = useState(25); - const showingResultsLabel = useCallback(({ count, current, itemsPerPage }) => t('Showing results %s - %s of %s', current + 1, Math.min(current + itemsPerPage, count), count), []); - const itemsPerPageLabel = useCallback(() => t('Items_per_page:'), []); + const showingResultsLabel = useCallback(({ count, current, itemsPerPage }) => t('Showing results %s - %s of %s', current + 1, Math.min(current + itemsPerPage, count), count), [t]); + const itemsPerPageLabel = useCallback(() => t('Items_per_page:'), [t]); return <> @@ -118,8 +118,8 @@ function PrepareChannels({ channels, channelsCount, setChannels }) { const t = useTranslation(); const [current, setCurrent] = useState(0); const [itemsPerPage, setItemsPerPage] = useState(25); - const showingResultsLabel = useCallback(({ count, current, itemsPerPage }) => t('Showing results %s - %s of %s', current + 1, Math.min(current + itemsPerPage, count), count), []); - const itemsPerPageLabel = useCallback(() => t('Items_per_page:'), []); + const showingResultsLabel = useCallback(({ count, current, itemsPerPage }) => t('Showing results %s - %s of %s', current + 1, Math.min(current + itemsPerPage, count), count), [t]); + const itemsPerPageLabel = useCallback(() => t('Items_per_page:'), [t]); return channels.length && <>
    @@ -213,7 +213,7 @@ function PrepareImportPage() { return () => { streamer.removeListener('progress', handleProgressUpdated); }; - }, []); + }, [setProgressRate]); useEffect(() => { const loadImportFileData = async () => { @@ -286,7 +286,7 @@ function PrepareImportPage() { }; loadCurrentOperation(); - }, []); + }, [getCurrentImportOperation, getImportFileData, handleError, importHistoryRoute, importProgressRoute, newImportRoute, setMessageCount, setPreparing, setProgressRate, setStatus, t]); const handleBackToImportsButtonClick = () => { importHistoryRoute.push(); diff --git a/client/admin/info/InformationRoute.js b/client/admin/info/InformationRoute.js index 11cacee30aff..d9393ef9c519 100644 --- a/client/admin/info/InformationRoute.js +++ b/client/admin/info/InformationRoute.js @@ -50,7 +50,7 @@ export const InformationRoute = React.memo(function InformationRoute() { return () => { didCancel = true; }; - }, [canViewStatistics]); + }, [canViewStatistics, getInstances, getStatistics]); const info = useServerInformation(); diff --git a/client/admin/integrations/IncomingWebhookForm.js b/client/admin/integrations/IncomingWebhookForm.js index 4bf2e20a1f9d..cf07e910126b 100644 --- a/client/admin/integrations/IncomingWebhookForm.js +++ b/client/admin/integrations/IncomingWebhookForm.js @@ -3,15 +3,13 @@ import { Field, TextInput, Box, ToggleSwitch, Icon, TextAreaInput, FieldGroup, M import { useTranslation } from '../../contexts/TranslationContext'; import { useAbsoluteUrl } from '../../contexts/ServerContext'; -import { useHilightCode } from '../../hooks/useHilightCode'; +import { useHighlightedCode } from '../../hooks/useHighlightedCode'; import { useExampleData } from './exampleIncomingData'; import Page from '../../components/basic/Page'; export default function IncomingWebhookForm({ formValues, formHandlers, extraData = {}, append, ...props }) { const t = useTranslation(); - const hilightCode = useHilightCode(); - const absoluteUrl = useAbsoluteUrl(); const { @@ -40,16 +38,18 @@ export default function IncomingWebhookForm({ formValues, formHandlers, extraDat const url = absoluteUrl(`hooks/${ extraData._id }/${ extraData.token }`); + const additionalFields = useMemo(() => ({ + ...alias && { alias }, + ...emoji && { emoji }, + ...avatarUrl && { avatar: avatarUrl }, + }), [alias, avatarUrl, emoji]); + const [exampleData, curlData] = useExampleData({ - aditionalFields: { - ...alias && { alias }, - ...emoji && { emoji }, - ...avatarUrl && { avatar: avatarUrl }, - }, + additionalFields, url, - }, [alias, emoji, avatarUrl]); + }); - const hilightedExampleJson = hilightCode('json', JSON.stringify(exampleData, null, 2)); + const hilightedExampleJson = useHighlightedCode('json', JSON.stringify(exampleData, null, 2)); return e.preventDefault(), [])} qa-admin-user-edit='form' { ...props }> @@ -59,14 +59,14 @@ export default function IncomingWebhookForm({ formValues, formHandlers, extraDat {t('Enabled')} - , [enabled, handleEnabled])} + , [enabled, handleEnabled, t])} {useMemo(() => {t('Name_optional')} {t('You_should_name_it_to_easily_manage_your_integrations')} - , [name, handleName])} + , [t, name, handleName])} {useMemo(() => {t('Post_to_Channel')} @@ -74,7 +74,7 @@ export default function IncomingWebhookForm({ formValues, formHandlers, extraDat {t('Messages_that_are_sent_to_the_Incoming_WebHook_will_be_posted_here')} - , [channel, handleChannel])} + , [channel, handleChannel, t])} {useMemo(() => {t('Post_as')} @@ -82,14 +82,14 @@ export default function IncomingWebhookForm({ formValues, formHandlers, extraDat {t('Choose_the_username_that_this_integration_will_post_as')} {t('Should_exists_a_user_with_this_username')} - , [username, handleUsername])} + , [t, username, handleUsername])} {useMemo(() => {`${ t('Alias') } (${ t('optional') })`} }/> {t('Choose_the_alias_that_will_appear_before_the_username_in_messages')} - , [alias, handleAlias])} + , [alias, handleAlias, t])} {useMemo(() => {`${ t('Avatar_URL') } (${ t('optional') })`} @@ -97,7 +97,7 @@ export default function IncomingWebhookForm({ formValues, formHandlers, extraDat {t('You_can_change_a_different_avatar_too')} {t('Should_be_a_URL_of_an_image')} - , [avatarUrl, handleAvatarUrl])} + , [avatarUrl, handleAvatarUrl, t])} {useMemo(() => {`${ t('Emoji') } (${ t('optional') })`} @@ -105,19 +105,19 @@ export default function IncomingWebhookForm({ formValues, formHandlers, extraDat {t('You_can_use_an_emoji_as_avatar')} - , [emoji, handleEmoji])} + , [emoji, handleEmoji, t])} {useMemo(() => {t('Script_Enabled')} - , [scriptEnabled, handleScriptEnabled])} + , [t, scriptEnabled, handleScriptEnabled])} {useMemo(() => {t('Script')} }/> - , [script, handleScript])} + , [t, script, handleScript])} {useMemo(() => !extraData._id && <> {t('Webhook_URL')} @@ -130,7 +130,7 @@ export default function IncomingWebhookForm({ formValues, formHandlers, extraDat } disabled/> - , [extraData._id])} + , [extraData._id, t])} {useMemo(() => extraData._id && <> {t('Webhook_URL')} @@ -143,7 +143,7 @@ export default function IncomingWebhookForm({ formValues, formHandlers, extraDat }/> - , [url, extraData._id, extraData.token])} + , [extraData._id, extraData.token, t, url])} {useMemo(() => {t('Example_payload')} @@ -151,13 +151,13 @@ export default function IncomingWebhookForm({ formValues, formHandlers, extraDat
    -
    , [hilightedExampleJson])} + , [hilightedExampleJson, t])} {useMemo(() => extraData._id && {t('Curl')} }/> - , [curlData])} + , [curlData, extraData._id, t])} { append }
    diff --git a/client/admin/integrations/IntegrationsPage.js b/client/admin/integrations/IntegrationsPage.js index ec9dc1adb688..b980da09c21e 100644 --- a/client/admin/integrations/IntegrationsPage.js +++ b/client/admin/integrations/IntegrationsPage.js @@ -15,21 +15,21 @@ function IntegrationsPage() { const handleNewButtonClick = useCallback(() => { router.push({ context: 'new', type: 'incoming' }); - }, []); + }, [router]); const context = useRouteParameter('context'); useEffect(() => { if (!context) { router.push({ context: 'webhook-incoming' }); } - }, [context]); + }, [context, router]); const showTable = !['zapier', 'bots'].includes(context); - const goToIncoming = useCallback(() => router.push({ context: 'webhook-incoming' }), []); - const goToOutgoing = useCallback(() => router.push({ context: 'webhook-outgoing' }), []); - const goToZapier = useCallback(() => router.push({ context: 'zapier' }), []); - const goToBots = useCallback(() => router.push({ context: 'bots' }), []); + const goToIncoming = useCallback(() => router.push({ context: 'webhook-incoming' }), [router]); + const goToOutgoing = useCallback(() => router.push({ context: 'webhook-outgoing' }), [router]); + const goToZapier = useCallback(() => router.push({ context: 'zapier' }), [router]); + const goToBots = useCallback(() => router.push({ context: 'bots' }), [router]); return diff --git a/client/admin/integrations/IntegrationsTable.js b/client/admin/integrations/IntegrationsTable.js index ba64ecf39930..8efe505a287e 100644 --- a/client/admin/integrations/IntegrationsTable.js +++ b/client/admin/integrations/IntegrationsTable.js @@ -19,30 +19,52 @@ const FilterByTypeAndText = React.memo(({ setFilter, ...props }) => { useEffect(() => { setFilter({ text }); - }, [text]); + }, [setFilter, text]); return e.preventDefault(), [])} display='flex' flexDirection='column' {...props}> } onChange={handleChange} value={text} /> ; }); -const useQuery = (params, sort) => useMemo(() => ({ - query: JSON.stringify({ name: { $regex: params.text || '', $options: 'i' }, type: params.type }), - sort: JSON.stringify({ [sort[0]]: sort[1] === 'asc' ? 1 : -1 }), - ...params.itemsPerPage && { count: params.itemsPerPage }, - ...params.current && { offset: params.current }, -}), [JSON.stringify(params), JSON.stringify(sort)]); +const useQuery = ({ text, type, itemsPerPage, current }, [column, direction]) => useMemo(() => ({ + query: JSON.stringify({ name: { $regex: text || '', $options: 'i' }, type }), + sort: JSON.stringify({ [column]: direction === 'asc' ? 1 : -1 }), + ...itemsPerPage && { count: itemsPerPage }, + ...current && { offset: current }, +}), [column, current, direction, itemsPerPage, text, type]); const useResizeInlineBreakpoint = (sizes = [], debounceDelay = 0) => { const { ref, borderBoxSize } = useResizeObserver({ debounceDelay }); const inlineSize = borderBoxSize ? borderBoxSize.inlineSize : 0; - sizes = useMemo(() => sizes.map((current) => (inlineSize ? inlineSize > current : true)), [inlineSize]); + sizes = useMemo(() => sizes.map((current) => (inlineSize ? inlineSize > current : true)), [inlineSize, sizes]); return [ref, ...sizes]; }; +function IntegrationRow({ + name, + _id, + type, + username, + _createdAt, + _createdBy: { username: createdBy }, + channel = [], + onClick, + isBig, +}) { + const formatDateAndTime = useFormatDateAndTime(); + + const handler = useMemo(() => onClick(_id, type), [onClick, _id, type]); + return + {name} + {channel.join(', ')} + {createdBy} + {isBig && {formatDateAndTime(_createdAt)}} + {username} + ; +} + export function IntegrationsTable({ type }) { const t = useTranslation(); - const formatDateAndTime = useFormatDateAndTime(); const [ref, isBig] = useResizeInlineBreakpoint([700], 200); const [params, setParams] = useState({ text: '', current: 0, itemsPerPage: 25 }); @@ -56,11 +78,11 @@ export function IntegrationsTable({ type }) { const router = useRoute('admin-integrations'); - const onClick = (_id, type) => () => router.push({ + const onClick = useCallback((_id, type) => () => router.push({ context: 'edit', type: type === 'webhook-incoming' ? 'incoming' : 'outgoing', id: _id, - }); + }), [router]); const onHeaderClick = useCallback((id) => { const [sortBy, sortDirection] = sort; @@ -78,18 +100,9 @@ export function IntegrationsTable({ type }) {
    , isBig && , , - ].filter(Boolean), [sort, isBig]); - - const renderRow = useCallback(({ name, _id, type, username, _createdAt, _createdBy: { username: createdBy }, channel = [] }) => { - const handler = useMemo(() => onClick(_id, type), []); - return - {name} - {channel.join(', ')} - {createdBy} - {isBig && {formatDateAndTime(_createdAt)}} - {username} - ; - }, []); + ].filter(Boolean), [sort, onHeaderClick, isBig, t]); + + const renderRow = useCallback((props) => , [isBig, onClick]); return ; } diff --git a/client/admin/integrations/OutgoiongWebhookForm.js b/client/admin/integrations/OutgoiongWebhookForm.js index 7435dbb6fbc4..f86d9ac4c67f 100644 --- a/client/admin/integrations/OutgoiongWebhookForm.js +++ b/client/admin/integrations/OutgoiongWebhookForm.js @@ -12,12 +12,13 @@ import { } from '@rocket.chat/fuselage'; import React, { useMemo, useCallback } from 'react'; -import { useHilightCode } from '../../hooks/useHilightCode'; +import { useHighlightedCode } from '../../hooks/useHighlightedCode'; import { useExampleData } from './exampleIncomingData'; import { useTranslation } from '../../contexts/TranslationContext'; import Page from '../../components/basic/Page'; import { integrations as eventList } from '../../../app/integrations/lib/rocketchat'; +const { outgoingEvents } = eventList; export default function OutgoingWebhookForm({ formValues, formHandlers, append, ...props }) { const t = useTranslation(); @@ -72,28 +73,26 @@ export default function OutgoingWebhookForm({ formValues, formHandlers, append, ['powers-of-ten', t('powers-of-ten')], ['powers-of-two', t('powers-of-two')], ['increments-of-two', t('increments-of-two')], - ], []); + ], [t]); - const { outgoingEvents } = eventList; - - const eventOptions = useMemo(() => Object.entries(outgoingEvents).map(([key, val]) => [key, t(val.label)]), []); - - const hilightCode = useHilightCode(); + const eventOptions = useMemo(() => Object.entries(outgoingEvents).map(([key, val]) => [key, t(val.label)]), [t]); const showChannel = useMemo(() => outgoingEvents[event].use.channel, [event]); const showTriggerWords = useMemo(() => outgoingEvents[event].use.triggerWords, [event]); const showTargetRoom = useMemo(() => outgoingEvents[event].use.targetRoom, [event]); + const additionalFields = useMemo(() => ({ + ...alias && { alias }, + ...emoji && { emoji }, + ...avatarUrl && { avatar: avatarUrl }, + }), [alias, avatarUrl, emoji]); + const [exampleData] = useExampleData({ - aditionalFields: { - ...alias && { alias }, - ...emoji && { emoji }, - ...avatarUrl && { avatar: avatarUrl }, - }, + additionalFields, url: null, - }, [alias, emoji, avatarUrl]); + }); - const hilightedExampleJson = hilightCode('json', JSON.stringify(exampleData, null, 2)); + const hilightedExampleJson = useHighlightedCode('json', JSON.stringify(exampleData, null, 2)); return e.preventDefault(), [])} qa-admin-user-edit='form' { ...props }> @@ -105,20 +104,20 @@ export default function OutgoingWebhookForm({ formValues, formHandlers, append, - , [retryDelay])} + , [handleRetryDelay, retryDelay, retryDelayOptions, t])} { useMemo(() => event === 'sendMessage' && @@ -249,12 +248,12 @@ export default function OutgoingWebhookForm({ formValues, formHandlers, append, - {t('Integration_Word_Trigger_Placement')} + {t('Integration_Run_When_Message_Is_Edited')} {t('Integration_Run_When_Message_Is_Edited_Description')} - , [triggerWordAnywhere, runOnEdits])} + , [event, t, triggerWordAnywhere, handleTriggerWordAnywhere, runOnEdits, handleRunOnEdits])} { append } diff --git a/client/admin/integrations/edit/EditIncomingWebhook.js b/client/admin/integrations/edit/EditIncomingWebhook.js index 7fa24155c299..48852377eb2b 100644 --- a/client/admin/integrations/edit/EditIncomingWebhook.js +++ b/client/admin/integrations/edit/EditIncomingWebhook.js @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react'; +import React, { useMemo, useState, useCallback } from 'react'; import { Field, Box, Skeleton, Margins, Button } from '@rocket.chat/fuselage'; import { SuccessModal, DeleteWarningModal } from './EditIntegrationsPage'; @@ -15,6 +15,7 @@ export default function EditIncomingWebhookWithData({ integrationId, ...props }) const t = useTranslation(); const [cache, setCache] = useState(); + // TODO: remove cache. Is necessary for data validation const { data, state, error } = useEndpointDataExperimental('integrations.get', useMemo(() => ({ integrationId }), [integrationId, cache])); const onChange = () => setCache(new Date()); @@ -65,7 +66,7 @@ function EditIncomingWebhook({ data, onChange, ...props }) { const router = useRoute('admin-integrations'); - const handleDeleteIntegration = () => { + const handleDeleteIntegration = useCallback(() => { const closeModal = () => setModal(); const onDelete = async () => { const result = await deleteIntegration(); @@ -73,9 +74,9 @@ function EditIncomingWebhook({ data, onChange, ...props }) { }; setModal(); - }; + }, [deleteIntegration, router]); - const handleSave = async () => { + const handleSave = useCallback(async () => { try { await saveIntegration(data._id, { ...formValues }); dispatchToastMessage({ type: 'success', message: t('Integration_updated') }); @@ -83,7 +84,7 @@ function EditIncomingWebhook({ data, onChange, ...props }) { } catch (e) { dispatchToastMessage({ type: 'error', message: e }); } - }; + }, [data._id, dispatchToastMessage, formValues, onChange, saveIntegration, t]); const actionButtons = useMemo(() => @@ -95,7 +96,7 @@ function EditIncomingWebhook({ data, onChange, ...props }) { - ); + , [handleDeleteIntegration, handleSave, reset, t]); return <> diff --git a/client/admin/integrations/edit/EditIntegrationsPage.js b/client/admin/integrations/edit/EditIntegrationsPage.js index 3ffdb8168c79..bc45da6e2e0e 100644 --- a/client/admin/integrations/edit/EditIntegrationsPage.js +++ b/client/admin/integrations/edit/EditIntegrationsPage.js @@ -57,11 +57,11 @@ export default function NewIntegrationsPage({ ...props }) { const handleClickReturn = useCallback(() => { router.push({ }); - }, []); + }, [router]); const handleClickHistory = useCallback(() => { router.push({ context: 'history', type: 'outgoing', id: integrationId }); - }, [integrationId]); + }, [integrationId, router]); return diff --git a/client/admin/integrations/edit/EditOutgoingWebhook.js b/client/admin/integrations/edit/EditOutgoingWebhook.js index ea03cd5fdeab..cf086a1af378 100644 --- a/client/admin/integrations/edit/EditOutgoingWebhook.js +++ b/client/admin/integrations/edit/EditOutgoingWebhook.js @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react'; +import React, { useMemo, useState, useCallback } from 'react'; import { Field, Box, @@ -21,6 +21,7 @@ export default function EditOutgoingWebhookWithData({ integrationId, ...props }) const t = useTranslation(); const [cache, setCache] = useState(); + // TODO: remove cache. Is necessary for data validation const { data, state, error } = useEndpointDataExperimental('integrations.get', useMemo(() => ({ integrationId }), [integrationId, cache])); const onChange = () => setCache(new Date()); @@ -83,7 +84,7 @@ function EditOutgoingWebhook({ data, onChange, setSaveAction, ...props }) { const deleteQuery = useMemo(() => ({ type: 'webhook-outgoing', integrationId: data._id }), [data._id]); const deleteIntegration = useEndpointAction('POST', 'integrations.remove', deleteQuery); - const handleDeleteIntegration = () => { + const handleDeleteIntegration = useCallback(() => { const closeModal = () => setModal(); const onDelete = async () => { const result = await deleteIntegration(); @@ -91,18 +92,18 @@ function EditOutgoingWebhook({ data, onChange, setSaveAction, ...props }) { }; setModal(); - }; + }, [deleteIntegration, router]); const { urls, triggerWords, } = formValues; - const handleSave = async () => { + const handleSave = useCallback(async () => { try { await saveIntegration(data._id, { ...formValues, - triggerWords: triggerWords.split(';'), + triggerWords: triggerWords.split(/\s*(?:;|$)\s*/), urls: urls.split('\n'), }); @@ -111,7 +112,7 @@ function EditOutgoingWebhook({ data, onChange, setSaveAction, ...props }) { } catch (e) { dispatchToastMessage({ type: 'error', message: e }); } - }; + }, [data._id, dispatchToastMessage, formValues, onChange, saveIntegration, t, triggerWords, urls]); const actionButtons = useMemo(() => @@ -123,7 +124,7 @@ function EditOutgoingWebhook({ data, onChange, setSaveAction, ...props }) { - ); + , [handleDeleteIntegration, handleSave, reset, t]); return <> diff --git a/client/admin/integrations/edit/OutgoingWebhookHistoryPage.js b/client/admin/integrations/edit/OutgoingWebhookHistoryPage.js index 63752ce098d8..13af690e5ce8 100644 --- a/client/admin/integrations/edit/OutgoingWebhookHistoryPage.js +++ b/client/admin/integrations/edit/OutgoingWebhookHistoryPage.js @@ -1,9 +1,9 @@ import { Button, ButtonGroup, Icon, Skeleton, Box, Accordion, Field, FieldGroup, Pagination } from '@rocket.chat/fuselage'; -import React, { useMemo, useCallback, useState, useEffect } from 'react'; +import React, { useMemo, useCallback, useState } from 'react'; import Page from '../../../components/basic/Page'; import { useTranslation } from '../../../contexts/TranslationContext'; -import { useHilightCode } from '../../../hooks/useHilightCode'; +import { useHighlightedCode } from '../../../hooks/useHighlightedCode'; import { integrations as eventList } from '../../../../app/integrations/lib/rocketchat'; import { useMethod } from '../../../contexts/ServerContext'; import { useEndpointDataExperimental, ENDPOINT_STATES } from '../../../hooks/useEndpointDataExperimental'; @@ -14,8 +14,6 @@ import { useToastMessageDispatch } from '../../../contexts/ToastMessagesContext' function HistoryItem({ data, onChange, ...props }) { const t = useTranslation(); - const hilightCode = useHilightCode(); - const replayOutgoingIntegration = useMethod('replayOutgoingIntegration'); const { @@ -40,10 +38,17 @@ function HistoryItem({ data, onChange, ...props }) { e.stopPropagation(); replayOutgoingIntegration({ integrationId, historyId: _id }); onChange(); - }, [_id]); + }, [_id, integrationId, onChange, replayOutgoingIntegration]); const formatDateAndTime = useFormatDateAndTime(); + const prepareSentMessageCode = useHighlightedCode('json', JSON.stringify(prepareSentMessage || '', null, 2)); + const processSentMessageCode = useHighlightedCode('json', JSON.stringify(processSentMessage || '', null, 2)); + const httpCallDataCode = useHighlightedCode('json', JSON.stringify(httpCallData || '', null, 2)); + const httpErrorCode = useHighlightedCode('json', JSON.stringify(httpError || '', null, 2)); + const httpResultCode = useHighlightedCode('json', JSON.stringify(httpResult || '', null, 2)); + const errorStackCode = useHighlightedCode('json', JSON.stringify(errorStack || '', null, 2)); + return @@ -100,7 +105,7 @@ function HistoryItem({ data, onChange, ...props }) { {t('Integration_Outgoing_WebHook_History_Data_Passed_To_Trigger')} -
    +
    @@ -108,7 +113,7 @@ function HistoryItem({ data, onChange, ...props }) { {t('Integration_Outgoing_WebHook_History_Messages_Sent_From_Prepare_Script')} -
    +
    } @@ -116,7 +121,7 @@ function HistoryItem({ data, onChange, ...props }) { {t('Integration_Outgoing_WebHook_History_Messages_Sent_From_Process_Script')} -
    +
    } @@ -132,7 +137,7 @@ function HistoryItem({ data, onChange, ...props }) { {t('Integration_Outgoing_WebHook_History_Data_Passed_To_URL')} -
    +
    } @@ -140,7 +145,7 @@ function HistoryItem({ data, onChange, ...props }) { {t('Integration_Outgoing_WebHook_History_Http_Response_Error')} -
    +
    } @@ -148,7 +153,7 @@ function HistoryItem({ data, onChange, ...props }) { {t('Integration_Outgoing_WebHook_History_Http_Response')} -
    +
    } @@ -156,7 +161,7 @@ function HistoryItem({ data, onChange, ...props }) { {t('Integration_Outgoing_WebHook_History_Error_Stacktrace')} -
    +
    } @@ -167,12 +172,7 @@ function HistoryItem({ data, onChange, ...props }) { function HistoryContent({ data, state, onChange, ...props }) { const t = useTranslation(); - const [loadedData, setLoadedData] = useState(); - useEffect(() => { - if (state === ENDPOINT_STATES.DONE) { setLoadedData(data); } - }, [state]); - - if (!loadedData || state === ENDPOINT_STATES.LOADING) { + if (!data || state === ENDPOINT_STATES.LOADING) { return @@ -183,13 +183,13 @@ function HistoryContent({ data, state, onChange, ...props }) { ; } - if (loadedData.history.length < 1) { + if (data.history.length < 1) { return {t('Integration_Outgoing_WebHook_No_History')}; } return <> - {loadedData.history.map((current) => { setCache(new Date()); - }); + }, []); const router = useRoute('admin-integrations'); @@ -231,13 +231,14 @@ function OutgoingWebhookHistoryPage(props) { const query = useMemo(() => ({ id, - cout: itemsPerPage, + count: itemsPerPage, offset: current, + // TODO: remove cache. Is necessary for data validation }), [id, itemsPerPage, current, cache]); const { data, state } = useEndpointDataExperimental('integrations.history', query); - const showingResultsLabel = useCallback(({ count, current, itemsPerPage }) => t('Showing results %s - %s of %s', current + 1, Math.min(current + itemsPerPage, count), count), []); + const showingResultsLabel = useCallback(({ count, current, itemsPerPage }) => t('Showing results %s - %s of %s', current + 1, Math.min(current + itemsPerPage, count), count), [t]); return diff --git a/client/admin/integrations/exampleIncomingData.js b/client/admin/integrations/exampleIncomingData.js index 0226156f69fc..8ac603ada9f1 100644 --- a/client/admin/integrations/exampleIncomingData.js +++ b/client/admin/integrations/exampleIncomingData.js @@ -1,20 +1,22 @@ import { useMemo } from 'react'; -export function useExampleData({ aditionalFields, url }, dep) { - const exampleData = { - ...aditionalFields, - text: 'Example message', - attachments: [{ - title: 'Rocket.Chat', - title_link: 'https://rocket.chat', - text: 'Rocket.Chat, the best open source chat', - image_url: '/images/integration-attachment-example.png', - color: '#764FA5', - }], - }; +export function useExampleData({ additionalFields, url }) { + return useMemo(() => { + const exampleData = { + ...additionalFields, + text: 'Example message', + attachments: [{ + title: 'Rocket.Chat', + title_link: 'https://rocket.chat', + text: 'Rocket.Chat, the best open source chat', + image_url: '/images/integration-attachment-example.png', + color: '#764FA5', + }], + }; - return useMemo(() => [ - exampleData, - `curl -X POST -H 'Content-Type: application/json' --data '${ JSON.stringify(exampleData) }' ${ url }`, - ], dep); + return [ + exampleData, + `curl -X POST -H 'Content-Type: application/json' --data '${ JSON.stringify(exampleData) }' ${ url }`, + ]; + }, [additionalFields, url]); } diff --git a/client/admin/integrations/new/NewIncomingWebhook.js b/client/admin/integrations/new/NewIncomingWebhook.js index 99cdbc628276..a04bd5aaa3d4 100644 --- a/client/admin/integrations/new/NewIncomingWebhook.js +++ b/client/admin/integrations/new/NewIncomingWebhook.js @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { Field, Box, Margins, Button } from '@rocket.chat/fuselage'; import { useTranslation } from '../../../contexts/TranslationContext'; @@ -26,14 +26,15 @@ export default function NewIncomingWebhook(props) { const { values: formValues, handlers: formHandlers, reset } = useForm(initialState); + // TODO: remove JSON.stringify. Is used to keep useEndpointAction from rerendering the page indefinitely. const saveAction = useEndpointAction('POST', 'integrations.create', useMemo(() => ({ ...formValues, type: 'webhook-incoming' }), [JSON.stringify(formValues)]), t('Integration_added')); - const handleSave = async () => { + const handleSave = useCallback(async () => { const result = await saveAction(); if (result.success) { router.push({ context: 'edit', type: 'incoming', id: result.integration._id }); } - }; + }, [router, saveAction]); const actionButtons = useMemo(() => @@ -44,7 +45,7 @@ export default function NewIncomingWebhook(props) {
    - ); + , [handleSave, reset, t]); return ; } diff --git a/client/admin/integrations/new/NewIntegrationsPage.js b/client/admin/integrations/new/NewIntegrationsPage.js index 70dfc441127d..805b34e571a8 100644 --- a/client/admin/integrations/new/NewIntegrationsPage.js +++ b/client/admin/integrations/new/NewIntegrationsPage.js @@ -13,19 +13,16 @@ export default function NewIntegrationsPage({ ...props }) { const router = useRoute('admin-integrations'); - const handleClickTab = (type) => () => { + const handleClickTab = useCallback((type) => () => { router.push({ context: 'new', type }); - }; + }, [router]); const handleClickReturn = useCallback(() => { router.push({ }); - }, []); + }, [router]); const tab = useRouteParameter('type'); - const handleIncomingTab = useCallback(handleClickTab('incoming'), []); - const handleOutgoingTab = useCallback(handleClickTab('outgoing'), []); - return @@ -37,13 +34,13 @@ export default function NewIntegrationsPage({ ...props }) { {t('Incoming')} {t('Outgoing')} diff --git a/client/admin/integrations/new/NewOutgoingWebhook.js b/client/admin/integrations/new/NewOutgoingWebhook.js index f211f07d5ba1..c37665494c86 100644 --- a/client/admin/integrations/new/NewOutgoingWebhook.js +++ b/client/admin/integrations/new/NewOutgoingWebhook.js @@ -46,6 +46,7 @@ export default function NewOutgoingWebhook({ data = defaultData, onChange, setSa ...formValues, urls: urls.split('\n'), triggerWords: triggerWords.split(';'), + // TODO: remove JSON.stringify. Is used to keep useEndpointAction from rerendering the page indefinitely. }), [JSON.stringify(formValues)]); const saveIntegration = useEndpointAction('POST', 'integrations.create', query, t('Integration_added')); @@ -61,7 +62,7 @@ export default function NewOutgoingWebhook({ data = defaultData, onChange, setSa - ); + , [handleSave, t]); return ; diff --git a/client/admin/invites/InvitesPage.js b/client/admin/invites/InvitesPage.js index caf247692a60..682dd44e34fc 100644 --- a/client/admin/invites/InvitesPage.js +++ b/client/admin/invites/InvitesPage.js @@ -124,7 +124,7 @@ function InvitesPage() { }; loadInvites(); - }, []); + }, [listInvites]); const handleInviteRemove = (_id) => { setInvites((invites = []) => invites.filter((invite) => invite._id !== _id)); diff --git a/client/admin/oauthApps/OAuthAddApp.js b/client/admin/oauthApps/OAuthAddApp.js index a31a083288bd..dc4a8bc92583 100644 --- a/client/admin/oauthApps/OAuthAddApp.js +++ b/client/admin/oauthApps/OAuthAddApp.js @@ -42,7 +42,7 @@ export default function OAuthAddApp(props) { } catch (error) { dispatchToastMessage({ type: 'error', message: error }); } - }, [JSON.stringify(newData)]); + }, [close, dispatchToastMessage, newData, saveApp, t]); const handleChange = (field, getValue = (e) => e.currentTarget.value) => (e) => setNewData({ ...newData, [field]: getValue(e) }); diff --git a/client/admin/oauthApps/OAuthAppsTable.js b/client/admin/oauthApps/OAuthAppsTable.js index f4bd9060729d..ad827da2fdee 100644 --- a/client/admin/oauthApps/OAuthAppsTable.js +++ b/client/admin/oauthApps/OAuthAppsTable.js @@ -15,24 +15,23 @@ export function OAuthAppsTable() { const router = useRoute('admin-oauth-apps'); - const onClick = (_id) => () => router.push({ + const onClick = useCallback((_id) => () => router.push({ context: 'edit', id: _id, - }); + }), [router]); const header = useMemo(() => [
    , , , - ]); + ], [t]); const renderRow = useCallback(({ _id, name, _createdAt, _createdBy: { username: createdBy } }) => {name} {createdBy} {formatDateAndTime(_createdAt)} - , - ); + , [formatDateAndTime, onClick]); return ; } diff --git a/client/admin/oauthApps/OAuthEditApp.js b/client/admin/oauthApps/OAuthEditApp.js index b525bbada583..c10ce6f06ad1 100644 --- a/client/admin/oauthApps/OAuthEditApp.js +++ b/client/admin/oauthApps/OAuthEditApp.js @@ -72,6 +72,7 @@ export default function EditOauthAppWithData({ _id, ...props }) { const query = useMemo(() => ({ appId: _id, + // TODO: remove cache. Is necessary for data invalidation }), [_id, cache]); const { data, state, error } = useEndpointDataExperimental('oauth-apps.get', query); @@ -115,8 +116,8 @@ function EditOauthApp({ onChange, data, ...props }) { const close = useCallback(() => router.push({}), [router]); const absoluteUrl = useAbsoluteUrl(); - const authUrl = useMemo(() => absoluteUrl('oauth/authorize')); - const tokenUrl = useMemo(() => absoluteUrl('oauth/token')); + const authUrl = useMemo(() => absoluteUrl('oauth/authorize'), [absoluteUrl]); + const tokenUrl = useMemo(() => absoluteUrl('oauth/token'), [absoluteUrl]); const saveApp = useMethod('updateOAuthApp'); const deleteApp = useMethod('deleteOAuthApp'); @@ -132,7 +133,7 @@ function EditOauthApp({ onChange, data, ...props }) { } catch (error) { dispatchToastMessage({ type: 'error', message: error }); } - }, [JSON.stringify(newData)]); + }, [data._id, dispatchToastMessage, newData, onChange, saveApp, t]); const onDeleteConfirm = useCallback(async () => { try { @@ -141,7 +142,7 @@ function EditOauthApp({ onChange, data, ...props }) { } catch (error) { dispatchToastMessage({ type: 'error', message: error }); } - }, [data._id]); + }, [close, data._id, deleteApp, dispatchToastMessage]); const openConfirmDelete = () => setModal(() => setModal(undefined)}/>); diff --git a/client/admin/rooms/EditRoom.js b/client/admin/rooms/EditRoom.js index 06ad356d9828..790d58edab9a 100644 --- a/client/admin/rooms/EditRoom.js +++ b/client/admin/rooms/EditRoom.js @@ -18,6 +18,7 @@ export function EditRoomContextBar({ rid }) { function EditRoomWithData({ rid }) { const [cache, setState] = useState(); + // TODO: remove cache. Is necessary for data invalidation const { data = {}, state, error } = useEndpointDataExperimental('rooms.adminRooms.getRoom', useMemo(() => ({ rid }), [rid, cache])); if (state === ENDPOINT_STATES.LOADING) { @@ -47,12 +48,12 @@ function EditRoom({ room, onChange }) { const canDelete = usePermission(`delete-${ room.t }`); - const hasUnsavedChanges = useMemo(() => Object.values(newData).filter((current) => current === null).length < Object.keys(newData).length, [JSON.stringify(newData)]); - const saveQuery = useMemo(() => ({ rid: room._id, ...Object.fromEntries(Object.entries(newData).filter(([, value]) => value !== null)) }), [room._id, JSON.stringify(newData)]); + const hasUnsavedChanges = useMemo(() => Object.values(newData).filter((current) => current === null).length < Object.keys(newData).length, [newData]); + const saveQuery = useMemo(() => ({ rid: room._id, ...Object.fromEntries(Object.entries(newData).filter(([, value]) => value !== null)) }), [room._id, newData]); const archiveSelector = room.archived ? 'unarchive' : 'archive'; const archiveMessage = archiveSelector === 'archive' ? 'Room_has_been_archived' : 'Room_has_been_archived'; - const archiveQuery = useMemo(() => ({ rid: room._id, action: room.archived ? 'unarchive' : 'archive' }), [room.rid, changeArchivation]); + const archiveQuery = useMemo(() => ({ rid: room._id, action: room.archived ? 'unarchive' : 'archive' }), [room._id, room.archived]); const saveAction = useEndpointAction('POST', 'rooms.saveRoomSettings', saveQuery, t('Room_updated_successfully')); const archiveAction = useEndpointAction('POST', 'rooms.changeArchivationState', archiveQuery, t(archiveMessage)); @@ -71,7 +72,7 @@ function EditRoom({ room, onChange }) { const handleDelete = useCallback(async () => { await deleteRoom(room._id); setDeleted(true); - }, [room]); + }, [deleteRoom, room._id]); const roomName = room.t === 'd' ? room.usernames.join(' x ') : roomTypes.getRoomName(room.t, { type: room.t, ...room }); const roomType = newData.roomType ?? room.t; diff --git a/client/admin/rooms/RoomsTable.js b/client/admin/rooms/RoomsTable.js index 195c00a6cff7..6703c69b03e9 100644 --- a/client/admin/rooms/RoomsTable.js +++ b/client/admin/rooms/RoomsTable.js @@ -36,7 +36,7 @@ const FilterByTypeAndText = ({ setFilter, ...props }) => { } const _types = Object.entries(types).filter(([, value]) => Boolean(value)).map(([key]) => key); setFilter({ text, types: _types }); - }, [text, types]); + }, [setFilter, text, types]); const idDirect = useUniqueId(); const idDPublic = useUniqueId(); @@ -75,13 +75,13 @@ const FilterByTypeAndText = ({ setFilter, ...props }) => { ; }; -const useQuery = (params, sort) => useMemo(() => ({ - filter: params.text || '', - types: params.types, - sort: JSON.stringify({ [sort[0]]: sort[1] === 'asc' ? 1 : -1 }), - ...params.itemsPerPage && { count: params.itemsPerPage }, - ...params.current && { offset: params.current }, -}), [JSON.stringify(params), JSON.stringify(sort)]); +const useQuery = ({ text, types, itemsPerPage, current }, [column, direction]) => useMemo(() => ({ + filter: text || '', + types, + sort: JSON.stringify({ [column]: direction === 'asc' ? 1 : -1 }), + ...itemsPerPage && { count: itemsPerPage }, + ...current && { offset: current }, +}), [text, types, itemsPerPage, current, column, direction]); function RoomsTable() { const t = useTranslation(); @@ -102,12 +102,12 @@ function RoomsTable() { const router = useRoute(routeName); - const onClick = (rid) => () => router.push({ + const onClick = useCallback((rid) => () => router.push({ context: 'edit', id: rid, - }); + }), [router]); - const onHeaderClick = (id) => { + const onHeaderClick = useCallback((id) => { const [sortBy, sortDirection] = sort; if (sortBy === id) { @@ -115,7 +115,7 @@ function RoomsTable() { return; } setSort([id, 'asc']); - }; + }, [sort]); if (sort[0] === 'name' && data.rooms) { data.rooms = data.rooms.sort((a, b) => { @@ -134,7 +134,7 @@ function RoomsTable() { mediaQuery && , mediaQuery && , mediaQuery && , - ].filter(Boolean), [sort, mediaQuery]); + ].filter(Boolean), [sort, onHeaderClick, t, mediaQuery]); const renderRow = useCallback(({ _id, name, t: type, usersCount, msgs, default: isDefault, featured, usernames, ...args }) => { const icon = roomTypes.getIcon({ t: type, usernames, ...args }); @@ -160,7 +160,7 @@ function RoomsTable() { {mediaQuery && {isDefault ? t('True') : t('False')}} {mediaQuery && {featured ? t('True') : t('False')}} ; - }, [mediaQuery]); + }, [mediaQuery, onClick, t]); return ; } diff --git a/client/admin/settings/GroupPage.js b/client/admin/settings/GroupPage.js index 2b8448b0b28f..fbfb1129621e 100644 --- a/client/admin/settings/GroupPage.js +++ b/client/admin/settings/GroupPage.js @@ -1,12 +1,80 @@ import { Accordion, Box, Button, ButtonGroup, Skeleton } from '@rocket.chat/fuselage'; -import React, { useMemo } from 'react'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import React, { useMemo, memo } from 'react'; import Page from '../../components/basic/Page'; -import { useTranslation } from '../../contexts/TranslationContext'; +import { useEditableSettingsDispatch, useEditableSettings } from '../../contexts/EditableSettingsContext'; +import { useSettingsDispatch, useSettings } from '../../contexts/SettingsContext'; +import { useToastMessageDispatch } from '../../contexts/ToastMessagesContext'; +import { useTranslation, useLoadLanguage } from '../../contexts/TranslationContext'; +import { useUser } from '../../contexts/UserContext'; import { Section } from './Section'; -export function GroupPage({ children, headerButtons, save, cancel, _id, i18nLabel, i18nDescription, changed }) { +function GroupPage({ children, headerButtons, _id, i18nLabel, i18nDescription }) { + const changedEditableSettings = useEditableSettings(useMemo(() => ({ + group: _id, + changed: true, + }), [_id])); + + const originalSettings = useSettings(useMemo(() => ({ + _id: changedEditableSettings.map(({ _id }) => _id), + }), [changedEditableSettings])); + + const dispatch = useSettingsDispatch(); + + const dispatchToastMessage = useToastMessageDispatch(); const t = useTranslation(); + const loadLanguage = useLoadLanguage(); + const user = useUser(); + + const save = useMutableCallback(async () => { + const changes = changedEditableSettings + .map(({ _id, value, editor }) => ({ _id, value, editor })); + + if (changes.length === 0) { + return; + } + + try { + await dispatch(changes); + + if (changes.some(({ _id }) => _id === 'Language')) { + const lng = user?.language + || changes.filter(({ _id }) => _id === 'Language').shift()?.value + || 'en'; + + await loadLanguage(lng); + dispatchToastMessage({ type: 'success', message: t('Settings_updated', { lng }) }); + return; + } + + dispatchToastMessage({ type: 'success', message: t('Settings_updated') }); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }); + + const dispatchToEditing = useEditableSettingsDispatch(); + + const cancel = useMutableCallback(() => { + dispatchToEditing( + changedEditableSettings + .map(({ _id }) => originalSettings.find((setting) => setting._id === _id)) + .map((setting) => { + if (!setting) { + return; + } + + return { + _id: setting._id, + value: setting.value, + editor: setting.editor, + changed: false, + }; + }) + .filter(Boolean), + ); + }); const handleSubmit = (event) => { event.preventDefault(); @@ -33,11 +101,11 @@ export function GroupPage({ children, headerButtons, save, cancel, _id, i18nLabe return - {changed && } + {changedEditableSettings.length > 0 && } - , [reset, handleSave]); + , [hasUnsavedChanges, reset, t, handleSave]); return ; } diff --git a/client/admin/users/CustomFieldsForm.js b/client/admin/users/CustomFieldsForm.js index e6ee3074fa5b..1df7d88f5cae 100644 --- a/client/admin/users/CustomFieldsForm.js +++ b/client/admin/users/CustomFieldsForm.js @@ -14,7 +14,7 @@ const CustomTextInput = (props) => { if (!state && required) { error.push(t('Field_required')); } if (state.length < minLength) { error.push(t('Min_length_is', minLength)); } return error.join(', '); - }, [required, minLength, maxLength, state]); + }, [state, required, minLength, t]); return useMemo(() => {name} @@ -22,14 +22,14 @@ const CustomTextInput = (props) => { setState(e.currentTarget.value)}/> {verify} - , [name, verify, state, required]); + , [name, verify, maxLength, state, required, setState]); }; const CustomSelect = (props) => { const t = useTranslation(); const { name, required, options, setState, state } = props; - const mappedOptions = useMemo(() => Object.values(options).map((value) => [value, value]), [...options]); - const verify = useMemo(() => (!state.length && required ? t('Field_required') : ''), [required, state]); + const mappedOptions = useMemo(() => Object.values(options).map((value) => [value, value]), [options]); + const verify = useMemo(() => (!state.length && required ? t('Field_required') : ''), [required, state.length, t]); return useMemo(() => {name} @@ -37,7 +37,7 @@ const CustomSelect = (props) => { , mediaQuery && , , - ].filter(Boolean), [sort, mediaQuery]); + ].filter(Boolean), [sort, onHeaderClick, t, mediaQuery]); - const renderRow = useCallback(({ emails, _id, username, name, roles, status, ...args }) => { - const avatarUrl = roomTypes.getConfig('d').getAvatarPath({ name: username || name, type: 'd', _id, ...args }); + const renderRow = useCallback(({ emails, _id, username, name, roles, status, avatarETag }) => { + const avatarUrl = getUserAvatarURL(username, avatarETag); return @@ -100,7 +100,7 @@ export function UsersTable() { {mediaQuery && {roles && roles.join(', ')}} {status} ; - }, [mediaQuery]); + }, [mediaQuery, onClick]); return ; } diff --git a/client/admin/viewLogs/ViewLogs.js b/client/admin/viewLogs/ViewLogs.js index e7445d9eca33..5b84d172b223 100644 --- a/client/admin/viewLogs/ViewLogs.js +++ b/client/admin/viewLogs/ViewLogs.js @@ -61,7 +61,7 @@ function ViewLogs() { }; fetchLines(); - }, []); + }, [dispatchToastMessage, getStdoutQueue]); useEffect(() => { const stdoutStreamer = new Meteor.Streamer('stdout'); @@ -117,13 +117,14 @@ function ViewLogs() { }, [isAtBottom, sendToBottom]); useEffect(() => { + const wrapper = wrapperRef.current; if (window.MutationObserver) { const observer = new MutationObserver((mutations) => { mutations.forEach(() => { sendToBottomIfNecessary(); }); }); - observer.observe(wrapperRef.current, { childList: true }); + observer.observe(wrapper, { childList: true }); return () => { observer.disconnect(); @@ -133,10 +134,10 @@ function ViewLogs() { const handleSubtreeModified = () => { sendToBottomIfNecessary(); }; - wrapperRef.current.addEventListener('DOMSubtreeModified', handleSubtreeModified); + wrapper.addEventListener('DOMSubtreeModified', handleSubtreeModified); return () => { - wrapperRef.current.removeEventListener('DOMSubtreeModified', handleSubtreeModified); + wrapper.removeEventListener('DOMSubtreeModified', handleSubtreeModified); }; }, [sendToBottomIfNecessary]); diff --git a/client/components/GenericTable.js b/client/components/GenericTable.js index cafd8fba5fff..d71d5e96b915 100644 --- a/client/components/GenericTable.js +++ b/client/components/GenericTable.js @@ -56,13 +56,13 @@ export const GenericTable = forwardRef(function GenericTable({ useEffect(() => { setParams({ ...params, current, itemsPerPage }); - }, [params, current, itemsPerPage]); + }, [params, current, itemsPerPage, setParams]); - const Loading = useCallback(() => Array.from({ length: 10 }, (_, i) => ), [header && header.length]); + const Loading = useCallback(() => Array.from({ length: 10 }, (_, i) => ), [header.length]); - const showingResultsLabel = useCallback(({ count, current, itemsPerPage }) => t('Showing results %s - %s of %s', current + 1, Math.min(current + itemsPerPage, count), count), []); + const showingResultsLabel = useCallback(({ count, current, itemsPerPage }) => t('Showing results %s - %s of %s', current + 1, Math.min(current + itemsPerPage, count), count), [t]); - const itemsPerPageLabel = useCallback(() => t('Items_per_page:'), []); + const itemsPerPageLabel = useCallback(() => t('Items_per_page:'), [t]); return <> diff --git a/client/components/RoomForeword.js b/client/components/RoomForeword.js index dfa9779fd975..ca95f1a569f3 100644 --- a/client/components/RoomForeword.js +++ b/client/components/RoomForeword.js @@ -1,11 +1,11 @@ import React from 'react'; import { Avatar, Margins, Flex, Box, Tag } from '@rocket.chat/fuselage'; -import { Rooms } from '../../app/models'; +import { Rooms, Users } from '../../app/models/client'; import { useTranslation } from '../contexts/TranslationContext'; import { useReactiveValue } from '../hooks/useReactiveValue'; import { useUser } from '../contexts/UserContext'; -import { roomTypes } from '../../app/utils/client'; +import { getUserAvatarURL } from '../../app/utils/client'; const RoomForeword = ({ _id: rid }) => { const t = useTranslation(); @@ -17,8 +17,8 @@ const RoomForeword = ({ _id: rid }) => { return t('Start_of_conversation'); } - const users = room.usernames.filter((username) => username !== user.username); - if (users.length < 1) { + const usernames = room.usernames.filter((username) => username !== user.username); + if (usernames.length < 1) { return null; } @@ -26,9 +26,12 @@ const RoomForeword = ({ _id: rid }) => { - {users.map( + {usernames.map( (username, index) => { - const avatarUrl = roomTypes.getConfig('d').getAvatarPath({ name: username, type: 'd' }); + const user = Users.findOne({ username }, { fields: { avatarETag: 1 } }); + + const avatarUrl = getUserAvatarURL(username, user?.avatarETag); + return ; })} @@ -36,7 +39,7 @@ const RoomForeword = ({ _id: rid }) => { {t('Direct_message_you_have_joined')} - {users.map((username, index) => + {usernames.map((username, index) => @@ -47,10 +48,10 @@ function SortModeList() { const saveUserPreferences = useMethod('saveUserPreferences'); const sidebarSortBy = useUserPreference('sidebarSortby', 'activity'); - const handleChange = (value) => () => saveUserPreferences({ sidebarSortby: value }); + const useHandleChange = (value) => useCallback(() => saveUserPreferences({ sidebarSortby: value }), [value]); - const setToAlphabetical = useCallback(handleChange('alphabetical'), []); - const setToActivity = useCallback(handleChange('activity'), []); + const setToAlphabetical = useHandleChange('alphabetical'); + const setToActivity = useHandleChange('activity'); return <> @@ -71,16 +72,16 @@ function ViewModeList() { const saveUserPreferences = useMethod('saveUserPreferences'); - const handleChange = (value) => () => saveUserPreferences({ sidebarViewMode: value }); + const useHandleChange = (value) => useCallback(() => saveUserPreferences({ sidebarViewMode: value }), [value]); const sidebarViewMode = useUserPreference('sidebarViewMode', 'extended'); const sidebarHideAvatar = useUserPreference('sidebarHideAvatar', false); - const setToExtended = useCallback(handleChange('extended'), []); - const setToMedium = useCallback(handleChange('medium'), []); - const setToCondensed = useCallback(handleChange('condensed'), []); + const setToExtended = useHandleChange('extended'); + const setToMedium = useHandleChange('medium'); + const setToCondensed = useHandleChange('condensed'); - const handleChangeSidebarHideAvatar = useCallback(() => saveUserPreferences({ sidebarHideAvatar: !sidebarHideAvatar }), [sidebarHideAvatar]); + const handleChangeSidebarHideAvatar = useCallback(() => saveUserPreferences({ sidebarHideAvatar: !sidebarHideAvatar }), [saveUserPreferences, sidebarHideAvatar]); return <> @@ -99,6 +100,7 @@ function ViewModeList() { function GroupingList() { + const isDiscussionEnabled = useSetting('Discussion_enabled'); const sidebarShowDiscussion = useUserPreference('sidebarShowDiscussion'); const sidebarGroupByType = useUserPreference('sidebarGroupByType'); const sidebarShowFavorites = useUserPreference('sidebarShowFavorites'); @@ -106,12 +108,12 @@ function GroupingList() { const saveUserPreferences = useMethod('saveUserPreferences'); - const handleChange = (key, value) => () => saveUserPreferences({ [key]: value }); + const useHandleChange = (key, value) => useCallback(() => saveUserPreferences({ [key]: value }), [key, value]); - const handleChangeShowDicussion = useCallback(handleChange('sidebarShowDiscussion', !sidebarShowDiscussion), [sidebarShowDiscussion]); - const handleChangeGroupByType = useCallback(handleChange('sidebarGroupByType', !sidebarGroupByType), [sidebarGroupByType]); - const handleChangeShoFavorite = useCallback(handleChange('sidebarShowFavorites', !sidebarShowFavorites), [sidebarShowFavorites]); - const handleChangeShowUnread = useCallback(handleChange('sidebarShowUnread', !sidebarShowUnread), [sidebarShowUnread]); + const handleChangeShowDicussion = useHandleChange('sidebarShowDiscussion', !sidebarShowDiscussion); + const handleChangeGroupByType = useHandleChange('sidebarGroupByType', !sidebarGroupByType); + const handleChangeShoFavorite = useHandleChange('sidebarShowFavorites', !sidebarShowFavorites); + const handleChangeShowUnread = useHandleChange('sidebarShowUnread', !sidebarShowUnread); const t = useTranslation(); @@ -121,7 +123,7 @@ function GroupingList() {
      - } /> + {isDiscussionEnabled && } />} } /> } /> } /> diff --git a/client/components/basic/Buttons/ActionButton.js b/client/components/basic/Buttons/ActionButton.js new file mode 100644 index 000000000000..013f7dd52eb8 --- /dev/null +++ b/client/components/basic/Buttons/ActionButton.js @@ -0,0 +1,4 @@ +import React from 'react'; +import { Button, Icon } from '@rocket.chat/fuselage'; +// TODO fuselage +export const ActionButton = ({ icon, ...props }) => ; diff --git a/client/components/basic/Modal.js b/client/components/basic/Modal.js index 7444bcb3c56d..95aa45206ac1 100644 --- a/client/components/basic/Modal.js +++ b/client/components/basic/Modal.js @@ -23,7 +23,7 @@ function ModalPortal({ children = '' }) { useEffect(() => { modalRoot.appendChild(node); return () => modalRoot.removeChild(node); - }, [node]); + }, [modalRoot, node]); return createPortal(
      {children}
      , node, diff --git a/client/contexts/EditableSettingsContext.ts b/client/contexts/EditableSettingsContext.ts new file mode 100644 index 000000000000..e647923783f8 --- /dev/null +++ b/client/contexts/EditableSettingsContext.ts @@ -0,0 +1,65 @@ +import { createContext, useContext, useMemo } from 'react'; +import { useSubscription, Subscription, Unsubscribe } from 'use-subscription'; + +import { + ISetting, + SectionName, + SettingId, + GroupId, +} from '../../definition/ISetting'; +import { SettingsContextQuery } from './SettingsContext'; + +export interface IEditableSetting extends ISetting { + disabled: boolean; + changed: boolean; +} + +export type EditableSettingsContextQuery = SettingsContextQuery & { + changed?: boolean; +}; + +export type EditableSettingsContextValue = { + readonly queryEditableSetting: (_id: SettingId) => Subscription; + readonly queryEditableSettings: (query: EditableSettingsContextQuery) => Subscription; + readonly queryGroupSections: (_id: GroupId) => Subscription; + readonly dispatch: (changes: Partial[]) => void; +}; + +export const EditableSettingsContext = createContext({ + queryEditableSetting: () => ({ + getCurrentValue: (): undefined => undefined, + subscribe: (): Unsubscribe => (): void => undefined, + }), + queryEditableSettings: () => ({ + getCurrentValue: (): IEditableSetting[] => [], + subscribe: (): Unsubscribe => (): void => undefined, + }), + queryGroupSections: () => ({ + getCurrentValue: (): SectionName[] => [], + subscribe: (): Unsubscribe => (): void => undefined, + }), + dispatch: () => undefined, +}); + +export const useEditableSetting = (_id: SettingId): IEditableSetting | undefined => { + const { queryEditableSetting } = useContext(EditableSettingsContext); + + const subscription = useMemo(() => queryEditableSetting(_id), [queryEditableSetting, _id]); + return useSubscription(subscription); +}; + +export const useEditableSettings = (query?: EditableSettingsContextQuery): IEditableSetting[] => { + const { queryEditableSettings } = useContext(EditableSettingsContext); + const subscription = useMemo(() => queryEditableSettings(query ?? {}), [queryEditableSettings, query]); + return useSubscription(subscription); +}; + +export const useEditableSettingsGroupSections = (_id: SettingId): SectionName[] => { + const { queryGroupSections } = useContext(EditableSettingsContext); + + const subscription = useMemo(() => queryGroupSections(_id), [queryGroupSections, _id]); + return useSubscription(subscription); +}; + +export const useEditableSettingsDispatch = (): ((changes: Partial[]) => void) => + useContext(EditableSettingsContext).dispatch; diff --git a/client/contexts/PrivilegedSettingsContext.ts b/client/contexts/PrivilegedSettingsContext.ts deleted file mode 100644 index da463d6de2da..000000000000 --- a/client/contexts/PrivilegedSettingsContext.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { useDebouncedCallback, useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { Tracker } from 'meteor/tracker'; -import { createContext, useContext, RefObject, useState, useEffect, useLayoutEffect, useMemo, useCallback } from 'react'; -import { useSubscription } from 'use-subscription'; - -import { useReactiveValue } from '../hooks/useReactiveValue'; -import { useBatchSettingsDispatch } from './SettingsContext'; -import { useToastMessageDispatch } from './ToastMessagesContext'; -import { useTranslation, useLoadLanguage } from './TranslationContext'; -import { useUser } from './UserContext'; - -export type PrivilegedSetting = object & { - _id: string; - type: string; - blocked: boolean; - enableQuery: unknown; - group: string; - section: string; - changed: boolean; - value: unknown; - packageValue: unknown; - packageEditor: unknown; - editor: unknown; - sorter: string; - i18nLabel: string; - disabled?: boolean; - update?: () => void; - reset?: () => void; -}; - -export type PrivilegedSettingsState = { - settings: PrivilegedSetting[]; - persistedSettings: PrivilegedSetting[]; -}; - -type EqualityFunction = (a: T, b: T) => boolean; - -// TODO: split editing into another context -type PrivilegedSettingsContextValue = { - authorized: boolean; - loading: boolean; - subscribers: Set<(state: PrivilegedSettingsState) => void>; - stateRef: RefObject; - hydrate: (changes: any[]) => void; - isDisabled: (setting: PrivilegedSetting) => boolean; -}; - -export const PrivilegedSettingsContext = createContext({ - authorized: false, - loading: false, - subscribers: new Set<(state: PrivilegedSettingsState) => void>(), - stateRef: { - current: { - settings: [], - persistedSettings: [], - }, - }, - hydrate: () => undefined, - isDisabled: () => false, -}); - -export const usePrivilegedSettingsAuthorized = (): boolean => - useContext(PrivilegedSettingsContext).authorized; - -export const useIsPrivilegedSettingsLoading = (): boolean => - useContext(PrivilegedSettingsContext).loading; - -export const usePrivilegedSettingsGroups = (filter?: string): any => { - const { stateRef, subscribers } = useContext(PrivilegedSettingsContext); - const t = useTranslation(); - - const getCurrentValue = useCallback(() => { - const filterRegex = filter ? new RegExp(filter, 'i') : null; - - const filterPredicate = (setting: PrivilegedSetting): boolean => - !filterRegex || filterRegex.test(t(setting.i18nLabel || setting._id)); - - const groupIds = Array.from(new Set( - (stateRef.current?.persistedSettings ?? []) - .filter(filterPredicate) - .map((setting) => setting.group || setting._id), - )); - - return (stateRef.current?.persistedSettings ?? []) - .filter(({ type, group, _id }) => type === 'group' && groupIds.includes(group || _id)) - .sort((a, b) => t(a.i18nLabel || a._id).localeCompare(t(b.i18nLabel || b._id))); - }, [filter]); - - const subscribe = useCallback((cb) => { - const handleUpdate = (): void => { - cb(getCurrentValue()); - }; - - subscribers.add(handleUpdate); - - return (): void => { - subscribers.delete(handleUpdate); - }; - }, [getCurrentValue]); - - return useSubscription(useMemo(() => ({ - getCurrentValue, - subscribe, - }), [getCurrentValue, subscribe])); -}; - -const useSelector = ( - selector: (state: PrivilegedSettingsState) => T, - equalityFunction: EqualityFunction = Object.is, -): T | null => { - const { subscribers, stateRef } = useContext(PrivilegedSettingsContext); - const [value, setValue] = useState(() => (stateRef.current ? selector(stateRef.current) : null)); - - const handleUpdate = useMutableCallback((state: PrivilegedSettingsState) => { - const newValue = selector(state); - - if (!value || !equalityFunction(newValue, value)) { - setValue(newValue); - } - }); - - useEffect(() => { - subscribers.add(handleUpdate); - - return (): void => { - subscribers.delete(handleUpdate); - }; - }, [handleUpdate]); - - useLayoutEffect(() => { - handleUpdate(stateRef.current); - }); - - return value; -}; - -export const usePrivilegedSettingsGroup = (groupId: string): any => { - const group = useSelector((state) => state.settings.find(({ _id, type }) => _id === groupId && type === 'group')); - - const filterSettings = (settings: any[]): any[] => settings.filter(({ group }) => group === groupId); - - const changed = useSelector((state) => filterSettings(state.settings).some(({ changed }) => changed)); - const sections = useSelector((state) => Array.from(new Set(filterSettings(state.settings).map(({ section }) => section || ''))), (a, b) => a.length === b.length && a.join() === b.join()); - - const batchSetSettings = useBatchSettingsDispatch(); - const { stateRef, hydrate } = useContext(PrivilegedSettingsContext); - - const dispatchToastMessage = useToastMessageDispatch() as any; - const t = useTranslation() as (key: string, ...args: any[]) => string; - const loadLanguage = useLoadLanguage() as any; - const user = useUser() as any; - - const save = useMutableCallback(async () => { - const state = stateRef.current; - const settings = filterSettings(state?.settings ?? []); - - const changes = settings.filter(({ changed }) => changed) - .map(({ _id, value, editor }) => ({ _id, value, editor })); - - if (changes.length === 0) { - return; - } - - try { - await batchSetSettings(changes); - - if (changes.some(({ _id }) => _id === 'Language')) { - const lng = user?.language - || changes.filter(({ _id }) => _id === 'Language').shift()?.value - || 'en'; - - try { - await loadLanguage(lng); - dispatchToastMessage({ type: 'success', message: t('Settings_updated', { lng }) }); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } - return; - } - - dispatchToastMessage({ type: 'success', message: t('Settings_updated') }); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } - }); - - const cancel = useMutableCallback(() => { - const state = stateRef.current; - const settings = filterSettings(state?.settings ?? []); - const persistedSettings = filterSettings(state?.persistedSettings ?? []); - - const changes = settings.filter(({ changed }) => changed) - .map((field) => { - const { _id, value, editor } = persistedSettings.find(({ _id }) => _id === field._id); - return { _id, value, editor, changed: false }; - }); - - hydrate(changes); - }); - - return group && { ...group, sections, changed, save, cancel }; -}; - -export const usePrivilegedSettingsSection = (groupId: string, sectionName?: string): any => { - sectionName = sectionName || ''; - - const filterSettings = (settings: any[]): any[] => - settings.filter(({ group, section }) => group === groupId && ((!sectionName && !section) || (sectionName === section))); - - const canReset = useSelector((state) => filterSettings(state.settings).some(({ value, packageValue }) => JSON.stringify(value) !== JSON.stringify(packageValue))); - const settingsIds = useSelector((state) => filterSettings(state.settings).map(({ _id }) => _id), (a, b) => a.length === b.length && a.join() === b.join()); - - const { stateRef, hydrate, isDisabled } = useContext(PrivilegedSettingsContext); - - const reset = useMutableCallback(() => { - const state = stateRef.current; - const settings = filterSettings(state?.settings ?? []) - .filter((setting) => Tracker.nonreactive(() => !isDisabled(setting))); // Ignore disabled settings - const persistedSettings = filterSettings(state?.persistedSettings ?? []); - - const changes = settings.map((setting) => { - const { _id, value, packageValue, packageEditor } = persistedSettings.find(({ _id }) => _id === setting._id); - return { - _id, - value: packageValue, - editor: packageEditor, - changed: JSON.stringify(packageValue) !== JSON.stringify(value), - }; - }); - - hydrate(changes); - }); - - return { - name: sectionName, - canReset, - settings: settingsIds, - reset, - }; -}; - -export const usePrivilegedSettingActions = (persistedSetting: PrivilegedSetting | null | undefined): { - update: () => void; - reset: () => void; -} => { - const { hydrate } = useContext(PrivilegedSettingsContext); - - const update = useDebouncedCallback(({ value, editor }) => { - const changes = [{ - _id: persistedSetting?._id, - ...value !== undefined && { value }, - ...editor !== undefined && { editor }, - changed: JSON.stringify(persistedSetting?.value) !== JSON.stringify(value) || JSON.stringify(editor) !== JSON.stringify(persistedSetting?.editor), - }]; - - hydrate(changes); - }, 100, [hydrate, persistedSetting]) as () => void; - - const reset = useDebouncedCallback(() => { - const changes = [{ - _id: persistedSetting?._id, - value: persistedSetting?.packageValue, - editor: persistedSetting?.packageEditor, - changed: JSON.stringify(persistedSetting?.packageValue) !== JSON.stringify(persistedSetting?.value) || JSON.stringify(persistedSetting?.packageEditor) !== JSON.stringify(persistedSetting?.editor), - }]; - - hydrate(changes); - }, 100, [hydrate, persistedSetting]) as () => void; - - return { update, reset }; -}; - -export const usePrivilegedSettingDisabledState = (setting: PrivilegedSetting | null | undefined): boolean => { - const { isDisabled } = useContext(PrivilegedSettingsContext); - return useReactiveValue(() => (setting ? isDisabled(setting) : false), [setting?.blocked, setting?.enableQuery]) as unknown as boolean; -}; - -export const usePrivilegedSettingsSectionChangedState = (groupId: string, sectionName: string): boolean => - !!useSelector((state) => - state.settings.some(({ group, section, changed }) => - group === groupId && ((!sectionName && !section) || (sectionName === section)) && changed)); - -export const usePrivilegedSetting = (_id: string): PrivilegedSetting | null | undefined => { - const selectSetting = (settings: PrivilegedSetting[]): PrivilegedSetting | undefined => settings.find((setting) => setting._id === _id); - - const setting = useSelector((state) => selectSetting(state.settings)); - const persistedSetting = useSelector((state) => selectSetting(state.persistedSettings)); - - const { update, reset } = usePrivilegedSettingActions(persistedSetting); - const disabled = usePrivilegedSettingDisabledState(persistedSetting); - - if (!setting) { - return null; - } - - return { - ...setting, - disabled, - update, - reset, - }; -}; diff --git a/client/contexts/RouterContext.js b/client/contexts/RouterContext.js index fda98b78b5ca..dafd6a7d31a4 100644 --- a/client/contexts/RouterContext.js +++ b/client/contexts/RouterContext.js @@ -24,7 +24,7 @@ export const useRoute = (name) => { getUrl: (...args) => getRouteUrl(name, ...args), push: (...args) => pushRoute(name, ...args), replace: (...args) => replaceRoute(name, ...args), - }), [getRoutePath, pushRoute, replaceRoute]); + }), [getRoutePath, getRouteUrl, name, pushRoute, replaceRoute]); }; export const useRoutePath = (name, params, queryStringParams) => { diff --git a/client/contexts/ServerContext.ts b/client/contexts/ServerContext.ts index 7934932a5fb2..df3d93571f59 100644 --- a/client/contexts/ServerContext.ts +++ b/client/contexts/ServerContext.ts @@ -47,7 +47,7 @@ export const useUpload = (endpoint: string): () => Promise => { export const useStream = (streamName: string, options?: object): IServerStream => { const { getStream } = useContext(ServerContext); - return useMemo(() => getStream(streamName, options), [streamName, options]); + return useMemo(() => getStream(streamName, options), [getStream, streamName, options]); }; export enum AsyncState { @@ -105,7 +105,7 @@ export const usePolledMethodData = (methodName: string, args: any[] = [], int return (): void => { clearInterval(timer); }; - }, []); + }, [fetchData, intervalMs]); return [data, state, fetchData]; }; diff --git a/client/contexts/SettingsContext.js b/client/contexts/SettingsContext.js deleted file mode 100644 index c784a40538b7..000000000000 --- a/client/contexts/SettingsContext.js +++ /dev/null @@ -1,24 +0,0 @@ -import { createContext, useCallback, useContext } from 'react'; - -import { useObservableValue } from '../hooks/useObservableValue'; - -export const SettingsContext = createContext({ - get: () => {}, - set: async () => {}, - batchSet: async () => {}, -}); - -export const useSetting = (name) => { - const { get } = useContext(SettingsContext); - return useObservableValue((listener) => get(name, listener)); -}; - -export const useSettingDispatch = (name) => { - const { set } = useContext(SettingsContext); - return useCallback((value) => set(name, value), [set, name]); -}; - -export const useBatchSettingsDispatch = () => { - const { batchSet } = useContext(SettingsContext); - return useCallback((entries) => batchSet(entries), []); -}; diff --git a/client/contexts/SettingsContext.ts b/client/contexts/SettingsContext.ts new file mode 100644 index 000000000000..2e4e817dca82 --- /dev/null +++ b/client/contexts/SettingsContext.ts @@ -0,0 +1,66 @@ +import { createContext, useCallback, useContext, useMemo } from 'react'; +import { useSubscription, Subscription, Unsubscribe } from 'use-subscription'; + +import { + SettingId, + ISetting, + GroupId, + SectionName, +} from '../../definition/ISetting'; + +export type SettingsContextQuery = { + readonly _id?: SettingId[]; + readonly group?: GroupId; + readonly section?: SectionName; +} + +export type SettingsContextValue = { + readonly hasPrivateAccess: boolean; + readonly isLoading: boolean; + readonly querySetting: (_id: SettingId) => Subscription; + readonly querySettings: (query: SettingsContextQuery) => Subscription; + readonly dispatch: (changes: Partial[]) => Promise; +} + +export const SettingsContext = createContext({ + hasPrivateAccess: false, + isLoading: false, + querySetting: () => ({ + getCurrentValue: (): undefined => undefined, + subscribe: (): Unsubscribe => (): void => undefined, + }), + querySettings: () => ({ + getCurrentValue: (): ISetting[] => [], + subscribe: (): Unsubscribe => (): void => undefined, + }), + dispatch: async () => undefined, +}); + +export const useIsPrivilegedSettingsContext = (): boolean => + useContext(SettingsContext).hasPrivateAccess; + +export const useIsSettingsContextLoading = (): boolean => + useContext(SettingsContext).isLoading; + +export const useSettingStructure = (_id: SettingId): ISetting | undefined => { + const { querySetting } = useContext(SettingsContext); + const subscription = useMemo(() => querySetting(_id), [querySetting, _id]); + return useSubscription(subscription); +}; + +export const useSetting = (_id: SettingId): unknown | undefined => + useSettingStructure(_id)?.value; + +export const useSettings = (query?: SettingsContextQuery): ISetting[] => { + const { querySettings } = useContext(SettingsContext); + const subscription = useMemo(() => querySettings(query ?? {}), [querySettings, query]); + return useSubscription(subscription); +}; + +export const useSettingsDispatch = (): ((changes: Partial[]) => Promise) => + useContext(SettingsContext).dispatch; + +export const useSettingSetValue = (_id: SettingId): ((value: T) => Promise) => { + const dispatch = useSettingsDispatch(); + return useCallback((value: T) => dispatch([{ _id, value }]), [dispatch, _id]); +}; diff --git a/client/fuselage-hooks.d.ts b/client/fuselage-hooks.d.ts index 2bef8fec95ab..9a0eda950b26 100644 --- a/client/fuselage-hooks.d.ts +++ b/client/fuselage-hooks.d.ts @@ -1,4 +1,7 @@ declare module '@rocket.chat/fuselage-hooks' { + import { RefObject } from 'react'; + export const useDebouncedCallback: (fn: (...args: any[]) => any, ms: number, deps: any[]) => (...args: any[]) => any; + export const useLazyRef: (initializer: () => T) => RefObject; export const useMutableCallback: (fn: (...args: any[]) => any) => (...args: any[]) => any; } diff --git a/client/hooks/useEndpointAction.js b/client/hooks/useEndpointAction.js index 7abf93140fab..ff7962091d8f 100644 --- a/client/hooks/useEndpointAction.js +++ b/client/hooks/useEndpointAction.js @@ -22,5 +22,5 @@ export const useEndpointAction = (httpMethod, endpoint, params = {}, successMess dispatchToastMessage({ type: 'error', message: error }); return { success: false }; } - }, [JSON.stringify(params)]); + }, [dispatchToastMessage, params, sendData, successMessage]); }; diff --git a/client/hooks/useEndpointData.js b/client/hooks/useEndpointData.js index b97bb47d3809..73e2352a8474 100644 --- a/client/hooks/useEndpointData.js +++ b/client/hooks/useEndpointData.js @@ -46,7 +46,7 @@ export const useEndpointData = (endpoint, params = {}) => { return () => { mounted = false; }; - }, [getData, params]); + }, [dispatchToastMessage, getData, params]); return data; }; diff --git a/client/hooks/useEndpointDataExperimental.js b/client/hooks/useEndpointDataExperimental.js index dd284a7a6c84..6bf3d9368cf1 100644 --- a/client/hooks/useEndpointDataExperimental.js +++ b/client/hooks/useEndpointDataExperimental.js @@ -55,7 +55,7 @@ export const useEndpointDataExperimental = (endpoint, params = {}, { delayTimeou return () => { mounted = false; }; - }, [getData, params]); + }, [delayTimeout, dispatchToastMessage, getData, params]); return data; }; diff --git a/client/hooks/useEndpointUpload.js b/client/hooks/useEndpointUpload.js index 7a73876c3882..0e15e74bd2ce 100644 --- a/client/hooks/useEndpointUpload.js +++ b/client/hooks/useEndpointUpload.js @@ -24,5 +24,5 @@ export const useEndpointUpload = (endpoint, params = {}, successMessage) => { dispatchToastMessage({ type: 'error', message: error }); return { success: false }; } - }, [JSON.stringify(params)]); + }, [dispatchToastMessage, params, sendData, successMessage]); }; diff --git a/client/hooks/useForm.js b/client/hooks/useForm.js index 90d967d93fcc..0da0149920d7 100644 --- a/client/hooks/useForm.js +++ b/client/hooks/useForm.js @@ -1,3 +1,4 @@ +/* eslint-disable react-hooks/rules-of-hooks */ import { useState, useCallback } from 'react'; import { capitalize } from '../helpers/capitalize'; @@ -7,6 +8,7 @@ const getValue = (e) => (e.currentTarget ? e.currentTarget.value : e); export const useForm = (obj) => { const resetCallbacks = []; const hasUnsavedChanges = []; + // TODO: use useReducer hook as we can't assure that obj will have the same structure on each render const ret = Object.keys(obj).sort().reduce((ret, key) => { const value = obj[key]; const [data, setData] = useState(value); diff --git a/client/hooks/useFormatTime.js b/client/hooks/useFormatTime.js index de72d4e50264..4ff287c71011 100644 --- a/client/hooks/useFormatTime.js +++ b/client/hooks/useFormatTime.js @@ -18,5 +18,5 @@ export const useFormatTime = () => { default: return moment(time).format(format); } - }, [clockMode, format]); + }, [clockMode, format, sameDay]); }; diff --git a/client/hooks/useHighlightedCode.js b/client/hooks/useHighlightedCode.js new file mode 100644 index 000000000000..8ce75542eae9 --- /dev/null +++ b/client/hooks/useHighlightedCode.js @@ -0,0 +1,6 @@ +import hljs from 'highlight.js'; +import { useMemo } from 'react'; + +export function useHighlightedCode(language, text) { + return useMemo(() => hljs.highlight(language, text).value, [language, text]); +} diff --git a/client/hooks/useHilightCode.js b/client/hooks/useHilightCode.js deleted file mode 100644 index eb081ef36a13..000000000000 --- a/client/hooks/useHilightCode.js +++ /dev/null @@ -1,6 +0,0 @@ -import hljs from 'highlight.js'; -import { useMemo } from 'react'; - -export function useHilightCode() { - return (language, text) => useMemo(() => hljs.highlight(language, text).value, [language, text]); -} diff --git a/client/hooks/useObservableValue.js b/client/hooks/useObservableValue.js index 0b26e1350da9..835eeaabdf23 100644 --- a/client/hooks/useObservableValue.js +++ b/client/hooks/useObservableValue.js @@ -16,7 +16,7 @@ export const useObservableValue = (getValue) => { mounted = false; typeof unsubscribe === 'function' && unsubscribe(); }; - }, [getValue, name]); + }, [getValue]); return value; }; diff --git a/client/hooks/useQuery.ts b/client/hooks/useQuery.ts new file mode 100644 index 000000000000..7fb48742650b --- /dev/null +++ b/client/hooks/useQuery.ts @@ -0,0 +1,30 @@ +import { Tracker } from 'meteor/tracker'; +import { useCallback, useMemo, useRef } from 'react'; +import { useSubscription } from 'use-subscription'; +import { Mongo } from 'meteor/mongo'; + +const allQuery = {}; + +export const useQuery = (collection: Mongo.Collection, query: object = allQuery, options?: object): T[] => { + const queryHandle = useMemo(() => collection.find(query, options), [collection, query, options]); + const resultRef = useRef([]); + resultRef.current = Tracker.nonreactive(() => queryHandle.fetch()) as unknown as T[]; + + const subscribe = useCallback((cb) => { + const computation = Tracker.autorun(() => { + resultRef.current = queryHandle.fetch(); + cb(resultRef.current); + }); + + return (): void => { + computation.stop(); + }; + }, [queryHandle]); + + const subscription = useMemo(() => ({ + getCurrentValue: (): T[] => resultRef.current ?? [], + subscribe, + }), [subscribe]); + + return useSubscription(subscription); +}; diff --git a/client/hooks/useReactiveSubscriptionFactory.ts b/client/hooks/useReactiveSubscriptionFactory.ts new file mode 100644 index 000000000000..ac9021186d0a --- /dev/null +++ b/client/hooks/useReactiveSubscriptionFactory.ts @@ -0,0 +1,28 @@ +import { Tracker } from 'meteor/tracker'; +import { useCallback } from 'react'; +import { Subscription, Unsubscribe } from 'use-subscription'; + +interface ISubscriptionFactory { + (...args: any[]): Subscription; +} + +export const useReactiveSubscriptionFactory = (fn: (...args: any[]) => T): ISubscriptionFactory => + useCallback>((...args: any[]) => { + const fnWithArgs = (): T => fn(...args); + + return { + getCurrentValue: (): T => Tracker.nonreactive(fnWithArgs) as unknown as T, + subscribe: (callback): Unsubscribe => { + const computation = Tracker.autorun((c) => { + fnWithArgs(); + if (!c.firstRun) { + callback(); + } + }); + + return (): void => { + computation.stop(); + }; + }, + }; + }, [fn]); diff --git a/client/lib/saveFile.js b/client/lib/saveFile.js new file mode 100644 index 000000000000..ff21605daaf2 --- /dev/null +++ b/client/lib/saveFile.js @@ -0,0 +1,9 @@ +export const saveFile = (content, name = 'download') => { + const blob = new Blob([content], { type: 'text/plain' }); + const anchor = document.createElement('a'); + + anchor.download = name; + anchor.href = (window.webkitURL || window.URL).createObjectURL(blob); + anchor.dataset.downloadurl = ['text/plain', anchor.download, anchor.href].join(':'); + anchor.click(); +}; diff --git a/client/admin/PrivateSettingsCachedCollection.ts b/client/lib/settings/PrivateSettingsCachedCollection.ts similarity index 83% rename from client/admin/PrivateSettingsCachedCollection.ts rename to client/lib/settings/PrivateSettingsCachedCollection.ts index c1ea4b3dacd4..9369d1c18265 100644 --- a/client/admin/PrivateSettingsCachedCollection.ts +++ b/client/lib/settings/PrivateSettingsCachedCollection.ts @@ -1,5 +1,5 @@ -import { CachedCollection } from '../../app/ui-cached-collection/client'; -import { Notifications } from '../../app/notifications/client'; +import { CachedCollection } from '../../../app/ui-cached-collection/client'; +import { Notifications } from '../../../app/notifications/client'; export class PrivateSettingsCachedCollection extends CachedCollection { constructor() { diff --git a/client/lib/settings/PublicSettingsCachedCollection.ts b/client/lib/settings/PublicSettingsCachedCollection.ts new file mode 100644 index 000000000000..f6ebca428dd7 --- /dev/null +++ b/client/lib/settings/PublicSettingsCachedCollection.ts @@ -0,0 +1,22 @@ +import { CachedCollection } from '../../../app/ui-cached-collection/client'; + +export class PublicSettingsCachedCollection extends CachedCollection { + constructor() { + super({ + name: 'public-settings', + eventType: 'onAll', + userRelated: false, + listenChangesForLoggedUsersOnly: true, + }); + } + + static instance: PublicSettingsCachedCollection; + + static get(): PublicSettingsCachedCollection { + if (!PublicSettingsCachedCollection.instance) { + PublicSettingsCachedCollection.instance = new PublicSettingsCachedCollection(); + } + + return PublicSettingsCachedCollection.instance; + } +} diff --git a/client/meteor.d.ts b/client/meteor.d.ts index 023a7d17e689..82c8aea70c9a 100644 --- a/client/meteor.d.ts +++ b/client/meteor.d.ts @@ -26,3 +26,22 @@ declare module 'meteor/meteor' { const connection: IMeteorConnection; } } + +declare module 'meteor/tracker' { + namespace Tracker { + function nonreactive(func: () => T): T; + } +} + +declare module 'meteor/mongo' { + namespace Mongo { + // eslint-disable-next-line @typescript-eslint/interface-name-prefix + interface CollectionStatic { + new (name: string | null, options?: { + connection?: object | null; + idGeneration?: string; + transform?: Function | null; + }): Collection; + } + } +} diff --git a/client/notifications/updateAvatar.js b/client/notifications/updateAvatar.js index 8fc2684842c5..908824a71efd 100644 --- a/client/notifications/updateAvatar.js +++ b/client/notifications/updateAvatar.js @@ -1,10 +1,10 @@ import { Meteor } from 'meteor/meteor'; -import { updateAvatarOfUsername } from '../../app/ui-utils'; import { Notifications } from '../../app/notifications'; Meteor.startup(function() { Notifications.onLogged('updateAvatar', function(data) { - updateAvatarOfUsername(data.username); + const { username, etag } = data; + Meteor.users.update({ username }, { $set: { avatarETag: etag } }); }); }); diff --git a/client/providers/EditableSettingsProvider.tsx b/client/providers/EditableSettingsProvider.tsx new file mode 100644 index 000000000000..38e8ebd0d069 --- /dev/null +++ b/client/providers/EditableSettingsProvider.tsx @@ -0,0 +1,137 @@ +import { useLazyRef, useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { Mongo } from 'meteor/mongo'; +import { Tracker } from 'meteor/tracker'; +import React, { useEffect, useMemo, FunctionComponent, useCallback } from 'react'; + +import { SettingId, GroupId } from '../../definition/ISetting'; +import { EditableSettingsContext, IEditableSetting, EditableSettingsContextValue } from '../contexts/EditableSettingsContext'; +import { useSettings, SettingsContextQuery } from '../contexts/SettingsContext'; +import { useReactiveSubscriptionFactory } from '../hooks/useReactiveSubscriptionFactory'; + +const defaultQuery: SettingsContextQuery = {}; + +type EditableSettingsProviderProps = { + readonly query: SettingsContextQuery; +}; + +const EditableSettingsProvider: FunctionComponent = ({ + children, + query = defaultQuery, +}) => { + const settingsCollectionRef = useLazyRef(() => new Mongo.Collection(null)); + const persistedSettings = useSettings(query); + + useEffect(() => { + if (!settingsCollectionRef.current) { + return; + } + + settingsCollectionRef.current.remove({ _id: { $nin: persistedSettings.map(({ _id }) => _id) } }); + for (const setting of persistedSettings) { + settingsCollectionRef.current.upsert(setting._id, { ...setting }); + } + }, [persistedSettings, settingsCollectionRef]); + + const queryEditableSetting = useReactiveSubscriptionFactory( + useCallback( + (_id: SettingId): IEditableSetting | undefined => { + if (!settingsCollectionRef.current) { + return; + } + + const editableSetting = settingsCollectionRef.current.findOne(_id); + + if (editableSetting.blocked) { + return { ...editableSetting, disabled: true }; + } + + if (!editableSetting.enableQuery) { + return { ...editableSetting, disabled: false }; + } + + const queries = [].concat(typeof editableSetting.enableQuery === 'string' + ? JSON.parse(editableSetting.enableQuery) + : editableSetting.enableQuery); + return { + ...editableSetting, + disabled: !queries.every((query) => (settingsCollectionRef.current?.find(query)?.count() ?? 0) > 0), + }; + }, + [settingsCollectionRef], + ), + ); + + const queryEditableSettings = useReactiveSubscriptionFactory( + useCallback( + (query = {}) => settingsCollectionRef.current?.find({ + ...('_id' in query) && { _id: { $in: query._id } }, + ...('group' in query) && { group: query.group }, + ...('section' in query) && ( + query.section + ? { section: query.section } + : { + $or: [ + { section: { $exists: false } }, + { section: null }, + ], + } + ), + ...('changed' in query) && { changed: query.changed }, + }, { + sort: { + section: 1, + sorter: 1, + i18nLabel: 1, + }, + }).fetch() ?? [], + [settingsCollectionRef], + ), + ); + + const queryGroupSections = useReactiveSubscriptionFactory( + useCallback( + (_id: GroupId) => Array.from(new Set( + (settingsCollectionRef.current?.find({ + group: _id, + }, { + fields: { + section: 1, + }, + sort: { + section: 1, + sorter: 1, + i18nLabel: 1, + }, + }).fetch() ?? []).map(({ section }) => section), + )), + [settingsCollectionRef], + ), + ); + + const dispatch = useMutableCallback((changes: Partial[]): void => { + for (const { _id, ...data } of changes) { + if (!_id) { + continue; + } + + settingsCollectionRef.current?.update(_id, { $set: data }); + } + Tracker.flush(); + }); + + const contextValue = useMemo(() => ({ + queryEditableSetting, + queryEditableSettings, + queryGroupSections, + dispatch, + }), [ + queryEditableSetting, + queryEditableSettings, + queryGroupSections, + dispatch, + ]); + + return ; +}; + +export default EditableSettingsProvider; diff --git a/client/providers/MeteorProvider.js b/client/providers/MeteorProvider.js index 0ebb65d27b68..55c0e3dd7c03 100644 --- a/client/providers/MeteorProvider.js +++ b/client/providers/MeteorProvider.js @@ -4,7 +4,7 @@ import { AuthorizationProvider } from './AuthorizationProvider'; import { ConnectionStatusProvider } from './ConnectionStatusProvider'; import { RouterProvider } from './RouterProvider'; import { SessionProvider } from './SessionProvider'; -import { SettingsProvider } from './SettingsProvider'; +import SettingsProvider from './SettingsProvider'; import { ServerProvider } from './ServerProvider'; import { SidebarProvider } from './SidebarProvider'; import { TranslationProvider } from './TranslationProvider'; diff --git a/client/providers/SettingsProvider.js b/client/providers/SettingsProvider.js deleted file mode 100644 index b75b48b71f22..000000000000 --- a/client/providers/SettingsProvider.js +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; - -import { settings } from '../../app/settings/client'; -import { SettingsContext } from '../contexts/SettingsContext'; -import { createObservableFromReactive } from './createObservableFromReactive'; - -const contextValue = { - get: createObservableFromReactive((name) => settings.get(name)), - set: (name, value) => new Promise((resolve, reject) => { - settings.set(name, value, (error, result) => { - if (error) { - reject(error); - return; - } - - resolve(result); - }); - }), - batchSet: (entries) => new Promise((resolve, reject) => { - settings.batchSet(entries, (error, result) => { - if (error) { - reject(error); - return; - } - - resolve(result); - }); - }), -}; - -export function SettingsProvider({ children }) { - return ; -} diff --git a/client/providers/SettingsProvider.tsx b/client/providers/SettingsProvider.tsx new file mode 100644 index 000000000000..15d7cb0f5fc1 --- /dev/null +++ b/client/providers/SettingsProvider.tsx @@ -0,0 +1,113 @@ +import { Tracker } from 'meteor/tracker'; +import React, { useCallback, useEffect, useMemo, useState, FunctionComponent } from 'react'; + +import { useMethod } from '../contexts/ServerContext'; +import { SettingsContext, SettingsContextValue } from '../contexts/SettingsContext'; +import { useReactiveSubscriptionFactory } from '../hooks/useReactiveSubscriptionFactory'; +import { PrivateSettingsCachedCollection } from '../lib/settings/PrivateSettingsCachedCollection'; +import { PublicSettingsCachedCollection } from '../lib/settings/PublicSettingsCachedCollection'; +import { useAtLeastOnePermission } from '../contexts/AuthorizationContext'; + +type SettingsProviderProps = { + readonly privileged?: boolean; +}; + +const SettingsProvider: FunctionComponent = ({ + children, + privileged = false, +}) => { + const hasPrivilegedPermission = useAtLeastOnePermission([ + 'view-privileged-setting', + 'edit-privileged-setting', + 'manage-selected-settings', + ]); + + const hasPrivateAccess = privileged && hasPrivilegedPermission; + + const cachedCollection = useMemo(() => ( + hasPrivateAccess + ? PrivateSettingsCachedCollection.get() + : PublicSettingsCachedCollection.get() + ), [hasPrivateAccess]); + + const [isLoading, setLoading] = useState(() => Tracker.nonreactive(() => !cachedCollection.ready.get())); + + useEffect(() => { + let mounted = true; + + const initialize = async (): Promise => { + if (!Tracker.nonreactive(() => cachedCollection.ready.get())) { + await cachedCollection.init(); + } + + if (!mounted) { + return; + } + + setLoading(false); + }; + + initialize(); + + return (): void => { + mounted = false; + }; + }, [cachedCollection]); + + const querySetting = useReactiveSubscriptionFactory( + useCallback( + (_id) => ({ ...cachedCollection.collection.findOne(_id) }), + [cachedCollection], + ), + ); + + const querySettings = useReactiveSubscriptionFactory( + useCallback( + (query = {}) => cachedCollection.collection.find({ + ...('_id' in query) && { _id: { $in: query._id } }, + ...('group' in query) && { group: query.group }, + ...('section' in query) && ( + query.section + ? { section: query.section } + : { + $or: [ + { section: { $exists: false } }, + { section: null }, + ], + } + ), + }, { + sort: { + section: 1, + sorter: 1, + i18nLabel: 1, + }, + }).fetch(), + [cachedCollection], + ), + ); + + const saveSettings = useMethod('saveSettings'); + const dispatch = useCallback((changes) => saveSettings(changes), [saveSettings]); + + const contextValue = useMemo(() => ({ + hasPrivateAccess, + isLoading, + querySetting, + querySettings, + dispatch, + }), [ + hasPrivateAccess, + isLoading, + querySetting, + querySettings, + dispatch, + ]); + + return ; +}; + +export default SettingsProvider; diff --git a/client/views/blocks/ModalBlock.js b/client/views/blocks/ModalBlock.js index d7f223baa903..fa749622a0bc 100644 --- a/client/views/blocks/ModalBlock.js +++ b/client/views/blocks/ModalBlock.js @@ -77,7 +77,7 @@ export function ModalBlock({ // save focus to restore after close const previousFocus = useMemo(() => document.activeElement, []); // restore the focus after the component unmount - useEffect(() => () => previousFocus && previousFocus.focus(), []); + useEffect(() => () => previousFocus && previousFocus.focus(), [previousFocus]); // Handle Tab, Shift + Tab, Enter and Escape const handleKeyDown = useCallback((event) => { if (event.keyCode === 13) { // ENTER @@ -115,7 +115,7 @@ export function ModalBlock({ event.preventDefault(); } } - }, [onSubmit]); + }, [onClose, onSubmit]); // Clean the events useEffect(() => { const element = document.querySelector('.rc-modal-wrapper'); @@ -143,7 +143,7 @@ export function ModalBlock({ document.removeEventListener('keydown', ignoreIfnotContains); element.removeEventListener('click', close); }; - }, handleKeyDown); + }, [handleKeyDown, onClose]); return ( diff --git a/client/views/directory/ChannelsTab.js b/client/views/directory/ChannelsTab.js index 583969ecb5d0..f8b8f572a659 100644 --- a/client/views/directory/ChannelsTab.js +++ b/client/views/directory/ChannelsTab.js @@ -4,6 +4,7 @@ import React, { useMemo, useState, useCallback, useEffect } from 'react'; import { GenericTable, Th } from '../../components/GenericTable'; import MarkdownText from '../../components/basic/MarkdownText'; +import NotAuthorizedPage from '../../admin/NotAuthorizedPage'; import { useTranslation } from '../../contexts/TranslationContext'; import { usePermission } from '../../contexts/AuthorizationContext'; import { useRoute } from '../../contexts/RouterContext'; @@ -31,14 +32,14 @@ const FilterByText = ({ setFilter, ...props }) => { useEffect(() => { setFilter({ text }); - }, [text]); + }, [setFilter, text]); return } onChange={handleChange} value={text} /> ; }; -export function ChannelsTab() { +function ChannelsTable() { const t = useTranslation(); const [sort, setSort] = useState(['name', 'asc']); const [params, setParams] = useState({ current: 0, itemsPerPage: 25 }); @@ -62,13 +63,11 @@ export function ChannelsTab() {
    , mediaQuery && , mediaQuery && , - ].filter(Boolean), [sort, mediaQuery]); + ].filter(Boolean), [sort, onHeaderClick, t, mediaQuery]); const channelRoute = useRoute('channel'); - const canViewPublicRooms = usePermission('view-c-room'); - - const data = (canViewPublicRooms && useEndpointData('directory', query)) || { result: [] }; + const data = useEndpointData('directory', query) || { result: [] }; const onClick = useMemo(() => (name) => (e) => { if (e.type === 'click' || e.key === 'Enter') { @@ -77,8 +76,9 @@ export function ChannelsTab() { }, [channelRoute]); const formatDate = useFormatDate(); - const renderRow = useCallback(({ _id, ts, name, fname, description, usersCount, lastMessage, topic, ...room }) => { - const avatarUrl = roomTypes.getConfig('d').getAvatarPath({ name: name || fname, type: 'd', _id }); + const renderRow = useCallback((room) => { + const { _id, ts, t, name, fname, usersCount, lastMessage, topic } = room; + const avatarUrl = roomTypes.getConfig(t).getAvatarPath(room); return @@ -88,7 +88,7 @@ export function ChannelsTab() { {fname || name} - {topic && } + {topic && } @@ -103,7 +103,17 @@ export function ChannelsTab() { } ; } - , [mediaQuery]); + , [formatDate, mediaQuery, onClick]); return ; } + +export default function ChannelsTab(props) { + const canViewPublicRooms = usePermission('view-c-room'); + + if (canViewPublicRooms) { + return ; + } + + return ; +} diff --git a/client/views/directory/DirectoryPage.js b/client/views/directory/DirectoryPage.js index b143715e2872..f5a7b8077e97 100644 --- a/client/views/directory/DirectoryPage.js +++ b/client/views/directory/DirectoryPage.js @@ -3,8 +3,8 @@ import { Tabs } from '@rocket.chat/fuselage'; import Page from '../../components/basic/Page'; import { useTranslation } from '../../contexts/TranslationContext'; -import { UserTab } from './UserTab'; -import { ChannelsTab } from './ChannelsTab'; +import UserTab from './UserTab'; +import ChannelsTab from './ChannelsTab'; import { useRoute, useRouteParameter } from '../../contexts/RouterContext'; import { useSetting } from '../../contexts/SettingsContext'; @@ -18,7 +18,7 @@ function DirectoryPage() { const tab = useRouteParameter('tab'); const directoryRoute = useRoute('directory'); - const handleTabClick = useCallback((tab) => () => directoryRoute.push({ tab }), [tab]); + const handleTabClick = useCallback((tab) => () => directoryRoute.push({ tab }), [directoryRoute]); useEffect(() => { if (!tab || (tab === 'external' && !federationEnabled)) { diff --git a/client/views/directory/UserTab.js b/client/views/directory/UserTab.js index c1a978be34f1..21e54f010d75 100644 --- a/client/views/directory/UserTab.js +++ b/client/views/directory/UserTab.js @@ -7,9 +7,10 @@ import { useTranslation } from '../../contexts/TranslationContext'; import { useRoute } from '../../contexts/RouterContext'; import { usePermission } from '../../contexts/AuthorizationContext'; import { useQuery } from './hooks'; -import { roomTypes } from '../../../app/utils/client'; +import { getUserAvatarURL } from '../../../app/utils/client'; import { useEndpointData } from '../../hooks/useEndpointData'; import { useFormatDate } from '../../hooks/useFormatDate'; +import NotAuthorizedPage from '../../admin/NotAuthorizedPage'; const style = { whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden' }; @@ -20,14 +21,14 @@ const FilterByText = ({ setFilter, ...props }) => { useEffect(() => { setFilter({ text }); - }, [text]); + }, [text, setFilter]); return } onChange={handleChange} value={text} /> ; }; -export function UserTab({ +function UserTable({ workspace = 'local', }) { const [params, setParams] = useState({ current: 0, itemsPerPage: 25 }); @@ -41,7 +42,7 @@ export function UserTab({ const mediaQuery = useMediaQuery('(min-width: 1024px)'); - const onHeaderClick = (id) => { + const onHeaderClick = useCallback((id) => { const [sortBy, sortDirection] = sort; if (sortBy === id) { @@ -49,23 +50,20 @@ export function UserTab({ return; } setSort([id, 'asc']); - }; + }, [sort]); const header = useMemo(() => [ , mediaQuery && canViewFullOtherUserInfo && , federation && , mediaQuery && , - ].filter(Boolean), [sort, federation, canViewFullOtherUserInfo, mediaQuery]); + ].filter(Boolean), [sort, onHeaderClick, t, mediaQuery, canViewFullOtherUserInfo, federation]); const directRoute = useRoute('direct'); - const canViewOutsideRoom = usePermission('view-outside-room'); - const canViewDM = usePermission('view-d-room'); - - const data = (canViewOutsideRoom && canViewDM && useEndpointData('directory', query)) || { result: [] }; + const data = useEndpointData('directory', query) || {}; - const onClick = useMemo(() => (username) => (e) => { + const onClick = useCallback((username) => (e) => { if (e.type === 'click' || e.key === 'Enter') { directRoute.push({ rid: username }); } @@ -74,8 +72,8 @@ export function UserTab({ const formatDate = useFormatDate(); - const renderRow = useCallback(({ createdAt, emails, _id, username, name, domain, bio }) => { - const avatarUrl = roomTypes.getConfig('d').getAvatarPath({ name: username || name, type: 'd', _id }); + const renderRow = useCallback(({ createdAt, emails, _id, username, name, domain, bio, avatarETag }) => { + const avatarUrl = getUserAvatarURL(username, avatarETag); return @@ -105,7 +103,18 @@ export function UserTab({ {formatDate(createdAt)} } ; - }, [mediaQuery, federation, canViewFullOtherUserInfo]); + }, [mediaQuery, federation, canViewFullOtherUserInfo, formatDate, onClick]); return ; } + +export default function UserTab(props) { + const canViewOutsideRoom = usePermission('view-outside-room'); + const canViewDM = usePermission('view-d-room'); + + if (canViewOutsideRoom && canViewDM) { + return ; + } + + return ; +} diff --git a/client/views/directory/hooks.js b/client/views/directory/hooks.js index 016849ffa425..066aae7f32d1 100644 --- a/client/views/directory/hooks.js +++ b/client/views/directory/hooks.js @@ -1,14 +1,14 @@ import { useMemo } from 'react'; -export function useQuery(params, sort, type, workspace = 'local') { +export function useQuery({ text, itemsPerPage, current }, [column, direction], type, workspace = 'local') { return useMemo(() => ({ query: JSON.stringify({ type, - text: params.text, + text, workspace, }), - sort: JSON.stringify({ [sort[0]]: sort[1] === 'asc' ? 1 : 0 }), - ...params.itemsPerPage && { count: params.itemsPerPage }, - ...params.current && { offset: params.current }, - }), [params.itemsPerPage, params.current, sort, type, workspace, params.text]); + sort: JSON.stringify({ [column]: direction === 'asc' ? 1 : 0 }), + ...itemsPerPage && { count: itemsPerPage }, + ...current && { offset: current }, + }), [itemsPerPage, current, column, direction, type, workspace, text]); } diff --git a/client/views/setupWizard/SetupWizardRoute.js b/client/views/setupWizard/SetupWizardRoute.js index 51cbf0647be6..81f0921cc843 100644 --- a/client/views/setupWizard/SetupWizardRoute.js +++ b/client/views/setupWizard/SetupWizardRoute.js @@ -36,7 +36,7 @@ const useRouteLock = () => { } setLocked(false); - }, [homeRoute, setupWizardState, userId, user, hasAdminRole]); + }, [homeRoute, setupWizardState, userId, user, hasAdminRole, locked]); return locked; }; diff --git a/client/views/setupWizard/SetupWizardState.js b/client/views/setupWizard/SetupWizardState.js index 5383e4a7cefc..2a3874587a25 100644 --- a/client/views/setupWizard/SetupWizardState.js +++ b/client/views/setupWizard/SetupWizardState.js @@ -72,7 +72,7 @@ const useParameters = () => { return () => { mounted = false; }; - }, []); + }, [getSetupWizardParameters]); return { loaded, @@ -110,12 +110,7 @@ function SetupWizardState() { goToPreviousStep, goToNextStep, goToFinalStep, - }), [ - currentStep, - loaded, - settings, - canDeclineServerRegistration, - ]); + }), [currentStep, loaded, settings, canDeclineServerRegistration, goToPreviousStep, goToNextStep, goToFinalStep]); return diff --git a/client/views/setupWizard/steps/FinalStep.js b/client/views/setupWizard/steps/FinalStep.js index 6d37663e64b3..f35379041523 100644 --- a/client/views/setupWizard/steps/FinalStep.js +++ b/client/views/setupWizard/steps/FinalStep.js @@ -1,13 +1,13 @@ import { Box, Button, Tile } from '@rocket.chat/fuselage'; import React from 'react'; -import { useSetting, useSettingDispatch } from '../../../contexts/SettingsContext'; +import { useSetting, useSettingSetValue } from '../../../contexts/SettingsContext'; import { useTranslation } from '../../../contexts/TranslationContext'; function FinalStep() { const t = useTranslation(); const siteUrl = useSetting('Site_Url'); - const setShowSetupWizard = useSettingDispatch('Show_Setup_Wizard'); + const setShowSetupWizard = useSettingSetValue('Show_Setup_Wizard'); const handleClick = () => { setShowSetupWizard('completed'); diff --git a/client/views/setupWizard/steps/RegisterServerStep.js b/client/views/setupWizard/steps/RegisterServerStep.js index 19089dea6c49..629e071a75d1 100644 --- a/client/views/setupWizard/steps/RegisterServerStep.js +++ b/client/views/setupWizard/steps/RegisterServerStep.js @@ -10,7 +10,7 @@ import { useAutoFocus, useMergedRefs, useUniqueId } from '@rocket.chat/fuselage- import React, { useRef, useState } from 'react'; import { useMethod } from '../../../contexts/ServerContext'; -import { useBatchSettingsDispatch } from '../../../contexts/SettingsContext'; +import { useSettingsDispatch } from '../../../contexts/SettingsContext'; import { useToastMessageDispatch } from '../../../contexts/ToastMessagesContext'; import { useTranslation } from '../../../contexts/TranslationContext'; import { Pager } from '../Pager'; @@ -91,7 +91,7 @@ function RegisterServerStep({ step, title, active }) { const [commiting, setComitting] = useState(false); - const batchSetSettings = useBatchSettingsDispatch(); + const dispatchSettings = useSettingsDispatch(); const registerCloudWorkspace = useMethod('cloud:registerWorkspace'); @@ -111,7 +111,7 @@ function RegisterServerStep({ step, title, active }) { throw new Object({ error: 'Register_Server_Terms_Alert' }); } - await batchSetSettings([ + await dispatchSettings([ { _id: 'Statistics_reporting', value: registerServer, diff --git a/client/views/setupWizard/steps/SettingsBasedStep.js b/client/views/setupWizard/steps/SettingsBasedStep.js index 25202a1448ea..fc8c1a69424e 100644 --- a/client/views/setupWizard/steps/SettingsBasedStep.js +++ b/client/views/setupWizard/steps/SettingsBasedStep.js @@ -11,7 +11,7 @@ import { import { useAutoFocus } from '@rocket.chat/fuselage-hooks'; import React, { useEffect, useReducer, useState } from 'react'; -import { useBatchSettingsDispatch } from '../../../contexts/SettingsContext'; +import { useSettingsDispatch } from '../../../contexts/SettingsContext'; import { useToastMessageDispatch } from '../../../contexts/ToastMessagesContext'; import { useTranslation, useLanguages } from '../../../contexts/TranslationContext'; import { Pager } from '../Pager'; @@ -57,11 +57,11 @@ function SettingsBasedStep({ step, title, active }) { .sort(({ wizard: { order: a } }, { wizard: { order: b } }) => a - b) .map(({ value, ...field }) => ({ ...field, value: value != null ? value : '' })), ); - }, [settings, currentStep]); + }, [settings, currentStep, resetFields, step]); const t = useTranslation(); - const batchSetSettings = useBatchSettingsDispatch(); + const dispatchSettings = useSettingsDispatch(); const autoFocusRef = useAutoFocus(active); @@ -77,7 +77,7 @@ function SettingsBasedStep({ step, title, active }) { setCommiting(true); try { - await batchSetSettings(fields.map(({ _id, value }) => ({ _id, value }))); + await dispatchSettings(fields.map(({ _id, value }) => ({ _id, value }))); goToNextStep(); } catch (error) { dispatchToastMessage({ type: 'error', message: error }); diff --git a/definition/ISetting.ts b/definition/ISetting.ts new file mode 100644 index 000000000000..54a3c779a72e --- /dev/null +++ b/definition/ISetting.ts @@ -0,0 +1,42 @@ +export type SettingId = string | Mongo.ObjectID; +export type GroupId = SettingId; +export type SectionName = string; + +export enum SettingType { + BOOLEAN = 'boolean', + STRING = 'string', + RELATIVE_URL = 'relativeUrl', + PASSWORD = 'password', + INT = 'int', + SELECT = 'select', + MULTI_SELECT = 'multiSelect', + LANGUAGE = 'language', + COLOR = 'color', + FONT = 'font', + CODE = 'code', + ACTION = 'action', + ASSET = 'asset', + ROOM_PICK = 'roomPick', + GROUP = 'group', +} + +export enum SettingEditor { + COLOR = 'color', + EXPRESSION = 'expression' +} + +export interface ISetting { + _id: SettingId; + type: SettingType; + public: boolean; + group?: GroupId; + section?: SectionName; + i18nLabel: string; + value: unknown; + packageValue: unknown; + editor?: SettingEditor; + packageEditor?: SettingEditor; + blocked: boolean; + enableQuery?: string | Mongo.ObjectID | Mongo.Query | Mongo.QueryWithModifiers; + sorter?: number; +} diff --git a/ee/app/engagement-dashboard/client/components/ChannelsTab/TableSection.js b/ee/app/engagement-dashboard/client/components/ChannelsTab/TableSection.js index dc6c083438d8..443a2a4abd8e 100644 --- a/ee/app/engagement-dashboard/client/components/ChannelsTab/TableSection.js +++ b/ee/app/engagement-dashboard/client/components/ChannelsTab/TableSection.js @@ -6,6 +6,11 @@ import { useTranslation } from '../../../../../../client/contexts/TranslationCon import { useEndpointData } from '../../../../../../client/hooks/useEndpointData'; import Growth from '../../../../../../client/components/data/Growth'; import { Section } from '../Section'; +import { ActionButton } from '../../../../../../client/components/basic/Buttons/ActionButton'; +import { saveFile } from '../../../../../../client/lib/saveFile'; + +const convertDataToCSV = (data) => `// type, name, messagesCount, updatedAt, createdAt +${ data.map(({ createdAt, messagesCount, name, t, updatedAt }) => `${ t }, ${ name }, ${ messagesCount }, ${ updatedAt }, ${ createdAt }`).join('\n') }`; export function TableSection() { const t = useTranslation(); @@ -73,7 +78,11 @@ export function TableSection() { })); }, [data]); - return
    }> + const downloadData = () => { + saveFile(convertDataToCSV(channels), `Channels_start_${ params.start }_end_${ params.end }.csv`); + }; + + return
    } > diff --git a/ee/app/engagement-dashboard/client/components/MessagesTab/MessagesSentSection.js b/ee/app/engagement-dashboard/client/components/MessagesTab/MessagesSentSection.js index d4fb1ccf674b..51a903418125 100644 --- a/ee/app/engagement-dashboard/client/components/MessagesTab/MessagesSentSection.js +++ b/ee/app/engagement-dashboard/client/components/MessagesTab/MessagesSentSection.js @@ -7,6 +7,11 @@ import { useTranslation } from '../../../../../../client/contexts/TranslationCon import { useEndpointData } from '../../../../../../client/hooks/useEndpointData'; import CounterSet from '../../../../../../client/components/data/CounterSet'; import { Section } from '../Section'; +import { ActionButton } from '../../../../../../client/components/basic/Buttons/ActionButton'; +import { saveFile } from '../../../../../../client/lib/saveFile'; + +const convertDataToCSV = (data) => `// date, newMessages +${ data.map(({ date, newMessages }) => `${ date }, ${ newMessages }`).join('\n') }`; export function MessagesSentSection() { const t = useTranslation(); @@ -81,9 +86,13 @@ export function MessagesSentSection() { ]; }, [data, period]); + const downloadData = () => { + saveFile(convertDataToCSV(values), `MessagesSentSection_start_${ params.start }_end_${ params.end }.csv`); + }; + return
    } + filter={<>} > `// date, users +${ data.map(({ users, hour, day, month, year }) => ({ date: moment([year, month - 1, day, hour, 0, 0, 0]), users })).sort((a, b) => a > b).map(({ date, users }) => `${ date.toISOString() }, ${ users }`).join('\n') }`; + export function UsersByTimeOfTheDaySection() { const t = useTranslation(); @@ -75,92 +81,93 @@ export function UsersByTimeOfTheDaySection() { dates.map((date) => date.toISOString()), values, ]; - }, [data]); + }, [data, period.end, period.start]); + const downloadData = () => { + saveFile(convertDataToCSV(data.week), `UsersByTimeOfTheDaySection_start_${ params.start }_end_${ params.end }.csv`); + }; return
    } + filter={<>
    {t('Created_by')}{t('Created_at')}{t('Post_as')}{t('Name')}{t('Created_by')}{t('Created_at')}{t('Msgs')}{t('Default')}{t('Featured')}{t('Email')}{t('Roles')}{t('Status')}{t('Users')}{t('Created_at')}{t('Last_Message')}{t('Name')}{t('Email')}{t('Domain')}{t('Joined_at')}