diff --git a/.changeset/lazy-avocados-whisper.md b/.changeset/lazy-avocados-whisper.md new file mode 100644 index 000000000000..b1296186c37c --- /dev/null +++ b/.changeset/lazy-avocados-whisper.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/i18n": minor +--- + +Improves thread metrics featuring user avatars, better titles and repositioned elements. diff --git a/.changeset/three-dragons-brush.md b/.changeset/three-dragons-brush.md new file mode 100644 index 000000000000..d80c4dc83306 --- /dev/null +++ b/.changeset/three-dragons-brush.md @@ -0,0 +1,8 @@ +--- +'@rocket.chat/apps-engine': minor +'@rocket.chat/livechat': minor +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +Prevent apps' subprocesses from crashing on unhandled rejections or uncaught exceptions diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 202a02dd7785..dd75ef25eddb 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -2,6 +2,8 @@ name: 'Code scanning - action' on: push: + branches-ignore: + - dependabot/** pull_request: schedule: - cron: '0 13 * * *' diff --git a/apps/meteor/app/api/server/lib/users.ts b/apps/meteor/app/api/server/lib/users.ts index 164ea8c2b747..aa99d9216667 100644 --- a/apps/meteor/app/api/server/lib/users.ts +++ b/apps/meteor/app/api/server/lib/users.ts @@ -183,20 +183,22 @@ export async function findPaginatedUsersByStatus({ ...(canSeeExtension ? { freeSwitchExtension: 1 } : {}), }; - match.$or = [ - ...(canSeeAllUserInfo ? [{ 'emails.address': { $regex: escapeRegExp(searchTerm || ''), $options: 'i' } }] : []), - { - username: { $regex: escapeRegExp(searchTerm || ''), $options: 'i' }, - }, - { - name: { $regex: escapeRegExp(searchTerm || ''), $options: 'i' }, - }, - ]; + if (searchTerm?.trim()) { + match.$or = [ + ...(canSeeAllUserInfo ? [{ 'emails.address': { $regex: escapeRegExp(searchTerm || ''), $options: 'i' } }] : []), + { + username: { $regex: escapeRegExp(searchTerm || ''), $options: 'i' }, + }, + { + name: { $regex: escapeRegExp(searchTerm || ''), $options: 'i' }, + }, + ]; + } if (roles?.length && !roles.includes('all')) { match.roles = { $in: roles }; } - const { cursor, totalCount } = await Users.findPaginated( + const { cursor, totalCount } = Users.findPaginated( { ...match, }, diff --git a/apps/meteor/app/api/server/v1/assets.ts b/apps/meteor/app/api/server/v1/assets.ts index 939cb5f1469f..fd9f31d40923 100644 --- a/apps/meteor/app/api/server/v1/assets.ts +++ b/apps/meteor/app/api/server/v1/assets.ts @@ -1,6 +1,7 @@ import { Settings } from '@rocket.chat/models'; import { isAssetsUnsetAssetProps } from '@rocket.chat/rest-typings'; +import { updateAuditedByUser } from '../../../../server/settings/lib/auditedSettingUpdates'; import { RocketChatAssets, refreshClients } from '../../../assets/server'; import { notifyOnSettingChangedById } from '../../../lib/server/lib/notifyListener'; import { settings } from '../../../settings/server'; @@ -36,7 +37,12 @@ API.v1.addRoute( const { key, value } = await RocketChatAssets.setAssetWithBuffer(fileBuffer, mimetype, assetName); - const { modifiedCount } = await Settings.updateValueById(key, value); + const { modifiedCount } = await updateAuditedByUser({ + _id: this.userId, + username: this.user.username!, + ip: this.requestIp, + useragent: this.request.headers['user-agent'] || '', + })(Settings.updateValueById, key, value); if (modifiedCount) { void notifyOnSettingChangedById(key); @@ -68,7 +74,12 @@ API.v1.addRoute( const { key, value } = await RocketChatAssets.unsetAsset(assetName); - const { modifiedCount } = await Settings.updateValueById(key, value); + const { modifiedCount } = await updateAuditedByUser({ + _id: this.userId, + username: this.user.username!, + ip: this.requestIp, + useragent: this.request.headers['user-agent'] || '', + })(Settings.updateValueById, key, value); if (modifiedCount) { void notifyOnSettingChangedById(key); diff --git a/apps/meteor/app/api/server/v1/chat.ts b/apps/meteor/app/api/server/v1/chat.ts index 3eb819070cd6..cef220b050bc 100644 --- a/apps/meteor/app/api/server/v1/chat.ts +++ b/apps/meteor/app/api/server/v1/chat.ts @@ -1,5 +1,5 @@ import { Message } from '@rocket.chat/core-services'; -import type { IMessage } from '@rocket.chat/core-typings'; +import type { IMessage, IThreadMainMessage } from '@rocket.chat/core-typings'; import { Messages, Users, Rooms, Subscriptions } from '@rocket.chat/models'; import { isChatReportMessageProps, @@ -550,7 +550,7 @@ API.v1.addRoute( }; const threadQuery = { ...query, ...typeThread, rid: room._id, tcount: { $exists: true } }; - const { cursor, totalCount } = await Messages.findPaginated(threadQuery, { + const { cursor, totalCount } = await Messages.findPaginated(threadQuery, { sort: sort || { tlm: -1 }, skip: offset, limit: count, diff --git a/apps/meteor/app/api/server/v1/misc.ts b/apps/meteor/app/api/server/v1/misc.ts index c026236231d8..1d502f04df1e 100644 --- a/apps/meteor/app/api/server/v1/misc.ts +++ b/apps/meteor/app/api/server/v1/misc.ts @@ -20,6 +20,7 @@ import { v4 as uuidv4 } from 'uuid'; import { i18n } from '../../../../server/lib/i18n'; import { SystemLogger } from '../../../../server/lib/logger/system'; +import { resetAuditedSettingByUser, updateAuditedByUser } from '../../../../server/settings/lib/auditedSettingUpdates'; import { getLogs } from '../../../../server/stream/stdout'; import { passwordPolicy } from '../../../lib/server'; import { notifyOnSettingChangedById } from '../../../lib/server/lib/notifyListener'; @@ -683,20 +684,32 @@ API.v1.addRoute( settingsIds.push('Deployment_FingerPrint_Verified'); + const auditSettingOperation = updateAuditedByUser({ + _id: this.userId, + username: this.user.username!, + ip: this.requestIp, + useragent: this.request.headers['user-agent'] || '', + }); + const promises = settingsIds.map((settingId) => { if (settingId === 'uniqueID') { - return Settings.resetValueById('uniqueID', process.env.DEPLOYMENT_ID || uuidv4()); + return auditSettingOperation(Settings.resetValueById, 'uniqueID', process.env.DEPLOYMENT_ID || uuidv4()); } if (settingId === 'Cloud_Workspace_Access_Token_Expires_At') { - return Settings.resetValueById('Cloud_Workspace_Access_Token_Expires_At', new Date(0)); + return auditSettingOperation(Settings.resetValueById, 'Cloud_Workspace_Access_Token_Expires_At', new Date(0)); } if (settingId === 'Deployment_FingerPrint_Verified') { - return Settings.updateValueById('Deployment_FingerPrint_Verified', true); + return auditSettingOperation(Settings.updateValueById, 'Deployment_FingerPrint_Verified', true); } - return Settings.resetValueById(settingId); + return resetAuditedSettingByUser({ + _id: this.userId, + username: this.user.username!, + ip: this.requestIp, + useragent: this.request.headers['user-agent'] || '', + })(Settings.resetValueById, settingId); }); (await Promise.all(promises)).forEach((value, index) => { diff --git a/apps/meteor/app/api/server/v1/settings.ts b/apps/meteor/app/api/server/v1/settings.ts index 0e3509fb1956..e9183cb9e38e 100644 --- a/apps/meteor/app/api/server/v1/settings.ts +++ b/apps/meteor/app/api/server/v1/settings.ts @@ -17,6 +17,7 @@ import { Meteor } from 'meteor/meteor'; import type { FindOptions } from 'mongodb'; import _ from 'underscore'; +import { updateAuditedByUser } from '../../../../server/settings/lib/auditedSettingUpdates'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { disableCustomScripts } from '../../../lib/server/functions/disableCustomScripts'; import { notifyOnSettingChanged, notifyOnSettingChangedById } from '../../../lib/server/lib/notifyListener'; @@ -200,9 +201,16 @@ API.v1.addRoute( return API.v1.success(); } + const auditSettingOperation = updateAuditedByUser({ + _id: this.userId, + username: this.user.username!, + ip: this.requestIp, + useragent: this.request.headers['user-agent'] || '', + }); + if (isSettingColor(setting) && isSettingsUpdatePropsColor(this.bodyParams)) { const updateOptionsPromise = Settings.updateOptionsById(this.urlParams._id, { editor: this.bodyParams.editor }); - const updateValuePromise = Settings.updateValueNotHiddenById(this.urlParams._id, this.bodyParams.value); + const updateValuePromise = auditSettingOperation(Settings.updateValueNotHiddenById, this.urlParams._id, this.bodyParams.value); const [updateOptionsResult, updateValueResult] = await Promise.all([updateOptionsPromise, updateValuePromise]); @@ -214,7 +222,12 @@ API.v1.addRoute( } if (isSettingsUpdatePropDefault(this.bodyParams)) { - const { matchedCount } = await Settings.updateValueNotHiddenById(this.urlParams._id, this.bodyParams.value); + const { matchedCount } = await auditSettingOperation( + Settings.updateValueNotHiddenById, + this.urlParams._id, + this.bodyParams.value, + ); + if (!matchedCount) { return API.v1.failure(); } diff --git a/apps/meteor/app/apps/server/bridges/settings.ts b/apps/meteor/app/apps/server/bridges/settings.ts index 37803d4f94f3..cada833aadbd 100644 --- a/apps/meteor/app/apps/server/bridges/settings.ts +++ b/apps/meteor/app/apps/server/bridges/settings.ts @@ -3,6 +3,7 @@ import type { ISetting } from '@rocket.chat/apps-engine/definition/settings'; import { ServerSettingBridge } from '@rocket.chat/apps-engine/server/bridges/ServerSettingBridge'; import { Settings } from '@rocket.chat/models'; +import { updateAuditedByApp } from '../../../../server/settings/lib/auditedSettingUpdates'; import { notifyOnSettingChanged, notifyOnSettingChangedById } from '../../../lib/server/lib/notifyListener'; export class AppSettingBridge extends ServerSettingBridge { @@ -56,7 +57,15 @@ export class AppSettingBridge extends ServerSettingBridge { throw new Error(`The setting "${setting.id}" is not readable.`); } - (await Settings.updateValueById(setting.id, setting.value)).modifiedCount && void notifyOnSettingChangedById(setting.id); + if ( + ( + await updateAuditedByApp({ + _id: appId, + })(Settings.updateValueById, setting.id, setting.value) + ).modifiedCount + ) { + void notifyOnSettingChangedById(setting.id); + } } protected async incrementValue(id: string, value: number, appId: string): Promise { diff --git a/apps/meteor/app/authentication/server/startup/index.js b/apps/meteor/app/authentication/server/startup/index.js index 0577ceac0ba7..03c9602e59f0 100644 --- a/apps/meteor/app/authentication/server/startup/index.js +++ b/apps/meteor/app/authentication/server/startup/index.js @@ -338,6 +338,7 @@ Accounts.insertUserDoc = async function (options, user) { if (!roles.includes('admin') && !hasAdmin) { roles.push('admin'); if (settings.get('Show_Setup_Wizard') === 'pending') { + // TODO: audit (await Settings.updateValueById('Show_Setup_Wizard', 'in_progress')).modifiedCount && void notifyOnSettingChangedById('Show_Setup_Wizard'); } diff --git a/apps/meteor/app/cloud/server/functions/getOAuthAuthorizationUrl.ts b/apps/meteor/app/cloud/server/functions/getOAuthAuthorizationUrl.ts index 0550a3d7f238..d787fd16cf65 100644 --- a/apps/meteor/app/cloud/server/functions/getOAuthAuthorizationUrl.ts +++ b/apps/meteor/app/cloud/server/functions/getOAuthAuthorizationUrl.ts @@ -1,6 +1,7 @@ import { Settings } from '@rocket.chat/models'; import { Random } from '@rocket.chat/random'; +import { updateAuditedBySystem } from '../../../../server/settings/lib/auditedSettingUpdates'; import { notifyOnSettingChangedById } from '../../../lib/server/lib/notifyListener'; import { settings } from '../../../settings/server'; import { userScopes } from '../oauthScopes'; @@ -9,7 +10,9 @@ import { getRedirectUri } from './getRedirectUri'; export async function getOAuthAuthorizationUrl() { const state = Random.id(); - await Settings.updateValueById('Cloud_Workspace_Registration_State', state); + await updateAuditedBySystem({ + reason: 'getOAuthAuthorizationUrl', + })(Settings.updateValueById, 'Cloud_Workspace_Registration_State', state); void notifyOnSettingChangedById('Cloud_Workspace_Registration_State'); diff --git a/apps/meteor/app/cloud/server/functions/removeWorkspaceRegistrationInfo.ts b/apps/meteor/app/cloud/server/functions/removeWorkspaceRegistrationInfo.ts index b77a89128ef4..678f6233b0a2 100644 --- a/apps/meteor/app/cloud/server/functions/removeWorkspaceRegistrationInfo.ts +++ b/apps/meteor/app/cloud/server/functions/removeWorkspaceRegistrationInfo.ts @@ -1,6 +1,7 @@ import { Settings, WorkspaceCredentials } from '@rocket.chat/models'; import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; +import { updateAuditedBySystem } from '../../../../server/settings/lib/auditedSettingUpdates'; import { notifyOnSettingChangedById } from '../../../lib/server/lib/notifyListener'; export async function removeWorkspaceRegistrationInfo() { @@ -24,10 +25,12 @@ export async function removeWorkspaceRegistrationInfo() { const promises = settingsIds.map((settingId) => { if (settingId === 'Show_Setup_Wizard') { - return Settings.updateValueById('Show_Setup_Wizard', 'in_progress'); + return updateAuditedBySystem({ + reason: 'removeWorkspaceRegistrationInfo', + })(Settings.updateValueById, 'Show_Setup_Wizard', 'in_progress'); } - return Settings.resetValueById(settingId, null); + return updateAuditedBySystem({ reason: 'removeWorkspaceRegistrationInfo' })(Settings.resetValueById, settingId, null); }); (await Promise.all(promises)).forEach((value, index) => { diff --git a/apps/meteor/app/cloud/server/functions/saveRegistrationData.ts b/apps/meteor/app/cloud/server/functions/saveRegistrationData.ts index e86a34fb76a2..c820411775d3 100644 --- a/apps/meteor/app/cloud/server/functions/saveRegistrationData.ts +++ b/apps/meteor/app/cloud/server/functions/saveRegistrationData.ts @@ -2,6 +2,7 @@ import { applyLicense } from '@rocket.chat/license'; import { Settings } from '@rocket.chat/models'; import { syncCloudData } from './syncWorkspace/syncCloudData'; +import { updateAuditedBySystem } from '../../../../server/settings/lib/auditedSettingUpdates'; import { notifyOnSettingChangedById } from '../../../lib/server/lib/notifyListener'; import { settings } from '../../../settings/server'; @@ -59,7 +60,13 @@ async function saveRegistrationDataBase({ { _id: 'Cloud_Workspace_Registration_Client_Uri', value: registration_client_uri }, ]; - const promises = [...settingsData.map(({ _id, value }) => Settings.updateValueById(_id, value))]; + const promises = [ + ...settingsData.map(({ _id, value }) => + updateAuditedBySystem({ + reason: 'saveRegistrationDataBase', + })(Settings.updateValueById, _id, value), + ), + ]; (await Promise.all(promises)).forEach((value, index) => { if (value?.modifiedCount) { @@ -67,7 +74,10 @@ async function saveRegistrationDataBase({ } }); - // TODO: Why is this taking so long that needs a timeout? + // Question: Why is this taking so long that needs a timeout? + // Answer: we use cache that requires a 'roundtrip' through the db and the application + // we need to make sure that the cache is updated before we continue the procedures + // we don't actually need to wait a whole second for this, but look this is just a retry mechanism it doesn't mean that actually takes all this time for await (const retry of Array.from({ length: 10 })) { const isSettingsUpdated = settings.get('Register_Server') === true && diff --git a/apps/meteor/app/cloud/server/functions/startRegisterWorkspace.ts b/apps/meteor/app/cloud/server/functions/startRegisterWorkspace.ts index 1455ed2329a5..bdd6cedc018d 100644 --- a/apps/meteor/app/cloud/server/functions/startRegisterWorkspace.ts +++ b/apps/meteor/app/cloud/server/functions/startRegisterWorkspace.ts @@ -5,6 +5,7 @@ import { buildWorkspaceRegistrationData } from './buildRegistrationData'; import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; import { syncWorkspace } from './syncWorkspace'; import { SystemLogger } from '../../../../server/lib/logger/system'; +import { updateAuditedBySystem } from '../../../../server/settings/lib/auditedSettingUpdates'; import { notifyOnSettingChangedById } from '../../../lib/server/lib/notifyListener'; import { settings } from '../../../settings/server'; @@ -15,7 +16,6 @@ export async function startRegisterWorkspace(resend = false) { return true; } - (await Settings.updateValueById('Register_Server', true)).modifiedCount && void notifyOnSettingChangedById('Register_Server'); const regInfo = await buildWorkspaceRegistrationData(undefined); @@ -48,7 +48,9 @@ export async function startRegisterWorkspace(resend = false) { return false; } - await Settings.updateValueById('Cloud_Workspace_Id', payload.id); + await updateAuditedBySystem({ + reason: 'startRegisterWorkspace', + })(Settings.updateValueById, 'Cloud_Workspace_Id', payload.id); return true; } diff --git a/apps/meteor/app/cloud/server/functions/supportedVersionsToken/supportedVersionsToken.ts b/apps/meteor/app/cloud/server/functions/supportedVersionsToken/supportedVersionsToken.ts index 68e38baf5cc2..ad0dffca27d8 100644 --- a/apps/meteor/app/cloud/server/functions/supportedVersionsToken/supportedVersionsToken.ts +++ b/apps/meteor/app/cloud/server/functions/supportedVersionsToken/supportedVersionsToken.ts @@ -12,6 +12,7 @@ import { supportedVersions as supportedVersionsFromBuild } from '../../../../uti import { buildVersionUpdateMessage } from '../../../../version-check/server/functions/buildVersionUpdateMessage'; import { generateWorkspaceBearerHttpHeader } from '../getWorkspaceAccessToken'; import { supportedVersionsChooseLatest } from './supportedVersionsChooseLatest'; +import { updateAuditedBySystem } from '../../../../../server/settings/lib/auditedSettingUpdates'; declare module '@rocket.chat/core-typings' { interface ILicenseV3 { @@ -66,7 +67,15 @@ const cacheValueInSettings = ( SystemLogger.debug(`Resetting cached value ${key} in settings`); const value = await fn(); - (await Settings.updateValueById(key, value)).modifiedCount && void notifyOnSettingChangedById(key); + if ( + ( + await updateAuditedBySystem({ + reason: 'cacheValueInSettings reset', + })(Settings.updateValueById, key, value) + ).modifiedCount + ) { + void notifyOnSettingChangedById(key); + } return value; }; diff --git a/apps/meteor/app/irc/server/irc-bridge/index.js b/apps/meteor/app/irc/server/irc-bridge/index.js index bc5b4f0bc33f..26d4948bd0f1 100644 --- a/apps/meteor/app/irc/server/irc-bridge/index.js +++ b/apps/meteor/app/irc/server/irc-bridge/index.js @@ -7,6 +7,7 @@ import { callbacks } from '../../../../lib/callbacks'; import { afterLeaveRoomCallback } from '../../../../lib/callbacks/afterLeaveRoomCallback'; import { afterLogoutCleanUpCallback } from '../../../../lib/callbacks/afterLogoutCleanUpCallback'; import { withThrottling } from '../../../../lib/utils/highOrderFunctions'; +import { updateAuditedBySystem } from '../../../../server/settings/lib/auditedSettingUpdates'; import { notifyOnSettingChangedById } from '../../../lib/server/lib/notifyListener'; import * as servers from '../servers'; import * as localCommandHandlers from './localHandlers'; @@ -22,7 +23,9 @@ const updateLastPing = withThrottling({ wait: 10_000 })(() => { } void (async () => { - const updatedValue = await Settings.updateValueById('IRC_Bridge_Last_Ping', new Date(), { upsert: true }); + const updatedValue = await updateAuditedBySystem({ + reason: 'updateLastPing', + })(Settings.updateValueById, 'IRC_Bridge_Last_Ping', new Date(), { upsert: true }); if (updatedValue.modifiedCount || updatedValue.upsertedCount) { void notifyOnSettingChangedById('IRC_Bridge_Last_Ping'); } diff --git a/apps/meteor/app/irc/server/methods/resetIrcConnection.ts b/apps/meteor/app/irc/server/methods/resetIrcConnection.ts index a42cd80667b4..1fed8c77d28e 100644 --- a/apps/meteor/app/irc/server/methods/resetIrcConnection.ts +++ b/apps/meteor/app/irc/server/methods/resetIrcConnection.ts @@ -2,6 +2,7 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Settings } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; +import { updateAuditedByUser } from '../../../../server/settings/lib/auditedSettingUpdates'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { notifyOnSettingChangedById } from '../../../lib/server/lib/notifyListener'; import { settings } from '../../../settings/server'; @@ -27,13 +28,23 @@ Meteor.methods({ throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'resetIrcConnection' }); } - const updatedLastPingValue = await Settings.updateValueById('IRC_Bridge_Last_Ping', new Date(0), { upsert: true }); + const auditSettingOperation = updateAuditedByUser({ + _id: uid, + username: (await Meteor.userAsync())!.username!, + ip: this.connection?.clientAddress || '', + useragent: this.connection?.httpHeaders['user-agent'] || '', + }); + const updatedLastPingValue = await auditSettingOperation(Settings.updateValueById, 'IRC_Bridge_Last_Ping', new Date(0), { + upsert: true, + }); if (updatedLastPingValue.modifiedCount || updatedLastPingValue.upsertedCount) { void notifyOnSettingChangedById('IRC_Bridge_Last_Ping'); } - const updatedResetTimeValue = await Settings.updateValueById('IRC_Bridge_Reset_Time', new Date(), { upsert: true }); + const updatedResetTimeValue = await auditSettingOperation(Settings.updateValueById, 'IRC_Bridge_Reset_Time', new Date(), { + upsert: true, + }); if (updatedResetTimeValue.modifiedCount || updatedResetTimeValue.upsertedCount) { void notifyOnSettingChangedById('IRC_Bridge_Last_Ping'); } diff --git a/apps/meteor/app/lib/server/methods/saveSetting.ts b/apps/meteor/app/lib/server/methods/saveSetting.ts index 61f5fbbd34cc..ed48dc4e9f4f 100644 --- a/apps/meteor/app/lib/server/methods/saveSetting.ts +++ b/apps/meteor/app/lib/server/methods/saveSetting.ts @@ -4,6 +4,7 @@ import { Settings } from '@rocket.chat/models'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; +import { updateAuditedByUser } from '../../../../server/settings/lib/auditedSettingUpdates'; import { twoFactorRequired } from '../../../2fa/server/twoFactorRequired'; import { getSettingPermissionId } from '../../../authorization/lib'; import { hasPermissionAsync, hasAllPermissionAsync } from '../../../authorization/server/functions/hasPermission'; @@ -18,7 +19,7 @@ declare module '@rocket.chat/ddp-client' { } Meteor.methods({ - saveSetting: twoFactorRequired(async (_id, value, editor) => { + saveSetting: twoFactorRequired(async function (_id, value, editor) { const uid = Meteor.userId(); if (!uid) { throw new Meteor.Error('error-action-not-allowed', 'Editing settings is not allowed', { @@ -66,7 +67,14 @@ Meteor.methods({ break; } - (await Settings.updateValueAndEditorById(_id, value as SettingValue, editor)).modifiedCount && + const auditSettingOperation = updateAuditedByUser({ + _id: uid, + username: (await Meteor.userAsync())!.username!, + ip: this.connection?.clientAddress || '', + useragent: this.connection?.httpHeaders['user-agent'] || '', + }); + + (await auditSettingOperation(Settings.updateValueAndEditorById, _id, value as SettingValue, editor)).modifiedCount && setting && void notifyOnSettingChanged({ ...setting, editor, value: value as SettingValue }); diff --git a/apps/meteor/app/lib/server/methods/saveSettings.ts b/apps/meteor/app/lib/server/methods/saveSettings.ts index 7a18bbc808d4..6ba989abda0d 100644 --- a/apps/meteor/app/lib/server/methods/saveSettings.ts +++ b/apps/meteor/app/lib/server/methods/saveSettings.ts @@ -5,6 +5,7 @@ import { Settings } from '@rocket.chat/models'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; +import { updateAuditedByUser } from '../../../../server/settings/lib/auditedSettingUpdates'; import { twoFactorRequired } from '../../../2fa/server/twoFactorRequired'; import { getSettingPermissionId } from '../../../authorization/lib'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; @@ -34,103 +35,107 @@ const validJSON = Match.Where((value: string) => { }); Meteor.methods({ - saveSettings: twoFactorRequired( - async ( - params: { - _id: ISetting['_id']; - value: ISetting['value']; - }[] = [], - ) => { - const uid = Meteor.userId(); - const settingsNotAllowed: ISetting['_id'][] = []; - if (uid === null) { - throw new Meteor.Error('error-action-not-allowed', 'Editing settings is not allowed', { - method: 'saveSetting', - }); - } - const editPrivilegedSetting = await hasPermissionAsync(uid, 'edit-privileged-setting'); - const manageSelectedSettings = await hasPermissionAsync(uid, 'manage-selected-settings'); - - // if the id contains Organization_Name then change the Site_Name - const orgName = params.find(({ _id }) => _id === 'Organization_Name'); + saveSettings: twoFactorRequired(async function ( + params: { + _id: ISetting['_id']; + value: ISetting['value']; + }[] = [], + ) { + const uid = Meteor.userId(); + const settingsNotAllowed: ISetting['_id'][] = []; + if (uid === null) { + throw new Meteor.Error('error-action-not-allowed', 'Editing settings is not allowed', { + method: 'saveSetting', + }); + } + const editPrivilegedSetting = await hasPermissionAsync(uid, 'edit-privileged-setting'); + const manageSelectedSettings = await hasPermissionAsync(uid, 'manage-selected-settings'); - if (orgName) { - // check if the site name is still the default value or ifs the same as organization name - const siteName = await Settings.findOneById('Site_Name'); + // if the id contains Organization_Name then change the Site_Name + const orgName = params.find(({ _id }) => _id === 'Organization_Name'); - if (siteName?.value === siteName?.packageValue || siteName?.value === settings.get('Organization_Name')) { - params.push({ - _id: 'Site_Name', - value: orgName.value, - }); - } - } + if (orgName) { + // check if the site name is still the default value or ifs the same as organization name + const siteName = await Settings.findOneById('Site_Name'); - await Promise.all( - params.map(async ({ _id, value }) => { - // Verify the _id passed in is a string. - check(_id, String); - if (!editPrivilegedSetting && !(manageSelectedSettings && (await hasPermissionAsync(uid, getSettingPermissionId(_id))))) { - return settingsNotAllowed.push(_id); - } - - // Disable custom scripts in cloud trials to prevent phishing campaigns - if (disableCustomScripts() && /^Custom_Script_/.test(_id)) { - return settingsNotAllowed.push(_id); - } - - const setting = await Settings.findOneById(_id); - // Verify the value is what it should be - switch (setting?.type) { - case 'roomPick': - check(value, Match.OneOf([Object], '')); - break; - case 'boolean': - check(value, Boolean); - break; - case 'timespan': - case 'int': - check(value, Number); - if (!Number.isInteger(value)) { - throw new Meteor.Error(`Invalid setting value ${value}`, 'Invalid setting value', { - method: 'saveSettings', - }); - } - - break; - case 'multiSelect': - check(value, Array); - break; - case 'code': - check(value, String); - if (isSettingCode(setting) && setting.code === 'application/json') { - check(value, validJSON); - } - break; - default: - check(value, String); - break; - } - }), - ); - - if (settingsNotAllowed.length) { - throw new Meteor.Error('error-action-not-allowed', 'Editing settings is not allowed', { - method: 'saveSettings', - settingIds: settingsNotAllowed, + if (siteName?.value === siteName?.packageValue || siteName?.value === settings.get('Organization_Name')) { + params.push({ + _id: 'Site_Name', + value: orgName.value, }); } + } + + await Promise.all( + params.map(async ({ _id, value }) => { + // Verify the _id passed in is a string. + check(_id, String); + if (!editPrivilegedSetting && !(manageSelectedSettings && (await hasPermissionAsync(uid, getSettingPermissionId(_id))))) { + return settingsNotAllowed.push(_id); + } + + // Disable custom scripts in cloud trials to prevent phishing campaigns + if (disableCustomScripts() && /^Custom_Script_/.test(_id)) { + return settingsNotAllowed.push(_id); + } - const promises = params.map(({ _id, value }) => Settings.updateValueById(_id, value)); + const setting = await Settings.findOneById(_id); + // Verify the value is what it should be + switch (setting?.type) { + case 'roomPick': + check(value, Match.OneOf([Object], '')); + break; + case 'boolean': + check(value, Boolean); + break; + case 'timespan': + case 'int': + check(value, Number); + if (!Number.isInteger(value)) { + throw new Meteor.Error(`Invalid setting value ${value}`, 'Invalid setting value', { + method: 'saveSettings', + }); + } - (await Promise.all(promises)).forEach((value, index) => { - if (value?.modifiedCount) { - void notifyOnSettingChangedById(params[index]._id); + break; + case 'multiSelect': + check(value, Array); + break; + case 'code': + check(value, String); + if (isSettingCode(setting) && setting.code === 'application/json') { + check(value, validJSON); + } + break; + default: + check(value, String); + break; } + }), + ); + + if (settingsNotAllowed.length) { + throw new Meteor.Error('error-action-not-allowed', 'Editing settings is not allowed', { + method: 'saveSettings', + settingIds: settingsNotAllowed, }); + } + + const auditSettingOperation = updateAuditedByUser({ + _id: uid, + username: (await Meteor.userAsync())!.username!, + ip: this.connection!.clientAddress || '', + useragent: this.connection!.httpHeaders['user-agent'] || '', + }); - return true; - }, - {}, - ), + const promises = params.map(({ _id, value }) => auditSettingOperation(Settings.updateValueById, _id, value)); + + (await Promise.all(promises)).forEach((value, index) => { + if (value?.modifiedCount) { + void notifyOnSettingChangedById(params[index]._id); + } + }); + + return true; + }, {}), }); diff --git a/apps/meteor/app/livechat/imports/server/rest/appearance.ts b/apps/meteor/app/livechat/imports/server/rest/appearance.ts index 7496b6243abe..215f208c06dc 100644 --- a/apps/meteor/app/livechat/imports/server/rest/appearance.ts +++ b/apps/meteor/app/livechat/imports/server/rest/appearance.ts @@ -3,6 +3,7 @@ import { Settings } from '@rocket.chat/models'; import { isPOSTLivechatAppearanceParams } from '@rocket.chat/rest-typings'; import { isTruthy } from '../../../../../lib/isTruthy'; +import { updateAuditedByUser } from '../../../../../server/settings/lib/auditedSettingUpdates'; import { API } from '../../../../api/server'; import { notifyOnSettingChangedById } from '../../../../lib/server/lib/notifyListener'; import { findAppearance } from '../../../server/api/lib/appearance'; @@ -92,7 +93,15 @@ API.v1.addRoute( .toArray(); const eligibleSettings = dbSettings.filter(isTruthy); - const promises = eligibleSettings.map(({ _id, value }) => Settings.updateValueById(_id, value)); + + const auditSettingOperation = updateAuditedByUser({ + _id: this.userId, + username: this.user.username!, + ip: this.requestIp, + useragent: this.request.headers['user-agent'] || '', + }); + + const promises = eligibleSettings.map(({ _id, value }) => auditSettingOperation(Settings.updateValueById, _id, value)); (await Promise.all(promises)).forEach((value, index) => { if (value?.modifiedCount) { void notifyOnSettingChangedById(eligibleSettings[index]._id); diff --git a/apps/meteor/app/livechat/server/api/v1/integration.ts b/apps/meteor/app/livechat/server/api/v1/integration.ts index a1f9c59ffb87..7e56c8ca8e59 100644 --- a/apps/meteor/app/livechat/server/api/v1/integration.ts +++ b/apps/meteor/app/livechat/server/api/v1/integration.ts @@ -2,6 +2,7 @@ import { Settings } from '@rocket.chat/models'; import { isPOSTomnichannelIntegrations } from '@rocket.chat/rest-typings'; import { trim } from '../../../../../lib/utils/stringUtils'; +import { updateAuditedByUser } from '../../../../../server/settings/lib/auditedSettingUpdates'; import { API } from '../../../../api/server'; import { notifyOnSettingChangedById } from '../../../../lib/server/lib/notifyListener'; @@ -50,7 +51,14 @@ API.v1.addRoute( }, ].filter(Boolean) as unknown as { _id: string; value: any }[]; - const promises = settingsIds.map((setting) => Settings.updateValueById(setting._id, setting.value)); + const auditSettingOperation = updateAuditedByUser({ + _id: this.userId, + username: this.user.username!, + ip: this.requestIp, + useragent: this.request.headers['user-agent'] || '', + }); + + const promises = settingsIds.map((setting) => auditSettingOperation(Settings.updateValueById, setting._id, setting.value)); (await Promise.all(promises)).forEach((value, index) => { if (value?.modifiedCount) { diff --git a/apps/meteor/app/livechat/server/business-hour/BusinessHourManager.ts b/apps/meteor/app/livechat/server/business-hour/BusinessHourManager.ts index 0f125910b9a4..adc7ebf05ce0 100644 --- a/apps/meteor/app/livechat/server/business-hour/BusinessHourManager.ts +++ b/apps/meteor/app/livechat/server/business-hour/BusinessHourManager.ts @@ -2,7 +2,7 @@ import type { ILivechatBusinessHour, IBusinessHourTimezone } from '@rocket.chat/ import { LivechatBusinessHourTypes } from '@rocket.chat/core-typings'; import type { AgendaCronJobs } from '@rocket.chat/cron'; import { LivechatBusinessHours, LivechatDepartment, Users } from '@rocket.chat/models'; -import moment from 'moment'; +import moment from 'moment-timezone'; import type { IBusinessHourBehavior, IBusinessHourType } from './AbstractBusinessHour'; import { closeBusinessHour } from './closeBusinessHour'; diff --git a/apps/meteor/app/livechat/server/lib/contacts/ContactMerger.ts b/apps/meteor/app/livechat/server/lib/contacts/ContactMerger.ts index 33b61e32fb89..a4bd703f1473 100644 --- a/apps/meteor/app/livechat/server/lib/contacts/ContactMerger.ts +++ b/apps/meteor/app/livechat/server/lib/contacts/ContactMerger.ts @@ -295,6 +295,10 @@ export class ContactMerger { ...(allConflicts.length ? { conflictingFields: { $each: allConflicts } } : {}), }; + if (newChannels.length) { + dataToSet.preRegistration = false; + } + const updateData: UpdateFilter = { ...(Object.keys(dataToSet).length ? { $set: dataToSet } : {}), ...(Object.keys(dataToAdd).length ? { $addToSet: dataToAdd } : {}), diff --git a/apps/meteor/app/smarsh-connector/server/functions/generateEml.ts b/apps/meteor/app/smarsh-connector/server/functions/generateEml.ts index cf0b7deab5db..1040e756529b 100644 --- a/apps/meteor/app/smarsh-connector/server/functions/generateEml.ts +++ b/apps/meteor/app/smarsh-connector/server/functions/generateEml.ts @@ -1,7 +1,6 @@ import { Messages, SmarshHistory, Users, Rooms } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; -import moment from 'moment'; -import 'moment-timezone'; +import moment from 'moment-timezone'; import { sendEmail } from './sendEmail'; import { i18n } from '../../../../server/lib/i18n'; diff --git a/apps/meteor/app/utils/server/lib/normalizeMessagesForUser.ts b/apps/meteor/app/utils/server/lib/normalizeMessagesForUser.ts index 3567b0131d78..3b34fe3aacec 100644 --- a/apps/meteor/app/utils/server/lib/normalizeMessagesForUser.ts +++ b/apps/meteor/app/utils/server/lib/normalizeMessagesForUser.ts @@ -3,7 +3,7 @@ import { Users } from '@rocket.chat/models'; import { settings } from '../../../settings/server'; -const filterStarred = (message: IMessage, uid?: string): IMessage => { +const filterStarred = (message: T, uid?: string): T => { // if Allow_anonymous_read is enabled, uid will be undefined if (!uid) return message; @@ -20,7 +20,7 @@ function getNameOfUsername(users: Map, username: string): string return users.get(username) || username; } -export const normalizeMessagesForUser = async (messages: IMessage[], uid?: string): Promise => { +export const normalizeMessagesForUser = async (messages: T[], uid?: string): Promise => { // if not using real names, there is nothing else to do if (!settings.get('UI_Use_Real_Name')) { return messages.map((message) => filterStarred(message, uid)); diff --git a/apps/meteor/app/version-check/server/functions/buildVersionUpdateMessage.ts b/apps/meteor/app/version-check/server/functions/buildVersionUpdateMessage.ts index 4cca28f1d5a9..be53cee5959a 100644 --- a/apps/meteor/app/version-check/server/functions/buildVersionUpdateMessage.ts +++ b/apps/meteor/app/version-check/server/functions/buildVersionUpdateMessage.ts @@ -3,6 +3,7 @@ import semver from 'semver'; import { i18n } from '../../../../server/lib/i18n'; import { sendMessagesToAdmins } from '../../../../server/lib/sendMessagesToAdmins'; +import { updateAuditedBySystem } from '../../../../server/settings/lib/auditedSettingUpdates'; import { notifyOnSettingChangedById } from '../../../lib/server/lib/notifyListener'; import { settings } from '../../../settings/server'; import { Info } from '../../../utils/rocketchat.info'; @@ -38,8 +39,11 @@ export const buildVersionUpdateMessage = async ( continue; } - (await Settings.updateValueById('Update_LatestAvailableVersion', version.version)).modifiedCount && - void notifyOnSettingChangedById('Update_LatestAvailableVersion'); + ( + await updateAuditedBySystem({ + reason: 'buildVersionUpdateMessage', + })(Settings.updateValueById, 'Update_LatestAvailableVersion', version.version) + ).modifiedCount && void notifyOnSettingChangedById('Update_LatestAvailableVersion'); await sendMessagesToAdmins({ msgs: async ({ adminUser }) => [ diff --git a/apps/meteor/client/components/UserInfo/UserInfoAvatar.tsx b/apps/meteor/client/components/UserInfo/UserInfoAvatar.tsx index c15992489264..f59355cfacbd 100644 --- a/apps/meteor/client/components/UserInfo/UserInfoAvatar.tsx +++ b/apps/meteor/client/components/UserInfo/UserInfoAvatar.tsx @@ -2,8 +2,6 @@ import { UserAvatar } from '@rocket.chat/ui-avatar'; import type { ComponentProps, ReactElement } from 'react'; import React from 'react'; -const UserInfoAvatar = ({ username, ...props }: ComponentProps): ReactElement => ( - -); +const UserInfoAvatar = (props: ComponentProps): ReactElement => ; export default UserInfoAvatar; diff --git a/apps/meteor/client/components/message/content/ThreadMetrics.spec.tsx b/apps/meteor/client/components/message/content/ThreadMetrics.spec.tsx new file mode 100644 index 000000000000..22e5e382fd74 --- /dev/null +++ b/apps/meteor/client/components/message/content/ThreadMetrics.spec.tsx @@ -0,0 +1,326 @@ +import { mockAppRoot, MockedRouterContext } from '@rocket.chat/mock-providers'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ReactNode } from 'react'; +import React from 'react'; + +import ThreadMetrics from './ThreadMetrics'; +import ThreadMetricsFollow from './ThreadMetricsFollow'; +import ThreadMetricsParticipants from './ThreadMetricsParticipants'; + +const toggleFollowMock = + (done: jest.DoneCallback | (() => undefined)) => + ({ mid }: { mid: string }) => { + expect(mid).toBe('mid'); + done(); + return null; + }; + +global.ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), +})); + +const mockRoot = () => { + const AppRoot = mockAppRoot(); + const buildWithRouter = (navigate: (...args: any[]) => void) => { + const Wrapper = AppRoot.build(); + return function Mock({ children }: { children: ReactNode }) { + return ( + + 'thread' as any }}>{children} + + ); + }; + }; + + return Object.assign(AppRoot, { buildWithRouter }); +}; + +const mockedTranslations = [ + 'en', + 'core', + { + Follower_one: 'follower', + Follower_other: 'followers', + __count__replies__date__: '{{count}} replies {{date}}', + __count__replies: '{{count}} replies', + }, +] as const; + +let inlineSize = 400; +jest.mock('@rocket.chat/fuselage-hooks', () => { + const originalModule = jest.requireActual('@rocket.chat/fuselage-hooks'); + return { + ...originalModule, + useResizeObserver: () => ({ ref: () => undefined, borderBoxSize: { inlineSize } }), + }; +}); + +describe('Thread Metrics', () => { + describe('Main component', () => { + it('should render large followed with 3 participants and unread', async () => { + const navigateSpy = jest.fn(); + const navigateCallback = (route: any) => { + navigateSpy(route.name, route.params.rid, route.params.tab, route.params.context); + }; + + render( + , + { + wrapper: mockRoot() + .withEndpoint( + 'POST', + '/v1/chat.followMessage', + toggleFollowMock(() => undefined), + ) + .withEndpoint( + 'POST', + '/v1/chat.unfollowMessage', + toggleFollowMock(() => undefined), + ) + .withUserPreference('clockMode', 1) + .withSetting('Message_TimeFormat', 'LT') + .withTranslations(...mockedTranslations) + .buildWithRouter(navigateCallback), + legacyRoot: true, + }, + ); + + const followButton = screen.getByTitle('Following'); + expect(followButton).toBeVisible(); + + const badge = screen.getByTitle('Unread'); + expect(badge).toBeVisible(); + + expect(screen.getByTitle('followers')).toBeVisible(); + expect(screen.getByText('3')).toBeVisible(); + + const replyButton = screen.getByText('View_thread'); + expect(replyButton).toBeVisible(); + await userEvent.click(replyButton); + + expect(navigateSpy).toHaveBeenCalledWith('thread', 'rid', 'thread', 'mid'); + + const threadCount = screen.getByTitle('Last_message__date__'); + expect(threadCount).toHaveTextContent('5 replies July 1, 2024'); + }); + + it('should render small not followed with 3 participants and unread', async () => { + const navigateSpy = jest.fn(); + const navigateCallback = (route: any) => { + navigateSpy(route.name, route.params.rid, route.params.tab, route.params.context); + }; + inlineSize = 200; + + render( + , + { + wrapper: mockRoot() + .withEndpoint( + 'POST', + '/v1/chat.followMessage', + toggleFollowMock(() => undefined), + ) + .withEndpoint( + 'POST', + '/v1/chat.unfollowMessage', + toggleFollowMock(() => undefined), + ) + .withUserPreference('clockMode', 1) + .withSetting('Message_TimeFormat', 'LT') + .withTranslations(...mockedTranslations) + .buildWithRouter(navigateCallback), + legacyRoot: true, + }, + ); + const followButton = screen.getByTitle('Not_following'); + expect(followButton).toBeVisible(); + + const badge = screen.getByTitle('Unread'); + expect(badge).toBeVisible(); + + expect(screen.getByTitle('followers')).toBeVisible(); + expect(screen.getByText('3')).toBeVisible(); + + const replyButton = screen.getByText('View_thread'); + expect(replyButton).toBeVisible(); + await userEvent.click(replyButton); + + expect(navigateSpy).toHaveBeenCalledWith('thread', 'rid', 'thread', 'mid'); + + const threadCount = screen.getByTitle('Last_message__date__'); + expect(threadCount).toHaveTextContent('5 replies'); + }); + }); + + describe('ThreadMetricsFollow', () => { + it('should render not followed', async () => { + render(, { + wrapper: mockAppRoot() + .withEndpoint( + 'POST', + '/v1/chat.followMessage', + toggleFollowMock(() => undefined), + ) + .build(), + legacyRoot: true, + }); + const followButton = screen.getByTitle('Not_following'); + expect(followButton).toBeVisible(); + await userEvent.click(followButton); + }); + it('should render followed', async () => { + render(, { + wrapper: mockAppRoot() + .withEndpoint( + 'POST', + '/v1/chat.unfollowMessage', + toggleFollowMock(() => undefined), + ) + .build(), + legacyRoot: true, + }); + const followButton = screen.getByTitle('Following'); + expect(followButton).toBeVisible(); + await userEvent.click(followButton); + }); + it('should render unread badge', () => { + render(, { + wrapper: mockAppRoot().build(), + legacyRoot: true, + }); + const badge = screen.getByTitle('Unread'); + expect(badge).toBeVisible(); + }); + it('should render mention-all badge', () => { + render(, { + wrapper: mockAppRoot().build(), + legacyRoot: true, + }); + const badge = screen.getByTitle('mention-all'); + expect(badge).toBeVisible(); + }); + it('should render Mentions_you badge', () => { + render(, { + wrapper: mockAppRoot().build(), + legacyRoot: true, + }); + const badge = screen.getByTitle('Mentions_you'); + expect(badge).toBeVisible(); + }); + }); + describe('ThreadMetricsParticipants', () => { + it('should render 1 avatars', () => { + render(, { + wrapper: mockAppRoot() + .withUserPreference('displayAvatars', true) + .withTranslations(...mockedTranslations) + .build(), + legacyRoot: true, + }); + expect(screen.getByTitle('follower')).toBeVisible(); + const avatars = screen.getAllByRole('figure'); + expect(avatars.length).toBe(1); + expect(avatars.pop()).toBeVisible(); + }); + it('should render 2 avatars', () => { + render(, { + wrapper: mockAppRoot() + .withUserPreference('displayAvatars', true) + .withTranslations(...mockedTranslations) + .build(), + legacyRoot: true, + }); + expect(screen.getByTitle('followers')).toBeVisible(); + const avatars = screen.getAllByRole('figure'); + expect(avatars.length).toBe(2); + avatars.forEach((avatar) => expect(avatar).toBeVisible()); + }); + it('should render 2 avatars and "+1" text', () => { + render(, { + wrapper: mockAppRoot() + .withUserPreference('displayAvatars', true) + .withTranslations(...mockedTranslations) + .build(), + legacyRoot: true, + }); + expect(screen.getByTitle('followers')).toBeVisible(); + const avatars = screen.getAllByRole('figure'); + expect(avatars.length).toBe(2); + avatars.forEach((avatar) => expect(avatar).toBeVisible()); + expect(screen.getByText('+1')).toBeVisible(); + }); + it('should render 2 avatars and "+5" text', () => { + render(, { + wrapper: mockAppRoot() + .withUserPreference('displayAvatars', true) + .withTranslations(...mockedTranslations) + .build(), + legacyRoot: true, + }); + expect(screen.getByTitle('followers')).toBeVisible(); + + const avatars = screen.getAllByRole('figure'); + expect(avatars.length).toBe(2); + avatars.forEach((avatar) => expect(avatar).toBeVisible()); + + expect(screen.getByText('+5')).toBeVisible(); + }); + + it('should render user icon and 1 follower', () => { + render(, { + wrapper: mockAppRoot() + .withUserPreference('displayAvatars', false) + .withTranslations(...mockedTranslations) + .build(), + legacyRoot: true, + }); + const follower = screen.getByTitle('follower'); + expect(follower).toBeVisible(); + + // eslint-disable-next-line testing-library/no-node-access + expect(follower.querySelector('.rcx-icon--name-user')).toBeVisible(); + + expect(screen.getByText('1')).toBeVisible(); + }); + + it('should render user icon and 5 followers', () => { + render(, { + wrapper: mockAppRoot() + .withUserPreference('displayAvatars', false) + .withTranslations(...mockedTranslations) + .build(), + legacyRoot: true, + }); + const follower = screen.getByTitle('followers'); + expect(follower).toBeVisible(); + + // eslint-disable-next-line testing-library/no-node-access + expect(follower.querySelector('.rcx-icon--name-user')).toBeVisible(); + + expect(screen.getByText('5')).toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/client/components/message/content/ThreadMetrics.tsx b/apps/meteor/client/components/message/content/ThreadMetrics.tsx index b3fd45646b72..6d98b65650dd 100644 --- a/apps/meteor/client/components/message/content/ThreadMetrics.tsx +++ b/apps/meteor/client/components/message/content/ThreadMetrics.tsx @@ -1,16 +1,20 @@ -import { MessageMetricsItem, MessageBlock, MessageMetrics, MessageMetricsReply, MessageMetricsFollowing } from '@rocket.chat/fuselage'; -import { useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { + MessageMetricsItem, + MessageBlock, + MessageMetrics, + MessageMetricsReply, + MessageMetricsItemIcon, + MessageMetricsItemLabel, +} from '@rocket.chat/fuselage'; +import { useResizeObserver } from '@rocket.chat/fuselage-hooks'; import type { ReactElement } from 'react'; -import React, { useCallback } from 'react'; +import React from 'react'; import { useTranslation } from 'react-i18next'; +import ThreadMetricsFollow from './ThreadMetricsFollow'; +import ThreadMetricsParticipants from './ThreadMetricsParticipants'; import { useTimeAgo } from '../../../hooks/useTimeAgo'; -import { useToggleFollowingThreadMutation } from '../../../views/room/contextualBar/Threads/hooks/useToggleFollowingThreadMutation'; import { useGoToThread } from '../../../views/room/hooks/useGoToThread'; -import { followStyle, anchor } from '../helpers/followSyle'; -import AllMentionNotification from '../notification/AllMentionNotification'; -import MeMentionNotification from '../notification/MeMentionNotification'; -import UnreadMessagesNotification from '../notification/UnreadMessagesNotification'; type ThreadMetricsProps = { unread: boolean; @@ -20,7 +24,7 @@ type ThreadMetricsProps = { mid: string; rid: string; counter: number; - participants: number; + participants: string[]; following: boolean; }; @@ -31,51 +35,33 @@ const ThreadMetrics = ({ unread, mention, all, rid, mid, counter, participants, const goToThread = useGoToThread(); - const dispatchToastMessage = useToastMessageDispatch(); - const toggleFollowingThreadMutation = useToggleFollowingThreadMutation({ - onError: (error) => { - dispatchToastMessage({ type: 'error', message: error }); - }, - }); + const { ref, borderBoxSize } = useResizeObserver(); - const handleFollow = useCallback(() => { - toggleFollowingThreadMutation.mutate({ rid, tmid: mid, follow: !following }); - }, [following, rid, mid, toggleFollowingThreadMutation]); + const isSmall = (borderBoxSize.inlineSize || Infinity) < 320; return ( - + - goToThread({ rid, tmid: mid })}> - {t('Reply')} + goToThread({ rid, tmid: mid })} + primary={!!unread} + position='relative' + overflow='visible' + > + {t('View_thread')} - - - {counter} + + {participants?.length > 0 && } + + + {isSmall ? ( + {t('__count__replies', { count: counter })} + ) : ( + {t('__count__replies__date__', { count: counter, date: format(lm) })} + )} - {!!participants && ( - - - {participants} - - )} - - - {format(lm)} - - - - - {(mention || all || unread) && ( - - - {(mention && ) || (all && ) || (unread && )} - - - )} ); diff --git a/apps/meteor/client/components/message/content/ThreadMetricsFollow.tsx b/apps/meteor/client/components/message/content/ThreadMetricsFollow.tsx new file mode 100644 index 000000000000..351d984d74d6 --- /dev/null +++ b/apps/meteor/client/components/message/content/ThreadMetricsFollow.tsx @@ -0,0 +1,50 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import { MessageMetricsItem, MessageMetricsFollowing } from '@rocket.chat/fuselage'; +import { useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; +import React, { useCallback } from 'react'; + +import ThreadMetricsBadge from './ThreadMetricsUnreadBadge'; +import { useToggleFollowingThreadMutation } from '../../../views/room/contextualBar/Threads/hooks/useToggleFollowingThreadMutation'; + +type ThreadMetricsFollowProps = { + following: boolean; + mid: IMessage['_id']; + rid: IMessage['rid']; + unread: boolean; + mention: boolean; + all: boolean; +}; + +const ThreadMetricsFollow = ({ following, mid, rid, unread, mention, all }: ThreadMetricsFollowProps): ReactElement => { + const t = useTranslation(); + + const dispatchToastMessage = useToastMessageDispatch(); + const toggleFollowingThreadMutation = useToggleFollowingThreadMutation({ + onError: (error) => { + dispatchToastMessage({ type: 'error', message: error }); + }, + }); + + const handleFollow = useCallback( + (e) => { + e.preventDefault(); + e.stopPropagation(); + toggleFollowingThreadMutation.mutate({ rid, tmid: mid, follow: !following }); + }, + [following, rid, mid, toggleFollowingThreadMutation], + ); + + return ( + + } + /> + + ); +}; + +export default ThreadMetricsFollow; diff --git a/apps/meteor/client/components/message/content/ThreadMetricsParticipants.tsx b/apps/meteor/client/components/message/content/ThreadMetricsParticipants.tsx new file mode 100644 index 000000000000..e647fba6ad54 --- /dev/null +++ b/apps/meteor/client/components/message/content/ThreadMetricsParticipants.tsx @@ -0,0 +1,49 @@ +import { + MessageMetricsItem, + MessageMetricsItemLabel, + MessageMetricsItemAvatarRow, + MessageMetricsItemIcon, + MessageMetricsItemAvatarRowContent, +} from '@rocket.chat/fuselage'; +import { UserAvatar } from '@rocket.chat/ui-avatar'; +import { useTranslation, useUserPreference } from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; +import React from 'react'; + +type ThreadMetricsParticipantsProps = { + participants: Array; +}; + +const ThreadMetricsParticipants = ({ participants }: ThreadMetricsParticipantsProps): ReactElement => { + const t = useTranslation(); + + const hideAvatar = !useUserPreference('displayAvatars'); + + const participantsLengthExcludingVisibleAvatars = participants.length - 2; + const participantsLabel = participantsLengthExcludingVisibleAvatars > 0 ? `+${participantsLengthExcludingVisibleAvatars}` : undefined; + + return ( + + {hideAvatar && ( + <> + + {participants.length} + + )} + {!hideAvatar && ( + <> + + {participants.slice(0, 2).map((uid) => ( + + + + ))} + + {participantsLabel && {participantsLabel}} + + )} + + ); +}; + +export default ThreadMetricsParticipants; diff --git a/apps/meteor/client/components/message/content/ThreadMetricsUnreadBadge.tsx b/apps/meteor/client/components/message/content/ThreadMetricsUnreadBadge.tsx new file mode 100644 index 000000000000..9bf3bfc2bc35 --- /dev/null +++ b/apps/meteor/client/components/message/content/ThreadMetricsUnreadBadge.tsx @@ -0,0 +1,38 @@ +import { Badge } from '@rocket.chat/fuselage'; +import type { TranslationKey } from '@rocket.chat/ui-contexts'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import React from 'react'; +import type { ComponentProps } from 'react'; + +const getBadgeVariantAndTitle = ( + unread: boolean, + mention: boolean, + all: boolean, +): false | [ComponentProps['variant'], TranslationKey] => { + if (!unread) { + return false; + } + + if (mention) { + return ['danger', 'Mentions_you']; + } + + if (all) { + return ['warning', 'mention-all']; + } + + return ['primary', 'Unread']; +}; + +const ThreadMetricsUnreadBadge = ({ unread, mention, all }: { unread: boolean; mention: boolean; all: boolean }) => { + const t = useTranslation(); + const result = getBadgeVariantAndTitle(unread, mention, all); + + if (!result) return null; + + const [variant, title] = result; + + return ; +}; + +export default ThreadMetricsUnreadBadge; diff --git a/apps/meteor/client/components/message/hooks/useStarMessageMutation.ts b/apps/meteor/client/components/message/hooks/useStarMessageMutation.ts new file mode 100644 index 000000000000..da73b73eacd6 --- /dev/null +++ b/apps/meteor/client/components/message/hooks/useStarMessageMutation.ts @@ -0,0 +1,33 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import { useToastMessageDispatch, useEndpoint } from '@rocket.chat/ui-contexts'; +import { useQueryClient, useMutation } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; + +import { toggleStarredMessage } from '../../../lib/mutationEffects/starredMessage'; +import { roomsQueryKeys } from '../../../lib/queryKeys'; + +export const useStarMessageMutation = () => { + const { t } = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + + const queryClient = useQueryClient(); + + const starMessage = useEndpoint('POST', '/v1/chat.starMessage'); + + return useMutation({ + mutationFn: async (message: IMessage) => { + await starMessage({ messageId: message._id }); + }, + onSuccess: (_data, message) => { + toggleStarredMessage(message, true); + dispatchToastMessage({ type: 'success', message: t('Message_has_been_starred') }); + }, + onError: (error) => { + dispatchToastMessage({ type: 'error', message: error }); + }, + onSettled: (_data, _error, message) => { + queryClient.invalidateQueries(roomsQueryKeys.starredMessages(message.rid)); + queryClient.invalidateQueries(roomsQueryKeys.messageActions(message.rid, message._id)); + }, + }); +}; diff --git a/apps/meteor/client/components/message/hooks/useUnstarMessageMutation.ts b/apps/meteor/client/components/message/hooks/useUnstarMessageMutation.ts new file mode 100644 index 000000000000..7cb29fd0bc3f --- /dev/null +++ b/apps/meteor/client/components/message/hooks/useUnstarMessageMutation.ts @@ -0,0 +1,33 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import { useToastMessageDispatch, useEndpoint } from '@rocket.chat/ui-contexts'; +import { useQueryClient, useMutation } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; + +import { toggleStarredMessage } from '../../../lib/mutationEffects/starredMessage'; +import { roomsQueryKeys } from '../../../lib/queryKeys'; + +export const useUnstarMessageMutation = () => { + const { t } = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + + const queryClient = useQueryClient(); + + const unstarMessage = useEndpoint('POST', '/v1/chat.unStarMessage'); + + return useMutation({ + mutationFn: async (message: IMessage) => { + await unstarMessage({ messageId: message._id }); + }, + onSuccess: (_data, message) => { + toggleStarredMessage(message, false); + dispatchToastMessage({ type: 'success', message: t('Message_has_been_unstarred') }); + }, + onError: (error) => { + dispatchToastMessage({ type: 'error', message: error }); + }, + onSettled: (_data, _error, message) => { + queryClient.invalidateQueries(roomsQueryKeys.starredMessages(message.rid)); + queryClient.invalidateQueries(roomsQueryKeys.messageActions(message.rid, message._id)); + }, + }); +}; diff --git a/apps/meteor/client/components/message/toolbar/MessageToolbar.tsx b/apps/meteor/client/components/message/toolbar/MessageToolbar.tsx index 2f52ebe42fa9..f846a1f9fd90 100644 --- a/apps/meteor/client/components/message/toolbar/MessageToolbar.tsx +++ b/apps/meteor/client/components/message/toolbar/MessageToolbar.tsx @@ -11,12 +11,16 @@ import React, { memo, useMemo, useRef } from 'react'; import MessageActionMenu from './MessageActionMenu'; import MessageToolbarStarsActionMenu from './MessageToolbarStarsActionMenu'; import { useNewDiscussionMessageAction } from './useNewDiscussionMessageAction'; +import { usePermalinkStar } from './usePermalinkStar'; +import { useStarMessageAction } from './useStarMessageAction'; +import { useUnstarMessageAction } from './useUnstarMessageAction'; import { useWebDAVMessageAction } from './useWebDAVMessageAction'; import type { MessageActionContext } from '../../../../app/ui-utils/client/lib/MessageAction'; import { MessageAction } from '../../../../app/ui-utils/client/lib/MessageAction'; import { useEmojiPickerData } from '../../../contexts/EmojiPickerContext'; import { useMessageActionAppsActionButtons } from '../../../hooks/useAppActionButtons'; import { useEmbeddedLayout } from '../../../hooks/useEmbeddedLayout'; +import { roomsQueryKeys } from '../../../lib/queryKeys'; import EmojiElement from '../../../views/composer/EmojiPicker/EmojiElement'; import { useIsSelecting } from '../../../views/room/MessageList/contexts/SelectedMessagesContext'; import { useAutoTranslate } from '../../../views/room/MessageList/hooks/useAutoTranslate'; @@ -87,17 +91,24 @@ const MessageToolbar = ({ // TODO: move this to another place useWebDAVMessageAction(); useNewDiscussionMessageAction(); - - const actionsQueryResult = useQuery(['rooms', room._id, 'messages', message._id, 'actions'] as const, async () => { - const props = { message, room, user, subscription, settings: mapSettings, chat }; - - const toolboxItems = await MessageAction.getAll(props, context, 'message'); - const menuItems = await MessageAction.getAll(props, context, 'menu'); - - return { - message: toolboxItems.filter((action) => !hiddenActions.includes(action.id)), - menu: menuItems.filter((action) => !(isLayoutEmbedded && action.id === 'reply-directly') && !hiddenActions.includes(action.id)), - }; + useStarMessageAction(message, { room, user }); + useUnstarMessageAction(message, { room, user }); + usePermalinkStar(message, { subscription, user }); + + const actionsQueryResult = useQuery({ + queryKey: roomsQueryKeys.messageActionsWithParameters(room._id, message), + queryFn: async () => { + const props = { message, room, user, subscription, settings: mapSettings, chat }; + + const toolboxItems = await MessageAction.getAll(props, context, 'message'); + const menuItems = await MessageAction.getAll(props, context, 'menu'); + + return { + message: toolboxItems.filter((action) => !hiddenActions.includes(action.id)), + menu: menuItems.filter((action) => !(isLayoutEmbedded && action.id === 'reply-directly') && !hiddenActions.includes(action.id)), + }; + }, + keepPreviousData: true, }); const toolbox = useRoomToolbox(); diff --git a/apps/meteor/client/components/message/toolbar/usePermalinkStar.tsx b/apps/meteor/client/components/message/toolbar/usePermalinkStar.tsx new file mode 100644 index 000000000000..15e6cc5056a7 --- /dev/null +++ b/apps/meteor/client/components/message/toolbar/usePermalinkStar.tsx @@ -0,0 +1,48 @@ +import type { IMessage, ISubscription, IUser } from '@rocket.chat/core-typings'; +import { isE2EEMessage } from '@rocket.chat/core-typings'; +import { useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { MessageAction } from '../../../../app/ui-utils/client/lib/MessageAction'; +import { getPermaLink } from '../../../lib/getPermaLink'; + +export const usePermalinkStar = ( + message: IMessage, + { user, subscription }: { user: IUser | undefined; subscription: ISubscription | undefined }, +) => { + const { t } = useTranslation(); + + const dispatchToastMessage = useToastMessageDispatch(); + + const encrypted = isE2EEMessage(message); + + useEffect(() => { + if (!subscription) { + return; + } + + MessageAction.addButton({ + id: 'permalink-star', + icon: 'permalink', + label: 'Copy_link', + context: ['starred'], + async action() { + try { + const permalink = await getPermaLink(message._id); + navigator.clipboard.writeText(permalink); + dispatchToastMessage({ type: 'success', message: t('Copied') }); + } catch (e) { + dispatchToastMessage({ type: 'error', message: e }); + } + }, + order: 10, + group: 'menu', + disabled: () => encrypted, + }); + + return () => { + MessageAction.removeButton('permalink-star'); + }; + }, [dispatchToastMessage, encrypted, message._id, message.starred, subscription, t, user?._id]); +}; diff --git a/apps/meteor/client/components/message/toolbar/useStarMessageAction.ts b/apps/meteor/client/components/message/toolbar/useStarMessageAction.ts new file mode 100644 index 000000000000..829a94db9aa8 --- /dev/null +++ b/apps/meteor/client/components/message/toolbar/useStarMessageAction.ts @@ -0,0 +1,40 @@ +import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; +import { isOmnichannelRoom } from '@rocket.chat/core-typings'; +import { useSetting } from '@rocket.chat/ui-contexts'; +import { useEffect } from 'react'; + +import { MessageAction } from '../../../../app/ui-utils/client/lib/MessageAction'; +import { useStarMessageMutation } from '../hooks/useStarMessageMutation'; + +export const useStarMessageAction = (message: IMessage, { room, user }: { room: IRoom; user: IUser | undefined }) => { + const allowStarring = useSetting('Message_AllowStarring', true); + + const { mutateAsync: starMessage } = useStarMessageMutation(); + + useEffect(() => { + if (!allowStarring || isOmnichannelRoom(room)) { + return; + } + + if (Array.isArray(message.starred) && message.starred.some((star) => star._id === user?._id)) { + return; + } + + MessageAction.addButton({ + id: 'star-message', + icon: 'star', + label: 'Star', + type: 'interaction', + context: ['starred', 'message', 'message-mobile', 'threads', 'federated', 'videoconf', 'videoconf-threads'], + async action() { + await starMessage(message); + }, + order: 3, + group: 'menu', + }); + + return () => { + MessageAction.removeButton('star-message'); + }; + }, [allowStarring, message, room, starMessage, user?._id]); +}; diff --git a/apps/meteor/client/components/message/toolbar/useUnstarMessageAction.ts b/apps/meteor/client/components/message/toolbar/useUnstarMessageAction.ts new file mode 100644 index 000000000000..851ce1ae4115 --- /dev/null +++ b/apps/meteor/client/components/message/toolbar/useUnstarMessageAction.ts @@ -0,0 +1,40 @@ +import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; +import { isOmnichannelRoom } from '@rocket.chat/core-typings'; +import { useSetting } from '@rocket.chat/ui-contexts'; +import { useEffect } from 'react'; + +import { MessageAction } from '../../../../app/ui-utils/client/lib/MessageAction'; +import { useUnstarMessageMutation } from '../hooks/useUnstarMessageMutation'; + +export const useUnstarMessageAction = (message: IMessage, { room, user }: { room: IRoom; user: IUser | undefined }) => { + const allowStarring = useSetting('Message_AllowStarring'); + + const { mutateAsync: unstarMessage } = useUnstarMessageMutation(); + + useEffect(() => { + if (!allowStarring || isOmnichannelRoom(room)) { + return; + } + + if (!Array.isArray(message.starred) || message.starred.every((star) => star._id !== user?._id)) { + return; + } + + MessageAction.addButton({ + id: 'unstar-message', + icon: 'star', + label: 'Unstar_Message', + type: 'interaction', + context: ['starred', 'message', 'message-mobile', 'threads', 'federated', 'videoconf', 'videoconf-threads'], + async action() { + await unstarMessage(message); + }, + order: 3, + group: 'menu', + }); + + return () => { + MessageAction.removeButton('unstar-message'); + }; + }, [allowStarring, message, room, unstarMessage, user?._id]); +}; diff --git a/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx b/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx index 89da3724cfbc..c23709ad37f9 100644 --- a/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx +++ b/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx @@ -101,7 +101,7 @@ const RoomMessageContent = ({ message, unread, all, mention, searchText }: RoomM unread={unread} mention={mention} all={all} - participants={normalizedMessage?.replies?.length} + participants={normalizedMessage?.replies} /> )} diff --git a/apps/meteor/client/hooks/useLoadRoomForAllowedAnonymousRead.ts b/apps/meteor/client/hooks/useLoadRoomForAllowedAnonymousRead.ts new file mode 100644 index 000000000000..b53ed9dde343 --- /dev/null +++ b/apps/meteor/client/hooks/useLoadRoomForAllowedAnonymousRead.ts @@ -0,0 +1,16 @@ +import { useSetting, useUserId } from '@rocket.chat/ui-contexts'; +import { useEffect } from 'react'; + +import { CachedChatRoom, CachedChatSubscription } from '../../app/models/client'; + +export const useLoadRoomForAllowedAnonymousRead = () => { + const userId = useUserId(); + const accountsAllowAnonymousRead = useSetting('Accounts_AllowAnonymousRead'); + + useEffect(() => { + if (!userId && accountsAllowAnonymousRead === true) { + CachedChatRoom.init(); + CachedChatSubscription.ready.set(true); + } + }, [accountsAllowAnonymousRead, userId]); +}; diff --git a/apps/meteor/client/hooks/useReactiveQuery.ts b/apps/meteor/client/hooks/useReactiveQuery.ts index 0083fce17ba2..61bffde48c26 100644 --- a/apps/meteor/client/hooks/useReactiveQuery.ts +++ b/apps/meteor/client/hooks/useReactiveQuery.ts @@ -11,9 +11,9 @@ export const useReactiveQuery = => { const queryClient = useQueryClient(); - return useQuery( + return useQuery({ queryKey, - (): Promise => + queryFn: (): Promise => new Promise((resolve, reject) => { queueMicrotask(() => { Tracker.autorun((c) => { @@ -33,6 +33,7 @@ export const useReactiveQuery = regex.test(threadMessage.msg); -export class ThreadsList extends MessageList { +export class ThreadsList extends MessageList { public constructor(private _options: ThreadsListOptions) { super(); } @@ -46,7 +46,7 @@ export class ThreadsList extends MessageList { this.clear(); } - protected filter(message: IMessage): boolean { + protected filter(message: IThreadMainMessage): boolean { const { rid } = this._options; if (!isThreadMessageInRoom(message, rid)) { @@ -77,7 +77,7 @@ export class ThreadsList extends MessageList { return true; } - protected compare(a: IMessage, b: IMessage): number { + protected compare(a: IThreadMainMessage, b: IThreadMainMessage): number { return (b.tlm ?? b.ts).getTime() - (a.tlm ?? a.ts).getTime(); } } diff --git a/apps/meteor/client/lib/mutationEffects/room.ts b/apps/meteor/client/lib/mutationEffects/room.ts new file mode 100644 index 000000000000..cba7005cf737 --- /dev/null +++ b/apps/meteor/client/lib/mutationEffects/room.ts @@ -0,0 +1,19 @@ +import { Meteor } from 'meteor/meteor'; + +import { Subscriptions } from '../../../app/models/client'; + +export const toggleFavoriteRoom = (roomId: string, favorite: boolean) => { + const userId = Meteor.userId()!; + + Subscriptions.update( + { + 'rid': roomId, + 'u._id': userId, + }, + { + $set: { + f: favorite, + }, + }, + ); +}; diff --git a/apps/meteor/client/lib/mutationEffects/starredMessage.ts b/apps/meteor/client/lib/mutationEffects/starredMessage.ts new file mode 100644 index 000000000000..45bd772415a8 --- /dev/null +++ b/apps/meteor/client/lib/mutationEffects/starredMessage.ts @@ -0,0 +1,29 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import { Meteor } from 'meteor/meteor'; + +import { Messages } from '../../../app/models/client'; + +export const toggleStarredMessage = (message: IMessage, starred: boolean) => { + const uid = Meteor.userId()!; + + if (starred) { + Messages.update( + { _id: message._id }, + { + $addToSet: { + starred: { _id: uid }, + }, + }, + ); + return; + } + + Messages.update( + { _id: message._id }, + { + $pull: { + starred: { _id: uid }, + }, + }, + ); +}; diff --git a/apps/meteor/client/lib/queryKeys.ts b/apps/meteor/client/lib/queryKeys.ts new file mode 100644 index 000000000000..57a2dc52493b --- /dev/null +++ b/apps/meteor/client/lib/queryKeys.ts @@ -0,0 +1,17 @@ +import type { IMessage, IRoom, Serialized } from '@rocket.chat/core-typings'; + +export const roomsQueryKeys = { + all: ['rooms'] as const, + room: (rid: IRoom['_id']) => ['rooms', rid] as const, + starredMessages: (rid: IRoom['_id']) => [...roomsQueryKeys.room(rid), 'starred-messages'] as const, + messages: (rid: IRoom['_id']) => [...roomsQueryKeys.room(rid), 'messages'] as const, + message: (rid: IRoom['_id'], mid: IMessage['_id']) => [...roomsQueryKeys.messages(rid), mid] as const, + messageActions: (rid: IRoom['_id'], mid: IMessage['_id']) => [...roomsQueryKeys.message(rid, mid), 'actions'] as const, + messageActionsWithParameters: (rid: IRoom['_id'], message: IMessage | Serialized) => + [...roomsQueryKeys.messageActions(rid, message._id), message] as const, +}; + +export const subscriptionsQueryKeys = { + all: ['subscriptions'] as const, + subscription: (rid: IRoom['_id']) => [...subscriptionsQueryKeys.all, { rid }] as const, +}; diff --git a/apps/meteor/client/methods/index.ts b/apps/meteor/client/methods/index.ts index c29f49ffb65d..7be75a2707fb 100644 --- a/apps/meteor/client/methods/index.ts +++ b/apps/meteor/client/methods/index.ts @@ -1,7 +1,5 @@ import './hideRoom'; import './openRoom'; import './pinMessage'; -import './starMessage'; -import './toggleFavorite'; import './unpinMessage'; import './updateMessage'; diff --git a/apps/meteor/client/methods/starMessage.ts b/apps/meteor/client/methods/starMessage.ts deleted file mode 100644 index 80572549c6b6..000000000000 --- a/apps/meteor/client/methods/starMessage.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { ServerMethods } from '@rocket.chat/ddp-client'; -import { Meteor } from 'meteor/meteor'; - -import { Messages, Subscriptions } from '../../app/models/client'; -import { settings } from '../../app/settings/client'; -import { t } from '../../app/utils/lib/i18n'; -import { dispatchToastMessage } from '../lib/toast'; - -Meteor.methods({ - starMessage(message) { - const uid = Meteor.userId(); - - if (!uid) { - dispatchToastMessage({ type: 'error', message: t('error-starring-message') }); - return false; - } - - if (!Subscriptions.findOne({ rid: message.rid })) { - dispatchToastMessage({ type: 'error', message: t('error-starring-message') }); - return false; - } - - if (!Messages.findOne({ _id: message._id, rid: message.rid })) { - dispatchToastMessage({ type: 'error', message: t('error-starring-message') }); - return false; - } - - if (!settings.get('Message_AllowStarring')) { - dispatchToastMessage({ type: 'error', message: t('error-starring-message') }); - return false; - } - - if (message.starred) { - Messages.update( - { _id: message._id }, - { - $addToSet: { - starred: { _id: uid }, - }, - }, - ); - - dispatchToastMessage({ type: 'success', message: t('Message_has_been_starred') }); - - return true; - } - - Messages.update( - { _id: message._id }, - { - $pull: { - starred: { _id: uid }, - }, - }, - ); - - dispatchToastMessage({ type: 'success', message: t('Message_has_been_unstarred') }); - return true; - }, -}); diff --git a/apps/meteor/client/methods/toggleFavorite.ts b/apps/meteor/client/methods/toggleFavorite.ts deleted file mode 100644 index a6deb281fea5..000000000000 --- a/apps/meteor/client/methods/toggleFavorite.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { ServerMethods } from '@rocket.chat/ddp-client'; -import { Meteor } from 'meteor/meteor'; - -import { Subscriptions } from '../../app/models/client'; - -Meteor.methods({ - async toggleFavorite(rid, f) { - if (!Meteor.userId()) { - return 0; - } - - return Subscriptions.update( - { - rid, - 'u._id': Meteor.userId(), - }, - { - $set: { - f, - }, - }, - ); - }, -}); diff --git a/apps/meteor/client/providers/AvatarUrlProvider.tsx b/apps/meteor/client/providers/AvatarUrlProvider.tsx index 606da39360d5..056bc10e0d52 100644 --- a/apps/meteor/client/providers/AvatarUrlProvider.tsx +++ b/apps/meteor/client/providers/AvatarUrlProvider.tsx @@ -10,16 +10,29 @@ type AvatarUrlProviderProps = { }; const AvatarUrlProvider = ({ children }: AvatarUrlProviderProps) => { - const contextValue = useMemo( - () => ({ - getUserPathAvatar: ((): ((uid: string, etag?: string) => string) => { - return (uid: string, etag?: string): string => getURL(`/avatar/${uid}${etag ? `?etag=${etag}` : ''}`); - })(), + const contextValue = useMemo(() => { + function getUserPathAvatar(username: string, etag?: string): string; + function getUserPathAvatar({ userId, etag }: { userId: string; etag?: string }): string; + function getUserPathAvatar({ username, etag }: { username: string; etag?: string }): string; + function getUserPathAvatar(...args: any): string { + if (typeof args[0] === 'string') { + const [username, etag] = args; + return getURL(`/avatar/${username}${etag ? `?etag=${etag}` : ''}`); + } + const [params] = args; + if ('userId' in params) { + const { userId, etag } = params; + return getURL(`/avatar/uid/${userId}${etag ? `?etag=${etag}` : ''}`); + } + const { username, etag } = params; + return getURL(`/avatar/${username}${etag ? `?etag=${etag}` : ''}`); + } + return { + getUserPathAvatar, getRoomPathAvatar: ({ type, ...room }: any): string => roomCoordinator.getRoomDirectives(type || room.t).getAvatarPath({ username: room._id, ...room }) || '', - }), - [], - ); + }; + }, []); return ; }; diff --git a/apps/meteor/client/startup/actionButtons/index.ts b/apps/meteor/client/startup/actionButtons/index.ts index 97ccf359d567..81c8979ae2a9 100644 --- a/apps/meteor/client/startup/actionButtons/index.ts +++ b/apps/meteor/client/startup/actionButtons/index.ts @@ -3,8 +3,5 @@ import './jumpToPinMessage'; import './jumpToSearchMessage'; import './jumpToStarMessage'; import './permalinkPinned'; -import './permalinkStar'; import './pinMessage'; -import './starMessage'; import './unpinMessage'; -import './unstarMessage'; diff --git a/apps/meteor/client/startup/actionButtons/permalinkStar.ts b/apps/meteor/client/startup/actionButtons/permalinkStar.ts deleted file mode 100644 index e4a235491cb7..000000000000 --- a/apps/meteor/client/startup/actionButtons/permalinkStar.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { isE2EEMessage } from '@rocket.chat/core-typings'; -import { Meteor } from 'meteor/meteor'; - -import { MessageAction } from '../../../app/ui-utils/client'; -import { t } from '../../../app/utils/lib/i18n'; -import { getPermaLink } from '../../lib/getPermaLink'; -import { dispatchToastMessage } from '../../lib/toast'; - -Meteor.startup(() => { - MessageAction.addButton({ - id: 'permalink-star', - icon: 'permalink', - label: 'Copy_link', - // classes: 'clipboard', - context: ['starred', 'threads', 'videoconf-threads'], - async action(_, { message }) { - try { - const permalink = await getPermaLink(message._id); - navigator.clipboard.writeText(permalink); - dispatchToastMessage({ type: 'success', message: t('Copied') }); - } catch (e) { - dispatchToastMessage({ type: 'error', message: e }); - } - }, - condition({ message, subscription, user }) { - if (subscription == null) { - return false; - } - - return Boolean(message.starred?.find((star) => star._id === user?._id)); - }, - order: 10, - group: 'menu', - disabled({ message }) { - return isE2EEMessage(message); - }, - }); -}); diff --git a/apps/meteor/client/startup/actionButtons/starMessage.ts b/apps/meteor/client/startup/actionButtons/starMessage.ts deleted file mode 100644 index 9e8fc7245191..000000000000 --- a/apps/meteor/client/startup/actionButtons/starMessage.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Meteor } from 'meteor/meteor'; - -import { settings } from '../../../app/settings/client'; -import { MessageAction } from '../../../app/ui-utils/client'; -import { sdk } from '../../../app/utils/client/lib/SDKClient'; -import { queryClient } from '../../lib/queryClient'; -import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; -import { dispatchToastMessage } from '../../lib/toast'; - -Meteor.startup(() => { - MessageAction.addButton({ - id: 'star-message', - icon: 'star', - label: 'Star', - type: 'interaction', - context: ['starred', 'message', 'message-mobile', 'threads', 'federated', 'videoconf', 'videoconf-threads'], - async action(_, { message }) { - try { - await sdk.call('starMessage', { ...message, starred: true }); - queryClient.invalidateQueries(['rooms', message.rid, 'starred-messages']); - } catch (error) { - if (error) { - dispatchToastMessage({ type: 'error', message: error }); - } - } - }, - condition({ message, subscription, user, room }) { - if (subscription == null && settings.get('Message_AllowStarring')) { - return false; - } - const isLivechatRoom = roomCoordinator.isLivechatRoom(room.t); - if (isLivechatRoom) { - return false; - } - - return !Array.isArray(message.starred) || !message.starred.find((star: any) => star._id === user?._id); - }, - order: 3, - group: 'menu', - }); -}); diff --git a/apps/meteor/client/startup/actionButtons/unstarMessage.ts b/apps/meteor/client/startup/actionButtons/unstarMessage.ts deleted file mode 100644 index af33492a689a..000000000000 --- a/apps/meteor/client/startup/actionButtons/unstarMessage.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Meteor } from 'meteor/meteor'; - -import { settings } from '../../../app/settings/client'; -import { MessageAction } from '../../../app/ui-utils/client'; -import { sdk } from '../../../app/utils/client/lib/SDKClient'; -import { queryClient } from '../../lib/queryClient'; -import { dispatchToastMessage } from '../../lib/toast'; - -Meteor.startup(() => { - MessageAction.addButton({ - id: 'unstar-message', - icon: 'star', - label: 'Unstar_Message', - type: 'interaction', - context: ['starred', 'message', 'message-mobile', 'threads', 'federated', 'videoconf', 'videoconf-threads'], - async action(_, { message }) { - try { - await sdk.call('starMessage', { ...message, starred: false }); - queryClient.invalidateQueries(['rooms', message.rid, 'starred-messages']); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } - }, - condition({ message, subscription, user }) { - if (subscription == null && settings.get('Message_AllowStarring')) { - return false; - } - - return Boolean(message.starred?.find((star: any) => star._id === user?._id)); - }, - order: 3, - group: 'menu', - }); -}); diff --git a/apps/meteor/client/startup/collections.ts b/apps/meteor/client/startup/collections.ts deleted file mode 100644 index 304047fd9c1d..000000000000 --- a/apps/meteor/client/startup/collections.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import { Tracker } from 'meteor/tracker'; - -import { CachedChatRoom, CachedChatSubscription } from '../../app/models/client'; -import { settings } from '../../app/settings/client'; - -Meteor.startup(() => { - Tracker.autorun(() => { - if (!Meteor.userId() && settings.get('Accounts_AllowAnonymousRead') === true) { - CachedChatRoom.init(); - CachedChatSubscription.ready.set(true); - } - }); -}); diff --git a/apps/meteor/client/startup/index.ts b/apps/meteor/client/startup/index.ts index e2264d795415..569b11bf1c18 100644 --- a/apps/meteor/client/startup/index.ts +++ b/apps/meteor/client/startup/index.ts @@ -5,7 +5,6 @@ import './afterLogoutCleanUp'; import './appRoot'; import './audit'; import './callbacks'; -import './collections'; import './customOAuth'; import './deviceManagement'; import './e2e'; diff --git a/apps/meteor/client/views/composer/AudioMessageRecorder/AudioMessageRecorder.tsx b/apps/meteor/client/views/composer/AudioMessageRecorder/AudioMessageRecorder.tsx index d3b1e7b342cf..15f997b2cd87 100644 --- a/apps/meteor/client/views/composer/AudioMessageRecorder/AudioMessageRecorder.tsx +++ b/apps/meteor/client/views/composer/AudioMessageRecorder/AudioMessageRecorder.tsx @@ -1,9 +1,9 @@ import type { IRoom } from '@rocket.chat/core-typings'; -import { Box, Throbber } from '@rocket.chat/fuselage'; +import { Box, Icon, Throbber } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { MessageComposerAction } from '@rocket.chat/ui-composer'; import type { ReactElement } from 'react'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { AudioRecorder } from '../../../../app/ui/client/lib/recorderjs/AudioRecorder'; @@ -100,43 +100,25 @@ const AudioMessageRecorder = ({ rid, chatContext, isMicrophoneDenied }: AudioMes }; }, [handleUnmount, handleRecord]); - const stateClass = useMemo(() => { - if (recordingRoomId && recordingRoomId !== rid) { - return 'rc-message-box__audio-message--busy'; - } - - return state && `rc-message-box__audio-message--${state}`; - }, [recordingRoomId, rid, state]); - if (isMicrophoneDenied) { return null; } return ( - + {state === 'recording' && ( <> - - - - {time} + + + + + {time} + - + )} - {state === 'loading' && ( -
- -
- )} + {state === 'loading' && }
); }; diff --git a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/getMomentChartLabelsAndData.spec.ts b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/getMomentChartLabelsAndData.spec.ts index ec5eb1268233..57abfb23a077 100644 --- a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/getMomentChartLabelsAndData.spec.ts +++ b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/getMomentChartLabelsAndData.spec.ts @@ -1,5 +1,6 @@ import moment from 'moment-timezone'; +import 'moment/locale/fa'; import { getMomentChartLabelsAndData } from './getMomentChartLabelsAndData'; moment.tz.setDefault('UTC'); diff --git a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/getMomentChartLabelsAndData.ts b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/getMomentChartLabelsAndData.ts index a0d4dda2d952..6457e05b499d 100644 --- a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/getMomentChartLabelsAndData.ts +++ b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/getMomentChartLabelsAndData.ts @@ -1,4 +1,4 @@ -import moment from 'moment'; +import moment from 'moment-timezone'; export const getMomentChartLabelsAndData = (timestamp = Date.now()) => { const timingLabels = []; diff --git a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/getMomentCurrentLabel.spec.ts b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/getMomentCurrentLabel.spec.ts index d0d98c1c39d9..46d24dfe3846 100644 --- a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/getMomentCurrentLabel.spec.ts +++ b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/getMomentCurrentLabel.spec.ts @@ -1,4 +1,5 @@ import moment from 'moment-timezone'; +import 'moment/locale/fa'; import { getMomentCurrentLabel } from './getMomentCurrentLabel'; diff --git a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/getMomentCurrentLabel.ts b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/getMomentCurrentLabel.ts index 17085a91b0d7..209169f55397 100644 --- a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/getMomentCurrentLabel.ts +++ b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/getMomentCurrentLabel.ts @@ -1,4 +1,4 @@ -import moment from 'moment'; +import moment from 'moment-timezone'; export const getMomentCurrentLabel = (timestamp = Date.now()) => { const m = moment(timestamp); diff --git a/apps/meteor/client/views/room/Header/Omnichannel/QuickActions/hooks/usePutChatOnHoldMutation.ts b/apps/meteor/client/views/room/Header/Omnichannel/QuickActions/hooks/usePutChatOnHoldMutation.ts index 0c9dbc767952..4cfbbe0aed77 100644 --- a/apps/meteor/client/views/room/Header/Omnichannel/QuickActions/hooks/usePutChatOnHoldMutation.ts +++ b/apps/meteor/client/views/room/Header/Omnichannel/QuickActions/hooks/usePutChatOnHoldMutation.ts @@ -3,6 +3,8 @@ import { useEndpoint } from '@rocket.chat/ui-contexts'; import type { UseMutationOptions, UseMutationResult } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { subscriptionsQueryKeys } from '../../../../../../lib/queryKeys'; + export const usePutChatOnHoldMutation = ( options?: Omit, 'mutationFn'>, ): UseMutationResult => { @@ -19,7 +21,7 @@ export const usePutChatOnHoldMutation = ( onSuccess: async (data, rid, context) => { await queryClient.invalidateQueries(['current-chats']); await queryClient.invalidateQueries(['rooms', rid]); - await queryClient.invalidateQueries(['subscriptions', { rid }]); + await queryClient.invalidateQueries(subscriptionsQueryKeys.subscription(rid)); return options?.onSuccess?.(data, rid, context); }, }, diff --git a/apps/meteor/client/views/room/Header/Omnichannel/QuickActions/hooks/useReturnChatToQueueMutation.ts b/apps/meteor/client/views/room/Header/Omnichannel/QuickActions/hooks/useReturnChatToQueueMutation.ts index c037f200514b..c44afc8a2d08 100644 --- a/apps/meteor/client/views/room/Header/Omnichannel/QuickActions/hooks/useReturnChatToQueueMutation.ts +++ b/apps/meteor/client/views/room/Header/Omnichannel/QuickActions/hooks/useReturnChatToQueueMutation.ts @@ -3,6 +3,8 @@ import { useMethod } from '@rocket.chat/ui-contexts'; import type { UseMutationOptions, UseMutationResult } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { subscriptionsQueryKeys } from '../../../../../../lib/queryKeys'; + export const useReturnChatToQueueMutation = ( options?: Omit, 'mutationFn'>, ): UseMutationResult => { @@ -18,9 +20,9 @@ export const useReturnChatToQueueMutation = ( ...options, onSuccess: async (data, rid, context) => { await queryClient.invalidateQueries(['current-chats']); - await queryClient.removeQueries(['rooms', rid]); - await queryClient.removeQueries(['/v1/rooms.info', rid]); - await queryClient.removeQueries(['subscriptions', { rid }]); + queryClient.removeQueries(['rooms', rid]); + queryClient.removeQueries(['/v1/rooms.info', rid]); + queryClient.removeQueries(subscriptionsQueryKeys.subscription(rid)); return options?.onSuccess?.(data, rid, context); }, }, diff --git a/apps/meteor/client/views/room/Header/icons/Favorite.tsx b/apps/meteor/client/views/room/Header/icons/Favorite.tsx index f6d17cb0e7b7..bf58a748697b 100644 --- a/apps/meteor/client/views/room/Header/icons/Favorite.tsx +++ b/apps/meteor/client/views/room/Header/icons/Favorite.tsx @@ -1,35 +1,25 @@ import type { IRoom, ISubscription } from '@rocket.chat/core-typings'; import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; -import { useSetting, useMethod, useTranslation, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useSetting, useTranslation } from '@rocket.chat/ui-contexts'; import React, { memo } from 'react'; import { HeaderState } from '../../../../components/Header'; import { useUserIsSubscribed } from '../../contexts/RoomContext'; +import { useToggleFavoriteMutation } from '../../hooks/useToggleFavoriteMutation'; const Favorite = ({ room: { _id, f: favorite = false, t: type, name } }: { room: IRoom & { f?: ISubscription['f'] } }) => { const t = useTranslation(); const subscribed = useUserIsSubscribed(); - const dispatchToastMessage = useToastMessageDispatch(); - const isFavoritesEnabled = useSetting('Favorite_Rooms') && ['c', 'p', 'd', 't'].includes(type); - const toggleFavorite = useMethod('toggleFavorite'); + const isFavoritesEnabled = useSetting('Favorite_Rooms', true) && ['c', 'p', 'd', 't'].includes(type); + const { mutate: toggleFavorite } = useToggleFavoriteMutation(); const handleFavoriteClick = useEffectEvent(() => { if (!isFavoritesEnabled) { return; } - try { - toggleFavorite(_id, !favorite); - dispatchToastMessage({ - type: 'success', - message: !favorite - ? t('__roomName__was_added_to_favorites', { roomName: name }) - : t('__roomName__was_removed_from_favorites', { roomName: name }), - }); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } + toggleFavorite({ roomId: _id, favorite: !favorite, roomName: name || '' }); }); const favoriteLabel = favorite ? `${t('Unfavorite')} ${name}` : `${t('Favorite')} ${name}`; diff --git a/apps/meteor/client/views/room/HeaderV2/Omnichannel/QuickActions/hooks/usePutChatOnHoldMutation.ts b/apps/meteor/client/views/room/HeaderV2/Omnichannel/QuickActions/hooks/usePutChatOnHoldMutation.ts index 0c9dbc767952..4cfbbe0aed77 100644 --- a/apps/meteor/client/views/room/HeaderV2/Omnichannel/QuickActions/hooks/usePutChatOnHoldMutation.ts +++ b/apps/meteor/client/views/room/HeaderV2/Omnichannel/QuickActions/hooks/usePutChatOnHoldMutation.ts @@ -3,6 +3,8 @@ import { useEndpoint } from '@rocket.chat/ui-contexts'; import type { UseMutationOptions, UseMutationResult } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { subscriptionsQueryKeys } from '../../../../../../lib/queryKeys'; + export const usePutChatOnHoldMutation = ( options?: Omit, 'mutationFn'>, ): UseMutationResult => { @@ -19,7 +21,7 @@ export const usePutChatOnHoldMutation = ( onSuccess: async (data, rid, context) => { await queryClient.invalidateQueries(['current-chats']); await queryClient.invalidateQueries(['rooms', rid]); - await queryClient.invalidateQueries(['subscriptions', { rid }]); + await queryClient.invalidateQueries(subscriptionsQueryKeys.subscription(rid)); return options?.onSuccess?.(data, rid, context); }, }, diff --git a/apps/meteor/client/views/room/HeaderV2/Omnichannel/QuickActions/hooks/useReturnChatToQueueMutation.ts b/apps/meteor/client/views/room/HeaderV2/Omnichannel/QuickActions/hooks/useReturnChatToQueueMutation.ts index c037f200514b..c44afc8a2d08 100644 --- a/apps/meteor/client/views/room/HeaderV2/Omnichannel/QuickActions/hooks/useReturnChatToQueueMutation.ts +++ b/apps/meteor/client/views/room/HeaderV2/Omnichannel/QuickActions/hooks/useReturnChatToQueueMutation.ts @@ -3,6 +3,8 @@ import { useMethod } from '@rocket.chat/ui-contexts'; import type { UseMutationOptions, UseMutationResult } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { subscriptionsQueryKeys } from '../../../../../../lib/queryKeys'; + export const useReturnChatToQueueMutation = ( options?: Omit, 'mutationFn'>, ): UseMutationResult => { @@ -18,9 +20,9 @@ export const useReturnChatToQueueMutation = ( ...options, onSuccess: async (data, rid, context) => { await queryClient.invalidateQueries(['current-chats']); - await queryClient.removeQueries(['rooms', rid]); - await queryClient.removeQueries(['/v1/rooms.info', rid]); - await queryClient.removeQueries(['subscriptions', { rid }]); + queryClient.removeQueries(['rooms', rid]); + queryClient.removeQueries(['/v1/rooms.info', rid]); + queryClient.removeQueries(subscriptionsQueryKeys.subscription(rid)); return options?.onSuccess?.(data, rid, context); }, }, diff --git a/apps/meteor/client/views/room/HeaderV2/icons/Favorite.tsx b/apps/meteor/client/views/room/HeaderV2/icons/Favorite.tsx index f6d17cb0e7b7..bf58a748697b 100644 --- a/apps/meteor/client/views/room/HeaderV2/icons/Favorite.tsx +++ b/apps/meteor/client/views/room/HeaderV2/icons/Favorite.tsx @@ -1,35 +1,25 @@ import type { IRoom, ISubscription } from '@rocket.chat/core-typings'; import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; -import { useSetting, useMethod, useTranslation, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useSetting, useTranslation } from '@rocket.chat/ui-contexts'; import React, { memo } from 'react'; import { HeaderState } from '../../../../components/Header'; import { useUserIsSubscribed } from '../../contexts/RoomContext'; +import { useToggleFavoriteMutation } from '../../hooks/useToggleFavoriteMutation'; const Favorite = ({ room: { _id, f: favorite = false, t: type, name } }: { room: IRoom & { f?: ISubscription['f'] } }) => { const t = useTranslation(); const subscribed = useUserIsSubscribed(); - const dispatchToastMessage = useToastMessageDispatch(); - const isFavoritesEnabled = useSetting('Favorite_Rooms') && ['c', 'p', 'd', 't'].includes(type); - const toggleFavorite = useMethod('toggleFavorite'); + const isFavoritesEnabled = useSetting('Favorite_Rooms', true) && ['c', 'p', 'd', 't'].includes(type); + const { mutate: toggleFavorite } = useToggleFavoriteMutation(); const handleFavoriteClick = useEffectEvent(() => { if (!isFavoritesEnabled) { return; } - try { - toggleFavorite(_id, !favorite); - dispatchToastMessage({ - type: 'success', - message: !favorite - ? t('__roomName__was_added_to_favorites', { roomName: name }) - : t('__roomName__was_removed_from_favorites', { roomName: name }), - }); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } + toggleFavorite({ roomId: _id, favorite: !favorite, roomName: name || '' }); }); const favoriteLabel = favorite ? `${t('Unfavorite')} ${name}` : `${t('Favorite')} ${name}`; diff --git a/apps/meteor/client/views/room/composer/ComposerOmnichannel/hooks/useResumeChatOnHoldMutation.ts b/apps/meteor/client/views/room/composer/ComposerOmnichannel/hooks/useResumeChatOnHoldMutation.ts index 9992be969758..e6bf592b1561 100644 --- a/apps/meteor/client/views/room/composer/ComposerOmnichannel/hooks/useResumeChatOnHoldMutation.ts +++ b/apps/meteor/client/views/room/composer/ComposerOmnichannel/hooks/useResumeChatOnHoldMutation.ts @@ -3,6 +3,8 @@ import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import type { UseMutationOptions, UseMutationResult } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { subscriptionsQueryKeys } from '../../../../../lib/queryKeys'; + export const useResumeChatOnHoldMutation = ( options?: Omit, 'mutationFn'>, ): UseMutationResult => { @@ -21,7 +23,7 @@ export const useResumeChatOnHoldMutation = ( onSuccess: async (data, rid, context) => { await queryClient.invalidateQueries(['current-chats']); await queryClient.invalidateQueries(['rooms', rid]); - await queryClient.invalidateQueries(['subscriptions', { rid }]); + await queryClient.invalidateQueries(subscriptionsQueryKeys.subscription(rid)); return options?.onSuccess?.(data, rid, context); }, onError: (error) => { diff --git a/apps/meteor/client/views/room/contextualBar/StarredMessagesTab.tsx b/apps/meteor/client/views/room/contextualBar/StarredMessagesTab.tsx index f120db8cb40d..9cf246719cf9 100644 --- a/apps/meteor/client/views/room/contextualBar/StarredMessagesTab.tsx +++ b/apps/meteor/client/views/room/contextualBar/StarredMessagesTab.tsx @@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next'; import MessageListTab from './MessageListTab'; import { onClientMessageReceived } from '../../../lib/onClientMessageReceived'; +import { roomsQueryKeys } from '../../../lib/queryKeys'; import { mapMessageFromApi } from '../../../lib/utils/mapMessageFromApi'; import { useRoom } from '../contexts/RoomContext'; @@ -14,19 +15,22 @@ const StarredMessagesTab = () => { const room = useRoom(); - const starredMessagesQueryResult = useQuery(['rooms', room._id, 'starred-messages'] as const, async () => { - const messages: IMessage[] = []; - - for ( - let offset = 0, result = await getStarredMessages({ roomId: room._id, offset: 0 }); - result.count > 0; - // eslint-disable-next-line no-await-in-loop - offset += result.count, result = await getStarredMessages({ roomId: room._id, offset }) - ) { - messages.push(...result.messages.map(mapMessageFromApi)); - } - - return Promise.all(messages.map(onClientMessageReceived)); + const starredMessagesQueryResult = useQuery({ + queryKey: roomsQueryKeys.starredMessages(room._id), + queryFn: async () => { + const messages: IMessage[] = []; + + for ( + let offset = 0, result = await getStarredMessages({ roomId: room._id, offset: 0 }); + result.count > 0; + // eslint-disable-next-line no-await-in-loop + offset += result.count, result = await getStarredMessages({ roomId: room._id, offset }) + ) { + messages.push(...result.messages.map(mapMessageFromApi)); + } + + return Promise.all(messages.map(onClientMessageReceived)); + }, }); const { t } = useTranslation(); diff --git a/apps/meteor/client/views/room/contextualBar/Threads/ThreadList.tsx b/apps/meteor/client/views/room/contextualBar/Threads/ThreadList.tsx index 6f6f941f48c7..287bb5658926 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/ThreadList.tsx +++ b/apps/meteor/client/views/room/contextualBar/Threads/ThreadList.tsx @@ -1,4 +1,4 @@ -import type { IMessage } from '@rocket.chat/core-typings'; +import type { IMessage, IThreadMainMessage } from '@rocket.chat/core-typings'; import { Box, Icon, TextInput, Select, Callout, Throbber } from '@rocket.chat/fuselage'; import { useResizeObserver, useAutoFocus, useLocalStorage, useDebouncedValue } from '@rocket.chat/fuselage-hooks'; import { useTranslation, useUserId } from '@rocket.chat/ui-contexts'; @@ -168,7 +168,7 @@ const ThreadList = () => { overscan={25} data={items} components={{ Scroller: VirtuosoScrollbars }} - itemContent={(_index, data: IMessage): ReactElement => ( + itemContent={(_index, data: IThreadMainMessage): ReactElement => ( void; + onClick: (tmid: IThreadMainMessage['_id']) => void; }; const ThreadListItem = ({ thread, unread, unreadUser, unreadGroup, onClick }: ThreadListItemProps): ReactElement => { @@ -26,32 +26,7 @@ const ThreadListItem = ({ thread, unread, unreadUser, unreadGroup, onClick }: Th const following = !!uid && (thread.replies?.includes(uid) ?? false); - const followMessage = useMethod('followMessage'); - const unfollowMessage = useMethod('unfollowMessage'); - const dispatchToastMessage = useToastMessageDispatch(); - - const toggleFollowMessage = useCallback(async (): Promise => { - try { - if (following) { - await unfollowMessage({ mid: thread._id }); - } else { - await followMessage({ mid: thread._id }); - } - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } - }, [following, unfollowMessage, thread._id, followMessage, dispatchToastMessage]); - - const handleToggleFollowButtonClick = useCallback( - (event: MouseEvent): void => { - event.preventDefault(); - event.stopPropagation(); - toggleFollowMessage(); - }, - [toggleFollowMessage], - ); - - const showRealNames = useSetting('UI_Use_Real_Name', false); + const showRealNames = (useSetting('UI_Use_Real_Name') as boolean | undefined) ?? false; const handleListItemClick = useCallback( (event: MouseEvent): void => { @@ -77,7 +52,7 @@ const ThreadListItem = ({ thread, unread, unreadUser, unreadGroup, onClick }: Th replies={thread.tcount ?? 0} tlm={thread.tlm} ts={thread.ts} - participants={thread.replies?.length} + participants={thread.replies} name={showRealNames ? name : thread.u.username} username={thread.u.username} unread={unread.includes(thread._id)} @@ -86,7 +61,7 @@ const ThreadListItem = ({ thread, unread, unreadUser, unreadGroup, onClick }: Th following={following} data-id={thread._id} msg={msg ?? ''} - handleFollowButton={handleToggleFollowButtonClick} + rid={thread.rid} onClick={handleListItemClick} emoji={thread?.emoji} /> diff --git a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadListMessage.tsx b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadListMessage.tsx index 4a0e71746417..7dc76c830b39 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadListMessage.tsx +++ b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadListMessage.tsx @@ -1,15 +1,13 @@ import type { IMessage } from '@rocket.chat/core-typings'; -import { Message, Box, IconButton } from '@rocket.chat/fuselage'; +import { Message, Box } from '@rocket.chat/fuselage'; import { MessageAvatar } from '@rocket.chat/ui-avatar'; -import type { ComponentProps, MouseEventHandler, ReactElement, ReactNode } from 'react'; +import type { ComponentProps, ReactElement, ReactNode } from 'react'; import React, { memo } from 'react'; -import { useTranslation } from 'react-i18next'; +import ThreadListMetrics from './ThreadListMetrics'; import Emoji from '../../../../../components/Emoji'; -import { followStyle, anchor } from '../../../../../components/message/helpers/followSyle'; -import AllMentionNotification from '../../../../../components/message/notification/AllMentionNotification'; -import MeMentionNotification from '../../../../../components/message/notification/MeMentionNotification'; -import UnreadMessagesNotification from '../../../../../components/message/notification/UnreadMessagesNotification'; +import ThreadMetricsFollow from '../../../../../components/message/content/ThreadMetricsFollow'; +import ThreadMetricsUnreadBadge from '../../../../../components/message/content/ThreadMetricsUnreadBadge'; import { useTimeAgo } from '../../../../../hooks/useTimeAgo'; type ThreadListMessageProps = { @@ -19,13 +17,13 @@ type ThreadListMessageProps = { username: IMessage['u']['username']; name?: IMessage['u']['name']; ts: IMessage['ts']; - replies: ReactNode; - participants: ReactNode; - handleFollowButton: MouseEventHandler; + replies: number; + participants: string[] | undefined; + rid: IMessage['rid']; unread: boolean; mention: boolean; all: boolean; - tlm: Date | undefined; + tlm: Date; emoji: IMessage['emoji']; } & Omit, 'is'>; @@ -38,8 +36,8 @@ const ThreadListMessage = ({ ts, replies, participants, - handleFollowButton, unread, + rid, mention, all, tlm, @@ -47,13 +45,10 @@ const ThreadListMessage = ({ emoji, ...props }: ThreadListMessageProps): ReactElement => { - const { t } = useTranslation(); const formatDate = useTimeAgo(); - const button = !following ? 'bell-off' : 'bell'; - const actionLabel = t(!following ? 'Not_Following' : 'Following'); return ( - + : undefined} username={username} size='x36' /> @@ -64,40 +59,15 @@ const ThreadListMessage = ({ {formatDate(ts)} {msg} - - - - - {replies} - - - - {participants} - - {tlm && ( - - - {formatDate(tlm)} - - )} - - + - - - {(mention && ) || (all && ) || (unread && )} - + + {unread && ( + + + + )} diff --git a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadListMetrics.tsx b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadListMetrics.tsx new file mode 100644 index 000000000000..ba2b32bd6b8d --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadListMetrics.tsx @@ -0,0 +1,42 @@ +import { MessageMetricsItem, MessageBlock, MessageMetrics, MessageMetricsItemIcon, MessageMetricsItemLabel } from '@rocket.chat/fuselage'; +import { useResizeObserver } from '@rocket.chat/fuselage-hooks'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; +import React from 'react'; + +import ThreadMetricsParticipants from '../../../../../components/message/content/ThreadMetricsParticipants'; +import { useTimeAgo } from '../../../../../hooks/useTimeAgo'; + +type ThreadMetricsProps = { + lm: Date; + counter: number; + participants: Array; +}; + +const ThreadListMetrics = ({ counter, participants, lm }: ThreadMetricsProps): ReactElement => { + const t = useTranslation(); + + const format = useTimeAgo(); + + const { ref, borderBoxSize } = useResizeObserver(); + + const isSmall = (borderBoxSize.inlineSize || Infinity) < 200; + + return ( + + + {participants?.length > 0 && } + + + {isSmall ? ( + {t('__count__replies', { count: counter })} + ) : ( + {t('__count__replies__date__', { count: counter, date: format(lm) })} + )} + + + + ); +}; + +export default ThreadListMetrics; diff --git a/apps/meteor/client/views/room/hooks/useToggleFavoriteMutation.spec.tsx b/apps/meteor/client/views/room/hooks/useToggleFavoriteMutation.spec.tsx new file mode 100644 index 000000000000..f5c625c8ae4a --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useToggleFavoriteMutation.spec.tsx @@ -0,0 +1,43 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import React from 'react'; + +import { useToggleFavoriteMutation } from './useToggleFavoriteMutation'; +import { subscriptionsQueryKeys } from '../../../lib/queryKeys'; + +it('should work', async () => { + const endpointHandler = jest.fn(() => null); + + const { result } = renderHook(() => useToggleFavoriteMutation(), { + legacyRoot: true, + wrapper: mockAppRoot().withEndpoint('POST', '/v1/rooms.favorite', endpointHandler).build(), + }); + + result.current.mutate({ roomId: 'general', favorite: true, roomName: 'general' }); + + await waitFor(() => expect(result.current.status).toBe('success')); + expect(endpointHandler).toHaveBeenCalledWith({ + roomId: 'general', + favorite: true, + }); +}); + +it('should invalidate any subscription queries', async () => { + const queryClient = new QueryClient(); + jest.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useToggleFavoriteMutation(), { + legacyRoot: true, + wrapper: mockAppRoot() + .withEndpoint('POST', '/v1/rooms.favorite', async () => null) + .wrap((children) => ) + .build(), + }); + + result.current.mutate({ roomId: 'general', favorite: true, roomName: 'general' }); + + await waitFor(() => expect(result.current.status).toBe('success')); + + expect(queryClient.invalidateQueries).toHaveBeenCalledWith(subscriptionsQueryKeys.subscription('general')); +}); diff --git a/apps/meteor/client/views/room/hooks/useToggleFavoriteMutation.ts b/apps/meteor/client/views/room/hooks/useToggleFavoriteMutation.ts new file mode 100644 index 000000000000..90806d68f903 --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useToggleFavoriteMutation.ts @@ -0,0 +1,48 @@ +import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; + +import { toggleFavoriteRoom } from '../../../lib/mutationEffects/room'; +import { subscriptionsQueryKeys } from '../../../lib/queryKeys'; + +export const useToggleFavoriteMutation = () => { + const { t } = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + + const toggleFavorite = useEndpoint('POST', '/v1/rooms.favorite'); + const queryClient = useQueryClient(); + + return useMutation( + async ({ roomId, favorite }: { roomId: string; favorite: boolean; roomName: string }) => { + await toggleFavorite({ roomId, favorite }); + }, + { + onMutate: ({ roomId, favorite }) => { + queryClient.setQueryData(subscriptionsQueryKeys.subscription(roomId), (subscription) => + subscription + ? { + ...subscription, + f: favorite, + } + : undefined, + ); + }, + onSuccess: (_data, { roomId, favorite, roomName }) => { + toggleFavoriteRoom(roomId, favorite); + dispatchToastMessage({ + type: 'success', + message: favorite + ? t('__roomName__was_added_to_favorites', { roomName }) + : t('__roomName__was_removed_from_favorites', { roomName }), + }); + }, + onError: (error) => { + dispatchToastMessage({ type: 'error', message: error }); + }, + onSettled: (_data, _error, { roomId }) => { + queryClient.invalidateQueries(subscriptionsQueryKeys.subscription(roomId)); + }, + }, + ); +}; diff --git a/apps/meteor/client/views/room/providers/RoomProvider.tsx b/apps/meteor/client/views/room/providers/RoomProvider.tsx index 7cf08bcbff2c..0ac4e81d54d8 100644 --- a/apps/meteor/client/views/room/providers/RoomProvider.tsx +++ b/apps/meteor/client/views/room/providers/RoomProvider.tsx @@ -17,6 +17,7 @@ import { useReactiveValue } from '../../../hooks/useReactiveValue'; import { useRoomInfoEndpoint } from '../../../hooks/useRoomInfoEndpoint'; import { useSidePanelNavigation } from '../../../hooks/useSidePanelNavigation'; import { RoomManager } from '../../../lib/RoomManager'; +import { subscriptionsQueryKeys } from '../../../lib/queryKeys'; import { roomCoordinator } from '../../../lib/rooms/roomCoordinator'; import ImageGalleryProvider from '../../../providers/ImageGalleryProvider'; import RoomNotFound from '../RoomNotFound'; @@ -45,7 +46,7 @@ const RoomProvider = ({ rid, children }: RoomProviderProps): ReactElement => { } }, [resultFromLocal.data, resultFromLocal.isSuccess, resultFromServer, router]); - const subscriptionQuery = useReactiveQuery(['subscriptions', { rid }], () => Subscriptions.findOne({ rid }) ?? null); + const subscriptionQuery = useReactiveQuery(subscriptionsQueryKeys.subscription(rid), () => Subscriptions.findOne({ rid }) ?? null); useRedirectOnSettingsChanged(subscriptionQuery.data); diff --git a/apps/meteor/client/views/root/AppLayout.tsx b/apps/meteor/client/views/root/AppLayout.tsx index 68e97c51d49c..20c117739d39 100644 --- a/apps/meteor/client/views/root/AppLayout.tsx +++ b/apps/meteor/client/views/root/AppLayout.tsx @@ -8,6 +8,7 @@ import { useGoogleTagManager } from './hooks/useGoogleTagManager'; import { useMessageLinkClicks } from './hooks/useMessageLinkClicks'; import { useAnalytics } from '../../../app/analytics/client/loadScript'; import { useAnalyticsEventTracking } from '../../hooks/useAnalyticsEventTracking'; +import { useLoadRoomForAllowedAnonymousRead } from '../../hooks/useLoadRoomForAllowedAnonymousRead'; import { useNotifyUser } from '../../hooks/useNotifyUser'; import { appLayout } from '../../lib/appLayout'; @@ -25,6 +26,7 @@ const AppLayout = () => { useAnalytics(); useEscapeKeyStroke(); useAnalyticsEventTracking(); + useLoadRoomForAllowedAnonymousRead(); useNotifyUser(); const layout = useSyncExternalStore(appLayout.subscribe, appLayout.getSnapshot); diff --git a/apps/meteor/definition/externals/meteor/meteor.d.ts b/apps/meteor/definition/externals/meteor/meteor.d.ts index 992595dc07bb..b7220937213f 100644 --- a/apps/meteor/definition/externals/meteor/meteor.d.ts +++ b/apps/meteor/definition/externals/meteor/meteor.d.ts @@ -60,6 +60,9 @@ declare module 'meteor/meteor' { } interface IMeteorConnection { + httpHeaders: Record; + referer: string; + clientAddress: string; _send(message: IDDPMessage): void; _methodInvokers: Record; diff --git a/apps/meteor/ee/app/license/server/airGappedRestrictions.ts b/apps/meteor/ee/app/license/server/airGappedRestrictions.ts index 01a15e72e820..03bc38e46ef4 100644 --- a/apps/meteor/ee/app/license/server/airGappedRestrictions.ts +++ b/apps/meteor/ee/app/license/server/airGappedRestrictions.ts @@ -4,9 +4,12 @@ import { Settings, Statistics } from '@rocket.chat/models'; import { notifyOnSettingChangedById } from '../../../../app/lib/server/lib/notifyListener'; import { i18n } from '../../../../server/lib/i18n'; import { sendMessagesToAdmins } from '../../../../server/lib/sendMessagesToAdmins'; +import { updateAuditedBySystem } from '../../../../server/settings/lib/auditedSettingUpdates'; const updateRestrictionSetting = async (remainingDays: number) => { - await Settings.updateValueById('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', remainingDays); + await updateAuditedBySystem({ + reason: 'updateRestrictionSetting', + })(Settings.updateValueById, 'Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', remainingDays); void notifyOnSettingChangedById('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days'); }; diff --git a/apps/meteor/ee/server/api/licenses.ts b/apps/meteor/ee/server/api/licenses.ts index 9d8d60c0be51..1e5a2a90575c 100644 --- a/apps/meteor/ee/server/api/licenses.ts +++ b/apps/meteor/ee/server/api/licenses.ts @@ -6,6 +6,7 @@ import { check } from 'meteor/check'; import { API } from '../../../app/api/server/api'; import { hasPermissionAsync } from '../../../app/authorization/server/functions/hasPermission'; import { notifyOnSettingChangedById } from '../../../app/lib/server/lib/notifyListener'; +import { updateAuditedByUser } from '../../../server/settings/lib/auditedSettingUpdates'; API.v1.addRoute( 'licenses.info', @@ -36,7 +37,14 @@ API.v1.addRoute( return API.v1.failure('Invalid license'); } - (await Settings.updateValueById('Enterprise_License', license)).modifiedCount && + const auditSettingOperation = updateAuditedByUser({ + _id: this.userId, + username: this.user.username!, + ip: this.requestIp, + useragent: this.request.headers['user-agent'] || '', + }); + + (await auditSettingOperation(Settings.updateValueById, 'Enterprise_License', license)).modifiedCount && void notifyOnSettingChangedById('Enterprise_License'); return API.v1.success(); diff --git a/apps/meteor/ee/server/startup/upsell.ts b/apps/meteor/ee/server/startup/upsell.ts index f2fdfc50ba29..751f921ea7b0 100644 --- a/apps/meteor/ee/server/startup/upsell.ts +++ b/apps/meteor/ee/server/startup/upsell.ts @@ -3,12 +3,16 @@ import { Settings } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import { notifyOnSettingChangedById } from '../../../app/lib/server/lib/notifyListener'; +import { updateAuditedBySystem } from '../../../server/settings/lib/auditedSettingUpdates'; const handleHadTrial = (): void => { if (License.getLicense()?.information.trial) { void (async () => { - (await Settings.updateValueById('Cloud_Workspace_Had_Trial', true)).modifiedCount && - void notifyOnSettingChangedById('Cloud_Workspace_Had_Trial'); + ( + await updateAuditedBySystem({ + reason: 'handleHadTrial', + })(Settings.updateValueById, 'Cloud_Workspace_Had_Trial', true) + ).modifiedCount && void notifyOnSettingChangedById('Cloud_Workspace_Had_Trial'); })(); } }; diff --git a/apps/meteor/ee/tests/unit/server/airgappedRestrictions/airgappedRestrictions.spec.ts b/apps/meteor/ee/tests/unit/server/airgappedRestrictions/airgappedRestrictions.spec.ts index a1aace7ae7dd..cf90a0c83463 100644 --- a/apps/meteor/ee/tests/unit/server/airgappedRestrictions/airgappedRestrictions.spec.ts +++ b/apps/meteor/ee/tests/unit/server/airgappedRestrictions/airgappedRestrictions.spec.ts @@ -1,4 +1,5 @@ import { Emitter } from '@rocket.chat/emitter'; +import { registerModel } from '@rocket.chat/models'; import { expect } from 'chai'; import proxyquire from 'proxyquire'; import sinon from 'sinon'; @@ -41,6 +42,11 @@ const licenseMock = { }, }; +registerModel('IServerEventsModel', { + insertOne: () => undefined, + createAuditServerEvent: () => undefined, +} as any); + proxyquire.noCallThru().load('../../../../app/license/server/airGappedRestrictions.ts', { '@rocket.chat/license': { AirGappedRestriction: airgappedRestrictionObj, diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 1e397b4387eb..c98120eb8d81 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -128,7 +128,6 @@ "@types/meteor-collection-hooks": "^0.8.9", "@types/mkdirp": "^1.0.2", "@types/mocha": "github:whitecolor/mocha-types", - "@types/moment-timezone": "^0.5.30", "@types/node": "~20.16.15", "@types/node-gcm": "^1.0.5", "@types/node-rsa": "^1.1.4", @@ -385,7 +384,7 @@ "mime-type": "^4.0.0", "mkdirp": "^1.0.4", "moleculer": "^0.14.35", - "moment": "^2.29.4", + "moment": "^2.30.1", "moment-timezone": "^0.5.46", "mongo-message-queue": "^1.1.0", "mongodb": "patch:mongodb@npm%3A4.17.2#~/.yarn/patches/mongodb-npm-4.17.2-40d1286d70.patch", diff --git a/apps/meteor/server/cron/federation.ts b/apps/meteor/server/cron/federation.ts index 2bcd4ba27e9b..ea0b38de017f 100644 --- a/apps/meteor/server/cron/federation.ts +++ b/apps/meteor/server/cron/federation.ts @@ -16,6 +16,7 @@ async function updateSetting(id: string, value: SettingValue | null): Promise implements IL 'channels.visitor.source.type': 1, 'channels.visitor.source.id': 1, }, + name: 'visitorAssociation', unique: false, }, { key: { - channels: 1, + 'channels.field': 1, + 'channels.value': 1, + 'channels.verified': 1, }, + partialFilterExpression: { 'channels.verified': true }, + name: 'verificationKey', + unique: false, + }, + { + key: { + preRegistration: 1, + }, + sparse: true, unique: false, }, ]; @@ -73,6 +85,7 @@ export class LivechatContactsRaw extends BaseRaw implements IL const result = await this.insertOne({ createdAt: new Date(), ...data, + preRegistration: !data.channels.length, }); return result.insertedId; @@ -81,7 +94,7 @@ export class LivechatContactsRaw extends BaseRaw implements IL async updateContact(contactId: string, data: Partial, options?: FindOneAndUpdateOptions): Promise { const updatedValue = await this.findOneAndUpdate( { _id: contactId }, - { $set: { ...data, unknown: false } }, + { $set: { ...data, unknown: false, ...(data.channels && { preRegistration: !data.channels.length }) } }, { returnDocument: 'after', ...options }, ); return updatedValue.value as ILivechatContact; @@ -132,7 +145,7 @@ export class LivechatContactsRaw extends BaseRaw implements IL ], }, { - channels: [], + preRegistration: true, }, ], }; @@ -164,7 +177,7 @@ export class LivechatContactsRaw extends BaseRaw implements IL } async addChannel(contactId: string, channel: ILivechatContactChannel): Promise { - await this.updateOne({ _id: contactId }, { $push: { channels: channel } }); + await this.updateOne({ _id: contactId }, { $push: { channels: channel }, $set: { preRegistration: false } }); } async updateLastChatById( diff --git a/apps/meteor/server/models/raw/ServerEvents.ts b/apps/meteor/server/models/raw/ServerEvents.ts index a36288e7604d..50722d690993 100644 --- a/apps/meteor/server/models/raw/ServerEvents.ts +++ b/apps/meteor/server/models/raw/ServerEvents.ts @@ -1,4 +1,10 @@ -import type { IServerEvent, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; +import type { + ExtractDataToParams, + IAuditServerActor, + IServerEvent, + IServerEvents, + RocketChatRecordDeleted, +} from '@rocket.chat/core-typings'; import { ServerEventType } from '@rocket.chat/core-typings'; import type { IServerEventsModel } from '@rocket.chat/model-typings'; import type { Collection, Db, IndexDescription } from 'mongodb'; @@ -87,4 +93,20 @@ export class ServerEventsRaw extends BaseRaw implements IServerEve 't': ServerEventType.FAILED_LOGIN_ATTEMPT, }); } + + async createAuditServerEvent( + key: K, + data: ExtractDataToParams, + actor: IAuditServerActor, + ): Promise { + await this.insertOne({ + t: key, + ts: new Date(), + actor, + data: Object.entries(data).map(([key, value]) => ({ key, value })) as E['data'], + // deprecated just to keep backward compatibility + ip: '0.0.0.0', + ...(actor.type === 'user' && { ip: actor?.ip || '0.0.0.0', u: { _id: actor._id, username: actor.username } }), + }); + } } diff --git a/apps/meteor/server/models/raw/Settings.ts b/apps/meteor/server/models/raw/Settings.ts index 3bb735c28905..a9c0e0a31269 100644 --- a/apps/meteor/server/models/raw/Settings.ts +++ b/apps/meteor/server/models/raw/Settings.ts @@ -66,7 +66,7 @@ export class SettingsRaw extends BaseRaw implements ISettingsModel { _id: string, value: (ISetting['value'] extends undefined ? never : ISetting['value']) | null, options?: UpdateOptions, - ): Promise { + ): Promise { const query = { blocked: { $ne: true }, value: { $ne: value }, diff --git a/apps/meteor/server/models/raw/Users.js b/apps/meteor/server/models/raw/Users.js index a5a2c7288577..0e5832b2aad9 100644 --- a/apps/meteor/server/models/raw/Users.js +++ b/apps/meteor/server/models/raw/Users.js @@ -95,6 +95,10 @@ export class UsersRaw extends BaseRaw { name: 'username_insensitive', collation: { locale: 'en', strength: 2, caseLevel: false }, }, + { + key: { active: 1, lastLogin: 1 }, + partialFilterExpression: { active: true, lastLogin: { $exists: true } }, + }, ]; } diff --git a/apps/meteor/server/services/federation/infrastructure/rocket-chat/adapters/Settings.ts b/apps/meteor/server/services/federation/infrastructure/rocket-chat/adapters/Settings.ts index 861137f15e47..443dfe883a4f 100644 --- a/apps/meteor/server/services/federation/infrastructure/rocket-chat/adapters/Settings.ts +++ b/apps/meteor/server/services/federation/infrastructure/rocket-chat/adapters/Settings.ts @@ -56,6 +56,7 @@ export class RocketChatSettingsAdapter { } public async disableFederation(): Promise { + // TODO: audit (await Settings.updateValueById('Federation_Matrix_enabled', false)).modifiedCount && void notifyOnSettingChangedById('Federation_Matrix_enabled'); } @@ -73,7 +74,8 @@ export class RocketChatSettingsAdapter { } public async setConfigurationStatus(status: 'Valid' | 'Invalid'): Promise { - const { modifiedCount } = await Settings.updateOne({ _id: 'Federation_Matrix_configuration_status' }, { $set: { value: status } }); + // TODO: audit + const { modifiedCount } = await Settings.updateValueById('Federation_Matrix_configuration_status', status); if (modifiedCount) { void notifyOnSettingChangedById('Federation_Matrix_configuration_status'); } diff --git a/apps/meteor/server/services/omnichannel-analytics/utils.ts b/apps/meteor/server/services/omnichannel-analytics/utils.ts index 92af92e3f629..bd3966bd22b5 100644 --- a/apps/meteor/server/services/omnichannel-analytics/utils.ts +++ b/apps/meteor/server/services/omnichannel-analytics/utils.ts @@ -1,4 +1,4 @@ -import moment from 'moment'; +import moment from 'moment-timezone'; const HOURS_IN_DAY = 24; diff --git a/apps/meteor/server/settings/lib/auditedSettingUpdates.ts b/apps/meteor/server/settings/lib/auditedSettingUpdates.ts new file mode 100644 index 000000000000..19f646641eaa --- /dev/null +++ b/apps/meteor/server/settings/lib/auditedSettingUpdates.ts @@ -0,0 +1,100 @@ +import type { IAuditServerAppActor, IAuditServerSystemActor, IAuditServerUserActor, ISetting } from '@rocket.chat/core-typings'; +import { ServerEvents } from '@rocket.chat/models'; + +import { settings } from '../../../app/settings/server/cached'; + +export const resetAuditedSettingByUser = + (actor: Omit) => + any>(fn: F, key: ISetting['_id']): ReturnType => { + const { value, packageValue } = settings.getSetting(key) ?? {}; + + void ServerEvents.createAuditServerEvent( + 'settings.changed', + { + id: key, + previous: value, + current: packageValue, + }, + { + type: 'user', + ...actor, + }, + ); + return fn(key); + }; + +export const updateAuditedByUser = + (actor: Omit) => + any>( + fn: F, + ...args: Parameters + ): ReturnType => { + const [key, value, ...rest] = args; + const setting = settings.getSetting(key); + + const previous = setting?.value; + + void ServerEvents.createAuditServerEvent( + 'settings.changed', + { + id: key, + previous, + current: value, + }, + { + type: 'user', + ...actor, + }, + ); + return fn(key, value, ...rest); + }; + +export const updateAuditedBySystem = + (actor: Omit) => + any>( + fn: F, + ...args: Parameters + ): ReturnType => { + const [key, value, ...rest] = args; + const setting = settings.getSetting(key); + + const previous = setting?.value; + + void ServerEvents.createAuditServerEvent( + 'settings.changed', + { + id: key, + previous, + current: value, + }, + { + type: 'system', + ...actor, + }, + ); + return fn(key, value, ...rest); + }; + +export const updateAuditedByApp = + any>( + actor: Omit, + ) => + (fn: F, ...args: Parameters): ReturnType => { + const [key, value, ...rest] = args; + const setting = settings.getSetting(key); + + const previous = setting?.value; + void ServerEvents.createAuditServerEvent( + 'settings.changed', + { + id: key, + previous, + current: value, + }, + { + type: 'app', + ...actor, + }, + ); + return fn(key, value, ...rest); + }; diff --git a/apps/meteor/server/settings/misc.ts b/apps/meteor/server/settings/misc.ts index af2725628ec3..46b809171c1c 100644 --- a/apps/meteor/server/settings/misc.ts +++ b/apps/meteor/server/settings/misc.ts @@ -4,6 +4,7 @@ import { Logger } from '@rocket.chat/logger'; import { Settings } from '@rocket.chat/models'; import { v4 as uuidv4 } from 'uuid'; +import { updateAuditedBySystem } from './lib/auditedSettingUpdates'; import { settingsRegistry, settings } from '../../app/settings/server'; const logger = new Logger('FingerPrint'); @@ -17,10 +18,13 @@ const generateFingerprint = function () { }; const updateFingerprint = async function (fingerprint: string, verified: boolean) { + const auditedSettingBySystem = updateAuditedBySystem({ + reason: 'updateFingerprint', + }); // No need to call ws listener because current function is called on startup await Promise.all([ - Settings.updateValueById('Deployment_FingerPrint_Hash', fingerprint), - Settings.updateValueById('Deployment_FingerPrint_Verified', verified), + auditedSettingBySystem(Settings.updateValueById, 'Deployment_FingerPrint_Hash', fingerprint), + auditedSettingBySystem(Settings.updateValueById, 'Deployment_FingerPrint_Verified', verified), ]); }; diff --git a/apps/meteor/server/startup/migrations/v295.ts b/apps/meteor/server/startup/migrations/v295.ts index 484d0ddd9850..7e09a9538bc9 100644 --- a/apps/meteor/server/startup/migrations/v295.ts +++ b/apps/meteor/server/startup/migrations/v295.ts @@ -3,6 +3,7 @@ import { Settings } from '@rocket.chat/models'; import { SystemLogger } from '../../lib/logger/system'; import { addMigration } from '../../lib/migrations'; +import { updateAuditedBySystem } from '../../settings/lib/auditedSettingUpdates'; addMigration({ version: 295, @@ -20,8 +21,12 @@ addMigration({ const crowdSyncInterval = await Settings.findOneById>('CROWD_Sync_Interval', { projection: { value: 1 } }); // update setting values - await Settings.updateOne({ _id: 'LDAP_Background_Sync_Interval' }, { $set: { value: newLdapDefault } }); - await Settings.updateOne({ _id: 'CROWD_Sync_Interval' }, { $set: { value: newCrowdDefault } }); + await updateAuditedBySystem({ + reason: 'Migration 295', + })(Settings.updateValueById, 'LDAP_Background_Sync_Interval', newLdapDefault); + await updateAuditedBySystem({ + reason: 'Migration 295', + })(Settings.updateValueById, 'CROWD_Sync_Interval', newCrowdDefault); // notify user about the changes if the value was different from the default diff --git a/apps/meteor/server/startup/migrations/v296.ts b/apps/meteor/server/startup/migrations/v296.ts index b301549ea29d..1cc00d8aa225 100644 --- a/apps/meteor/server/startup/migrations/v296.ts +++ b/apps/meteor/server/startup/migrations/v296.ts @@ -14,6 +14,7 @@ addMigration({ const loginTermsValue = settings.get('Layout_Login_Terms'); if (loginTermsValue === oldLoginTermsValue) { + // TODO: audit await Settings.updateOne({ _id: 'Layout_Login_Terms' }, { $set: { value: '', packageValue: '' } }); SystemLogger.warn(`The default value of the setting 'Login Terms' has changed to an empty string. Please review your settings.`); } diff --git a/apps/meteor/server/startup/migrations/v308.ts b/apps/meteor/server/startup/migrations/v308.ts index fc27fdd31868..d1c97c532414 100644 --- a/apps/meteor/server/startup/migrations/v308.ts +++ b/apps/meteor/server/startup/migrations/v308.ts @@ -19,6 +19,7 @@ addMigration({ const isValidAvatarSyncInterval = ldapAvatarSyncInterval && isValidCron(ldapAvatarSyncInterval.value as string); const isValidAutoLogoutInterval = ldapAutoLogoutInterval && isValidCron(ldapAutoLogoutInterval.value as string); + // TODO audit await Settings.updateOne( { _id: 'LDAP_Background_Sync_Avatars_Interval' }, { $set: { packageValue: newAvatarSyncPackageValue, ...(!isValidAvatarSyncInterval && { value: newAvatarSyncPackageValue }) } }, diff --git a/apps/meteor/server/startup/migrations/v310.ts b/apps/meteor/server/startup/migrations/v310.ts index 6905b37293d3..75a7738959d8 100644 --- a/apps/meteor/server/startup/migrations/v310.ts +++ b/apps/meteor/server/startup/migrations/v310.ts @@ -14,6 +14,8 @@ addMigration({ const newPackageValue = '

{Forgot_password}

{Lets_get_you_new_one_}

{Reset}

{If_you_didnt_ask_for_reset_ignore_this_email}

'; + // TODO: audit + await Settings.updateOne( { _id: 'Forgot_Password_Email' }, { diff --git a/apps/meteor/server/startup/migrations/v311.ts b/apps/meteor/server/startup/migrations/v311.ts index 3e131d81b812..ca77630d017c 100644 --- a/apps/meteor/server/startup/migrations/v311.ts +++ b/apps/meteor/server/startup/migrations/v311.ts @@ -6,6 +6,7 @@ addMigration({ version: 311, name: 'Update default behavior of E2E_Enabled_Mentions setting, to allow mentions in encrypted messages by default.', async up() { + // TODO: audit await Settings.updateOne( { _id: 'E2E_Enabled_Mentions', diff --git a/apps/meteor/server/startup/migrations/v314.ts b/apps/meteor/server/startup/migrations/v314.ts index 0b46cc42838a..e7b4ef9555c4 100644 --- a/apps/meteor/server/startup/migrations/v314.ts +++ b/apps/meteor/server/startup/migrations/v314.ts @@ -6,6 +6,7 @@ addMigration({ version: 314, name: 'Update default behavior of E2E_Allow_Unencrypted_Messages setting, to not allow un-encrypted messages by default.', async up() { + // TODO: audit await Settings.updateOne( { _id: 'E2E_Allow_Unencrypted_Messages', diff --git a/apps/meteor/server/startup/migrations/v317.ts b/apps/meteor/server/startup/migrations/v317.ts index 0a7905bf32fd..d114b0aa7221 100644 --- a/apps/meteor/server/startup/migrations/v317.ts +++ b/apps/meteor/server/startup/migrations/v317.ts @@ -61,6 +61,7 @@ addMigration({ version: 317, name: 'Change default color of OAuth login services buttons', async up() { + // TODO: audit const promises = settingsToUpdate .map(async ({ key, defaultValue, newValue }) => { const oldSettingValue = await getSettingValue(key); diff --git a/apps/meteor/server/startup/migrations/v319.ts b/apps/meteor/server/startup/migrations/v319.ts index ec7b948685bf..996190ce112d 100644 --- a/apps/meteor/server/startup/migrations/v319.ts +++ b/apps/meteor/server/startup/migrations/v319.ts @@ -29,6 +29,8 @@ addMigration({ const newValue = convertDaysToMs(Number(value)); + // TODO: audit + promises.push( Settings.updateOne( { diff --git a/apps/meteor/tests/mocks/client/meteor.ts b/apps/meteor/tests/mocks/client/meteor.ts index aa55781bf674..08be68865f90 100644 --- a/apps/meteor/tests/mocks/client/meteor.ts +++ b/apps/meteor/tests/mocks/client/meteor.ts @@ -10,11 +10,14 @@ export const Meteor = { setItem: jest.fn(), }, users: {}, + userId: () => 'uid', }; export const Mongo = { Collection: class Collection { findOne = jest.fn(); + + update = jest.fn(); }, }; diff --git a/apps/meteor/tests/unit/app/livechat/server/business-hour/BusinessHourManager.spec.ts b/apps/meteor/tests/unit/app/livechat/server/business-hour/BusinessHourManager.spec.ts index 105f6c89682a..f83b5535deb6 100644 --- a/apps/meteor/tests/unit/app/livechat/server/business-hour/BusinessHourManager.spec.ts +++ b/apps/meteor/tests/unit/app/livechat/server/business-hour/BusinessHourManager.spec.ts @@ -29,7 +29,7 @@ const { BusinessHourManager } = proxyquire.noCallThru().load('../../../../../../ '../../../../lib/callbacks': {}, '../../../../ee/app/livechat-enterprise/server/business-hour/Helper': {}, './AbstractBusinessHour': {}, - 'moment': momentStub, + 'moment-timezone': momentStub, '@rocket.chat/models': { LivechatBusinessHours: LivechatBusinessHoursStub, }, diff --git a/apps/meteor/tests/unit/server/services/omnichannel-analytics/AgentData.tests.ts b/apps/meteor/tests/unit/server/services/omnichannel-analytics/AgentData.tests.ts index 0894c1f8d894..4c9320163774 100644 --- a/apps/meteor/tests/unit/server/services/omnichannel-analytics/AgentData.tests.ts +++ b/apps/meteor/tests/unit/server/services/omnichannel-analytics/AgentData.tests.ts @@ -1,7 +1,7 @@ /* eslint-disable new-cap */ import type { ILivechatRoomsModel } from '@rocket.chat/model-typings'; import { expect } from 'chai'; -import moment from 'moment'; +import moment from 'moment-timezone'; import sinon from 'sinon'; import { AgentOverviewData } from '../../../../../server/services/omnichannel-analytics/AgentData'; diff --git a/apps/meteor/tests/unit/server/services/omnichannel-analytics/OverviewData.tests.ts b/apps/meteor/tests/unit/server/services/omnichannel-analytics/OverviewData.tests.ts index 08fd30530690..e55d4dc685fe 100644 --- a/apps/meteor/tests/unit/server/services/omnichannel-analytics/OverviewData.tests.ts +++ b/apps/meteor/tests/unit/server/services/omnichannel-analytics/OverviewData.tests.ts @@ -1,6 +1,6 @@ /* eslint-disable new-cap */ import { expect } from 'chai'; -import moment from 'moment'; +import moment from 'moment-timezone'; import sinon from 'sinon'; import { conversations } from './mockData'; diff --git a/apps/uikit-playground/package.json b/apps/uikit-playground/package.json index 1ca09539bfd4..eda9543d7921 100644 --- a/apps/uikit-playground/package.json +++ b/apps/uikit-playground/package.json @@ -30,7 +30,7 @@ "@rocket.chat/ui-contexts": "workspace:~", "codemirror": "^6.0.1", "eslint4b-prebuilt": "^6.7.2", - "moment": "^2.29.4", + "moment": "^2.30.1", "rc-scrollbars": "^1.1.6", "react": "^17.0.2", "react-beautiful-dnd": "^13.1.1", diff --git a/ee/packages/pdf-worker/package.json b/ee/packages/pdf-worker/package.json index 76ce9a63473e..a35b7c2da96f 100644 --- a/ee/packages/pdf-worker/package.json +++ b/ee/packages/pdf-worker/package.json @@ -23,7 +23,7 @@ "@rocket.chat/fuselage-tokens": "^0.33.2", "emoji-assets": "^7.0.1", "emoji-toolkit": "^7.0.1", - "moment": "^2.29.4", + "moment": "^2.30.1", "moment-timezone": "^0.5.46", "react": "~18.3.1" }, diff --git a/packages/apps-engine/deno-runtime/error-handlers.ts b/packages/apps-engine/deno-runtime/error-handlers.ts new file mode 100644 index 000000000000..1e042e0f2c62 --- /dev/null +++ b/packages/apps-engine/deno-runtime/error-handlers.ts @@ -0,0 +1,33 @@ +import * as Messenger from './lib/messenger.ts'; + +export function unhandledRejectionListener(event: PromiseRejectionEvent) { + event.preventDefault(); + + const { type, reason } = event; + + Messenger.sendNotification({ + method: 'unhandledRejection', + params: [ + { + type, + reason: reason instanceof Error ? reason.message : reason, + timestamp: new Date(), + }, + ], + }); +} + +export function unhandledExceptionListener(event: ErrorEvent) { + event.preventDefault(); + + const { type, message, filename, lineno, colno } = event; + Messenger.sendNotification({ + method: 'uncaughtException', + params: [{ type, message, filename, lineno, colno }], + }); +} + +export default function registerErrorListeners() { + addEventListener('unhandledrejection', unhandledRejectionListener); + addEventListener('error', unhandledExceptionListener); +} diff --git a/packages/apps-engine/deno-runtime/main.ts b/packages/apps-engine/deno-runtime/main.ts index 09be5258ecd0..fa2822908954 100644 --- a/packages/apps-engine/deno-runtime/main.ts +++ b/packages/apps-engine/deno-runtime/main.ts @@ -21,6 +21,7 @@ import videoConferenceHandler from './handlers/videoconference-handler.ts'; import apiHandler from './handlers/api-handler.ts'; import handleApp from './handlers/app/handler.ts'; import handleScheduler from './handlers/scheduler-handler.ts'; +import registerErrorListeners from './error-handlers.ts'; type Handlers = { app: typeof handleApp; @@ -126,4 +127,6 @@ async function main() { } } +registerErrorListeners(); + main(); diff --git a/packages/apps-engine/src/definition/metadata/AppMethod.ts b/packages/apps-engine/src/definition/metadata/AppMethod.ts index 8ec07f53e001..b7fcf306f6aa 100644 --- a/packages/apps-engine/src/definition/metadata/AppMethod.ts +++ b/packages/apps-engine/src/definition/metadata/AppMethod.ts @@ -101,4 +101,6 @@ export enum AppMethod { EXECUTE_POST_USER_STATUS_CHANGED = 'executePostUserStatusChanged', // Runtime specific methods RUNTIME_RESTART = 'runtime:restart', + RUNTIME_UNCAUGHT_EXCEPTION = 'runtime:uncaughtException', + RUNTIME_UNHANDLED_REJECTION = 'runtime:unhandledRejection', } diff --git a/packages/apps-engine/src/server/runtime/deno/AppsEngineDenoRuntime.ts b/packages/apps-engine/src/server/runtime/deno/AppsEngineDenoRuntime.ts index d462098d8e65..458799286c83 100644 --- a/packages/apps-engine/src/server/runtime/deno/AppsEngineDenoRuntime.ts +++ b/packages/apps-engine/src/server/runtime/deno/AppsEngineDenoRuntime.ts @@ -16,6 +16,7 @@ import type { IParseAppPackageResult } from '../../compiler'; import { AppConsole, type ILoggerStorageEntry } from '../../logging'; import type { AppAccessorManager, AppApiManager } from '../../managers'; import type { AppLogStorage, IAppStorageItem } from '../../storage'; +import { AppMethod } from '../../../definition/metadata'; const baseDebug = debugFactory('appsEngine:runtime:deno'); @@ -367,7 +368,7 @@ export class DenoRuntimeSubprocessController extends EventEmitter { this.deno.stderr.on('data', this.parseError.bind(this)); this.deno.on('error', (err) => { this.state = 'invalid'; - console.error('Failed to startup Deno subprocess', err); + console.error(`Failed to startup Deno subprocess for app ${this.getAppId()}`, err); }); this.once('ready', this.onReady.bind(this)); this.parseStdout(this.deno.stdout); @@ -544,12 +545,28 @@ export class DenoRuntimeSubprocessController extends EventEmitter { case 'log': console.log('SUBPROCESS LOG', message); break; + case 'unhandledRejection': + case 'uncaughtException': + await this.logUnhandledError(`runtime:${method}`, message); + break; default: console.warn('Unrecognized method from sub process'); break; } } + private async logUnhandledError( + method: `${AppMethod.RUNTIME_UNCAUGHT_EXCEPTION | AppMethod.RUNTIME_UNHANDLED_REJECTION}`, + message: jsonrpc.IParsedObjectRequest | jsonrpc.IParsedObjectNotification, + ) { + this.debug('Unhandled error of type "%s" caught in subprocess', method); + + const logger = new AppConsole(method); + logger.error(message.payload); + + await this.logStorage.storeEntries(AppConsole.toStorageEntry(this.getAppId(), logger)); + } + private async handleResultMessage(message: jsonrpc.IParsedObjectError | jsonrpc.IParsedObjectSuccess): Promise { const { id } = message.payload; diff --git a/packages/core-typings/src/ILivechatContact.ts b/packages/core-typings/src/ILivechatContact.ts index c6591ad1d9e5..7f9301d81d7b 100644 --- a/packages/core-typings/src/ILivechatContact.ts +++ b/packages/core-typings/src/ILivechatContact.ts @@ -45,4 +45,7 @@ export interface ILivechatContact extends IRocketChatRecord { ts: Date; }; importIds?: string[]; + // When preRegistration is true, the contact was added by an admin and it doesn't have any visitor association yet + // This contact may then be linked to new visitors that use the same email address or phone number + preRegistration?: boolean; } diff --git a/packages/core-typings/src/IServerEvent.ts b/packages/core-typings/src/IServerEvent.ts index c920f2dd5cb5..2f4348260ae8 100644 --- a/packages/core-typings/src/IServerEvent.ts +++ b/packages/core-typings/src/IServerEvent.ts @@ -7,8 +7,63 @@ export enum ServerEventType { export interface IServerEvent { _id: string; - t: ServerEventType; + t: ServerEventType | keyof IServerEvents; ts: Date; + + // @deprecated ip: string; + // @deprecated u?: Partial>; + + actor?: IAuditServerActor; + + data?: AuditServerEventPayloadItem[]; +} + +export interface IAuditServerUserActor { + type: 'user'; + _id: string; + username: string; + ip: string; + useragent: string; +} + +export interface IAuditServerSystemActor { + type: 'system'; + reason?: string; +} + +export interface IAuditServerAppActor { + type: 'app'; + _id: string; + reason?: string; } + +export type IAuditServerActor = IAuditServerUserActor | IAuditServerSystemActor | IAuditServerAppActor; + +interface IAuditServerEvent { + t: string; + ts: Date; + + actor: IAuditServerActor; +} + +type AuditServerEventPayloadItem = { + key: string; + value: unknown; +}; + +export interface IAuditServerEventType extends IAuditServerEvent { + data: E[]; +} + +export interface IServerEvents {} + +type KeyValuePair = { key: string; value: any }; + +type ArrayToObject = { + [K in T[number] as K['key']]: K['value']; +}; + +export type ExtractDataToParams> = + T extends IAuditServerEventType ? ArrayToObject : never; diff --git a/packages/core-typings/src/ServerAudit/IAuditServerSettingEvent.ts b/packages/core-typings/src/ServerAudit/IAuditServerSettingEvent.ts new file mode 100644 index 000000000000..a49c4ae5f9be --- /dev/null +++ b/packages/core-typings/src/ServerAudit/IAuditServerSettingEvent.ts @@ -0,0 +1,26 @@ +import type { IAuditServerEventType } from '../IServerEvent'; +import type { ISetting } from '../ISetting'; + +interface IServerEventSettingsChanged + extends IAuditServerEventType< + | { + key: 'id'; + value: ISetting['_id']; + } + | { + key: 'previous'; + value: ISetting['value']; + } + | { + key: 'current'; + value: ISetting['value']; + } + > { + t: 'settings.changed'; +} + +declare module '../IServerEvent' { + interface IServerEvents { + 'settings.changed': IServerEventSettingsChanged; + } +} diff --git a/packages/core-typings/src/index.ts b/packages/core-typings/src/index.ts index 56db4cd73b1f..0f589b22ba22 100644 --- a/packages/core-typings/src/index.ts +++ b/packages/core-typings/src/index.ts @@ -1,3 +1,5 @@ +import './ServerAudit/IAuditServerSettingEvent'; + export * from './Apps'; export * from './AppOverview'; export * from './FeaturedApps'; diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index deb7f0e136c7..f6a6aea00eb6 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -6,6 +6,10 @@ "__count__message_pruned_one": "{{count}} message pruned", "__count__message_pruned_other": "{{count}} messages pruned", "__count__conversations__period__": "{{count}} conversations, {{period}}", + "__count__replies": "{{count}} replies", + "__count__replies__date__": "{{count}} replies, {{date}}", + "__count__follower_one": "+{{count}} follower", + "__count__follower_other": "+{{count}} followers", "__count__tags__and__count__conversations__period__": "{{count}} tags and {{conversations}} conversations, {{period}}", "__departments__departments_and__count__conversations__period__": "{{departments}} departments and {{count}} conversations, {{period}}", "__usersCount__member_joined_one": "+ {{count}} member joined", @@ -943,6 +947,7 @@ "Cancel": "Cancel", "Cancel_message_input": "Cancel", "Canceled": "Canceled", + "Cancel_recording": "Cancel recording", "Cancel_subscription": "Cancel subscription", "Cancel__planName__subscription": "Cancel {{planName}} subscription", "Cancel_subscription_message": "This workspace will downgrage to Community and lose free access to premium capabilities.

While you can keep using Rocket.Chat, your team will lose access to unlimited mobile push notifications, read receipts, marketplace apps <4>and other capabilities.", @@ -2536,12 +2541,15 @@ "Filters_applied": "Filters applied", "Financial_Services": "Financial Services", "Finish": "Finish", + "Finish_recording": "Finish Recording", "Finish_Registration": "Finish Registration", "First_Channel_After_Login": "First Channel After Login", "First_response_time": "First Response Time", "Flags": "Flags", "Follow_message": "Follow message", "Follow_social_profiles": "Follow our social profiles, fork us on github and share your thoughts about the rocket.chat app on our trello board.", + "Follower_one": "Follower", + "Follower_other": "Followers", "Following": "Following", "Fonts": "Fonts", "Food_and_Drink": "Food & Drink", @@ -3044,6 +3052,7 @@ "Last_Heartbeat_Time": "Last Heartbeat Time", "Last_login": "Last login", "Last_Message": "Last Message", + "Last_message__date__": "Last message: {{date}}", "Last_Message_At": "Last Message At", "Last_seen": "Last seen", "Last_Status": "Last Status", @@ -6028,6 +6037,7 @@ "view-room-administration_description": "Permission to view public, private and direct message statistics. Does not include the ability to view conversations or archives", "view-statistics": "View Statistics", "view-statistics_description": "Permission to view system statistics such as number of users logged in, number of rooms, operating system information", + "View_thread": "View thread", "view-user-administration": "View User Administration", "view-user-administration_description": "Permission to partial, read-only list view of other user accounts currently logged into the system. No user account information is accessible with this permission", "view-user-voip-extension": "View User VoIP Extension", diff --git a/packages/mock-providers/src/MockedRouterContext.tsx b/packages/mock-providers/src/MockedRouterContext.tsx new file mode 100644 index 000000000000..b1fb2e96cbb8 --- /dev/null +++ b/packages/mock-providers/src/MockedRouterContext.tsx @@ -0,0 +1,33 @@ +import { RouterContext } from '@rocket.chat/ui-contexts'; +import type { ContextType } from 'react'; +import React from 'react'; + +export const MockedRouterContext = ({ + children, + router, +}: { + children: React.ReactNode; + router?: Partial>; +}) => { + return ( + () => undefined, + getLocationPathname: () => '/', + getLocationSearch: () => '', + getRouteParameters: () => ({}), + getSearchParameters: () => ({}), + getRouteName: () => undefined, + buildRoutePath: () => '/', + navigate: () => undefined, + defineRoutes: () => () => undefined, + getRoutes: () => [], + subscribeToRoutesChange: () => () => undefined, + getRoomRoute: () => ({ path: '/' }), + ...router, + }} + > + {children} + + ); +}; diff --git a/packages/mock-providers/src/index.ts b/packages/mock-providers/src/index.ts index 38a92c053e4b..941d613a1412 100644 --- a/packages/mock-providers/src/index.ts +++ b/packages/mock-providers/src/index.ts @@ -2,6 +2,7 @@ import { MockedAppRootBuilder } from './MockedAppRootBuilder'; export const mockAppRoot = () => new MockedAppRootBuilder(); +export * from './MockedRouterContext'; export * from './MockedAuthorizationContext'; export * from './MockedModalContext'; export * from './MockedServerContext'; diff --git a/packages/model-typings/src/models/IServerEventsModel.ts b/packages/model-typings/src/models/IServerEventsModel.ts index b4c91d12dedc..666893d2cabd 100644 --- a/packages/model-typings/src/models/IServerEventsModel.ts +++ b/packages/model-typings/src/models/IServerEventsModel.ts @@ -1,4 +1,4 @@ -import type { IServerEvent } from '@rocket.chat/core-typings'; +import type { ExtractDataToParams, IAuditServerActor, IServerEvent, IServerEvents } from '@rocket.chat/core-typings'; import type { IBaseModel } from './IBaseModel'; @@ -11,4 +11,9 @@ export interface IServerEventsModel extends IBaseModel { countFailedAttemptsByIpSince(ip: string, since: Date): Promise; countFailedAttemptsByIp(ip: string): Promise; countFailedAttemptsByUsername(username: string): Promise; + createAuditServerEvent( + key: K, + data: ExtractDataToParams, + actor: IAuditServerActor, + ): Promise; } diff --git a/packages/model-typings/src/models/ISettingsModel.ts b/packages/model-typings/src/models/ISettingsModel.ts index 196b1a6bce1c..c333cdf00f6f 100644 --- a/packages/model-typings/src/models/ISettingsModel.ts +++ b/packages/model-typings/src/models/ISettingsModel.ts @@ -25,7 +25,7 @@ export interface ISettingsModel extends IBaseModel { _id: string, value: (ISetting['value'] extends undefined ? never : ISetting['value']) | null, options?: UpdateOptions, - ): Promise; + ): Promise; resetValueById( _id: string, diff --git a/packages/rest-typings/src/v1/chat.ts b/packages/rest-typings/src/v1/chat.ts index 3d1217042e4e..d2fb3915b405 100644 --- a/packages/rest-typings/src/v1/chat.ts +++ b/packages/rest-typings/src/v1/chat.ts @@ -1,4 +1,12 @@ -import type { IMessage, IRoom, MessageAttachment, ReadReceipt, OtrSystemMessages, MessageUrl } from '@rocket.chat/core-typings'; +import type { + IMessage, + IRoom, + MessageAttachment, + ReadReceipt, + OtrSystemMessages, + MessageUrl, + IThreadMainMessage, +} from '@rocket.chat/core-typings'; import Ajv from 'ajv'; import type { PaginatedRequest } from '../helpers/PaginatedRequest'; @@ -921,7 +929,7 @@ export type ChatEndpoints = { }; '/v1/chat.getThreadsList': { GET: (params: ChatGetThreadsList) => { - threads: IMessage[]; + threads: IThreadMainMessage[]; total: number; }; }; diff --git a/packages/ui-avatar/src/components/UserAvatar.tsx b/packages/ui-avatar/src/components/UserAvatar.tsx index c190f341ef24..9945aa2b26b0 100644 --- a/packages/ui-avatar/src/components/UserAvatar.tsx +++ b/packages/ui-avatar/src/components/UserAvatar.tsx @@ -1,22 +1,37 @@ import { useUserAvatarPath } from '@rocket.chat/ui-contexts'; -import type { FC } from 'react'; import { memo } from 'react'; import type { BaseAvatarProps } from './BaseAvatar'; import BaseAvatar from './BaseAvatar'; -type UserAvatarProps = Omit & { +type UsernameProp = { username: string; + userId?: never; +}; + +type UserIdProp = { + userId: string; + username?: never; +}; +type UserAvatarProps = Omit & { etag?: string; url?: string; title?: string; -}; +} & (UsernameProp | UserIdProp); -const UserAvatar: FC = ({ username, etag, ...rest }) => { +const UserAvatar = ({ username, userId, etag, ...rest }: UserAvatarProps) => { const getUserAvatarPath = useUserAvatarPath(); - const { url = getUserAvatarPath(username, etag), ...props } = rest; - return ; + if (userId) { + const { url = getUserAvatarPath({ userId, etag }), ...props } = rest; + return ; + } + if (username) { + const { url = getUserAvatarPath({ username, etag }), ...props } = rest; + return ; + } + + throw new Error('ui-avatar(UserAvatar) - Either username or userId must be provided'); }; export default memo(UserAvatar); diff --git a/packages/ui-contexts/src/AvatarUrlContext.ts b/packages/ui-contexts/src/AvatarUrlContext.ts index 8404d67a0c41..1f13e2764720 100644 --- a/packages/ui-contexts/src/AvatarUrlContext.ts +++ b/packages/ui-contexts/src/AvatarUrlContext.ts @@ -3,7 +3,11 @@ import { createContext } from 'react'; const dummy = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQYV2Oora39DwAFaQJ3y3rKeAAAAABJRU5ErkJggg=='; export type AvatarUrlContextValue = { - getUserPathAvatar: (uid: string, etag?: string) => string; + getUserPathAvatar: { + (username: string, etag?: string): string; + (params: { userId: string; etag?: string }): string; + (params: { username: string; etag?: string }): string; + }; getRoomPathAvatar: (...args: any) => string; }; diff --git a/packages/ui-contexts/src/hooks/useUserAvatarPath.ts b/packages/ui-contexts/src/hooks/useUserAvatarPath.ts index 91a25e585384..5411bf089ee3 100644 --- a/packages/ui-contexts/src/hooks/useUserAvatarPath.ts +++ b/packages/ui-contexts/src/hooks/useUserAvatarPath.ts @@ -1,5 +1,5 @@ import { useContext } from 'react'; -import { AvatarUrlContext } from '../AvatarUrlContext'; +import { AvatarUrlContext, type AvatarUrlContextValue } from '../AvatarUrlContext'; -export const useUserAvatarPath = (): ((uid: string, etag?: string) => string) => useContext(AvatarUrlContext).getUserPathAvatar; +export const useUserAvatarPath = (): AvatarUrlContextValue['getUserPathAvatar'] => useContext(AvatarUrlContext).getUserPathAvatar; diff --git a/yarn.lock b/yarn.lock index 0baba79019d3..a5a1e03b7807 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9276,7 +9276,6 @@ __metadata: "@types/meteor-collection-hooks": "npm:^0.8.9" "@types/mkdirp": "npm:^1.0.2" "@types/mocha": "github:whitecolor/mocha-types" - "@types/moment-timezone": "npm:^0.5.30" "@types/node": "npm:~20.16.15" "@types/node-gcm": "npm:^1.0.5" "@types/node-rsa": "npm:^1.1.4" @@ -9423,7 +9422,7 @@ __metadata: mkdirp: "npm:^1.0.4" mocha: "npm:^9.2.2" moleculer: "npm:^0.14.35" - moment: "npm:^2.29.4" + moment: "npm:^2.30.1" moment-timezone: "npm:^0.5.46" mongo-message-queue: "npm:^1.1.0" mongodb: "patch:mongodb@npm%3A4.17.2#~/.yarn/patches/mongodb-npm-4.17.2-40d1286d70.patch" @@ -9733,7 +9732,7 @@ __metadata: emoji-toolkit: "npm:^7.0.1" eslint: "npm:~8.45.0" jest: "npm:~29.7.0" - moment: "npm:^2.29.4" + moment: "npm:^2.30.1" moment-timezone: "npm:^0.5.46" react: "npm:~18.3.1" react-dom: "npm:~18.3.1" @@ -10408,7 +10407,7 @@ __metadata: eslint-plugin-react-hooks: "npm:^5.0.0" eslint-plugin-react-refresh: "npm:^0.4.14" eslint4b-prebuilt: "npm:^6.7.2" - moment: "npm:^2.29.4" + moment: "npm:^2.30.1" rc-scrollbars: "npm:^1.1.6" react: "npm:^17.0.2" react-beautiful-dnd: "npm:^13.1.1" @@ -12887,15 +12886,6 @@ __metadata: languageName: node linkType: hard -"@types/moment-timezone@npm:^0.5.30": - version: 0.5.30 - resolution: "@types/moment-timezone@npm:0.5.30" - dependencies: - moment-timezone: "npm:*" - checksum: 10/488b5880b346101f15e3f90267eb8d848ce20a41ea8b51305b1fee25f4fac57b93d553dc0de6f2eb8412be764bd74c9c347ed678c9a6d20d800a06c106b674f9 - languageName: node - linkType: hard - "@types/ms@npm:*": version: 0.7.31 resolution: "@types/ms@npm:0.7.31" @@ -28865,21 +28855,21 @@ __metadata: languageName: node linkType: hard -"moment-timezone@npm:*, moment-timezone@npm:^0.5.x": - version: 0.5.43 - resolution: "moment-timezone@npm:0.5.43" +"moment-timezone@npm:^0.5.46, moment-timezone@npm:~0.5.46": + version: 0.5.46 + resolution: "moment-timezone@npm:0.5.46" dependencies: moment: "npm:^2.29.4" - checksum: 10/f8b66f8562960d6c2ec90ea7e2ca8c10bd5f5cf5ced2eaaac83deb1011b145d0154e8d77018cf5e913d489898a343122a3d815768809653ab039306dce1db1eb + checksum: 10/7613ba388fa6004af62675fb9945cb0d37758b559d07470a5e188419ffe1ac03eb2ed16fe80aa34d1e7dd39fc5bd67dc02cd59e8dcdab95504cface2c78e4b3d languageName: node linkType: hard -"moment-timezone@npm:^0.5.46, moment-timezone@npm:~0.5.46": - version: 0.5.46 - resolution: "moment-timezone@npm:0.5.46" +"moment-timezone@npm:^0.5.x": + version: 0.5.43 + resolution: "moment-timezone@npm:0.5.43" dependencies: moment: "npm:^2.29.4" - checksum: 10/7613ba388fa6004af62675fb9945cb0d37758b559d07470a5e188419ffe1ac03eb2ed16fe80aa34d1e7dd39fc5bd67dc02cd59e8dcdab95504cface2c78e4b3d + checksum: 10/f8b66f8562960d6c2ec90ea7e2ca8c10bd5f5cf5ced2eaaac83deb1011b145d0154e8d77018cf5e913d489898a343122a3d815768809653ab039306dce1db1eb languageName: node linkType: hard @@ -28890,6 +28880,13 @@ __metadata: languageName: node linkType: hard +"moment@npm:^2.30.1": + version: 2.30.1 + resolution: "moment@npm:2.30.1" + checksum: 10/ae42d876d4ec831ef66110bdc302c0657c664991e45cf2afffc4b0f6cd6d251dde11375c982a5c0564ccc0fa593fc564576ddceb8c8845e87c15f58aa6baca69 + languageName: node + linkType: hard + "mongo-message-queue@npm:^1.1.0": version: 1.1.0 resolution: "mongo-message-queue@npm:1.1.0"