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/.changeset/four-cows-sin.md b/.changeset/four-cows-sin.md new file mode 100644 index 000000000000..27f3cd14c0cd --- /dev/null +++ b/.changeset/four-cows-sin.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes an issue where room members menu doesn't display properly without enough space diff --git a/.changeset/green-queens-end.md b/.changeset/green-queens-end.md new file mode 100644 index 000000000000..d6cf413bfcf6 --- /dev/null +++ b/.changeset/green-queens-end.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes an issue with Federation startup where the bridge would intermittently fail to start causing error being shown "Matrix Bridge isn't running yet". 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/.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/.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/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/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); } 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/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/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/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; 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/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembersActions.tsx b/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembersActions.tsx index a9ba5dd9dc0c..76185e3b1cbd 100644 --- a/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembersActions.tsx +++ b/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembersActions.tsx @@ -25,7 +25,7 @@ const RoomMembersActions = ({ username, _id, name, rid, freeSwitchExtension, rel if (!menuOptions) { return null; } - return ; + return ; }; export default RoomMembersActions; diff --git a/apps/meteor/ee/server/local-services/federation/service.ts b/apps/meteor/ee/server/local-services/federation/service.ts index 5e813caf928a..0fc952d2b6f2 100644 --- a/apps/meteor/ee/server/local-services/federation/service.ts +++ b/apps/meteor/ee/server/local-services/federation/service.ts @@ -133,7 +133,7 @@ abstract class AbstractBaseFederationServiceEE extends AbstractFederationService await super.cleanUpHandlers(); } - public async created(): Promise { + public async started(): Promise { await super.setupFederation(); await this.startFederation(); } @@ -213,8 +213,8 @@ export class FederationServiceEE extends AbstractBaseFederationServiceEE impleme return federationService; } - async created(): Promise { - return super.created(); + async started(): Promise { + return super.started(); } async stopped(): Promise { 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 443dfe883a4f..3bbb803efccc 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 @@ -160,6 +160,7 @@ export class RocketChatSettingsAdapter { 'sender_localpart': registrationFile.botName, 'namespaces': registrationFile.listenTo, 'de.sorunome.msc2409.push_ephemeral': registrationFile.enableEphemeralEvents, + 'use_appservice_legacy_authorization': true, }), ); } diff --git a/apps/meteor/server/services/federation/service.ts b/apps/meteor/server/services/federation/service.ts index 904e73913a17..346ca1c4a4e7 100644 --- a/apps/meteor/server/services/federation/service.ts +++ b/apps/meteor/server/services/federation/service.ts @@ -414,7 +414,7 @@ abstract class AbstractBaseFederationService extends AbstractFederationService { await super.cleanUpSettingObserver(); } - public async created(): Promise { + public async started(): Promise { await super.setupFederation(); await this.startFederation(); } @@ -447,8 +447,8 @@ export class FederationService extends AbstractBaseFederationService implements return super.stopped(); } - public async created(): Promise { - return super.created(); + public async started(): Promise { + return super.started(); } public async verifyConfiguration(): Promise { 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/apps/meteor/tests/e2e/feature-preview.spec.ts b/apps/meteor/tests/e2e/feature-preview.spec.ts index 428440a5c523..cc48b4271777 100644 --- a/apps/meteor/tests/e2e/feature-preview.spec.ts +++ b/apps/meteor/tests/e2e/feature-preview.spec.ts @@ -1,6 +1,8 @@ +import { faker } from '@faker-js/faker'; + import { Users } from './fixtures/userStates'; import { AccountProfile, HomeChannel } from './page-objects'; -import { createTargetChannel, setSettingValueById } from './utils'; +import { createTargetChannel, createTargetTeam, deleteChannel, deleteTeam, setSettingValueById } from './utils'; import { setUserPreferences } from './utils/setUserPreferences'; import { test, expect } from './utils/test'; @@ -10,15 +12,17 @@ test.describe.serial('feature preview', () => { let poHomeChannel: HomeChannel; let poAccountProfile: AccountProfile; let targetChannel: string; + let sidepanelTeam: string; + const targetChannelNameInTeam = `channel-from-team-${faker.number.int()}`; test.beforeAll(async ({ api }) => { await setSettingValueById(api, 'Accounts_AllowFeaturePreview', true); - targetChannel = await createTargetChannel(api); + targetChannel = await createTargetChannel(api, { members: ['user1'] }); }); test.afterAll(async ({ api }) => { await setSettingValueById(api, 'Accounts_AllowFeaturePreview', false); - await api.post('/channels.delete', { roomName: targetChannel }); + await deleteChannel(api, targetChannel); }); test.beforeEach(async ({ page }) => { @@ -155,8 +159,162 @@ test.describe.serial('feature preview', () => { const collapser = poHomeChannel.sidebar.getCollapseGroupByName('Channels'); await collapser.click(); - await expect(poHomeChannel.sidebar.getItemUnreadBadge(collapser)).toBeVisible(); }); }); + + test.describe('Sidepanel', () => { + test.beforeEach(async ({ api }) => { + sidepanelTeam = await createTargetTeam(api, { sidepanel: { items: ['channels', 'discussions'] } }); + + await setUserPreferences(api, { + sidebarViewMode: 'Medium', + featuresPreview: [ + { + name: 'newNavigation', + value: true, + }, + { + name: 'sidepanelNavigation', + value: true, + }, + ], + }); + }); + + test.afterEach(async ({ api }) => { + await deleteTeam(api, sidepanelTeam); + + await setUserPreferences(api, { + sidebarViewMode: 'Medium', + featuresPreview: [ + { + name: 'newNavigation', + value: false, + }, + { + name: 'sidepanelNavigation', + value: false, + }, + ], + }); + }); + test('should be able to toggle "Sidepanel" feature', async ({ page }) => { + await page.goto('/account/feature-preview'); + + await poAccountProfile.getAccordionItemByName('Navigation').click(); + const sidepanelCheckbox = poAccountProfile.getCheckboxByLabelText('Secondary navigation for teams'); + await expect(sidepanelCheckbox).toBeChecked(); + await sidepanelCheckbox.click(); + await expect(sidepanelCheckbox).not.toBeChecked(); + + await poAccountProfile.btnSaveChanges.click(); + + await expect(poAccountProfile.btnSaveChanges).not.toBeVisible(); + await expect(sidepanelCheckbox).not.toBeChecked(); + }); + + test('should display sidepanel on a team and hide it on edit', async ({ page }) => { + await page.goto(`/group/${sidepanelTeam}`); + await poHomeChannel.content.waitForChannel(); + await expect(poHomeChannel.sidepanel.sidepanelList).toBeVisible(); + + await poHomeChannel.tabs.btnRoomInfo.click(); + await poHomeChannel.tabs.room.btnEdit.click(); + await poHomeChannel.tabs.room.advancedSettingsAccordion.click(); + await poHomeChannel.tabs.room.toggleSidepanelItems(); + await poHomeChannel.tabs.room.btnSave.click(); + + await expect(poHomeChannel.sidepanel.sidepanelList).not.toBeVisible(); + }); + + test('should display new channel from team on the sidepanel', async ({ page, api }) => { + await page.goto(`/group/${sidepanelTeam}`); + await poHomeChannel.content.waitForChannel(); + + await poHomeChannel.tabs.btnChannels.click(); + await poHomeChannel.tabs.channels.btnCreateNew.click(); + await poHomeChannel.sidenav.inputChannelName.fill(targetChannelNameInTeam); + await poHomeChannel.sidenav.checkboxPrivateChannel.click(); + await poHomeChannel.sidenav.btnCreate.click(); + + await expect(poHomeChannel.sidepanel.sidepanelList).toBeVisible(); + await expect(poHomeChannel.sidepanel.getItemByName(targetChannelNameInTeam)).toBeVisible(); + + await deleteChannel(api, targetChannelNameInTeam); + }); + + test('should display sidepanel item with the same display preference as the sidebar', async ({ page }) => { + await page.goto('/home'); + const message = 'hello world'; + + await poHomeChannel.sidebar.setDisplayMode('Extended'); + await poHomeChannel.sidebar.openChat(sidepanelTeam); + await poHomeChannel.content.sendMessage(message); + await expect(poHomeChannel.sidepanel.getExtendedItem(sidepanelTeam, message)).toBeVisible(); + }); + + // remove .fail after fix + test.fail('should escape special characters on item subtitle', async ({ page }) => { + await page.goto('/home'); + const message = 'hello > world'; + const parsedWrong = 'hello > world'; + + await poHomeChannel.sidebar.setDisplayMode('Extended'); + await poHomeChannel.sidebar.openChat(sidepanelTeam); + await poHomeChannel.content.sendMessage(message); + + await expect(poHomeChannel.sidepanel.getExtendedItem(sidepanelTeam, message)).toBeVisible(); + await expect(poHomeChannel.sidepanel.getExtendedItem(sidepanelTeam, message)).not.toHaveText(parsedWrong); + }); + + test('should show channel in sidepanel after adding existing one', async ({ page }) => { + await page.goto(`/group/${sidepanelTeam}`); + + await poHomeChannel.tabs.btnChannels.click(); + await poHomeChannel.tabs.channels.btnAddExisting.click(); + await poHomeChannel.tabs.channels.inputChannels.fill(targetChannel); + await page.getByRole('listbox').getByRole('option', { name: targetChannel }).click(); + await poHomeChannel.tabs.channels.btnAdd.click(); + await poHomeChannel.content.waitForChannel(); + + await expect(poHomeChannel.sidepanel.getItemByName(targetChannel)).toBeVisible(); + }); + + // remove .fail after fix + test.fail('should sort by last message even if unread message is inside thread', async ({ page, browser }) => { + const user1Page = await browser.newPage({ storageState: Users.user1.state }); + const user1Channel = new HomeChannel(user1Page); + + await page.goto(`/group/${sidepanelTeam}`); + + await poHomeChannel.tabs.btnChannels.click(); + await poHomeChannel.tabs.channels.btnAddExisting.click(); + await poHomeChannel.tabs.channels.inputChannels.fill(targetChannel); + await page.getByRole('listbox').getByRole('option', { name: targetChannel }).click(); + await poHomeChannel.tabs.channels.btnAdd.click(); + + const sidepanelTeamItem = poHomeChannel.sidepanel.getItemByName(sidepanelTeam); + const targetChannelItem = poHomeChannel.sidepanel.getItemByName(targetChannel); + + await targetChannelItem.click(); + expect(page.url()).toContain(`/channel/${targetChannel}`); + await poHomeChannel.content.sendMessage('hello channel'); + await sidepanelTeamItem.focus(); + await sidepanelTeamItem.click(); + expect(page.url()).toContain(`/group/${sidepanelTeam}`); + await poHomeChannel.content.sendMessage('hello team'); + + await user1Page.goto(`/channel/${targetChannel}`); + await user1Channel.content.waitForChannel(); + await user1Channel.content.openReplyInThread(); + await user1Channel.content.toggleAlsoSendThreadToChannel(false); + await user1Channel.content.sendMessageInThread('hello thread'); + + const item = poHomeChannel.sidepanel.getItemByName(targetChannel); + await expect(item.locator('..')).toHaveAttribute('data-item-index', '0'); + + await user1Page.close(); + }); + }); }); diff --git a/apps/meteor/tests/e2e/page-objects/account-profile.ts b/apps/meteor/tests/e2e/page-objects/account-profile.ts index f7f41911e6d3..c1493a077fa4 100644 --- a/apps/meteor/tests/e2e/page-objects/account-profile.ts +++ b/apps/meteor/tests/e2e/page-objects/account-profile.ts @@ -119,4 +119,8 @@ export class AccountProfile { getCheckboxByLabelText(name: string): Locator { return this.page.locator('label', { has: this.page.getByRole('checkbox', { name }) }); } + + get btnSaveChanges(): Locator { + return this.page.getByRole('button', { name: 'Save changes', exact: true }); + } } diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts index 0c77364ed57a..59df066d8163 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts @@ -323,7 +323,10 @@ export class HomeContent { } async toggleAlsoSendThreadToChannel(isChecked: boolean): Promise { - await this.page.getByRole('dialog').locator('[name="alsoSendThreadToChannel"]').setChecked(isChecked); + await this.page + .getByRole('dialog') + .locator('label', { has: this.page.getByRole('checkbox', { name: 'Also send to channel' }) }) + .setChecked(isChecked); } get lastSystemMessageBody(): Locator { @@ -398,7 +401,20 @@ export class HomeContent { async waitForChannel(): Promise { await this.page.locator('role=main').waitFor(); await this.page.locator('role=main >> role=heading[level=1]').waitFor(); + const messageList = this.page.getByRole('main').getByRole('list', { name: 'Message list', exact: true }); + await messageList.waitFor(); - await expect(this.page.locator('role=main >> role=list')).not.toHaveAttribute('aria-busy', 'true'); + await expect(messageList).not.toHaveAttribute('aria-busy', 'true'); + } + + async openReplyInThread(): Promise { + await this.page.locator('[data-qa-type="message"]').last().hover(); + await this.page.locator('[data-qa-type="message"]').last().locator('role=button[name="Reply in thread"]').waitFor(); + await this.page.locator('[data-qa-type="message"]').last().locator('role=button[name="Reply in thread"]').click(); + } + + async sendMessageInThread(text: string): Promise { + await this.page.getByRole('dialog').getByRole('textbox', { name: 'Message' }).fill(text); + await this.page.getByRole('dialog').getByRole('button', { name: 'Send', exact: true }).click(); } } diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-flextab-room.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-flextab-room.ts index f0eb7b726d45..61d5ca5945d5 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-flextab-room.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-flextab-room.ts @@ -122,4 +122,17 @@ export class HomeFlextabRoom { get checkboxIgnoreThreads(): Locator { return this.page.getByRole('dialog').locator('label', { has: this.page.getByRole('checkbox', { name: 'Do not prune Threads' }) }); } + + get checkboxChannels(): Locator { + return this.page.getByRole('dialog').locator('label', { has: this.page.getByRole('checkbox', { name: 'Channels' }) }); + } + + get checkboxDiscussions(): Locator { + return this.page.getByRole('dialog').locator('label', { has: this.page.getByRole('checkbox', { name: 'Discussions' }) }); + } + + async toggleSidepanelItems() { + await this.checkboxChannels.click(); + await this.checkboxDiscussions.click(); + } } diff --git a/apps/meteor/tests/e2e/page-objects/fragments/index.ts b/apps/meteor/tests/e2e/page-objects/fragments/index.ts index fc5ab5e62385..f80290a0d373 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/index.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/index.ts @@ -6,3 +6,4 @@ export * from './omnichannel-sidenav'; export * from './omnichannel-close-chat-modal'; export * from './navbar'; export * from './sidebar'; +export * from './sidepanel'; diff --git a/apps/meteor/tests/e2e/page-objects/fragments/sidebar.ts b/apps/meteor/tests/e2e/page-objects/fragments/sidebar.ts index 5e5488c127f8..a34a1af480a5 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/sidebar.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/sidebar.ts @@ -42,6 +42,12 @@ export class Sidebar { return this.sidebarSearchSection.getByRole('searchbox'); } + async setDisplayMode(mode: 'Extended' | 'Medium' | 'Condensed'): Promise { + await this.sidebarSearchSection.getByRole('button', { name: 'Display', exact: true }).click(); + await this.sidebarSearchSection.getByRole('menuitemcheckbox', { name: mode }).click(); + await this.sidebarSearchSection.click(); + } + async escSearch(): Promise { await this.page.keyboard.press('Escape'); } @@ -49,9 +55,10 @@ export class Sidebar { async waitForChannel(): Promise { await this.page.locator('role=main').waitFor(); await this.page.locator('role=main >> role=heading[level=1]').waitFor(); - await this.page.locator('role=main >> role=list').waitFor(); + const messageList = this.page.getByRole('main').getByRole('list', { name: 'Message list', exact: true }); + await messageList.waitFor(); - await expect(this.page.locator('role=main >> role=list')).not.toHaveAttribute('aria-busy', 'true'); + await expect(messageList).not.toHaveAttribute('aria-busy', 'true'); } async typeSearch(name: string): Promise { diff --git a/apps/meteor/tests/e2e/page-objects/fragments/sidepanel.ts b/apps/meteor/tests/e2e/page-objects/fragments/sidepanel.ts new file mode 100644 index 000000000000..a7c8b036d70c --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/sidepanel.ts @@ -0,0 +1,30 @@ +import type { Locator, Page } from '@playwright/test'; + +export class Sidepanel { + private readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + get sidepanelList(): Locator { + return this.page.getByRole('main').getByRole('list', { name: 'Channels' }); + } + + get firstChannelFromList(): Locator { + return this.sidepanelList.getByRole('listitem').first(); + } + + getItemByName(name: string): Locator { + return this.sidepanelList.getByRole('link').filter({ hasText: name }); + } + + getExtendedItem(name: string, subtitle?: string): Locator { + const regex = new RegExp(`${name}.*${subtitle}`); + return this.sidepanelList.getByRole('link', { name: regex }); + } + + getItemUnreadBadge(item: Locator): Locator { + return item.getByRole('status', { name: 'unread' }); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/home-channel.ts b/apps/meteor/tests/e2e/page-objects/home-channel.ts index 76929b6c2dcf..10e28bb23618 100644 --- a/apps/meteor/tests/e2e/page-objects/home-channel.ts +++ b/apps/meteor/tests/e2e/page-objects/home-channel.ts @@ -1,6 +1,6 @@ import type { Locator, Page } from '@playwright/test'; -import { HomeContent, HomeSidenav, HomeFlextab, Navbar, Sidebar } from './fragments'; +import { HomeContent, HomeSidenav, HomeFlextab, Navbar, Sidebar, Sidepanel } from './fragments'; export class HomeChannel { public readonly page: Page; @@ -11,6 +11,8 @@ export class HomeChannel { readonly sidebar: Sidebar; + readonly sidepanel: Sidepanel; + readonly navbar: Navbar; readonly tabs: HomeFlextab; @@ -20,6 +22,7 @@ export class HomeChannel { this.content = new HomeContent(page); this.sidenav = new HomeSidenav(page); this.sidebar = new Sidebar(page); + this.sidepanel = new Sidepanel(page); this.navbar = new Navbar(page); this.tabs = new HomeFlextab(page); } diff --git a/apps/meteor/tests/e2e/utils/create-target-channel.ts b/apps/meteor/tests/e2e/utils/create-target-channel.ts index 8f7a25aa9718..370f4dc2c8ec 100644 --- a/apps/meteor/tests/e2e/utils/create-target-channel.ts +++ b/apps/meteor/tests/e2e/utils/create-target-channel.ts @@ -1,4 +1,5 @@ import { faker } from '@faker-js/faker'; +import type { IRoom } from '@rocket.chat/core-typings'; import type { ChannelsCreateProps, GroupsCreateProps } from '@rocket.chat/rest-typings'; import type { BaseTest } from './test'; @@ -25,9 +26,12 @@ export async function createTargetPrivateChannel(api: BaseTest['api'], options?: return name; } -export async function createTargetTeam(api: BaseTest['api']): Promise { +export async function createTargetTeam( + api: BaseTest['api'], + options?: { sidepanel?: IRoom['sidepanel'] } & Omit, +): Promise { const name = faker.string.uuid(); - await api.post('/teams.create', { name, type: 1, members: ['user2', 'user1'] }); + await api.post('/teams.create', { name, type: 1, members: ['user2', 'user1'], ...options }); return name; } 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 () => { 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)); + }); + }); + }); + }); }); 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); + }); +}); 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 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'>) => ( - + );