;
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'>) => (
-
+
);