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
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:
26
apps/meteor/client/startup/appRoot.tsx
Normal file
26
apps/meteor/client/startup/appRoot.tsx
Normal 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>,
|
||||
);
|
||||
86
apps/meteor/client/startup/audit.tsx
Normal file
86
apps/meteor/client/startup/audit.tsx
Normal 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();
|
||||
},
|
||||
});
|
||||
25
apps/meteor/client/startup/callbacks.ts
Normal file
25
apps/meteor/client/startup/callbacks.ts
Normal 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);
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
33
apps/meteor/client/startup/deviceManagement.ts
Normal file
33
apps/meteor/client/startup/deviceManagement.ts
Normal 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();
|
||||
},
|
||||
});
|
||||
37
apps/meteor/client/startup/incomingMessages.ts
Normal file
37
apps/meteor/client/startup/incomingMessages.ts
Normal 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;
|
||||
});
|
||||
12
apps/meteor/client/startup/index.ts
Normal file
12
apps/meteor/client/startup/index.ts
Normal 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';
|
||||
12
apps/meteor/client/startup/livechatEnterprise.ts
Normal file
12
apps/meteor/client/startup/livechatEnterprise.ts
Normal 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());
|
||||
},
|
||||
});
|
||||
40
apps/meteor/client/startup/roles.ts
Normal file
40
apps/meteor/client/startup/roles.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
273
apps/meteor/client/startup/routes.tsx
Normal file
273
apps/meteor/client/startup/routes.tsx
Normal 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 />,
|
||||
},
|
||||
]);
|
||||
11
apps/meteor/client/startup/slashCommands/archive.ts
Normal file
11
apps/meteor/client/startup/slashCommands/archive.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { slashCommands } from '../../lib/slashCommand';
|
||||
|
||||
slashCommands.add({
|
||||
command: 'archive',
|
||||
options: {
|
||||
description: 'Archive',
|
||||
params: '#channel',
|
||||
permission: 'archive-room',
|
||||
},
|
||||
providesPreview: false,
|
||||
});
|
||||
32
apps/meteor/client/startup/slashCommands/ban.ts
Normal file
32
apps/meteor/client/startup/slashCommands/ban.ts
Normal 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,
|
||||
});
|
||||
11
apps/meteor/client/startup/slashCommands/create.ts
Normal file
11
apps/meteor/client/startup/slashCommands/create.ts
Normal 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,
|
||||
});
|
||||
29
apps/meteor/client/startup/slashCommands/federation.ts
Normal file
29
apps/meteor/client/startup/slashCommands/federation.ts
Normal 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,
|
||||
});
|
||||
22
apps/meteor/client/startup/slashCommands/gimme.ts
Normal file
22
apps/meteor/client/startup/slashCommands/gimme.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
9
apps/meteor/client/startup/slashCommands/hide.ts
Normal file
9
apps/meteor/client/startup/slashCommands/hide.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { slashCommands } from '../../lib/slashCommand';
|
||||
|
||||
slashCommands.add({
|
||||
command: 'hide',
|
||||
options: {
|
||||
description: 'Hide_room',
|
||||
params: '#room',
|
||||
},
|
||||
});
|
||||
19
apps/meteor/client/startup/slashCommands/index.ts
Normal file
19
apps/meteor/client/startup/slashCommands/index.ts
Normal 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';
|
||||
16
apps/meteor/client/startup/slashCommands/invite.ts
Normal file
16
apps/meteor/client/startup/slashCommands/invite.ts
Normal 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'] });
|
||||
},
|
||||
});
|
||||
26
apps/meteor/client/startup/slashCommands/inviteAllTo.ts
Normal file
26
apps/meteor/client/startup/slashCommands/inviteAllTo.ts
Normal 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,
|
||||
});
|
||||
60
apps/meteor/client/startup/slashCommands/join.spec.ts
Normal file
60
apps/meteor/client/startup/slashCommands/join.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
17
apps/meteor/client/startup/slashCommands/join.ts
Normal file
17
apps/meteor/client/startup/slashCommands/join.ts
Normal 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 });
|
||||
}
|
||||
},
|
||||
});
|
||||
73
apps/meteor/client/startup/slashCommands/kick.spec.ts
Normal file
73
apps/meteor/client/startup/slashCommands/kick.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
24
apps/meteor/client/startup/slashCommands/kick.ts
Normal file
24
apps/meteor/client/startup/slashCommands/kick.ts
Normal 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'] });
|
||||
},
|
||||
});
|
||||
23
apps/meteor/client/startup/slashCommands/lenny.ts
Normal file
23
apps/meteor/client/startup/slashCommands/lenny.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
160
apps/meteor/client/startup/slashCommands/open.spec.ts
Normal file
160
apps/meteor/client/startup/slashCommands/open.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
50
apps/meteor/client/startup/slashCommands/open.ts
Normal file
50
apps/meteor/client/startup/slashCommands/open.ts
Normal 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'],
|
||||
},
|
||||
});
|
||||
21
apps/meteor/client/startup/slashCommands/shrug.ts
Normal file
21
apps/meteor/client/startup/slashCommands/shrug.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
@@ -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'];
|
||||
}
|
||||
});
|
||||
24
apps/meteor/client/startup/slashCommands/status.ts
Normal file
24
apps/meteor/client/startup/slashCommands/status.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
21
apps/meteor/client/startup/slashCommands/tableflip.ts
Normal file
21
apps/meteor/client/startup/slashCommands/tableflip.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
113
apps/meteor/client/startup/slashCommands/topic.spec.ts
Normal file
113
apps/meteor/client/startup/slashCommands/topic.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
28
apps/meteor/client/startup/slashCommands/topic.ts
Normal file
28
apps/meteor/client/startup/slashCommands/topic.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
11
apps/meteor/client/startup/slashCommands/unarchive.ts
Normal file
11
apps/meteor/client/startup/slashCommands/unarchive.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { slashCommands } from '../../lib/slashCommand';
|
||||
|
||||
slashCommands.add({
|
||||
command: 'unarchive',
|
||||
options: {
|
||||
description: 'Unarchive',
|
||||
params: '#channel',
|
||||
permission: 'unarchive-room',
|
||||
},
|
||||
providesPreview: false,
|
||||
});
|
||||
21
apps/meteor/client/startup/slashCommands/unflip.ts
Normal file
21
apps/meteor/client/startup/slashCommands/unflip.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
157
apps/meteor/client/startup/startup.ts
Normal file
157
apps/meteor/client/startup/startup.ts
Normal 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();
|
||||
}
|
||||
28
apps/meteor/client/startup/streamMessage/autotranslate.ts
Normal file
28
apps/meteor/client/startup/streamMessage/autotranslate.ts
Normal 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);
|
||||
1
apps/meteor/client/startup/streamMessage/index.ts
Normal file
1
apps/meteor/client/startup/streamMessage/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
import './autotranslate';
|
||||
Reference in New Issue
Block a user