From 5c9dba66ab25fc330a031a8fda6a4a2f98ae01df Mon Sep 17 00:00:00 2001 From: Martin Schoeler Date: Thu, 5 Dec 2024 17:52:04 -0300 Subject: [PATCH 1/8] chore: remove meteor.startup from mark-as-unread (#34083) Co-authored-by: Tasso Evangelista <2263066+tassoevan@users.noreply.github.com> --- .../client/actionButton.ts | 48 ------------------- .../message-mark-as-unread/client/index.ts | 1 - .../message/hooks/useMarkAsUnreadMutation.tsx | 20 ++++++++ .../message/toolbar/MessageToolbar.tsx | 2 + .../toolbar/useMarkAsUnreadMessageAction.ts | 47 ++++++++++++++++++ apps/meteor/client/importPackages.ts | 1 - 6 files changed, 69 insertions(+), 50 deletions(-) delete mode 100644 apps/meteor/app/message-mark-as-unread/client/actionButton.ts delete mode 100644 apps/meteor/app/message-mark-as-unread/client/index.ts create mode 100644 apps/meteor/client/components/message/hooks/useMarkAsUnreadMutation.tsx create mode 100644 apps/meteor/client/components/message/toolbar/useMarkAsUnreadMessageAction.ts diff --git a/apps/meteor/app/message-mark-as-unread/client/actionButton.ts b/apps/meteor/app/message-mark-as-unread/client/actionButton.ts deleted file mode 100644 index fc052eaf63d3..000000000000 --- a/apps/meteor/app/message-mark-as-unread/client/actionButton.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Meteor } from 'meteor/meteor'; - -import { roomCoordinator } from '../../../client/lib/rooms/roomCoordinator'; -import { dispatchToastMessage } from '../../../client/lib/toast'; -import { router } from '../../../client/providers/RouterProvider'; -import { Subscriptions } from '../../models/client'; -import { LegacyRoomManager, MessageAction } from '../../ui-utils/client'; -import { sdk } from '../../utils/client/lib/SDKClient'; - -Meteor.startup(() => { - MessageAction.addButton({ - id: 'mark-message-as-unread', - icon: 'flag', - label: 'Mark_unread', - context: ['message', 'message-mobile', 'threads'], - type: 'interaction', - async action(_, { message }) { - try { - const subscription = Subscriptions.findOne({ - rid: message.rid, - }); - - if (subscription == null) { - return; - } - router.navigate('/home'); - await LegacyRoomManager.close(subscription.t + subscription.name); - await sdk.call('unreadMessages', message); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } - }, - condition({ message, user, room }) { - const isLivechatRoom = roomCoordinator.isLivechatRoom(room.t); - if (isLivechatRoom) { - return false; - } - - if (!user) { - return false; - } - - return message.u._id !== user._id; - }, - order: 4, - group: 'menu', - }); -}); diff --git a/apps/meteor/app/message-mark-as-unread/client/index.ts b/apps/meteor/app/message-mark-as-unread/client/index.ts deleted file mode 100644 index 4a15cde3d04c..000000000000 --- a/apps/meteor/app/message-mark-as-unread/client/index.ts +++ /dev/null @@ -1 +0,0 @@ -import './actionButton'; diff --git a/apps/meteor/client/components/message/hooks/useMarkAsUnreadMutation.tsx b/apps/meteor/client/components/message/hooks/useMarkAsUnreadMutation.tsx new file mode 100644 index 000000000000..cd52c5e34dd8 --- /dev/null +++ b/apps/meteor/client/components/message/hooks/useMarkAsUnreadMutation.tsx @@ -0,0 +1,20 @@ +import type { IMessage, ISubscription } from '@rocket.chat/core-typings'; +import { useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useMutation } from '@tanstack/react-query'; + +import { LegacyRoomManager } from '../../../../app/ui-utils/client'; +import { sdk } from '../../../../app/utils/client/lib/SDKClient'; + +export const useMarkAsUnreadMutation = () => { + const dispatchToastMessage = useToastMessageDispatch(); + + return useMutation({ + mutationFn: async ({ message, subscription }: { message: IMessage; subscription: ISubscription }) => { + await LegacyRoomManager.close(subscription.t + subscription.name); + await sdk.call('unreadMessages', message); + }, + onError: (error) => { + dispatchToastMessage({ type: 'error', message: error }); + }, + }); +}; diff --git a/apps/meteor/client/components/message/toolbar/MessageToolbar.tsx b/apps/meteor/client/components/message/toolbar/MessageToolbar.tsx index 1c814d9255a6..9e5a0f10f85f 100644 --- a/apps/meteor/client/components/message/toolbar/MessageToolbar.tsx +++ b/apps/meteor/client/components/message/toolbar/MessageToolbar.tsx @@ -12,6 +12,7 @@ import MessageActionMenu from './MessageActionMenu'; import MessageToolbarStarsActionMenu from './MessageToolbarStarsActionMenu'; import { useFollowMessageAction } from './useFollowMessageAction'; import { useJumpToMessageContextAction } from './useJumpToMessageContextAction'; +import { useMarkAsUnreadMessageAction } from './useMarkAsUnreadMessageAction'; import { useNewDiscussionMessageAction } from './useNewDiscussionMessageAction'; import { usePermalinkAction } from './usePermalinkAction'; import { usePinMessageAction } from './usePinMessageAction'; @@ -133,6 +134,7 @@ const MessageToolbar = ({ context: ['starred'], }); useReactionMessageAction(message, { user, room, subscription }); + useMarkAsUnreadMessageAction(message, { user, room, subscription }); const actionsQueryResult = useQuery({ queryKey: roomsQueryKeys.messageActionsWithParameters(room._id, message), diff --git a/apps/meteor/client/components/message/toolbar/useMarkAsUnreadMessageAction.ts b/apps/meteor/client/components/message/toolbar/useMarkAsUnreadMessageAction.ts new file mode 100644 index 000000000000..208a83679f7e --- /dev/null +++ b/apps/meteor/client/components/message/toolbar/useMarkAsUnreadMessageAction.ts @@ -0,0 +1,47 @@ +import { isOmnichannelRoom } from '@rocket.chat/core-typings'; +import type { ISubscription, IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; +import { useRouter } from '@rocket.chat/ui-contexts'; +import { useEffect } from 'react'; + +import { MessageAction } from '../../../../app/ui-utils/client'; +import { useMarkAsUnreadMutation } from '../hooks/useMarkAsUnreadMutation'; + +export const useMarkAsUnreadMessageAction = ( + message: IMessage, + { user, room, subscription }: { user: IUser | undefined; room: IRoom; subscription: ISubscription | undefined }, +) => { + const { mutateAsync: markAsUnread } = useMarkAsUnreadMutation(); + + const router = useRouter(); + + useEffect(() => { + if (isOmnichannelRoom(room) || !user) { + return; + } + + if (!subscription) { + return; + } + + if (message.u._id === user._id) { + return; + } + + MessageAction.addButton({ + id: 'mark-message-as-unread', + icon: 'flag', + label: 'Mark_unread', + context: ['message', 'message-mobile', 'threads'], + type: 'interaction', + async action() { + router.navigate('/home'); + await markAsUnread({ message, subscription }); + }, + order: 4, + group: 'menu', + }); + return () => { + MessageAction.removeButton('mark-message-as-unread'); + }; + }, [markAsUnread, message, room, router, subscription, user]); +}; diff --git a/apps/meteor/client/importPackages.ts b/apps/meteor/client/importPackages.ts index f51e51335c9b..59e7ec3d7c81 100644 --- a/apps/meteor/client/importPackages.ts +++ b/apps/meteor/client/importPackages.ts @@ -16,7 +16,6 @@ import '../app/iframe-login/client'; import '../app/license/client'; import '../app/lib/client'; import '../app/livechat-enterprise/client'; -import '../app/message-mark-as-unread/client'; import '../app/nextcloud/client'; import '../app/notifications/client'; import '../app/otr/client'; From a28c47833a2437ad0a126f307161a95e164f6a75 Mon Sep 17 00:00:00 2001 From: Aleksander Nicacio da Silva Date: Fri, 6 Dec 2024 12:37:40 -0300 Subject: [PATCH 2/8] refactor: moved `UserCard` user-related props into a single `user` prop (#34125) --- .../components/UserCard/UserCard.stories.tsx | 69 +++++++++++++------ .../client/components/UserCard/UserCard.tsx | 34 ++++----- .../oauth/components/CurrentUserDisplay.tsx | 44 ++++++------ .../views/room/UserCard/UserCardWithData.tsx | 2 +- 4 files changed, 86 insertions(+), 63 deletions(-) diff --git a/apps/meteor/client/components/UserCard/UserCard.stories.tsx b/apps/meteor/client/components/UserCard/UserCard.stories.tsx index 95b7e27becdc..ac68b113a22d 100644 --- a/apps/meteor/client/components/UserCard/UserCard.stories.tsx +++ b/apps/meteor/client/components/UserCard/UserCard.stories.tsx @@ -3,6 +3,20 @@ import React from 'react'; import { UserCard, UserCardRole, UserCardAction } from '.'; +const user = { + name: 'guilherme.gazzo', + customStatus: '🛴 currently working on User Card', + roles: ( + <> + Admin + Rocket.Chat + Team + + ), + bio: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla tempus, eros convallis vulputate cursus, nisi neque eleifend libero, eget lacinia justo purus nec est. In at sodales ipsum. Sed lacinia quis purus eget pulvinar. Aenean eu pretium nunc, at aliquam magna. Praesent dignissim, tortor sed volutpat mattis, mauris diam pulvinar leo, porta commodo risus est non purus. Mauris in justo vel lorem ullamcorper hendrerit. Nam est metus, viverra a pellentesque vitae, ornare eget odio. Morbi tempor feugiat mattis. Morbi non felis tempor, aliquam justo sed, sagittis nibh. Mauris consequat ex metus. Praesent sodales sit amet nibh a vulputate. Integer commodo, mi vel bibendum sollicitudin, urna lectus accumsan ante, eget faucibus augue ex id neque. Aenean consectetur, orci a pellentesque mattis, tortor tellus fringilla elit, non ullamcorper risus nunc feugiat risus. Fusce sit amet nisi dapibus turpis commodo placerat. In tortor ante, vehicula sit amet augue et, imperdiet porta sem.', + localTime: 'Local Time: 7:44 AM', +}; + export default { title: 'Components/UserCard', component: UserCard, @@ -10,23 +24,13 @@ export default { layout: 'centered', }, args: { - name: 'guilherme.gazzo', - customStatus: '🛴 currently working on User Card', - roles: ( - <> - Admin - Rocket.Chat - Team - - ), - bio: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla tempus, eros convallis vulputate cursus, nisi neque eleifend libero, eget lacinia justo purus nec est. In at sodales ipsum. Sed lacinia quis purus eget pulvinar. Aenean eu pretium nunc, at aliquam magna. Praesent dignissim, tortor sed volutpat mattis, mauris diam pulvinar leo, porta commodo risus est non purus. Mauris in justo vel lorem ullamcorper hendrerit. Nam est metus, viverra a pellentesque vitae, ornare eget odio. Morbi tempor feugiat mattis. Morbi non felis tempor, aliquam justo sed, sagittis nibh. Mauris consequat ex metus. Praesent sodales sit amet nibh a vulputate. Integer commodo, mi vel bibendum sollicitudin, urna lectus accumsan ante, eget faucibus augue ex id neque. Aenean consectetur, orci a pellentesque mattis, tortor tellus fringilla elit, non ullamcorper risus nunc feugiat risus. Fusce sit amet nisi dapibus turpis commodo placerat. In tortor ante, vehicula sit amet augue et, imperdiet porta sem.', + user, actions: ( <> ), - localTime: 'Local Time: 7:44 AM', }, } satisfies Meta; @@ -36,18 +40,27 @@ export const Example = Template.bind({}); export const Nickname = Template.bind({}); Nickname.args = { - nickname: 'nicknamenickname', + user: { + ...user, + nickname: 'nicknamenickname', + }, } as any; export const LargeName = Template.bind({}); LargeName.args = { - customStatus: '🛴 currently working on User Card on User Card on User Card on User Card on User Card ', - name: 'guilherme.gazzo.guilherme.gazzo.guilherme.gazzo.guilherme.gazzo.guilherme.gazzo.guilherme.gazzo.guilherme.gazzo.guilherme.gazzo.', + user: { + ...user, + customStatus: '🛴 currently working on User Card on User Card on User Card on User Card on User Card ', + name: 'guilherme.gazzo.guilherme.gazzo.guilherme.gazzo.guilherme.gazzo.guilherme.gazzo.guilherme.gazzo.guilherme.gazzo.guilherme.gazzo.', + }, } as any; export const NoRoles = Template.bind({}); NoRoles.args = { - roles: undefined, + user: { + ...user, + roles: undefined, + }, } as any; export const NoActions = Template.bind({}); @@ -57,25 +70,37 @@ NoActions.args = { export const NoLocalTime = Template.bind({}); NoLocalTime.args = { - localTime: undefined, + user: { + ...user, + localTime: undefined, + }, } as any; export const NoBio = Template.bind({}); NoBio.args = { - bio: undefined, + user: { + ...user, + bio: undefined, + }, } as any; export const NoBioAndNoLocalTime = Template.bind({}); NoBioAndNoLocalTime.args = { - bio: undefined, - localTime: undefined, + user: { + ...user, + bio: undefined, + localTime: undefined, + }, } as any; export const NoBioNoLocalTimeNoRoles = Template.bind({}); NoBioNoLocalTimeNoRoles.args = { - bio: undefined, - localTime: undefined, - roles: undefined, + user: { + ...user, + bio: undefined, + localTime: undefined, + roles: undefined, + }, } as any; export const Loading = () => ; diff --git a/apps/meteor/client/components/UserCard/UserCard.tsx b/apps/meteor/client/components/UserCard/UserCard.tsx index 98e1cce2ab78..9ac99a6b4cbd 100644 --- a/apps/meteor/client/components/UserCard/UserCard.tsx +++ b/apps/meteor/client/components/UserCard/UserCard.tsx @@ -23,33 +23,27 @@ const clampStyle = css` `; type UserCardProps = { - onOpenUserInfo?: () => void; - name?: string; - username?: string; - etag?: string; - customStatus?: ReactNode; - roles?: ReactNode; - bio?: ReactNode; - status?: ReactNode; + user?: { + nickname?: string; + name?: string; + username?: string; + etag?: string; + customStatus?: ReactNode; + roles?: ReactNode; + bio?: ReactNode; + status?: ReactNode; + localTime?: ReactNode; + }; actions?: ReactNode; - localTime?: ReactNode; + onOpenUserInfo?: () => void; onClose?: () => void; - nickname?: string; } & ComponentProps; const UserCard = ({ - onOpenUserInfo, - name, - username, - etag, - customStatus, - roles, - bio, - status = , + user: { name, username, etag, customStatus, roles, bio, status = , localTime, nickname } = {}, actions, - localTime, + onOpenUserInfo, onClose, - nickname, ...props }: UserCardProps) => { const { t } = useTranslation(); diff --git a/apps/meteor/client/views/oauth/components/CurrentUserDisplay.tsx b/apps/meteor/client/views/oauth/components/CurrentUserDisplay.tsx index 59e1584d5ae2..7eaba997727b 100644 --- a/apps/meteor/client/views/oauth/components/CurrentUserDisplay.tsx +++ b/apps/meteor/client/views/oauth/components/CurrentUserDisplay.tsx @@ -2,7 +2,7 @@ import type { IUser } from '@rocket.chat/core-typings'; import { css } from '@rocket.chat/css-in-js'; import { UserStatus } from '@rocket.chat/ui-client'; import { useRolesDescription, useSetting } from '@rocket.chat/ui-contexts'; -import React from 'react'; +import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import LocalTime from '../../../components/LocalTime'; @@ -26,29 +26,33 @@ const CurrentUserDisplay = ({ user }: CurrentUserDisplayProps) => { const getRoles = useRolesDescription(); const { t } = useTranslation(); + const { username, avatarETag, name, statusText, nickname, roles, utcOffset, bio } = user; + + const data = useMemo( + () => ({ + username, + etag: avatarETag, + name: showRealNames ? name : username, + nickname, + status: , + customStatus: statusText ?? <>, + roles: roles && getRoles(roles).map((role, index) => {role}), + localTime: utcOffset && Number.isInteger(utcOffset) && , + bio: bio ? ( + + {typeof bio === 'string' ? : bio} + + ) : ( + <> + ), + }), + [avatarETag, bio, getRoles, name, nickname, roles, showRealNames, statusText, username, utcOffset], + ); return ( <>

{t('core.You_are_logged_in_as')}

- } - customStatus={user.statusText ?? <>} - roles={user.roles && getRoles(user.roles).map((role, index) => {role})} - localTime={user.utcOffset && Number.isInteger(user.utcOffset) && } - bio={ - user.bio ? ( - - {typeof user.bio === 'string' ? : user.bio} - - ) : ( - <> - ) - } - /> + ); }; diff --git a/apps/meteor/client/views/room/UserCard/UserCardWithData.tsx b/apps/meteor/client/views/room/UserCard/UserCardWithData.tsx index 70826fd24e40..4c3312770435 100644 --- a/apps/meteor/client/views/room/UserCard/UserCardWithData.tsx +++ b/apps/meteor/client/views/room/UserCard/UserCardWithData.tsx @@ -110,7 +110,7 @@ const UserCardWithData = ({ username, rid, onOpenUserInfo, onClose }: UserCardWi return ; } - return ; + return ; }; export default UserCardWithData; From 18cea50a5b1f94f8133ef0acf64a12dd23950737 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Fri, 6 Dec 2024 10:14:08 -0600 Subject: [PATCH 3/8] ci: Fix `apps-engine` version showing as undefined (#34108) --- packages/release-action/src/getMetadata.ts | 11 +++-------- packages/release-action/src/utils.ts | 2 +- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/release-action/src/getMetadata.ts b/packages/release-action/src/getMetadata.ts index 3c677b8e7774..5ca93ed5e8c5 100644 --- a/packages/release-action/src/getMetadata.ts +++ b/packages/release-action/src/getMetadata.ts @@ -1,8 +1,6 @@ import { readFile } from 'fs/promises'; import path from 'path'; -import { getExecOutput } from '@actions/exec'; - import { readPackageJson } from './utils'; export async function getMongoVersion(cwd: string) { @@ -27,14 +25,11 @@ export async function getNodeNpmVersions(cwd: string): Promise<{ node: string; y return packageJson.engines; } -export async function getAppsEngineVersion() { +export async function getAppsEngineVersion(cwd: string) { try { - const result = await getExecOutput('yarn why @rocket.chat/apps-engine --json'); + const result = await readPackageJson(path.join(cwd, 'packages/apps-engine')); - const match = result.stdout.match(/"@rocket\.chat\/meteor@workspace:apps\/meteor".*"@rocket\.chat\/apps\-engine@[^#]+#npm:([^"]+)"/); - if (match) { - return match[1]; - } + return result.version ?? 'Not Available'; } catch (e) { console.error(e); } diff --git a/packages/release-action/src/utils.ts b/packages/release-action/src/utils.ts index 608379fb7c37..ff7dc06318e1 100644 --- a/packages/release-action/src/utils.ts +++ b/packages/release-action/src/utils.ts @@ -109,7 +109,7 @@ Bump ${pkgName} version. export async function getEngineVersionsMd(cwd: string) { const { node } = await getNodeNpmVersions(cwd); - const appsEngine = await getAppsEngineVersion(); + const appsEngine = await getAppsEngineVersion(cwd); const mongo = await getMongoVersion(cwd); return `### Engine versions From 072a74947014e2d2f8d913cac632bf056dfd3550 Mon Sep 17 00:00:00 2001 From: Aleksander Nicacio da Silva Date: Fri, 6 Dec 2024 14:10:01 -0300 Subject: [PATCH 4/8] fix: Omnichannel queue starting multiple times due to race condition (#34062) Co-authored-by: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com> --- .changeset/green-shirts-fold.md | 5 + .../server/services/omnichannel/queue.ts | 43 ++++++++- .../server/services/omnichannel/service.ts | 6 +- packages/core-services/src/index.ts | 1 + .../core-services/src/lib/ServiceStarter.ts | 68 ++++++++++++++ .../tests/ServiceStarter.test.ts | 91 +++++++++++++++++++ 6 files changed, 206 insertions(+), 8 deletions(-) create mode 100644 .changeset/green-shirts-fold.md create mode 100644 packages/core-services/src/lib/ServiceStarter.ts create mode 100644 packages/core-services/tests/ServiceStarter.test.ts diff --git a/.changeset/green-shirts-fold.md b/.changeset/green-shirts-fold.md new file mode 100644 index 000000000000..c4dc5cfcf3ad --- /dev/null +++ b/.changeset/green-shirts-fold.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes condition causing Omnichannel queue to start more than once. diff --git a/apps/meteor/server/services/omnichannel/queue.ts b/apps/meteor/server/services/omnichannel/queue.ts index 8db6eedd386b..29d48b9f1f6b 100644 --- a/apps/meteor/server/services/omnichannel/queue.ts +++ b/apps/meteor/server/services/omnichannel/queue.ts @@ -1,3 +1,4 @@ +import { ServiceStarter } from '@rocket.chat/core-services'; import { type InquiryWithAgentInfo, type IOmnichannelQueue } from '@rocket.chat/core-typings'; import { License } from '@rocket.chat/license'; import { LivechatInquiry, LivechatRooms } from '@rocket.chat/models'; @@ -11,6 +12,17 @@ import { settings } from '../../../app/settings/server'; const DEFAULT_RACE_TIMEOUT = 5000; export class OmnichannelQueue implements IOmnichannelQueue { + private serviceStarter: ServiceStarter; + + private timeoutHandler: ReturnType | null = null; + + constructor() { + this.serviceStarter = new ServiceStarter( + () => this._start(), + () => this._stop(), + ); + } + private running = false; private queues: (string | undefined)[] = []; @@ -24,7 +36,7 @@ export class OmnichannelQueue implements IOmnichannelQueue { return this.running; } - async start() { + private async _start() { if (this.running) { return; } @@ -37,7 +49,7 @@ export class OmnichannelQueue implements IOmnichannelQueue { return this.execute(); } - async stop() { + private async _stop() { if (!this.running) { return; } @@ -45,9 +57,23 @@ export class OmnichannelQueue implements IOmnichannelQueue { await LivechatInquiry.unlockAll(); this.running = false; + + if (this.timeoutHandler !== null) { + clearTimeout(this.timeoutHandler); + this.timeoutHandler = null; + } + queueLogger.info('Service stopped'); } + async start() { + return this.serviceStarter.start(); + } + + async stop() { + return this.serviceStarter.stop(); + } + private async getActiveQueues() { // undefined = public queue(without department) return ([undefined] as typeof this.queues).concat(await LivechatInquiry.getDistinctQueuedDepartments({})); @@ -118,10 +144,21 @@ export class OmnichannelQueue implements IOmnichannelQueue { err: e, }); } finally { - setTimeout(this.execute.bind(this), this.delay()); + this.scheduleExecution(); } } + private scheduleExecution(): void { + if (this.timeoutHandler !== null) { + return; + } + + this.timeoutHandler = setTimeout(() => { + this.timeoutHandler = null; + return this.execute(); + }, this.delay()); + } + async shouldStart() { if (!settings.get('Livechat_enabled')) { void this.stop(); diff --git a/apps/meteor/server/services/omnichannel/service.ts b/apps/meteor/server/services/omnichannel/service.ts index ccfe2026b2ba..e5b21f4aae97 100644 --- a/apps/meteor/server/services/omnichannel/service.ts +++ b/apps/meteor/server/services/omnichannel/service.ts @@ -33,11 +33,7 @@ export class OmnichannelService extends ServiceClassInternal implements IOmnicha } async started() { - settings.watch('Livechat_enabled', (enabled) => { - void (enabled && RoutingManager.isMethodSet() ? this.queueWorker.shouldStart() : this.queueWorker.stop()); - }); - - settings.watch('Livechat_Routing_Method', async () => { + settings.watchMultiple(['Livechat_enabled', 'Livechat_Routing_Method'], () => { this.queueWorker.shouldStart(); }); diff --git a/packages/core-services/src/index.ts b/packages/core-services/src/index.ts index a0b3f65ded0c..cae8d7c77d64 100644 --- a/packages/core-services/src/index.ts +++ b/packages/core-services/src/index.ts @@ -78,6 +78,7 @@ export { } from './types/IOmnichannelAnalyticsService'; export { getConnection, getTrashCollection } from './lib/mongo'; +export { ServiceStarter } from './lib/ServiceStarter'; export { AutoUpdateRecord, diff --git a/packages/core-services/src/lib/ServiceStarter.ts b/packages/core-services/src/lib/ServiceStarter.ts new file mode 100644 index 000000000000..9c38ea6b07ec --- /dev/null +++ b/packages/core-services/src/lib/ServiceStarter.ts @@ -0,0 +1,68 @@ +// This class is used to manage calls to a service's .start and .stop functions +// Specifically for cases where the start function has different conditions that may cause the service to actually start or not, +// or when the start process can take a while to complete +// Using this class, you ensure that calls to .start and .stop will be chained, so you avoid race conditions +// At the same time, it prevents those functions from running more times than necessary if there are several calls to them (for example when loading setting values) +export class ServiceStarter { + private lock = Promise.resolve(); + + private currentCall?: 'start' | 'stop'; + + private nextCall?: 'start' | 'stop'; + + private starterFn: () => Promise; + + private stopperFn?: () => Promise; + + constructor(starterFn: () => Promise, stopperFn?: () => Promise) { + this.starterFn = starterFn; + this.stopperFn = stopperFn; + } + + private async checkStatus(): Promise { + if (this.nextCall === 'start') { + return this.doCall('start'); + } + + if (this.nextCall === 'stop') { + return this.doCall('stop'); + } + } + + private async doCall(call: 'start' | 'stop'): Promise { + this.nextCall = undefined; + this.currentCall = call; + try { + if (call === 'start') { + await this.starterFn(); + } else if (this.stopperFn) { + await this.stopperFn(); + } + } finally { + this.currentCall = undefined; + await this.checkStatus(); + } + } + + private async call(call: 'start' | 'stop'): Promise { + // If something is already chained to run after the current call, it's okay to replace it with the new call + this.nextCall = call; + if (this.currentCall) { + return this.lock; + } + this.lock = this.checkStatus(); + return this.lock; + } + + async start(): Promise { + return this.call('start'); + } + + async stop(): Promise { + return this.call('stop'); + } + + async wait(): Promise { + return this.lock; + } +} diff --git a/packages/core-services/tests/ServiceStarter.test.ts b/packages/core-services/tests/ServiceStarter.test.ts new file mode 100644 index 000000000000..2c1a20da6115 --- /dev/null +++ b/packages/core-services/tests/ServiceStarter.test.ts @@ -0,0 +1,91 @@ +import { ServiceStarter } from '../src/lib/ServiceStarter'; + +const wait = (time: number) => { + return new Promise((resolve) => { + setTimeout(() => resolve(undefined), time); + }); +}; + +describe('ServiceStarter', () => { + it('should call the starterFn and stopperFn when calling .start and .stop', async () => { + const start = jest.fn(); + const stop = jest.fn(); + + const instance = new ServiceStarter(start, stop); + + expect(start).not.toHaveBeenCalled(); + expect(stop).not.toHaveBeenCalled(); + + await instance.start(); + + expect(start).toHaveBeenCalled(); + expect(stop).not.toHaveBeenCalled(); + + start.mockReset(); + + await instance.stop(); + + expect(start).not.toHaveBeenCalled(); + expect(stop).toHaveBeenCalled(); + }); + + it('should only call .start for the second time after the initial call has finished running', async () => { + let running = false; + const start = jest.fn(async () => { + expect(running).toBe(false); + + running = true; + await wait(100); + running = false; + }); + const stop = jest.fn(); + + const instance = new ServiceStarter(start, stop); + + void instance.start(); + void instance.start(); + + await instance.wait(); + + expect(start).toHaveBeenCalledTimes(2); + expect(stop).not.toHaveBeenCalled(); + }); + + it('should chain up to two calls to .start', async () => { + const start = jest.fn(async () => { + await wait(100); + }); + const stop = jest.fn(); + + const instance = new ServiceStarter(start, stop); + + void instance.start(); + void instance.start(); + void instance.start(); + void instance.start(); + + await instance.wait(); + + expect(start).toHaveBeenCalledTimes(2); + expect(stop).not.toHaveBeenCalled(); + }); + + it('should skip the chained calls to .start if .stop is called', async () => { + const start = jest.fn(async () => { + await wait(100); + }); + const stop = jest.fn(); + + const instance = new ServiceStarter(start, stop); + + void instance.start(); + void instance.start(); + void instance.start(); + void instance.stop(); + + await instance.wait(); + + expect(start).toHaveBeenCalledTimes(1); + expect(stop).toHaveBeenCalledTimes(1); + }); +}); From 211c10dfa435c371cbe9f31f4aad945de0befb2d Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Fri, 6 Dec 2024 13:53:33 -0600 Subject: [PATCH 5/8] fix: `im.counters` returns `null` for unread msgs for user who never opened the DM (#34109) --- .changeset/proud-cups-share.md | 5 + apps/meteor/app/api/server/v1/im.ts | 4 +- .../tests/end-to-end/api/direct-message.ts | 131 +++++++++++++++--- 3 files changed, 118 insertions(+), 22 deletions(-) create mode 100644 .changeset/proud-cups-share.md diff --git a/.changeset/proud-cups-share.md b/.changeset/proud-cups-share.md new file mode 100644 index 000000000000..eb51d15a9382 --- /dev/null +++ b/.changeset/proud-cups-share.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes `im.counters` endpoint returning `null` on `unread` messages property for users that have never opened the queried DM diff --git a/apps/meteor/app/api/server/v1/im.ts b/apps/meteor/app/api/server/v1/im.ts index fa274ef69467..d74d3decfbab 100644 --- a/apps/meteor/app/api/server/v1/im.ts +++ b/apps/meteor/app/api/server/v1/im.ts @@ -195,9 +195,9 @@ API.v1.addRoute( lm = room?.lm ? new Date(room.lm).toISOString() : new Date(room._updatedAt).toISOString(); // lm is the last message timestamp - if (subscription?.open) { + if (subscription) { + unreads = subscription.unread ?? null; if (subscription.ls && room.msgs) { - unreads = subscription.unread; unreadsFrom = new Date(subscription.ls).toISOString(); // last read timestamp } userMentions = subscription.userMentions; diff --git a/apps/meteor/tests/end-to-end/api/direct-message.ts b/apps/meteor/tests/end-to-end/api/direct-message.ts index 3146d351798b..9a6155fd40fe 100644 --- a/apps/meteor/tests/end-to-end/api/direct-message.ts +++ b/apps/meteor/tests/end-to-end/api/direct-message.ts @@ -343,26 +343,117 @@ describe('[Direct Messages]', () => { .end(done); }); - it('/im.counters', (done) => { - void request - .get(api('im.counters')) - .set(credentials) - .query({ - roomId: directMessage._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - expect(res.body).to.have.property('joined', true); - expect(res.body).to.have.property('members'); - expect(res.body).to.have.property('unreads'); - expect(res.body).to.have.property('unreadsFrom'); - expect(res.body).to.have.property('msgs'); - expect(res.body).to.have.property('latest'); - expect(res.body).to.have.property('userMentions'); - }) - .end(done); + describe('/im.counters', () => { + it('should require auth', async () => { + await request + .get(api('im.counters')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + it('should require a roomId', async () => { + await request + .get(api('im.counters')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + it('should work with all params right', (done) => { + void request + .get(api('im.counters')) + .set(credentials) + .query({ + roomId: directMessage._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('joined', true); + expect(res.body).to.have.property('members'); + expect(res.body).to.have.property('unreads'); + expect(res.body).to.have.property('unreadsFrom'); + expect(res.body).to.have.property('msgs'); + expect(res.body).to.have.property('latest'); + expect(res.body).to.have.property('userMentions'); + }) + .end(done); + }); + + describe('with valid room id', () => { + let testDM: IRoom & { rid: IRoom['_id'] }; + let user2: TestUser; + let userCreds: Credentials; + + before(async () => { + user2 = await createUser(); + userCreds = await login(user2.username, password); + await request + .post(api('im.create')) + .set(credentials) + .send({ + username: user2.username, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + testDM = res.body.room; + }); + + await request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + text: 'Sample message', + rid: testDM._id, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + after(async () => { + await request + .post(api('im.delete')) + .set(credentials) + .send({ + roomId: testDM._id, + }) + .expect(200); + + await deleteUser(user2); + }); + + it('should properly return counters before opening the dm', async () => { + await request + .get(api('im.counters')) + .set(userCreds) + .query({ + roomId: testDM._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('joined', true); + expect(res.body).to.have.property('members').and.to.be.a('number').and.to.be.eq(2); + expect(res.body).to.have.property('unreads').and.to.be.a('number').and.to.be.eq(1); + expect(res.body).to.have.property('unreadsFrom'); + expect(res.body).to.have.property('msgs').and.to.be.a('number').and.to.be.eq(1); + expect(res.body).to.have.property('latest'); + expect(res.body).to.have.property('userMentions').and.to.be.a('number').and.to.be.eq(0); + }); + }); + }); }); describe('[/im.files]', async () => { From d569f26fa4567adf1bb148c83b1a459596834694 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Fri, 6 Dec 2024 15:06:53 -0600 Subject: [PATCH 6/8] fix: Allow any user in e2ee room to create and propagate room keys (#34038) --- .changeset/chilly-pants-hunt.md | 5 +++++ apps/meteor/app/e2e/client/rocketchat.e2e.room.ts | 12 +----------- 2 files changed, 6 insertions(+), 11 deletions(-) create mode 100644 .changeset/chilly-pants-hunt.md diff --git a/.changeset/chilly-pants-hunt.md b/.changeset/chilly-pants-hunt.md new file mode 100644 index 000000000000..0127ae7e174f --- /dev/null +++ b/.changeset/chilly-pants-hunt.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Removes a validation that allowed only the room creator to propagate E2EE room keys. This was causing issues when the rooms were created via apps or some other integration, as the creator may not be online or able to create E2EE keys diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.room.ts b/apps/meteor/app/e2e/client/rocketchat.e2e.room.ts index dc7efb60dc14..f9913831533b 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.room.ts +++ b/apps/meteor/app/e2e/client/rocketchat.e2e.room.ts @@ -326,8 +326,7 @@ export class E2ERoom extends Emitter { try { const room = Rooms.findOne({ _id: this.roomId })!; - // Only room creator can set keys for room - if (!room.e2eKeyId && this.userShouldCreateKeys(room)) { + if (!room.e2eKeyId) { this.setState(E2ERoomState.CREATING_KEYS); await this.createGroupKey(); this.setState(E2ERoomState.READY); @@ -343,15 +342,6 @@ export class E2ERoom extends Emitter { } } - userShouldCreateKeys(room: any) { - // On DMs, we'll allow any user to set the keys - if (room.t === 'd') { - return true; - } - - return room.u._id === this.userId; - } - isSupportedRoomType(type: any) { return roomCoordinator.getRoomDirectives(type).allowRoomSettingChange({}, RoomSettingsEnum.E2E); } From c2cf2d773adb161f331bd3736a1a94e5b21dd035 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Jaeger=20Foresti?= <60678893+juliajforesti@users.noreply.github.com> Date: Mon, 9 Dec 2024 11:05:40 -0300 Subject: [PATCH 7/8] chore: add announcement and topic classNames (#34140) --- .../RoomAnnouncement/AnnouncementComponent.tsx | 2 +- apps/meteor/client/views/room/body/RoomTopic.tsx | 2 +- .../src/components/RoomBanner/RoomBanner.tsx | 7 +++++-- .../components/RoomBanner/RoomBannerContent.tsx | 14 ++++++++++++-- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/apps/meteor/client/views/room/RoomAnnouncement/AnnouncementComponent.tsx b/apps/meteor/client/views/room/RoomAnnouncement/AnnouncementComponent.tsx index f9daf3d14816..e58db0f06162 100644 --- a/apps/meteor/client/views/room/RoomAnnouncement/AnnouncementComponent.tsx +++ b/apps/meteor/client/views/room/RoomAnnouncement/AnnouncementComponent.tsx @@ -7,7 +7,7 @@ type AnnouncementComponenttParams = { }; const AnnouncementComponent: FC = ({ children, onClickOpen }) => ( - + {children} ); diff --git a/apps/meteor/client/views/room/body/RoomTopic.tsx b/apps/meteor/client/views/room/body/RoomTopic.tsx index 0987f39602a8..fee06bd087d6 100644 --- a/apps/meteor/client/views/room/body/RoomTopic.tsx +++ b/apps/meteor/client/views/room/body/RoomTopic.tsx @@ -51,7 +51,7 @@ export const RoomTopic = ({ room, user }: RoomTopicProps) => { if (!topic && !roomLeader) return null; return ( - + {roomLeader && !topic && canEdit ? ( diff --git a/packages/ui-client/src/components/RoomBanner/RoomBanner.tsx b/packages/ui-client/src/components/RoomBanner/RoomBanner.tsx index e5ab04580314..8b9bda9e4020 100644 --- a/packages/ui-client/src/components/RoomBanner/RoomBanner.tsx +++ b/packages/ui-client/src/components/RoomBanner/RoomBanner.tsx @@ -10,6 +10,10 @@ const clickable = css` } `; +const style = css` + background-color: ${Palette.surface['surface-room']}; +`; + export const RoomBanner = ({ onClick, className, ...props }: ComponentProps) => { const { isMobile } = useLayout(); @@ -25,8 +29,7 @@ export const RoomBanner = ({ onClick, className, ...props }: ComponentProps, 'is'>) => ( - + ); From ed692691cde3fdcf483f0b8cb065adfcee4e58a2 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 9 Dec 2024 12:45:36 -0600 Subject: [PATCH 8/8] fix: Ignore mention-like text inside Markdown links (#34127) --- .changeset/six-snails-study.md | 5 ++ .../meteor/app/mentions/lib/MentionsParser.ts | 10 +++- .../tests/unit/app/mentions/server.tests.js | 60 +++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 .changeset/six-snails-study.md diff --git a/.changeset/six-snails-study.md b/.changeset/six-snails-study.md new file mode 100644 index 000000000000..331bfacbbec1 --- /dev/null +++ b/.changeset/six-snails-study.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes a behavior of the mentions parser that identified mentions inside markdown links text. Now, these components will be removed from the text before trying to parse mentions. diff --git a/apps/meteor/app/mentions/lib/MentionsParser.ts b/apps/meteor/app/mentions/lib/MentionsParser.ts index c4180eecf845..7a600c7d5636 100644 --- a/apps/meteor/app/mentions/lib/MentionsParser.ts +++ b/apps/meteor/app/mentions/lib/MentionsParser.ts @@ -119,11 +119,17 @@ export class MentionsParser { return this.roomTemplate({ prefix, reference, channel, mention }); }); - getUserMentions(str: string) { + getUserMentions(msg: string) { + // First remove the text inside md links + const str = msg.replace(/\[[^\]]*\]\([^)]+\)/g, ''); + // Then do the match return (str.match(this.userMentionRegex) || []).map((match) => match.trim()); } - getChannelMentions(str: string) { + getChannelMentions(msg: string) { + // First remove the text inside md links + const str = msg.replace(/\[[^\]]*\]\([^)]+\)/g, ''); + // Then do the match return (str.match(this.channelMentionRegex) || []).map((match) => match.trim()); } diff --git a/apps/meteor/tests/unit/app/mentions/server.tests.js b/apps/meteor/tests/unit/app/mentions/server.tests.js index 335f72af491d..04064de7c3ca 100644 --- a/apps/meteor/tests/unit/app/mentions/server.tests.js +++ b/apps/meteor/tests/unit/app/mentions/server.tests.js @@ -224,4 +224,64 @@ describe('Mention Server', () => { expect(result).to.be.deep.equal(expected); }); }); + + describe('getUserMentions', () => { + describe('for message with only an md link', () => { + const result = []; + [ + '[@rocket.cat](https://rocket.chat)', + '[@rocket.cat](https://rocket.chat) hello', + '[@rocket.cat](https://rocket.chat) hello how are you?', + '[test](https://rocket.chat)', + ].forEach((text) => { + it(`should return "${JSON.stringify(result)}" from "${text}"`, () => { + expect(result).to.be.deep.equal(mention.getUserMentions(text)); + }); + }); + }); + + describe('for message with md link and text', () => { + const result = ['@sauron']; + [ + '@sauron please work on [user@password](https://rocket.chat)', + '@sauron hello [user@password](https://rocket.chat) hello', + '[user@password](https://rocket.chat) hello @sauron', + '@sauron please work on [user@password](https://rocket.chat) hello', + ].forEach((text) => { + it(`should return "${JSON.stringify(result)}" from "${text}"`, () => { + expect(result).to.be.deep.equal(mention.getUserMentions(text)); + }); + }); + }); + }); + + describe('getChannelMentions', () => { + describe('for message with md link', () => { + const result = []; + [ + '[#general](https://rocket.chat)', + '[#general](https://rocket.chat) hello', + '[#general](https://rocket.chat) hello how are you?', + '[test #general #other](https://rocket.chat)', + ].forEach((text) => { + it(`should return "${JSON.stringify(result)}" from "${text}"`, () => { + expect(result).to.be.deep.equal(mention.getChannelMentions(text)); + }); + }); + }); + + describe('for message with md link and text', () => { + const result = ['#somechannel']; + [ + '#somechannel please [user#password](https://rocket.chat)', + '#somechannel hello [user#password](https://rocket.chat) hello', + '[user#password](https://rocket.chat) hello #somechannel', + '#somechannel join [#general on #other](https://rocket.chat)', + ].forEach((text) => { + it(`should return "${JSON.stringify(result)}" from "${text}"`, () => { + expect(result).to.be.deep.equal(mention.getChannelMentions(text)); + }); + }); + }); + }); });