snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import AppRoot from '../views/root/AppRoot';
const createContainer = (): Element => {
const container = document.getElementById('react-root');
if (!container) {
throw new Error('could not find the element #react-root on DOM tree');
}
document.body.insertBefore(container, document.body.firstChild);
return container;
};
const container = createContainer();
const root = createRoot(container);
root.render(
<StrictMode>
<AppRoot />
</StrictMode>,
);

View File

@@ -0,0 +1,86 @@
import type { ReactNode } from 'react';
import { lazy } from 'react';
import { appLayout } from '../lib/appLayout';
import { hasAllPermission } from '../lib/authorization';
import { onToggledFeature } from '../lib/onToggledFeature';
import { router } from '../providers/RouterProvider';
import SettingsProvider from '../providers/SettingsProvider';
import NotAuthorizedPage from '../views/notAuthorized/NotAuthorizedPage';
import MainLayout from '../views/root/MainLayout';
const AuditPage = lazy(() => import('../views/audit/AuditPage'));
const AuditLogPage = lazy(() => import('../views/audit/AuditLogPage'));
const SecurityLogsPage = lazy(() => import('../views/audit/SecurityLogsPage'));
declare module '@rocket.chat/ui-contexts' {
interface IRouterPaths {
'audit-home': {
pathname: '/audit';
pattern: '/audit/:tab?';
};
'audit-log': {
pathname: '/audit-log';
pattern: '/audit-log';
};
'security-logs': {
pathname: '/security-logs';
pattern: '/security-logs';
};
}
}
export type PermissionGuardProps = { children: ReactNode; permission: string };
const PermissionGuard = ({ children, permission }: PermissionGuardProps) => {
const canView = hasAllPermission(permission);
return <>{canView ? children : <NotAuthorizedPage />}</>;
};
let unregisterAuditRoutes: () => void;
onToggledFeature('auditing', {
up: () => {
unregisterAuditRoutes = router.defineRoutes([
{
path: '/audit/:tab?',
id: 'audit-home',
element: appLayout.wrap(
<MainLayout>
<PermissionGuard permission='can-audit'>
<AuditPage />
</PermissionGuard>
</MainLayout>,
),
},
{
path: '/audit-log',
id: 'audit-log',
element: appLayout.wrap(
<MainLayout>
<PermissionGuard permission='can-audit-log'>
<AuditLogPage />
</PermissionGuard>
</MainLayout>,
),
},
{
path: '/security-logs',
id: 'security-logs',
element: appLayout.wrap(
<MainLayout>
<SettingsProvider>
<PermissionGuard permission='can-audit'>
<SecurityLogsPage />
</PermissionGuard>
</SettingsProvider>
</MainLayout>,
),
},
]);
},
down: () => {
unregisterAuditRoutes();
},
});

View File

@@ -0,0 +1,25 @@
import { clientCallbacks } from '@rocket.chat/ui-client';
import { performance } from 'universal-perf-hooks';
import { getConfig } from '../lib/utils/getConfig';
if ([getConfig('debug'), getConfig('timed-callbacks')].includes('true')) {
clientCallbacks.setMetricsTrackers({
trackCallback: ({ hook, id, stack }) => {
const start = performance.now();
return (): void => {
const end = performance.now();
console.log(String(end - start), hook, id, stack?.split('\n')?.[2]?.match(/\(.+\)/)?.[0]);
};
},
trackHook: ({ hook }) => {
const start = performance.now();
return (): void => {
const end = performance.now();
console.log(`${hook}:`, end - start);
};
},
});
}

View File

@@ -0,0 +1,33 @@
import { lazy } from 'react';
import { onToggledFeature } from '../lib/onToggledFeature';
import { registerAccountRoute, registerAccountSidebarItem, unregisterSidebarItem } from '../views/account';
declare module '@rocket.chat/ui-contexts' {
interface IRouterPaths {
'manage-devices': {
pathname: '/account/manage-devices';
pattern: '/account/manage-devices';
};
}
}
const [registerAccountRouter, unregisterAccountRouter] = registerAccountRoute('/manage-devices', {
name: 'manage-devices',
component: lazy(() => import('../views/account/deviceManagement/DeviceManagementAccountPage')),
});
onToggledFeature('device-management', {
up: () => {
registerAccountSidebarItem({
href: '/account/manage-devices',
i18nLabel: 'Manage_Devices',
icon: 'mobile',
});
registerAccountRouter();
},
down: () => {
unregisterSidebarItem('Manage_Devices');
unregisterAccountRouter();
},
});

View File

@@ -0,0 +1,37 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { sdk } from '../lib/SDKClient';
import { onLoggedIn } from '../lib/loggedIn';
import { getUserId } from '../lib/user';
import { upsertThreadMessageInCache } from '../lib/utils/threadMessageUtils';
import { Messages } from '../stores';
onLoggedIn(() => {
// Only event I found triggers this is from ephemeral messages
// Other types of messages come from another stream
return sdk.stream('notify-user', [`${getUserId()}/message`], (msg: IMessage) => {
msg.u = msg.u || { username: 'rocket.cat' };
msg.private = true;
if (msg.tmid) {
upsertThreadMessageInCache(msg, msg.rid, msg.tmid);
}
return Messages.state.store(msg);
}).stop;
});
onLoggedIn(() => {
return sdk.stream('notify-user', [`${getUserId()}/subscriptions-changed`], (_action, sub) => {
Messages.state.update(
(record) => record.rid === sub.rid && ('ignored' in sub && sub.ignored ? !sub.ignored.includes(record.u._id) : 'ignored' in record),
({ ignored: _, ...record }) => record,
);
if ('ignored' in sub && sub.ignored) {
Messages.state.update(
(record) => record.rid === sub.rid && record.t !== 'command' && (sub.ignored?.includes(record.u._id) ?? false),
(record) => ({ ...record, ignored: true }),
);
}
}).stop;
});

View File

@@ -0,0 +1,12 @@
import '../lib/rooms/roomTypes';
import './appRoot';
import './audit';
import './callbacks';
import './deviceManagement';
import './incomingMessages';
import './livechatEnterprise';
import './roles';
import './routes';
import './slashCommands';
import './startup';
import './streamMessage';

View File

@@ -0,0 +1,12 @@
import { onToggledFeature } from '../lib/onToggledFeature';
const loadSideNavItems = () => import('../lib/omnichannel/livechatEnterprise/livechatSideNavItems');
onToggledFeature('livechat-enterprise', {
up: () => {
void loadSideNavItems().then(({ registerLivechatEnterpriseSidebarItems }) => registerLivechatEnterpriseSidebarItems());
},
down: () => {
void loadSideNavItems().then(({ unregisterLivechatEnterpriseSidebarItems }) => unregisterLivechatEnterpriseSidebarItems());
},
});

View File

@@ -0,0 +1,40 @@
import type { IRole } from '@rocket.chat/core-typings';
import { sdk } from '../lib/SDKClient';
import { onLoggedIn } from '../lib/loggedIn';
import { userIdStore } from '../lib/user';
import { Roles } from '../stores';
onLoggedIn(async () => {
const { roles } = await sdk.rest.get('/v1/roles.list');
// if a role is checked before this collection is populated, it will return undefined
Roles.state.replaceAll(roles.map((role) => ({ ...role, _updatedAt: new Date(role._updatedAt) })));
});
type ClientAction = 'inserted' | 'updated' | 'removed' | 'changed';
const events: Record<string, ((role: IRole & { type?: ClientAction }) => void) | undefined> = {
changed: (role) => {
delete role.type;
Roles.state.store(role);
},
removed: (role) => {
Roles.state.delete(role._id);
},
};
const subscribeToRolesStream = () => {
sdk.stream('roles', ['roles'], (role) => {
events[role.type]?.(role);
});
};
if (userIdStore.getState()) {
subscribeToRolesStream();
} else {
const unsubscribe = userIdStore.subscribe((uid) => {
if (!uid) return;
unsubscribe();
subscribeToRolesStream();
});
}

View File

@@ -0,0 +1,273 @@
import { SetupWizardRoute } from '@rocket.chat/ui-client';
import { createElement, lazy, useEffect } from 'react';
import { appLayout } from '../lib/appLayout';
import { router } from '../providers/RouterProvider';
import OAuthTwoFactorAuthenticationRouter from '../views/OAuthTwoFactorAuthentication/OAuthTwoFactorAuthenticationRouter';
import MainLayout from '../views/root/MainLayout';
const IndexRoute = lazy(() => import('../views/root/IndexRoute'));
const HomePage = lazy(() => import('../views/home/HomePage'));
const DirectoryPage = lazy(() => import('../views/directory'));
const OmnichannelDirectoryRouter = lazy(() => import('../views/omnichannel/directory/OmnichannelDirectoryRouter'));
const OmnichannelQueueList = lazy(() => import('../views/omnichannel/queueList'));
const CMSPage = lazy(() => import('@rocket.chat/web-ui-registration').then(({ CMSPage }) => ({ default: CMSPage })));
const SecretURLPage = lazy(() => import('../views/invite/SecretURLPage'));
const InvitePage = lazy(() => import('../views/invite/InvitePage'));
const ConferenceRoute = lazy(() => import('../views/conference/ConferenceRoute'));
const MailerUnsubscriptionPage = lazy(() => import('../views/mailer/MailerUnsubscriptionPage'));
const LoginTokenRoute = lazy(() => import('../views/root/LoginTokenRoute'));
const SAMLLoginRoute = lazy(() => import('../views/root/SAMLLoginRoute'));
const ResetPasswordPage = lazy(() =>
import('@rocket.chat/web-ui-registration').then(({ ResetPasswordPage }) => ({ default: ResetPasswordPage })),
);
const OAuthAuthorizationPage = lazy(() => import('../views/oauth/OAuthAuthorizationPage'));
const OAuthErrorPage = lazy(() => import('../views/oauth/OAuthErrorPage'));
const NotFoundPage = lazy(() => import('../views/notFound/NotFoundPage'));
const CallHistoryPage = lazy(() => import('../views/mediaCallHistory/CallHistoryPage'));
const SearchPage = lazy(() => import('../views/search/SearchPage'));
declare module '@rocket.chat/ui-contexts' {
interface IRouterPaths {
'index': {
pathname: '/';
pattern: '/';
};
'login': {
pathname: '/login';
pattern: '/login';
};
'meet': {
pathname: `/meet/${string}`;
pattern: '/meet/:rid';
};
'2fa': {
pathname: `/2fa/${string}/${string}`;
pattern: '/2fa/:method/:challengeId';
};
'home': {
pathname: '/home';
pattern: '/home';
};
'directory': {
pathname: `/directory${`/${'users' | 'channels' | 'teams' | 'external'}` | ''}`;
pattern: '/directory/:tab?';
};
'omnichannel-directory': {
pathname: `/omnichannel-directory${`/${string}` | ''}${`/${string}` | ''}${`/${string}` | ''}`;
pattern: '/omnichannel-directory/:tab?/:context?/:id?';
};
'livechat-queue': {
pathname: '/livechat-queue';
pattern: '/livechat-queue';
};
'terms-of-service': {
pathname: '/terms-of-service';
pattern: '/terms-of-service';
};
'privacy-policy': {
pathname: '/privacy-policy';
pattern: '/privacy-policy';
};
'legal-notice': {
pathname: '/legal-notice';
pattern: '/legal-notice';
};
'register-secret-url': {
pathname: `/register/${string}`;
pattern: '/register/:hash';
};
'invite': {
pathname: `/invite/${string}`;
pattern: '/invite/:hash';
};
'conference': {
pathname: `/conference/${string}`;
pattern: '/conference/:id';
};
'setup-wizard': {
pathname: `/setup-wizard${`/${string}` | ''}`;
pattern: '/setup-wizard/:step?';
};
'mailer-unsubscribe': {
pathname: `/mailer/unsubscribe/${string}/${string}`;
pattern: '/mailer/unsubscribe/:_id/:createdAt';
};
'tokenLogin': {
pathname: `/login-token/${string}`;
pattern: '/login-token/:token';
};
'resetPassword': {
pathname: `/reset-password/${string}`;
pattern: '/reset-password/:token';
};
'oauth/authorize': {
pathname: `/oauth/authorize`;
pattern: '/oauth/authorize';
};
'oauth/error': {
pathname: `/oauth/error/${string}`;
pattern: '/oauth/error/:error';
};
'saml': {
pathname: `/saml/${string}`;
pattern: '/saml/:token';
};
'call-history': {
pathname: `/call-history${`/details/${string}` | ''}`;
pattern: '/call-history/:tab?/:historyId?';
};
'search': {
pathname: '/search';
pattern: '/search';
};
}
}
router.defineRoutes([
{
path: '/',
id: 'index',
element: appLayout.wrap(<IndexRoute />),
},
{
path: '/login',
id: 'login',
element: createElement(() => {
useEffect(() => {
router.navigate('/home');
}, []);
return null;
}),
},
{
path: '/2fa/:method/:challengeId',
id: '2fa',
element: appLayout.wrap(<OAuthTwoFactorAuthenticationRouter />),
},
{
path: '/home',
id: 'home',
element: appLayout.wrap(
<MainLayout>
<HomePage />
</MainLayout>,
),
},
{
path: '/directory/:tab?',
id: 'directory',
element: appLayout.wrap(
<MainLayout>
<DirectoryPage />
</MainLayout>,
),
},
{
path: '/omnichannel-directory/:tab?/:context?/:id?',
id: 'omnichannel-directory',
element: appLayout.wrap(
<MainLayout>
<OmnichannelDirectoryRouter />
</MainLayout>,
),
},
{
path: '/livechat-queue',
id: 'livechat-queue',
element: appLayout.wrap(
<MainLayout>
<OmnichannelQueueList />
</MainLayout>,
),
},
{
path: '/terms-of-service',
id: 'terms-of-service',
element: appLayout.wrap(<CMSPage page='Layout_Terms_of_Service' />),
},
{
path: '/privacy-policy',
id: 'privacy-policy',
element: appLayout.wrap(<CMSPage page='Layout_Privacy_Policy' />),
},
{
path: '/legal-notice',
id: 'legal-notice',
element: appLayout.wrap(<CMSPage page='Layout_Legal_Notice' />),
},
{
path: '/register/:hash',
id: 'register-secret-url',
element: appLayout.wrap(<SecretURLPage />),
},
{
path: '/invite/:hash',
id: 'invite',
element: appLayout.wrap(<InvitePage />),
},
{
path: '/conference/:id',
id: 'conference',
element: appLayout.wrap(<ConferenceRoute />),
},
{
path: '/setup-wizard/:step?',
id: 'setup-wizard',
element: <SetupWizardRoute />,
},
{
path: '/mailer/unsubscribe/:_id/:createdAt',
id: 'mailer-unsubscribe',
element: appLayout.wrap(<MailerUnsubscriptionPage />),
},
{
path: '/login-token/:token',
id: 'tokenLogin',
element: appLayout.wrap(<LoginTokenRoute />),
},
{
path: '/reset-password/:token',
id: 'resetPassword',
element: appLayout.wrap(<ResetPasswordPage />),
},
{
path: '/oauth/authorize',
id: 'oauth/authorize',
element: appLayout.wrap(<OAuthAuthorizationPage />),
},
{
path: '/oauth/error/:error',
id: 'oauth/error',
element: appLayout.wrap(<OAuthErrorPage />),
},
{
path: '/saml/:token',
id: 'saml',
element: appLayout.wrap(<SAMLLoginRoute />),
},
{
path: '/call-history/:tab?/:historyId?',
id: 'call-history',
element: appLayout.wrap(
<MainLayout>
<CallHistoryPage />
</MainLayout>,
),
},
{
path: '/search',
id: 'search',
element: appLayout.wrap(
<MainLayout>
<SearchPage />
</MainLayout>,
),
},
{
path: '*',
id: 'not-found',
element: <NotFoundPage />,
},
]);

View File

@@ -0,0 +1,11 @@
import { slashCommands } from '../../lib/slashCommand';
slashCommands.add({
command: 'archive',
options: {
description: 'Archive',
params: '#channel',
permission: 'archive-room',
},
providesPreview: false,
});

View File

@@ -0,0 +1,32 @@
import { queryClient } from '../../lib/queryClient';
import { roomsQueryKeys } from '../../lib/queryKeys';
import { slashCommands } from '../../lib/slashCommand';
const invalidateMembers = (err: unknown, _result: unknown, params: { msg: { rid: string } }) => {
if (err) return;
void queryClient.invalidateQueries({ queryKey: roomsQueryKeys.bannedUsers(params.msg.rid) });
void queryClient.invalidateQueries({ queryKey: [...roomsQueryKeys.room(params.msg.rid), 'members'] });
};
slashCommands.add({
command: 'ban',
providesPreview: false,
options: {
description: 'Ban_user_from_room',
params: '@username',
permission: 'ban-user',
},
result: invalidateMembers,
});
slashCommands.add({
command: 'unban',
providesPreview: false,
options: {
description: 'Unban_user_from_room',
params: '@username',
permission: 'ban-user',
},
result: invalidateMembers,
});

View File

@@ -0,0 +1,11 @@
import { slashCommands } from '../../lib/slashCommand';
slashCommands.add({
command: 'create',
options: {
description: 'Create_A_New_Channel',
params: '#channel',
permission: ['create-c', 'create-p'],
},
providesPreview: false,
});

View File

@@ -0,0 +1,29 @@
import { slashCommands } from '../../lib/slashCommand';
const callback = undefined;
const result = undefined;
const providesPreview = false;
const previewer = undefined;
const previewCallback = undefined;
slashCommands.add({
command: 'federation',
callback,
options: {
description: 'Federation_slash_commands',
params: '#command (dm) #user',
},
result,
providesPreview,
previewer,
previewCallback,
});
slashCommands.add({
command: 'xmpp-join',
options: {
description: 'Join xmpp rooms',
params: '#channel',
},
providesPreview: false,
});

View File

@@ -0,0 +1,22 @@
import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings';
import { sdk } from '../../lib/SDKClient';
import { slashCommands } from '../../lib/slashCommand';
/*
* Gimme is a named function that will replace /gimme commands
* @param {Object} message - The message object
*/
async function Gimme({ message, params }: SlashCommandCallbackParams<'gimme'>): Promise<void> {
const msg = message;
await sdk.rest.post('/v1/chat.sendMessage', { message: { ...msg, msg: `༼ つ ◕_◕ ༽つ ${params}` } });
}
slashCommands.add({
command: 'gimme',
callback: Gimme,
options: {
description: 'Slash_Gimme_Description',
params: 'your_message_optional',
clientOnly: true,
},
});

View File

@@ -0,0 +1,9 @@
import { slashCommands } from '../../lib/slashCommand';
slashCommands.add({
command: 'hide',
options: {
description: 'Hide_room',
params: '#room',
},
});

View File

@@ -0,0 +1,19 @@
import './archive';
import './ban';
import './create';
import './federation';
import './gimme';
import './hide';
import './invite';
import './inviteAllTo';
import './join';
import './kick';
import './lenny';
import './open';
import './shrug';
import './slackbridgeImport';
import './status';
import './tableflip';
import './topic';
import './unarchive';
import './unflip';

View File

@@ -0,0 +1,16 @@
import { queryClient } from '../../lib/queryClient';
import { roomsQueryKeys } from '../../lib/queryKeys';
import { slashCommands } from '../../lib/slashCommand';
slashCommands.add({
command: 'invite',
options: {
description: 'Invite_user_to_join_channel',
params: '@username',
permission: 'add-user-to-joined-room',
},
providesPreview: false,
result: (err, _result, params) => {
if (!err) void queryClient.invalidateQueries({ queryKey: [...roomsQueryKeys.room(params.msg.rid), 'members'] });
},
});

View File

@@ -0,0 +1,26 @@
import { queryClient } from '../../lib/queryClient';
import { roomsQueryKeys } from '../../lib/queryKeys';
import { slashCommands } from '../../lib/slashCommand';
const invalidateMembers = (_err: unknown, _result: unknown, params: { msg: { rid: string } }) => {
if (!_err) void queryClient.invalidateQueries({ queryKey: [...roomsQueryKeys.room(params.msg.rid), 'members'] });
};
slashCommands.add({
command: 'invite-all-to',
options: {
description: 'Invite_user_to_join_channel_all_to',
params: '#room',
permission: ['add-user-to-joined-room', 'add-user-to-any-c-room', 'add-user-to-any-p-room'],
},
result: invalidateMembers,
});
slashCommands.add({
command: 'invite-all-from',
options: {
description: 'Invite_user_to_join_channel_all_from',
params: '#room',
permission: 'add-user-to-joined-room',
},
result: invalidateMembers,
});

View File

@@ -0,0 +1,60 @@
import { createFakeMessage } from '../../../tests/mocks/data';
import { slashCommands } from '../../lib/slashCommand';
import './join';
const getResultHandler = () => {
const { result } = slashCommands.commands.join;
expect(result).toBeDefined();
return result as NonNullable<typeof result>;
};
describe('/join slash command', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('redirects to /open and rewrites the command message when the user is already in the room', () => {
const result = getResultHandler();
const run = jest.spyOn(slashCommands, 'run').mockResolvedValue(undefined);
const params = {
cmd: 'join',
params: 'general',
msg: createFakeMessage({ _id: 'message-id', rid: 'room-id', msg: '/join general' }),
userId: 'user-id',
};
result({ error: 'error-user-already-in-room' }, undefined, params);
expect(params.cmd).toBe('open');
expect(params.msg.msg).toBe('/open general');
expect(run).toHaveBeenCalledWith({
command: 'open',
params: 'general',
message: params.msg,
triggerId: '',
userId: 'user-id',
});
});
it.each([
['a successful result', undefined],
['an unrelated structured error', { error: 'error-not-allowed' }],
['a generic error', new Error('Unexpected error')],
])('does not redirect to /open for %s', (_case, error) => {
const result = getResultHandler();
const run = jest.spyOn(slashCommands, 'run').mockResolvedValue(undefined);
const params = {
cmd: 'join',
params: 'general',
msg: createFakeMessage({ _id: 'message-id', rid: 'room-id', msg: '/join general' }),
userId: 'user-id',
};
result(error, undefined, params);
expect(params.cmd).toBe('join');
expect(params.msg.msg).toBe('/join general');
expect(run).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,17 @@
import { slashCommands } from '../../lib/slashCommand';
slashCommands.add({
command: 'join',
options: {
description: 'Join_the_given_channel',
params: '#channel',
permission: 'view-c-room',
},
result(err, _result: unknown, params: Record<string, any>) {
if ((err as { error?: string } | undefined)?.error === 'error-user-already-in-room') {
params.cmd = 'open';
params.msg.msg = params.msg.msg.replace('join', 'open');
return void slashCommands.run({ command: 'open', params: params.params, message: params.msg, triggerId: '', userId: params.userId });
}
},
});

View File

@@ -0,0 +1,73 @@
import { createFakeMessage } from '../../../tests/mocks/data';
import { queryClient } from '../../lib/queryClient';
import { slashCommands } from '../../lib/slashCommand';
import './kick';
jest.mock('../../lib/queryClient', () => ({
queryClient: {
invalidateQueries: jest.fn(),
},
}));
const invalidateQueries = jest.mocked(queryClient.invalidateQueries);
const getResultHandler = () => {
const { result } = slashCommands.commands.kick;
expect(result).toBeDefined();
return result as NonNullable<typeof result>;
};
const getCallback = () => {
const { callback } = slashCommands.commands.kick;
expect(callback).toBeDefined();
return callback as NonNullable<typeof callback>;
};
const runCommand = (params: string) =>
getCallback()({
command: 'kick',
params,
message: { _id: 'message-id', rid: 'room-id' },
userId: 'user-id',
});
describe('/kick slash command', () => {
afterEach(() => {
jest.clearAllMocks();
});
it.each([
['an unadorned username', 'alice'],
['surrounding whitespace', ' alice '],
['an @ prefix and surrounding whitespace', ' @alice '],
])('returns the normalized username when given %s', (_case, input) => {
expect(runCommand(input)).toBe('alice');
});
it('returns nothing when the input is empty after trimming', () => {
expect(runCommand(' ')).toBeUndefined();
});
it('invalidates the room-members query after success', () => {
const result = getResultHandler();
result(undefined, undefined, {
cmd: 'kick',
params: 'alice',
msg: createFakeMessage({ rid: 'room-id' }),
});
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['rooms', 'room-id', 'members'] });
});
it('does not invalidate the room-members query after an error', () => {
const result = getResultHandler();
result(new Error('Failed to kick user'), undefined, {
cmd: 'kick',
params: 'alice',
msg: createFakeMessage({ rid: 'room-id' }),
});
expect(invalidateQueries).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,24 @@
import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings';
import { queryClient } from '../../lib/queryClient';
import { roomsQueryKeys } from '../../lib/queryKeys';
import { slashCommands } from '../../lib/slashCommand';
slashCommands.add({
command: 'kick',
callback({ params }: SlashCommandCallbackParams<'kick'>) {
const username = params.trim();
if (username === '') {
return;
}
return username.replace('@', '');
},
options: {
description: 'Remove_someone_from_room',
params: '@username',
permission: 'remove-user',
},
result: (err, _result, params) => {
if (!err) void queryClient.invalidateQueries({ queryKey: [...roomsQueryKeys.room(params.msg.rid), 'members'] });
},
});

View File

@@ -0,0 +1,23 @@
import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings';
import { sdk } from '../../lib/SDKClient';
import { slashCommands } from '../../lib/slashCommand';
/*
* Lenny is a named function that will replace /lenny commands
* @param {Object} message - The message object
*/
async function LennyFace({ message, params }: SlashCommandCallbackParams<'lenny'>): Promise<void> {
const msg = message;
await sdk.rest.post('/v1/chat.sendMessage', { message: { ...msg, msg: `${params} ( ͡° ͜ʖ ͡°)` } });
}
slashCommands.add({
command: 'lennyface',
callback: LennyFace,
options: {
description: 'Slash_LennyFace_Description',
params: 'your_message_optional',
clientOnly: true,
},
});

View File

@@ -0,0 +1,160 @@
import { createFakeSubscription } from '../../../tests/mocks/data';
import { sdk } from '../../lib/SDKClient';
import { roomCoordinator } from '../../lib/rooms/roomCoordinator';
import { slashCommands } from '../../lib/slashCommand';
import { router } from '../../providers/RouterProvider';
import { Subscriptions } from '../../stores';
import './open';
jest.mock('../../lib/SDKClient', () => ({
sdk: {
rest: {
post: jest.fn(),
},
},
}));
jest.mock('../../lib/rooms/roomCoordinator', () => ({
roomCoordinator: {
openRouteLink: jest.fn(),
},
}));
jest.mock('../../providers/RouterProvider', () => ({
router: {
getSearchParameters: jest.fn(),
},
}));
jest.mock('../../stores', () => ({
Subscriptions: {
state: {
find: jest.fn(),
},
},
}));
const post = jest.mocked(sdk.rest.post);
const openRouteLink = jest.mocked(roomCoordinator.openRouteLink);
const getSearchParameters = jest.mocked(router.getSearchParameters);
const findSubscription = jest.mocked(Subscriptions.state.find);
const getCallback = () => {
const { callback } = slashCommands.commands.open;
expect(callback).toBeDefined();
return callback as NonNullable<typeof callback>;
};
const runCommand = (params: string) =>
getCallback()({
command: 'open',
params,
message: { _id: 'message-id', rid: 'room-id' },
userId: 'user-id',
});
const setSubscriptions = (...subscriptions: ReturnType<typeof createFakeSubscription>[]) => {
findSubscription.mockImplementation((predicate) => subscriptions.find(predicate));
};
describe('/open slash command', () => {
beforeEach(() => {
getSearchParameters.mockReturnValue({});
});
afterEach(() => {
jest.resetAllMocks();
});
it.each([
['channel', 'c', 'general'],
['group', 'p', 'secret'],
] as const)('opens the matching existing %s subscription', async (_kind, type, name) => {
const wrongType = createFakeSubscription({ name, t: 'd' });
const subscription = createFakeSubscription({ _id: 'subscription-id', rid: 'target-room-id', name, t: type });
setSubscriptions(wrongType, subscription);
await runCommand(` #${name} `);
expect(openRouteLink).toHaveBeenCalledWith(type, subscription, {});
expect(openRouteLink).toHaveBeenCalledTimes(1);
expect(post).not.toHaveBeenCalled();
});
it('opens a matching existing subscription when the input has no prefix', async () => {
const subscription = createFakeSubscription({ name: 'general', t: 'c' });
setSubscriptions(subscription);
await runCommand('general');
expect(openRouteLink).toHaveBeenCalledWith('c', subscription, {});
expect(post).not.toHaveBeenCalled();
});
it('forwards the current search parameters when opening a subscription', async () => {
const subscription = createFakeSubscription({ name: 'general', t: 'c' });
setSubscriptions(subscription);
getSearchParameters.mockReturnValue({ layout: 'embedded' });
await runCommand('#general');
expect(openRouteLink).toHaveBeenCalledWith('c', subscription, { layout: 'embedded' });
});
it('uses the trimmed username from direct-message input', async () => {
post.mockResolvedValue({} as never);
await runCommand(' @alice ');
expect(post).toHaveBeenCalledWith('/v1/im.create', { username: 'alice' });
expect(post).toHaveBeenCalledTimes(1);
});
it('opens an existing direct-message subscription', async () => {
const subscription = createFakeSubscription({ name: 'alice', t: 'd' });
setSubscriptions(subscription);
post.mockResolvedValue({} as never);
await runCommand('@alice');
expect(openRouteLink).toHaveBeenCalledWith('d', subscription, {});
});
it('opens the direct-message subscription created by the REST API', async () => {
const subscription = createFakeSubscription({ _id: 'subscription-id', rid: 'dm-room-id', name: 'alice', t: 'd' });
findSubscription.mockReturnValueOnce(undefined).mockReturnValueOnce(subscription);
post.mockResolvedValue({} as never);
await runCommand('@alice');
expect(post).toHaveBeenCalledWith('/v1/im.create', { username: 'alice' });
expect(openRouteLink).toHaveBeenCalledWith('d', subscription, {});
expect(post.mock.invocationCallOrder[0]).toBeLessThan(openRouteLink.mock.invocationCallOrder[0]);
});
it('does nothing when a channel or group subscription is missing', async () => {
await expect(runCommand('#missing')).resolves.toBeUndefined();
expect(post).not.toHaveBeenCalled();
expect(openRouteLink).not.toHaveBeenCalled();
});
it('does not crash when the direct-message subscription is still missing after creation', async () => {
post.mockResolvedValue({} as never);
await expect(runCommand('@missing')).resolves.toBeUndefined();
expect(post).toHaveBeenCalledWith('/v1/im.create', { username: 'missing' });
expect(post).toHaveBeenCalledTimes(1);
expect(openRouteLink).not.toHaveBeenCalled();
});
it('does not crash when direct-message creation fails', async () => {
post.mockRejectedValue(new Error('Failed to create direct message'));
await expect(runCommand('@alice')).resolves.toBeUndefined();
expect(post).toHaveBeenCalledWith('/v1/im.create', { username: 'alice' });
expect(post).toHaveBeenCalledTimes(1);
expect(openRouteLink).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,50 @@
import type { RoomType, ISubscription, SlashCommandCallbackParams } from '@rocket.chat/core-typings';
import { sdk } from '../../lib/SDKClient';
import { roomCoordinator } from '../../lib/rooms/roomCoordinator';
import { slashCommands } from '../../lib/slashCommand';
import { router } from '../../providers/RouterProvider';
import { Subscriptions } from '../../stores';
slashCommands.add({
command: 'open',
callback: async function Open({ params }: SlashCommandCallbackParams<'open'>): Promise<void> {
const dict: Record<string, RoomType[]> = {
'#': ['c', 'p'],
'@': ['d'],
};
const room = params.trim().replace(/#|@/, '');
const type = dict[params.trim()[0]] || [];
const predicate = ({ name, t }: ISubscription) => {
return name === room && (type.length ? type.includes(t) : true);
};
const subscription = Subscriptions.state.find(predicate);
if (subscription) {
roomCoordinator.openRouteLink(subscription.t, subscription, router.getSearchParameters());
}
if (type?.indexOf('d') === -1) {
return;
}
try {
await sdk.rest.post('/v1/im.create', { username: room });
const subscription = Subscriptions.state.find(predicate);
if (!subscription) {
return;
}
roomCoordinator.openRouteLink(subscription.t, subscription, router.getSearchParameters());
} catch (err: unknown) {
// noop
}
},
options: {
description: 'Opens_a_channel_group_or_direct_message',
params: 'room_name',
clientOnly: true,
permission: ['view-c-room', 'view-c-room', 'view-d-room', 'view-joined-room', 'create-d'],
},
});

View File

@@ -0,0 +1,21 @@
import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings';
import { sdk } from '../../lib/SDKClient';
import { slashCommands } from '../../lib/slashCommand';
/*
* Shrug is a named function that will replace /shrug commands
* @param {Object} message - The message object
*/
slashCommands.add({
command: 'shrug',
callback: async ({ message, params }: SlashCommandCallbackParams<'shrug'>): Promise<void> => {
const msg = message;
await sdk.rest.post('/v1/chat.sendMessage', { message: { ...msg, msg: `${params} ¯\\\\_(ツ)_/¯` } });
},
options: {
description: 'Slash_Shrug_Description',
params: 'your_message_optional',
clientOnly: true,
},
});

View File

@@ -0,0 +1,15 @@
import { settings } from '../../lib/settings';
import { slashCommands } from '../../lib/slashCommand';
settings.observe('SlackBridge_Enabled', (_key, value) => {
if (value) {
slashCommands.add({
command: 'slackbridge-import',
options: {
description: 'Import_old_messages_from_slackbridge',
},
});
} else {
delete slashCommands.commands['slackbridge-import'];
}
});

View File

@@ -0,0 +1,24 @@
import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings';
import { sdk } from '../../lib/SDKClient';
import { slashCommands } from '../../lib/slashCommand';
import { dispatchToastMessage } from '../../lib/toast';
slashCommands.add({
command: 'status',
callback: async function Status({ params, userId }: SlashCommandCallbackParams<'status'>): Promise<void> {
if (!userId) {
return;
}
try {
await sdk.rest.post('/v1/users.setStatus', { message: params });
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
},
options: {
description: 'Slash_Status_Description',
params: 'Slash_Status_Params',
},
});

View File

@@ -0,0 +1,21 @@
import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings';
import { sdk } from '../../lib/SDKClient';
import { slashCommands } from '../../lib/slashCommand';
/*
* Tableflip is a named function that will replace /Tableflip commands
* @param {Object} message - The message object
*/
slashCommands.add({
command: 'tableflip',
callback: async ({ message, params }: SlashCommandCallbackParams<'tableflip'>): Promise<void> => {
const msg = message;
await sdk.rest.post('/v1/chat.sendMessage', { message: { ...msg, msg: `${params} (╯°□°)╯︵ ┻━┻` } });
},
options: {
description: 'Slash_Tableflip_Description',
params: 'your_message_optional',
clientOnly: true,
},
});

View File

@@ -0,0 +1,113 @@
import { clientCallbacks } from '@rocket.chat/ui-client';
import { createFakeRoom } from '../../../tests/mocks/data';
import { sdk } from '../../lib/SDKClient';
import { hasPermission } from '../../lib/authorization';
import { slashCommands } from '../../lib/slashCommand';
import { dispatchToastMessage } from '../../lib/toast';
import { Rooms } from '../../stores';
import './topic';
jest.mock('@rocket.chat/ui-client', () => ({
clientCallbacks: {
run: jest.fn(),
},
}));
jest.mock('../../lib/SDKClient', () => ({
sdk: {
rest: {
post: jest.fn(),
},
},
}));
jest.mock('../../lib/authorization', () => ({
hasPermission: jest.fn(),
}));
jest.mock('../../lib/toast', () => ({
dispatchToastMessage: jest.fn(),
}));
jest.mock('../../stores', () => ({
Rooms: {
state: {
get: jest.fn(),
},
},
}));
const post = jest.mocked(sdk.rest.post);
const checkPermission = jest.mocked(hasPermission);
const runClientCallback = jest.mocked(clientCallbacks.run);
const showToast = jest.mocked(dispatchToastMessage);
const getRoom = jest.mocked(Rooms.state.get);
const room = createFakeRoom({ _id: 'room-id', t: 'c', name: 'general' });
const getCallback = () => {
const { callback } = slashCommands.commands.topic;
expect(callback).toBeDefined();
return callback as NonNullable<typeof callback>;
};
const runCommand = (params = 'New topic') =>
getCallback()({
command: 'topic',
params,
message: { _id: 'message-id', rid: 'room-id' },
userId: 'user-id',
});
describe('/topic slash command', () => {
beforeEach(() => {
checkPermission.mockReturnValue(true);
post.mockResolvedValue({} as never);
runClientCallback.mockResolvedValue(undefined);
getRoom.mockReturnValue(room);
});
afterEach(() => {
jest.clearAllMocks();
});
it('updates the topic when the user has permission', async () => {
await runCommand('Updated topic');
expect(checkPermission).toHaveBeenCalledWith('edit-room', 'room-id');
expect(post).toHaveBeenCalledWith('/v1/rooms.saveRoomSettings', {
rid: 'room-id',
roomTopic: 'Updated topic',
});
});
it('does not call the API when permission is missing', async () => {
checkPermission.mockReturnValue(false);
await runCommand();
expect(post).not.toHaveBeenCalled();
expect(runClientCallback).not.toHaveBeenCalled();
expect(getRoom).not.toHaveBeenCalled();
expect(showToast).not.toHaveBeenCalled();
});
it('runs the roomTopicChanged callback after a successful update', async () => {
await runCommand();
expect(runClientCallback).toHaveBeenCalledWith('roomTopicChanged', room);
expect(post.mock.invocationCallOrder[0]).toBeLessThan(runClientCallback.mock.invocationCallOrder[0]);
});
it('displays an error toast and propagates the REST API error when the update fails', async () => {
const error = new Error('Failed to update topic');
post.mockRejectedValue(error);
await expect(runCommand()).rejects.toBe(error);
expect(showToast).toHaveBeenCalledWith({ type: 'error', message: error });
expect(runClientCallback).not.toHaveBeenCalled();
expect(getRoom).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,28 @@
import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings';
import { clientCallbacks } from '@rocket.chat/ui-client';
import { sdk } from '../../lib/SDKClient';
import { hasPermission } from '../../lib/authorization';
import { slashCommands } from '../../lib/slashCommand';
import { dispatchToastMessage } from '../../lib/toast';
import { Rooms } from '../../stores';
slashCommands.add({
command: 'topic',
callback: async function Topic({ params, message }: SlashCommandCallbackParams<'topic'>): Promise<void> {
if (hasPermission('edit-room', message.rid)) {
try {
await sdk.rest.post('/v1/rooms.saveRoomSettings', { rid: message.rid, roomTopic: params });
await clientCallbacks.run('roomTopicChanged', Rooms.state.get(message.rid));
} catch (error: unknown) {
dispatchToastMessage({ type: 'error', message: error });
throw error;
}
}
},
options: {
description: 'Slash_Topic_Description',
params: 'Slash_Topic_Params',
permission: 'edit-room',
},
});

View File

@@ -0,0 +1,11 @@
import { slashCommands } from '../../lib/slashCommand';
slashCommands.add({
command: 'unarchive',
options: {
description: 'Unarchive',
params: '#channel',
permission: 'unarchive-room',
},
providesPreview: false,
});

View File

@@ -0,0 +1,21 @@
import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings';
import { sdk } from '../../lib/SDKClient';
import { slashCommands } from '../../lib/slashCommand';
/*
* Unflip is a named function that will replace /unflip commands
* @param {Object} message - The message object
*/
slashCommands.add({
command: 'unflip',
callback: async ({ message, params }: SlashCommandCallbackParams<'unflip'>): Promise<void> => {
const msg = message;
await sdk.rest.post('/v1/chat.sendMessage', { message: { ...msg, msg: `${params} ┬─┬ ノ( ゜-゜ノ)` } });
},
options: {
description: 'Slash_TableUnflip_Description',
params: 'your_message_optional',
clientOnly: true,
},
});

View File

@@ -0,0 +1,157 @@
import type { UserStatus } from '@rocket.chat/core-typings';
import 'highlight.js/styles/github.css';
import { sdk } from '../lib/SDKClient';
import { onLoggedIn } from '../lib/loggedIn';
import { clearStoredCredentials, ensureConnectedAndAuthenticated, getDdpSdk, isAuthError } from '../lib/sdk/ddpSdk';
import { isSdkTransportEnabled } from '../lib/sdk/sdkTransportEnabled';
import { STORAGE_KEYS, getStoredItem } from '../lib/sdk/storage';
import { userIdStore } from '../lib/user';
import { removeLocalUserData, synchronizeUserData } from '../lib/userData';
import { fireGlobalEvent } from '../lib/utils/fireGlobalEvent';
import { Users } from '../stores';
const sdkTransportEnabled = isSdkTransportEnabled();
let status: UserStatus | undefined = undefined;
const emitStatusChange = (next: UserStatus | undefined) => {
if (next === status) return;
status = next;
fireGlobalEvent('status-changed', status);
};
if (!sdkTransportEnabled) {
// Flag off: develop's exact onLoggedIn handler — single Accounts.onLogin
// hook, no SDK auth gate, no syncOnce dedup, no userIdStore.subscribe
// belt-and-braces.
onLoggedIn(async () => {
const uid = userIdStore.getState();
if (!uid) return;
const user = await synchronizeUserData(uid);
if (!user) return;
const utcOffset = -new Date().getTimezoneOffset() / 60;
if (user.utcOffset !== utcOffset) {
void sdk.rest.post('/v1/users.setPreferences', { data: { utcOffset } });
}
emitStatusChange(user.status);
});
} else {
const runUserDataSync = async (uid: string) => {
// synchronizeUserData opens a `stream-notify-user/${uid}/userData` sub
// over DDPSDK. The server rejects that sub with "not-allowed" until
// DDPSDK has completed loginWithToken on its own socket. Both
// runUserDataSync and ensureConnectedAndAuthenticated are subscribers
// of userIdStore, so without sequencing the sub races the auth and
// hits the rejection on every re-login. Await the SDK auth here so
// the sub fires authenticated.
const tokenBeforeSync = getStoredItem(STORAGE_KEYS.LOGIN_TOKEN);
try {
await ensureConnectedAndAuthenticated();
} catch {
// non-fatal: sdk.stream queues until DDPSDK eventually auths
}
let user: Awaited<ReturnType<typeof synchronizeUserData>>;
try {
user = await synchronizeUserData(uid);
} catch (error) {
// When the stored token is expired/revoked server-side, the userData
// stream sub + /v1/me come back 401/403, so synchronizeUserData throws
// and useUserDataSyncReady never flips true. useMainReady then stays
// false forever (uid is still truthy from the stale credentials), so
// Preload sits on PageLoading — and because Preload WRAPS
// AuthenticationCheck, the user never reaches LoginPage and the
// workspace looks stuck "loading"/grayed-out. Escalate the dead-session
// signal to a credential wipe so userId drops to null and the router
// falls through to the login screen. Token-stable guard: only clear
// when localStorage still holds the token we synced with, so a parallel
// re-auth that already rotated the token isn't kicked out.
if (isAuthError(error) && getStoredItem(STORAGE_KEYS.LOGIN_TOKEN) === tokenBeforeSync) {
clearStoredCredentials();
}
throw error;
}
if (!user) return;
const utcOffset = -new Date().getTimezoneOffset() / 60;
if (user.utcOffset !== utcOffset) {
void sdk.rest.post('/v1/users.setPreferences', { data: { utcOffset } });
}
emitStatusChange(user.status);
};
// Both `onLoggedIn` (from accounts-base) and `userIdStore.subscribe`
// (belt-and-braces in case the loggedInAndDataReadyCallback's user-await
// autorun gets wedged on logout → fresh login) fire for the same uid on a
// successful login. runUserDataSync calls userSetUtcOffset which is
// rate-limited on CI/prod, so without a shared guard the second call
// returns 400 too-many-requests and downstream REST calls (sessions/list
// etc.) start coming back 401 because the rate limiter throttles auth
// checks for the rest of the window. Use a single guarded sync gate, but
// reset it on failure so SAML/oauth/post-logout flows can retry — those
// flows depend on a second runUserDataSync after the SDK socket finishes
// authenticating, otherwise the userData stream subscription comes back
// nosub and synchronizeUserData throws, leaving useUserDataSyncReady
// false and the page stuck on PageLoading.
let lastSyncedUid: string | undefined;
const syncOnce = (uid: string | undefined): void => {
// Reset on logout transitions so a subsequent re-login (same uid or different)
// runs a fresh sync. Force-logout via the SDK loginWithToken wrap clears
// creds via Accounts._unstoreLoginToken() + setUserId(null), which does NOT
// fire Accounts.onLogout — so without this branch, lastSyncedUid stays set,
// the next login is deduped, runUserDataSync is skipped, and
// useUserDataSyncReady stays false (page wedged on PageLoading).
if (!uid) {
lastSyncedUid = undefined;
return;
}
if (uid === lastSyncedUid) return;
lastSyncedUid = uid;
void runUserDataSync(uid).catch((err) => {
console.warn('[startup] runUserDataSync failed; clearing dedup to allow a retry', err);
if (lastSyncedUid === uid) lastSyncedUid = undefined;
});
};
onLoggedIn(() => {
syncOnce(userIdStore.getState());
});
userIdStore.subscribe((uid) => {
syncOnce(uid);
});
syncOnce(userIdStore.getState());
getDdpSdk().account.onLogout(() => {
lastSyncedUid = undefined;
});
}
Users.use.subscribe(() => {
const uid = userIdStore.getState();
if (!uid) return;
const user = Users.use.getState().get(uid);
if (!user) return;
emitStatusChange(user.status);
});
getDdpSdk().account.onLogout(() => {
removeLocalUserData();
status = undefined;
});
// Session-resume failure (expired stored token on page load): Meteor has already
// cleared Meteor.loginToken before this module runs, userId stays null, and no
// Accounts.onLogout callback fires for this scenario. Detect via the combination
// of missing token and missing uid at module init and clean up residual keys
// (e.g. E2EE public_key / private_key). Do NOT subscribe to userIdStore for this —
// the valid-session resume path is async and would clobber a valid token mid-flight.
if (!userIdStore.getState() && localStorage.getItem('Meteor.loginToken') === null) {
removeLocalUserData();
}

View File

@@ -0,0 +1,28 @@
import { clientCallbacks } from '@rocket.chat/ui-client';
import { PermissionsCachedStore } from '../../cachedStores';
import { hasPermission } from '../../lib/authorization';
import { settings } from '../../lib/settings';
import { Users } from '../../stores';
const STREAM_HANDLER_ID = 'autotranslate-stream';
const applyAutoTranslateStreamHandler = () => {
const isEnabled = settings.peek('AutoTranslate_Enabled') && hasPermission('auto-translate');
if (!isEnabled) {
clientCallbacks.remove('streamMessage', STREAM_HANDLER_ID);
return;
}
void import('../../lib/autotranslate').then(({ createAutoTranslateMessageStreamHandler }) => {
const streamMessage = createAutoTranslateMessageStreamHandler();
clientCallbacks.remove('streamMessage', STREAM_HANDLER_ID);
clientCallbacks.add('streamMessage', streamMessage, clientCallbacks.priority.HIGH - 3, STREAM_HANDLER_ID);
});
};
applyAutoTranslateStreamHandler();
settings.observe('AutoTranslate_Enabled', applyAutoTranslateStreamHandler);
PermissionsCachedStore.useReady.subscribe(applyAutoTranslateStreamHandler);
Users.use.subscribe(applyAutoTranslateStreamHandler);

View File

@@ -0,0 +1 @@
import './autotranslate';