,
+ );
+
+ return Object.entries(groups);
+ }, [settings]);
return (
- <>
-
-
- {t('Settings')}
-
-
- {Object.values(settings).map((field) => (
-
- ))}
-
-
- >
+
+
+ {groupedSettings.map(([section, sectionSettings], index) => (
+
+
+ {sectionSettings.map((field) => (
+
+ ))}
+
+
+ ))}
+
+
);
};
diff --git a/apps/meteor/client/views/marketplace/hooks/useAppTranslation.ts b/apps/meteor/client/views/marketplace/hooks/useAppTranslation.ts
new file mode 100644
index 000000000000..e897a4c19b39
--- /dev/null
+++ b/apps/meteor/client/views/marketplace/hooks/useAppTranslation.ts
@@ -0,0 +1,44 @@
+import { useTranslation } from '@rocket.chat/ui-contexts';
+import { useCallback } from 'react';
+
+import { Utilities } from '../../../../ee/lib/misc/Utilities';
+
+type AppTranslationFunction = {
+ (key: string, ...replaces: unknown[]): string;
+ has: (key: string | undefined) => boolean;
+};
+
+export const useAppTranslation = (appId: string): AppTranslationFunction => {
+ const t = useTranslation();
+
+ const tApp = useCallback(
+ (key: string, ...args: unknown[]) => {
+ if (!key) {
+ return '';
+ }
+ const appKey = Utilities.getI18nKeyForApp(key, appId);
+
+ if (t.has(appKey)) {
+ return t(appKey, ...args);
+ }
+ if (t.has(key)) {
+ return t(key, ...args);
+ }
+ return key;
+ },
+ [t, appId],
+ );
+
+ return Object.assign(tApp, {
+ has: useCallback(
+ (key: string | undefined) => {
+ if (!key) {
+ return false;
+ }
+
+ return t.has(Utilities.getI18nKeyForApp(key, appId)) || t.has(key);
+ },
+ [t, appId],
+ ),
+ });
+};
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/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/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/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'>) => (
-
+
);